拼多多面试官没想到ThreadLocal我用得这么溜,人直接傻掉

点赞再看,养成习惯,微信搜一搜【敖丙】关注这个互联网苟且偷生的程序员。

本文 GitHub https://github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

开场白

张三最近天气很热心情不是很好,所以他决定出去面试跟面试官聊聊天排解一下,结果刚投递简历就有人约了面试。

我丢,什么情况怎么刚投递出去就有人约我面试了?诶。。。真烦啊,哥已经不在江湖这么久了,江湖还是有哥的传说,我还是这么抢手的么?太烦恼了,帅无罪。

暗自窃喜的张三来到了某东现场面试的办公室,我丢,这面试官?不是吧,这满是划痕的Mac,这发量,难道就是传说中的架构师?

张三的心态一下子就崩了,出来第一场面试就遇到一个顶级面试官,这谁顶得住啊。

你好,我是你的面试官Tony,看我的发型应该你能猜到我的身份了,我也话不说,我们直接开始好不好?看你简历写了多线程,来你跟我聊一下ThreadLocal吧,我很久没写代码不太熟悉了,你帮我回忆一下。

我丢?这TM是人话?这是什么逻辑啊,说是问多线程然后一上来就来个这么冷门的ThreadLocal?心态崩了呀,再说你TM自己忘了不知道下去看看书么,来我这里找答案是什么鬼啊…

尽管十分不情愿,但是张三还是高速运转他的小脑袋,回忆起了ThreadLocal的种种细节…

面试官说实话我在实际开发过程中用到ThreadLocal的地方不是很多,我在写这个文章的时候还刻意去把我电脑上几十个项目打开之后去全局搜索ThreadLocal发现除了系统源码的使用,很少在项目中用到,不过也还是有的。

ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,如何防止自己的变量被其它线程篡改。

你能跟我说说它隔离有什么用,会用在什么场景么?

这,我都说了我很少用了,还问我,难受了呀,哦哦哦,有了想起来了,事务隔离级别。

面试官你好,其实我第一时间想到的就是Spring实现事务隔离级别的源码,这还是当时我大学被女朋友甩了,一个人在图书馆哭泣的时候无意间发现的。

Spring采用Threadlocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。

Spring框架里面就是用的ThreadLocal来实现这种隔离,主要是在TransactionSynchronizationManager这个类里面,代码如下所示:

private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);private static final ThreadLocal<Map<Object, Object>> resources =new NamedThreadLocal<>("Transactional resources");private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =new NamedThreadLocal<>("Transaction synchronizations");private static final ThreadLocal<String> currentTransactionName =new NamedThreadLocal<>("Current transaction name");……

Spring的事务主要是ThreadLocal和AOP去做实现的,我这里提一下,大家知道每个线程自己的链接是靠ThreadLocal保存的就好了,继续的细节我会在Spring章节细说的,暖么?

除了源码里面使用到ThreadLocal的场景,你自己有使用他的场景么?一般你会怎么用呢?

来了来了,加分项来了,这个我还真遇到过,装B的机会终于来了。

有的有的面试官,这个我会!!!

之前我们上线后发现部分用户的日期居然不对了,排查下来是SimpleDataFormat的锅,当时我们使用SimpleDataFormat的parse()方法,内部有一个Calendar对象,调用SimpleDataFormat的parse()方法会先调用Calendar.clear(),然后调用Calendar.add(),如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse()方法解析的时间就不对了。

其实要解决这个问题很简单,让每个线程都new 一个自己的 SimpleDataFormat就好了,但是1000个线程难道new1000个SimpleDataFormat

所以当时我们使用了线程池加上ThreadLocal包装SimpleDataFormat,再调用initialValue让每个线程有一个SimpleDataFormat的副本,从而解决了线程安全的问题,也提高了性能。

那……

还有还有,我还有,您别着急问下一个,让我再加点分,拖延一下面试时间。

我在项目中存在一个线程经常遇到横跨若干方法调用,需要传递的对象,也就是上下文(Context),它是一种状态,经常就是是用户身份、任务信息等,就会存在过渡传参的问题。

使用到类似责任链模式,给每个方法增加一个context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,对象参数就传不进去了,所以我使用到了ThreadLocal去做了一下改造,这样只需要在调用前在ThreadLocal中设置参数,其他地方get一下就好了。

