当前位置: 首页 > 科技观察

Java面试题:ThreadLocal的极致

时间:2023-03-12 22:57:39 科技观察

转载本文请联系三太子敖丙公众号。开场白张三最近因为天气太热心情不太好,决定出去面试,和面试官聊天解决一下。结果刚投完简历就有人约了面试。我输了,为什么刚投稿就有人约我面试?嘿。..烦什么,我哥这么久没上过江湖,江湖上还有他的传说,我还这么受追捧吗?太麻烦了,又帅又无辜。暗喜的张三来到了现场面试的办公室。我失去了它。这位面试官?没办法,这台Mac到处都是划痕。这卷是传说中的建筑师吗?张三心态崩了,出来后第一个面试就遇到了顶级面试官。谁受得了。你好,我是你的面试官托尼,你看我的发型应该能猜出我的身份,我什么都不说,直接开始吧?看你的简历是多线程的,来找我聊聊ThreadLocal吧。好久没写代码了,不熟悉。请帮我回忆一下。我失去了它?这个TM是人类语言吗?这是什么逻辑?据说问问多线程然后搞出这么冷门的ThreadLocal?心态崩了,再说了,你TM都忘了,你会读书吗?来吧,我到底在这找什么答案……虽然很不情愿,但张三还是高速转动着小脑袋,回忆着ThreadLocal的细节……面试官实话实说,我用的是ThreadLocal在实际开发过程中没有太多地方。写这篇文章的时候,特意在电脑上打开了几十个项目,然后全局搜索ThreadLocal。我发现除了系统源码的使用,在项目中很少用到,但还是有的。ThreadLocal的作用主要是数据隔离。填充的数据只属于当前线程,可变数据与其他线程相对隔离。在多线程环境下,如何防止自己的变量被其他线程篡改。能不能说说它的隔离有什么用,会用在什么场景下?这个,我已经说了,我很少用,你再问我,我就觉得不舒服,哦哦哦,我记住了,业务隔离级别。面试官你好,其实我首先想到的是Spring实现事务隔离级别的源码。这是我在大学被女朋友甩了,一个人在图书馆哭的时候无意中发现的。Spring采用Threadlocal的方式来保证单个线程中的数据库操作使用同一个数据库连接。同时,这种方式允许业务层在不感知和管理连接对象的情况下使用事务。通过传播层,可以巧妙地管理多个事务配置之间的切换,挂起和恢复。Spring框架使用ThreadLocal来实现这种隔离,主要是在TransactionSynchronizationManager类中。代码如下:privatestaticfinalLoglogger=LogFactory.getLog(TransactionSynchronizationManager.class);privatestaticfinalThreadLocal>resources=newNamedThreadLocal<>("Transactionalresources");privatestaticfinalThreadLocal>synchronizations=newNamedThreadLocal<>("Transactionsynchronizations");privatestaticfinalThreadLocalcurrentTransactionName=newNamedThreadLocal<>("Currenttransactionname");...Spring的事务主要是ThreadLocal和AOP来做实现,这里提一下。大家都知道,每个线程的链接都是通过ThreadLocal来保存的。我将在Spring章节中详细说明。暖和吗?除了源码中使用ThreadLocal的场景,你自己用过吗?你一般会如何使用它?加分项来了。我以前遇到过这个。装B的机会终于来了。一些面试官,我知道这一点!在我们上线之前,我们发现一些用户的日期是错误的。经查,是SimpleDataFormat的错。当时我们用的是SimpleDataFormat的parse()方法,里面有一个Calendar对象。调用SimpleDataFormat的parse()方法会先调用Calendar.clear(),再调用Calendar.add()。如果一个线程先调用add(),然后另一个线程调用clear(),那么parse()方法解析的时候就是错误的。其实解决这个问题很简单,让每个线程都有一个自己的newSimpleDataFormat,但是1000个线程应该new1000个SimpleDataFormat吗?所以当时我们使用线程池和ThreadLocal来包装SimpleDataFormat,然后调用initialValue让每个线程都有一份SimpleDataFormat,解决了线程安全问题,提高了性能。然后……还有,我还有很多,别着急问下一个,让我加分,耽误面试时间。我的项目中有一个线程,经常会遇到需要跨越几个方法调用传递的对象,就是上下文(Context),它是一个状态,经常是用户身份,任务信息等,会有transitional参数传递问题。使用类似的责任链模型,给每个方法添加上下文参数很麻烦,而且有时候,如果调用链有第三方库无法修改源码,对象参数也无法传入,所以我使用ThreadLocal来做。做一个改造,让调用前只需要在ThreadLocal中设置参数,其他地方获取即可。beforevoidwork(Useruser){getInfo(user);checkInfo(user);setSomeThing(user);log(user);}thenvoidwork(Useruser){try{threadLocalUser.set(user);//他们内部的Useru=threadLocalUser.get();只是getInfo();checkInfo();setSomeThing();log();}finally{threadLocalUser.remove();}}看了很多场景下的cookie、session等数据隔离都是通过ThreadLocal做的。顺便说一句,我的面试官让我再次展示了知识的广度。在Android中,Looper类利用ThreadLocal的特性保证每个线程中只存在一个Looper对象。staticfinalThreadLocalsThreadLocal=newThreadLocal();privatestaticvoidprepare(booleanquitAllowed){if(sThreadLocal.get()!=null){thrownewRuntimeException("OnlyoneLoopermaybecreatedperthread");}sThreadLocal.set(newLooper(quitAllowed));}面试官:我晕了,这家伙怎么知道这么多场景?他还把安卓给拔了,对了大佬,我接下来要考他的原理。嗯,你的回答很好,能说说它的底层实现原理吗?面试官好,先说说他的使用:ThreadLocallocalName=newThreadLocal();localName.set("张三");Stringname=localName.get();localName.remove();其实就是使用起来非常简单。线程进来后,初始化一个通用的ThreadLocal对象。之后只要线程去get之前remove,都可以拿到之前set的值。请注意,我说的是删除之前。他可以实现线程间的数据隔离,其他线程无法使用get()方法获取其他线程的值,但是有一种方法可以做到,后面会讲到。我们先看他的set的源码:publicvoidset(Tvalue){Threadt=Thread.currentThread();//获取当前线程ThreadLocalMapmap=getMap(t);//获取ThreadLocalMap对象if(map!=null)//检查对象是否为空map.set(this,value);//不为空setelsecreateMap(t,value);//为空创建一个map对象}可以发现set的源码很简单,主要是我们需要注意的ThreadLocalMap,而ThreadLocalMap是从当前线程Thread的一个叫做threadLocals的变量中获取的。ThreadLocalMapgetMap(Threadt){returnt.threadLocals;}publicclassThreadimplementsRunnable{……/*ThreadLocalvaluespertainingtothisthread.Thismapismaintained*bytheThreadLocalclass.*/ThreadLocal.ThreadLocalMapthreadLocals=null;/**InheritableThreadLocalvaluespertainingtothisthread.Thismapis*maintainedbytheInheritableThreadLocalclass.*/ThreadLocal.ThreadLocalMapinheritableThreadLocals=null;……这里ThreadLocal数据隔离的道理我们基本可以摸清了。每个线程Thread都维护自己的threadLocals变量,所以当每个线程创建一个ThreadLocal时,数据实际上存在于自己线程Thread的threadLocals变量中。其他人没有方法获得,从而实现隔离。ThreadLocalMap的底层结构是什么样的?面试官问的好,心里暗骂,能不能让我休息一下?张三笑着回答,既然有Map,他的数据结构其实很像HashMap,但是看源码可以发现,它并没有实现Map接口,它的Entry继承了WeakReference(弱引用),并且在HashMap中看不到next,所以没有链表。staticclassThreadLocalMap{staticclassEntryextendsWeakReference>{/**ThevalueassociatedwiththisThreadLocal.*/Objectvalue;Entry(ThreadLocalk,Objectv){super(k);value=v;}}...}结构如下所示:稍等,我有两个问题,你能回答吗?好的,面试官。为什么需要数组?没有链表怎么解决Hash冲突?使用数组的原因是在开发过程中,一个线程可以有多个TreadLocals来存放不同类型的对象,但是它们都会放在你当前线程的ThreadLocalMap中。在,所以它必须存储在一个数组中。至于Hash冲突,先看源码:privatevoidset(ThreadLocalkey,Objectvalue){Entry[]tab=table;intlen=tab.length;inti=key.threadLocalHashCode&(len-1);for(Entrye=tab[i];e!=null;e=tab[i=nextIndex(i,len)]){ThreadLocalk=e.get();if(k==key){e.value=值;return;}if(k==null){replaceStaleEntry(key,value,i);return;}}tab[i]=newEntry(key,value);intsz=++size;if(!cleanSomeSlots(i,sz)&&sz>=threshold)rehash();}我从源码中看到ThreadLocalMap在存储的时候会给每个ThreadLocal对象一个threadLocalHashCode。插入过程中,根据ThreadLocal对象的hash值,定位表中的位置i,inti=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值定位到表中的位置,然后判断该位置的Entry对象中的key是否和get的key一致,如果不一致,判断下一个位置,set如果和get冲突严重,效率还是很低的。下面是get的源码,是不是感觉很容易理解:privateEntrygetEntry(ThreadLocalkey){inti=key.threadLocalHashCode&(table.length-1);Entrye=table[i];if(e!=null&&e.get()==key)返回;elsereturngetEntryAfterMiss(key,i,e);}privateEntrygetEntryAfterMiss(ThreadLocalkey,inti,Entrye){Entry[]tab=table;intlen=tab.length;/get同样是获取i值的根据ThreadLocal查表,得到查找数据后,再检查key是否等于if(e!=null&&e.get()==key)。while(e!=null){ThreadLocalk=e.get();//若相等则直接返回,若不相等则继续查找,找到相等的位置。if(k==key)return;if(k==null)expungeStaleEntry(i);elsei=nextIndex(i,len);e=tab[i];}returnnull;}你能告诉我对象存储在哪里吗什么?在Java中,栈内存属于单个线程,每个线程都有一个栈内存。其中存储的变量只能在其所属的线程中可见,即栈内存可以理解为线程的私有内存,而堆内存堆中的对象对所有线程可见,并且堆内存中的对象可以被所有线程访问。那么是不是说ThreadLocal的实例和它的值都存在栈中呢?其实不是的,因为ThreadLocal实例其实是被它创建的类持有的(最上面应该是线程持有的),而ThreadLocal的值其实也是被线程实例持有的,它们都位于堆上,但是通过一些技巧修改了可见性,使其对线程可见。如果我想共享线程的ThreadLocal数据怎么办?使用InheritableThreadLocal允许多个线程访问ThreadLocal的值。我们在主线程中创建一个InheritableThreadLocal实例,然后在子线程中获取InheritableThreadLocal实例设置的值。privatevoidtest(){finalThreadLocalthreadLocal=newInheritableThreadLocal();threadLocal.set("帅");Threadt=newThread(){@Overridepublicvoidrun(){super.run();Log.i("张三好帅"="="+threadLocal.get());}};t.start();}在子线程中,我可以正常输出那行log,这也是我之前采访中提到的父子线程间数据传递的问题视频。它是如何通过的?转移的逻辑非常简单。一开始在Thread代码中提到threadLocals的时候,大家可以往下看,我特意放了另外一个变量:在Thread源码中,我们看一下Thread.init的初始化和创建做了什么:publicclassThreadimplementsRunnable{...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对象中的值可能不会被回收,就会发生内存泄漏。.比如线程池中的线程都是复用的。之前的线程实例处理完后,为了复用,线程仍然存活。因此,ThreadLocal设置的值被持有,导致内存泄漏。按照原理,当一个线程用完了,ThreadLocalMap应该被清空,但是现在线程被重用了。那么如何解决呢?在代码的最后使用remove就可以了,我们只需要记住在使用结束时使用remove清除值即可。ThreadLocallocalName=newThreadLocal();try{localName.set("张三");...}finally{localName.remove();}remove的源码很简单,找到对应的值,全部置空,这样在垃圾收集器回收时,他们会自动回收。那为什么ThreadLocalMap的key要设计成弱引用呢?如果key没有设置为弱引用,会造成和entry中的value一样的内存泄漏。还要补充一点:我认为可以通过查看netty的fastThreadLocal来弥补ThreadLocal的不足。有兴趣的可以补上。好吧,你不仅回答了我所有的问题,还说了我不知道的,你通过了ThreadLocal,但是JUC的面试才刚刚开始,希望你再努力一点,最后拿到一个好的offer。什么鬼,突然这么轰动,这不是让我丢脸吗?难不成是来训练我的?难为师傅对我这么体贴,心里一直骂他。综上所述,ThreadLocal的用法非常简单。里面只有几个方法,加上注释源码也没有多少行。我花了十多分钟的时间才看完,但是当我深入挖掘每个方法背后的逻辑时,我也让我不得不感受到JoshBloch和DougLea的强大。细节设计的处理,往往是我们和大神的区别。我觉得很多不合理的地方,只有谷歌和自己不断去了解之后,才变得合理。我真的不接受。ThreadLocal是多线程中比较冷门的一个类。它不像其他方法和类那样被频繁使用,但是通过我的这篇文章,不知道大家有没有新的认识呢?