java多线程nullpointerexception_多线程与高并发绕不开的ThreadLocal以及其GC友好设置...

310d4ea4cdfcfe136e7157ca7043c2d8.png

前言

在并发量较大的场景下,为了处理一个耗时的操作,大部分开发人员的解决办法就是使用多线程处理,从而提高处理效率,但是与此同时又会引出一个新的问题:如果线程其私有的数据需要存储,做其自定义的操作怎么办?这就引出了今天的主角:ThreadLocal。那他究竟是何方神圣呢?

ThreadLocal是JDK1.2提供的一个工具,它为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序,解决共享参数的频繁传递与线程安全等问题。

解释完毕,接下来我们一起从点到面,从面到体的剖析。

正文

通过上文的介绍,我们初步有了一个对ThreadLocal的认知,简单来说,他就是线程的一个私有属性,每个线程对象都可以在自己的空间内,存储自己的变量。翻一翻自己公司的项目代码中ThreadLocal是怎么使用的,为了剔除复杂的业务逻辑,小编拿出最简单的一个使用场景来抛砖引玉吧。

每次接口调用,处理业务的都是一个线程,那么为了防止大量的方法传值我们需要怎么做呢?我们是不是可以将调用接口的用户信息以及令牌等存储到一个ThreadLocal中呢?在接下来用户信息获取的时候就可以非常方便,随心所欲的拿到用户信息。二话不说上代码:

在接口调用的时候存储用户令牌:

242be5198d881b2151a62c569f734ead.png

AND

3e9692a2e1060efbb793a818e51f295c.png

AND

78ca2afca21819de5f9ae6a88763354c.png

在业务代码中需要使用到用户令牌时:

8f7316841eff48f0fbc0229c8d5f2ab3.png

AND

99adc7277aa7c74a9d0b2b6581596bfc.png

透过现象看本质,ThreadLocal是如何实现的呢,我们从他的数据结构入手,下图为其数据结构原理图:

1197bf21da64cf6920240258fd64f2d4.png

ThreadLocal有四个重要的方法,如下:

  • set()方法用于保存当前线程的副本变量值。
  • get()方法用于获取当前线程的副本变量值。
  • initialValue()为当前线程初始副本变量值。
  • remove()方法移除当前线程的副本变量值。

我们从set()方法开始剖析:

a4b50f5fc6ecf485a144288f541bc0c1.png

上文交代,ThreadLocal是线程对象的一个属性,所以第一件事先拿到当前线程。我们接下来看一下如何获取Map的:

01d2f958de62c01fc1e6a64b1465608b.png

AND

e720550908486ab41cb9cd2735b609ba.png

很容易理解,Map即为一个属性,直接使用即可。接下来我们看如何添加节点的:

Map已经存在:

3d86592e4bb4bb2e665f87edfe1e1797.png

操作如下:

  • 线性遍历的方式寻找Entry合适的存放位置。
  • 当ThreadLocal为空(已经被回收)时的替换。
  • Map长度的管理。

看源码我们可以看到Entry的存放位置取决于key(ThreadLocal)的HashCode,那么对照HashMap思考如果遇到hash冲突怎么办?

ThreadLocalMap和HashMap的最大的不同在于,ThreadLocalMap结构非常简单,没有next引用,也就是说ThreadLocalMap中解决Hash冲突的方式并非链表的方式,而是采用线性探测的方式,所谓线性探测,就是根据初始key的hashcode值确定元素在table数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。最后判断是否达到了扩容的条件,如果达到了,那么就进行扩容。

这里有两点需要注意:一是nextIndex方法,二是key失效,这里先解释第一个注意点,第二个注意点涉及到弱引用JVM GC问题,文章最后做出详细解释。

nextIndex()方法的具体代码如下所示:

37adddc487f35aa149a96b6392000249.png

其实就是寻找下一个合适位置,找到最后一个后还不合适的话,那么从数组头部重新开始找,且一定可以找到,因为存在扩容阈值,数组必定有冗余的位置存放当前键值对所对应的Entry对象。其实nextIndex方法就是大名鼎鼎的『开放寻址法』的应用。

这一点和HashMap不一样,HashMap存储HashEntry对象发生哈希冲突的时候采用的是链表方式进行存储,而这里是去寻找下一个合适的位置,思想就是『开放寻址法』。

Map不存在:

bc099aab58883e57a125f6ee660ab720.png

AND

b0c94100c518ef38c5032394324305d6.png

接下来是get()方法:

de96b24fb518ffb6ae114fdd176a633b.png

步骤:

  • 获取当前线程的ThreadLocalMap对象threadLocals
  • 从map中获取线程存储的K-V Entry节点。
  • 从Entry节点获取存储的Value副本值返回。
  • map为空的话返回初始值null,即线程变量副本为null,在使用时需要注意判断NullPointerException。

接下来是initialValue()方法:

8e6d101dcdf64f68682627a91f928c08.png

接下来是remove()方法:

8b62815231a523c75e35b69900b401a9.png

AND

7bbfb6f5b2bc3eb73635b816b3f47610.png

贯穿了ThreadLocal的源码的实现,小编整理了一张图,如下:

baca2dace85cfbb4cce2456d81b81fc2.png

当我们看完上面的源码的时候,不知道小伙伴们有没有注意到ThreadLocal类中的内部类ThreadLocalMap中的内部类Entry继承了一个WeakReference<ThreadLocal<?>>,有没有很神奇,为什么Entry在维护与ThreadLocal的关系时使用了一个弱引用呢?我先来简单讲述一下java的四种引用类型,再来讨论他的GC友好的设置。