beforevoid work(User user) {getInfo(user);checkInfo(user);setSomeThing(user);log(user);
}thenvoid work(User user) {
try{threadLocalUser.set(user);// 他们内部  User u = threadLocalUser.get(); 就好了getInfo();checkInfo();setSomeThing();log();} finally {threadLocalUser.remove();}
}

我看了一下很多场景的cookie,session等数据隔离都是通过ThreadLocal去做实现的。

对了我面试官允许我再秀一下知识广度,在Android中,Looper类就是利用了ThreadLocal的特性,保证每个线程只存在一个Looper对象。

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
private static void prepare(boolean quitAllowed) {if (sThreadLocal.get() != null) {throw new RuntimeException("Only one Looper may be created per thread");}sThreadLocal.set(new Looper(quitAllowed));
}

面试官:我丢,这货怎么知道这么多场景?还把Android都扯了出来,不是吧阿sir,下面我要考考他原理了。

嗯嗯,你回答得很好,那你能跟我说说他底层实现的原理么?

好的面试官,我先说一下他的使用:

ThreadLocal<String> localName = new ThreadLocal();
localName.set("张三");
String name = localName.get();
localName.remove();

其实使用真的很简单,线程进来之后初始化一个可以泛型的ThreadLocal对象,之后这个线程只要在remove之前去get,都能拿到之前set的值,注意这里我说的是remove之前。

他是能做到线程间数据隔离的,所以别的线程使用get()方法是没办法拿到其他线程的值的,但是有办法可以做到,我后面会说。

我们先看看他set的源码:

public void set(T value) {Thread t = Thread.currentThread();// 获取当前线程ThreadLocalMap map = getMap(t);// 获取ThreadLocalMap对象if (map != null) // 校验对象是否为空map.set(this, value); // 不为空setelsecreateMap(t, value); // 为空创建一个map对象
}

大家可以发现set的源码很简单,主要就是ThreadLocalMap我们需要关注一下,而ThreadLocalMap呢是当前线程Thread一个叫threadLocals的变量中获取的。

ThreadLocalMap getMap(Thread t) {return t.threadLocals;}
public class Thread implements Runnable {……/* ThreadLocal values pertaining to this thread. This map is maintained* by the ThreadLocal class. */ThreadLocal.ThreadLocalMap threadLocals = null;/** InheritableThreadLocal values pertaining to this thread. This map is* maintained by the InheritableThreadLocal class.*/ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;……

这里我们基本上可以找到ThreadLocal数据隔离的真相了,每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离。

ThreadLocalMap底层结构是怎么样子的呢?

面试官这个问题问得好啊,内心暗骂,让我歇一会不行么?

张三笑着回答道,既然有个Map那他的数据结构其实是很像HashMap的,但是看源码可以发现,它并未实现Map接口,而且他的Entry是继承WeakReference(弱引用)的,也没有看到HashMap中的next,所以不存在链表了。

static class ThreadLocalMap {static class Entry extends WeakReference<ThreadLocal<?>> {/** The value associated with this ThreadLocal. */Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}}……}    

结构大概长这样:

稍等,我有两个疑问你可以解答一下么?

好呀,面试官你说。

为什么需要数组呢?没有了链表怎么解决Hash冲突呢?

用数组是因为,我们开发过程中可以一个线程可以有多个TreadLocal来存放不同类型的对象的,但是他们都将放到你当前线程的ThreadLocalMap里,所以肯定要数组来存。

至于Hash冲突,我们先看一下源码:

private void set(ThreadLocal<?> key, Object value) {Entry[] tab = table;int len = tab.length;int i = key.threadLocalHashCode & (len-1);for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {ThreadLocal<?> k = e.get();if (k == key) {e.value = value;return;}if (k == null) {replaceStaleEntry(key, value, i);return;}}tab[i] = new Entry(key, value);int sz = ++size;if (!cleanSomeSlots(i, sz) && sz >= threshold)rehash();}

我从源码里面看到ThreadLocalMap在存储的时候会给每一个ThreadLocal对象一个threadLocalHashCode,在插入过程中,根据ThreadLocal对象的hash值,定位到table中的位置i,int i = key.threadLocalHashCode & (len-1)

然后会判断一下:如果当前位置是空的,就初始化一个Entry对象放在位置i上;

if (k == null) {replaceStaleEntry(key, value, i);return;
}

如果位置i不为空,如果这个Entry对象的key正好是即将设置的key,那么就刷新Entry中的value;

if (k == key) {e.value = value;return;
}

如果位置i的不为空,而且key不等于entry,那就找下一个空位置,直到为空为止。

这样的话,在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该位置Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置,set和get如果冲突严重的话,效率还是很低的。

以下是get的源码,是不是就感觉很好懂了:

 private Entry getEntry(ThreadLocal<?> key) {int i = key.threadLocalHashCode & (table.length - 1);Entry e = table[i];if (e != null && e.get() == key)return e;elsereturn getEntryAfterMiss(key, i, e);}private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {Entry[] tab = table;int len = tab.length;
// get的时候一样是根据ThreadLocal获取到table的i值,然后查找数据拿到后会对比key是否相等  if (e != null && e.get() == key)。while (e != null) {ThreadLocal<?> k = e.get();// 相等就直接返回,不相等就继续查找,找到相等位置。if (k == key)return e;if (k == null)expungeStaleEntry(i);elsei = nextIndex(i, len);e = tab[i];}return null;}

能跟我说一下对象存放在哪里么?

在Java中,栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存,而堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。

那么是不是说ThreadLocal的实例以及其值存放在栈上呢?

其实不是的,因为ThreadLocal实例实际上也是被其创建的类持有(更顶端应该是被线程持有),而ThreadLocal的值其实也是被线程实例持有,它们都是位于堆上,只是通过一些技巧将可见性修改成了线程可见。

如果我想共享线程的ThreadLocal数据怎么办?

使用InheritableThreadLocal可以实现多个线程访问ThreadLocal的值,我们在主线程中创建一个InheritableThreadLocal的实例,然后在子线程中得到这个InheritableThreadLocal实例设置的值。

private void test() {    
final ThreadLocal threadLocal = new InheritableThreadLocal();       
threadLocal.set("帅得一匹");    
Thread t = new Thread() {        @Override        public void run() {            super.run();            Log.i( "张三帅么 =" + threadLocal.get());        }    };          t.start(); 
} 

在子线程中我是能够正常输出那一行日志的,这也是我之前面试视频提到过的父子线程数据传递的问题。

怎么传递的呀?

传递的逻辑很简单,我在开头Thread代码提到threadLocals的时候,你们再往下看看我刻意放了另外一个变量:

Thread源码中,我们看看Thread.init初始化创建的时候做了什么:

public class Thread implements Runnable {……if (inheritThreadLocals && parent.inheritableThreadLocals != null)this.inheritableThreadLocals=ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);……
}

我就截取了部分代码,如果线程的inheritThreadLocals变量不为空,比如我们上面的例子,而且父线程的inheritThreadLocals也存在,那么我就把父线程的inheritThreadLocals给当前线程的inheritThreadLocals

是不是很有意思?

小伙子你懂的确实很多,那你算是一个深度的ThreadLocal用户了,你发现ThreadLocal的问题了么?

你是说内存泄露么?

我丢,这小子为啥知道我要问什么?嗯嗯对的,你说一下。

这个问题确实会存在的,我跟大家说一下为什么,还记得我上面的代码么?

ThreadLocal在保存的时候会把自己当做Key存在ThreadLocalMap中,正常情况应该是key和value都应该被外界强引用才对,但是现在key被设计成WeakReference弱引用了。

我先给大家介绍一下弱引用:

只具有弱引用的对象拥有更短暂的生命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

这就导致了一个问题,ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。

就比如线程池里面的线程,线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以,ThreadLocal设定的value值被持有,导致内存泄露。

按照道理一个线程使用完,ThreadLocalMap是应该要被清空的,但是现在线程被复用了。

那怎么解决?

在代码的最后使用remove就好了,我们只要记得在使用的最后用remove把值清空就好了。

ThreadLocal<String> localName = new ThreadLocal();
try {localName.set("张三");……
} finally {localName.remove();
}

remove的源码很简单,找到对应的值全部置空,这样在垃圾回收器回收的时候,会自动把他们回收掉。

那为什么ThreadLocalMap的key要设计成弱引用?

key不设置成弱引用的话就会造成和entry中value一样内存泄漏的场景。

补充一点:ThreadLocal的不足,我觉得可以通过看看netty的fastThreadLocal来弥补,大家有兴趣可以康康。

好了,你不仅把我问的都回答了,我不知道的你甚至都说了,ThreadLocal你过关了,不过JUC的面试才刚刚开始,希望你以后越战越勇,最后拿个好offer哟。

什么鬼,突然这么煽情,不是很为难我的么?难道是为了锻炼我?难为大师这样为我着想,我还一直心里暗骂他,不说了回去好好学了。

总结

其实ThreadLocal用法很简单,里面的方法就那几个,算上注释源码都没多少行,我用了十多分钟就过了一遍了,但是在我深挖每一个方法背后逻辑的时候,也让我不得不感慨Josh Bloch 和 Doug Lea的厉害之处。

在细节设计的处理其实往往就是我们和大神的区别,我认为很多不合理的点,在Google和自己不断深入了解之后才发现这才是合理,真的不服不行。

ThreadLocal是多线程里面比较冷门的一个类,使用频率比不上别的方法和类,但是通过我这篇文章,不知道你是否有新的认知呢?

絮叨

另外,敖丙把自己的面试文章整理成了一本电子书,共 1630页!目录如下,还有我复习时总结的面试题以及简历模板

现在免费送给大家,在我的公众号三太子敖丙回复 【888】 即可获取。

我是敖丙,你知道的越多,你不知道的越多,我们下期见!

人才们的 【三连】 就是敖丙创作的最大动力,如果本篇博客有任何错误和建议,欢迎人才们留言!


文章持续更新,可以微信搜一搜「 敖丙 」第一时间阅读,关注后回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHub https://github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/50027.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

大数据面试吹牛草稿V2.0

面试吹牛之前先打个草稿&#xff01; 本文首发于微信公众号【五分钟学大数据】&#xff0c;公众号上有很多大数据学习方法&#xff0c;学习文档&#xff0c;最全的大数据面试八股文等 各位面试官好! 1. 我叫 xxx&#xff0c;毕业于 xxx&#xff0c;之前在 xxx 公司待了 1 年多&…

抢程序员饭碗?自动写代码的Deep TabNine真如此神奇?

作者 | James Vincent等 编译 | 夕颜 出品 | AI科技大本营&#xff08;ID:rgznai100&#xff09; 导读&#xff1a;在过去的一年中&#xff0c;AI 生成书面文字的能力大大提高。通过扫描庞大的文本数据集&#xff0c;机器学习软件可以生成从短篇小说到歌词的各种令人信服的样本…

刘强东的代码水平如何?网友:当年一晚赚5万

作为京东集团大佬&#xff0c;刘强东备受关注。互联网界的大佬&#xff0c;很多都是程序员出身&#xff0c;或者会写代码&#xff0c;就像雷军、马化腾、李彦宏、周鸿祎等&#xff0c;几乎都是编程高手。于是有人问道&#xff0c;刘强东的代码水平如何? 关于这个问题&#xff…

【我惊呆了】Chatgpt比程序员还懂代码

Chatgpt竟然比程序员还懂代码&#xff1f;这个AI让我惊呆了 你有没有遇到过这样的情况&#xff1a;你在写代码的时候&#xff0c;突然发现一个bug&#xff0c;但是你怎么也找不出原因&#xff0c;只能无奈地看着程序崩溃。你想要求助&#xff0c;但是同事都很忙&#xff0c;网上…

刘强东不学编程,今天很可能没有京东......一位码农的发家致富史

公元前216年&#xff0c;16岁的项羽随叔父离开老家宿迁&#xff0c;踏上了西楚霸王之路。2200多年后&#xff0c;“大强子”带着村里人为他凑的500块钱和76个茶叶蛋&#xff0c;离开老家宿迁&#xff0c;来到了中国人民大学社会学院。他跟朋友说&#xff1a;我就是想当官&#…

afl-fuzz.c 源码全分析

afl-fuzz.c 源码全分析 picasso big sb 这是整个 afl 项目的核心&#xff0c;本文根据 clion 调试的执行顺序进行分析。 首先使用 afl-gcc 编译并插桩程序&#xff1a; afl-gcc -g test1.c -o afl-test然后设置 afl-fuzz 的参数&#xff0c;fuzz_in 和 fuzz_out 是新建的&a…

C++高级编程-高级特性

临时总结&#xff1a; <utility> std::move 获得右值引用 奇形怪状的函数 1.传统函数 void f&#xff08;int a&#xff09;{}2.<functional> bind bind( F&& f, Args&&... args ); 参数 f - 可调用 (Callable) 对象&#xff08;函数对象、指向函…

Vue——May(1)

VUE 一、vue基础1.1 引用1.2 基础应用1.3 模板语法1.4 数据处理1.5 el与 .$mount1.6 data的函数式写法1.7 架构模型——MVVM模型1.8 数据代理Object.defineproperty1.9 理解数据代理1.10 事件处理1.10.1 参数1.10.2 this1.10.3 简写1.10.4 传参 1.11 事件修饰符1.12 键盘事件1.…

三次输错密码后,系统是怎么做到不让我继续尝试的?

故事背景&#x1f34a; 忘记密码这件事&#xff0c;相信绝大多数人都遇到过&#xff0c;输一次错一次&#xff0c;错到几次以上&#xff0c;就不允许你继续尝试了。 但当你尝试重置密码&#xff0c;又发现新密码不能和原密码重复&#xff1a; 相信此刻心情只能用一张图形容&a…

chatgpt赋能Python-python_b怎么用

Python在SEO中的应用 介绍 Python是一种高级编程语言&#xff0c;具有清晰简洁的语法和强大的功能。它的应用场景非常广泛&#xff0c;除了在工业、科学、金融等领域中得到广泛应用&#xff0c;还可以在SEO中发挥重要作用。 SEO意为搜索引擎优化&#xff0c;是指通过优化网站…

【AI面试】hard label与soft label,Label Smoothing Loss 和 Smooth L1 Loss

往期文章: AI/CV面试,直达目录汇总【AI面试】NMS 与 Soft NMS 的辨析【AI面试】L1 loss、L2 loss和Smooth L1 Loss,L1正则化和L2正则化在一次询问chatGPT时候,在他的回答中,由smooth L1联想提到了Label Smoothing Loss 。我把问题贴到下面,和chatGPT的回答,供你参考。不…

2023 海外工具站 3 月复盘

3 月的碎碎念&#xff0c;大致总结了商业人生、付费软件、创业方向选择、创业感性还是理性、如何解决复杂问题及如何成长这几个方面的内容。 商业人生 商业人生需要试错能力和快速信息收集与验证校准&#xff1b; 商业逻辑需要试错能力&#xff0c;收集各种渠道信息后整理决…

零入门kubernetes网络实战-33->基于nat+brigde+veth pair形成的跨主机的内网通信方案

《零入门kubernetes网络实战》视频专栏地址 https://www.ixigua.com/7193641905282875942 本篇文章视频地址(稍后上传) 本文主要使用的技术是 nat技术Linux虚拟网桥虚拟网络设备veth pair来实现跨主机网桥的通信 1、测试环境介绍 两台centos虚拟机 # 查看操作系统版本 cat …

ai写作生成器有哪些?试试这几款工具吧

近年来&#xff0c;随着ai技术的飞速发展&#xff0c;越来越多的人开始意识到ai文本生成器的重要性和实用性。这种文本生成器可以帮助我们快速生成各种类型的文章&#xff0c;如报告、评论、新闻、邮件等&#xff0c;它的应用范围已经非常广泛了。不仅如此&#xff0c;随着机器…

公募基金投资者盈利洞察报告

导读&#xff1a; 近一年市场表现不佳&#xff0c;购买成长型风格基金的基民承受一定亏损&#xff0c;但是投资价值型基金的基民逆势取得正收益&#xff0c;均衡型基金基民也有所亏损&#xff0c;但幅度较小。近三年、近五年的时间维度里&#xff0c;成长型及均衡型基金的投资者…

2023年直播电商618创新趋势研究

导读&#xff1a; 竞争激烈的618已经落下帷幕&#xff0c;作为一年中重要的大促节点之一&#xff0c;618的发展变迁在一定程度上反映了电商行业兴起与成长的过程&#xff0c;见证着中国消费市场的日益繁荣。 从直播电商平台的崛起&#xff0c;到双11、618等年度购物节的涌现&am…

chatgpt赋能python:Python画在一张图上的SEO

Python画在一张图上的SEO 在SEO优化中&#xff0c;数据可视化是非常有效的工具。而Python作为数据可视化的利器&#xff0c;在实现数据的可视化方面也是非常得心应手的。在这篇文章中&#xff0c;我们将会介绍Python如何能够绘制多个图形在一张图片上&#xff0c;从而达到我们…

chatgpt赋能python:python绘图的优势与不足

python绘图的优势与不足 Python是一门高效的编程语言&#xff0c;近年来&#xff0c;Python在科学计算和数据可视化方面表现出色&#xff0c;成为绘制图形的首选语言之一。本文将介绍使用Python绘图的优势和不足&#xff0c;并重点介绍几种常用的Python绘图库。 优势 简单易…

chatgpt赋能python:Python散点图的颜色设置

Python散点图的颜色设置 什么是散点图&#xff1f; 散点图是一种数据可视化的图表类型。它用于观察两个变量之间的关系。通常&#xff0c;x轴表示一个变量&#xff0c;y轴表示另一个变量。每个点表示一个数据点&#xff0c;它在x和y轴上分别具有对应的值。我们可以通过比较散…

chatgpt赋能python:Python怎么画图形:从入门到精通

Python怎么画图形&#xff1a;从入门到精通 简介&#xff1a; Python 作为一种高级编程语言&#xff0c;在数据分析与可视化方面被广泛应用。在数据分析过程中&#xff0c;图形可视化是十分重要的一部分&#xff0c;因此也需要掌握可视化的技巧和方法。 基础知识 在 Python…