从Java1.2开始,JVM开发团队发现,单一的强引用类型,无法很好的管理对象在JVM里面的生命周期,垃圾回收策略过于简单,无法适用绝大多数场景。为了更好的管理对象的内存,更好的进行垃圾回收,JVM团队扩展了引用类型,从最早的强引用类型增加到强、软、弱、虚四个引用类型,关系图如下:

20fdda9946633c556a9aee21bb8a483b.png

StrongRerence为JVM内部实现。其他三类引用类型全部继承自Reference父类。

强引用(StrongReference)

最常用到的引用类型,StrongRerence这个类并不存在,而是在JVM底层实现。默认的对象都是强引用类型。最简单的强引用示例:

9fa8937e3738cff7ef7467918e43ac1e.png

强引用类型,如果JVM垃圾回收器GC Roots可达性分析结果为可达,表示引用类型仍然被引用着,这类对象始终不会被垃圾回收器回收,即使JVM发生OOM也不会回收。只有在GC Roots的可达性分析结果为不可达时,发生GC才有可能被回收。

软引用(SoftReference)

软引用是一种比强引用生命周期稍弱的一种引用类型。在JVM内存充足的情况下,软引用并不会被垃圾回收器回收,只有在JVM内存不足的情况下,才会被垃圾回收器回收。所以软引用的这种特性,一般用来实现一些内存敏感的缓存,只要内存空间足够,对象就会保持不被回收掉,比如网页缓存、图片缓存等。

软引用使用示例

9cd95dcbdb423a8b1145d4018f1663a6.png

弱引用(WeakReference)

弱引用是一种比软引用生命周期更短的引用。他的生命周期很短,不论当前内存是否充足,都只能存活到下一次垃圾收集之前。

来让我们看一个示例:

f0910c46b804c6bd2a280d5e10d97b2e.png

输出结果:

c16b75868e0dff6ed223eb15b74a9185.png

从结果中能看出其存活的周期。

虚引用(PhantomReference)

虚引用与前面的几种都不一样,这种引用类型不会影响对象的生命周期,所持有的引用就跟没持有一样,随时都能被GC回收。需要注意的是,在使用虚引用时,必须和引用队列关联使用。在对象的垃圾回收过程中,如果GC发现一个对象还存在虚引用,则会把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象内存被回收之前采取必要的行动防止被回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

示例:

58901bbd43ee96e082beb47bc059d167.png

运行后,发现结果总是null,引用跟没有持有差不多。

总结:

a4168bb1d8350265111bf8251227a4ba.png

以上这些即为java四种引用的简单介绍,接下来我们来剖析为什么ThreadLocal会使用弱引用,以及会不会出现内存泄漏呢?小编先贴上一张我理解的ThreadLocal内存模型图:

6d01fd3a9dcaf440193b89531db15ac9.png

思考:如果Entry使用强引用会出现什么问题:

若是使用强引用,即为threadLocal = null之后,但是key的引用依然指向ThreadLocal对象,所以会有内存泄露,但是如果使用弱引用则不会。但是在这个基础上同样会有内存泄露的情况,虽然Key被回收变成了null,但是value依然存在,因为这个value无法被垃圾回收,所以依然有内存泄漏问题。

上面抛出一个问题,当然jvm的开发者也注意到了这个问题,看看他们是怎么解决的呢?

如果细心的同学应该已经注意到了,我在上面画图做注释的时候get()方法在判断key为null的时候有一些操作,最终会调用expungeStaleEntry(i)方法,路线为:get() =》map.getEntry(this) =》getEntryAfterMiss(key, i, e) =》expungeStaleEntry(i),expunge的意思是擦除,删除的意思,见名知意,在来看expungeStaleEntry方法的内部实现:

279b702c5ec886f1292544dbadbb66bd.png

注意这里,将当前Entry删除后,会继续循环往下检查是否有key为null的节点,如果有则一并删除,防止内存泄漏。

但是遗憾的是,这样也并不能保证ThreadLocal不会发生内存泄漏,例如:

  • 使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏。
  • 分配使用了ThreadLocal又不再调用get()、set()、remove()方法,那么就会导致内存泄漏。

我们对比在这里使用强引用和弱引用发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key,就会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用保证ThreadLocal会及时被回收,对应的value在下一次ThreadLocal调用set,get,remove的时候会被清除。

思考,既然弱引用+expungeStaleEntry(i)只能更加有利于GC回收,尽可能的避免内存泄露,但是我们应该怎么样才能保证防止内存泄漏呢?

对,你肯定想到了,在ThreadLocal上面完全避免内存泄漏的方式就是使用ThreadLocal的时候,当里面的值不用了,我们及时手动调用.remove()方法。

结语

今天从头到尾,按照方法调用的顺序分析了一遍ThreadLocal的源码,进而根据小编的理解画了他的数据结构模型图,内存模型图,当然如果大家发现有问题的地方可以直接联系小编,小编立即修改。上面有些图是从原型图里面导出或者截图的,不是很清晰,如果小伙伴想要pdf原件也可以关注我的微信公众号。

2877f3f28ca1042df9b7bd6d69a6c6f4.png

点关注不迷路,请搜索《杂讲java》微信公众号,上面会持续更新更多技术分享文章!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值