(7)ThreadLocal造成OOM内存溢出案例演示与原理分析
下图中描述了:一个Thread中只有一个ThreadLocalMap,1个ThreadLocalMap中可以有多个ThreadLocal对象,其中一个ThreadLocal对象对应一个ThreadLocalMap中的一个Entry,也就是说一个Thread可以依附有多个ThreadLocal对象。
下图中实线代表强引用,虚线代表弱引用
-
1.ThreadLocal的实现是这样的:每个Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal实例本身,value 是真正需要存储的 Object。
-
2.也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。值得注意的是图中的虚线,表示 ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
-
3.ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
-
4.总的来说就是,ThreadLocal里面使用了一个存在弱引用的map, map的类型是ThreadLocal.ThreadLocalMap. Map中的key为一个threadlocal实例。这个Map的确使用了弱引用,不过弱引用只是针对key。每个key都弱引用指向threadlocal。 当把threadlocal实例置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收。
-
5.但是,我们的value却不能回收,而这块value永远不会被访问到了,所以存在着内存泄露。因为存在一条从current thread连接过来的强引用。只有当前thread结束以后current thread就不会存在栈中,强引用断开,Current Thread、Map value将全部被GC回收。最好的做法是将调用threadlocal的remove方法,
-
6.其实,ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value
-
7.但是这些被动的预防措施并不能保证不会内存泄漏:
(1)使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致内存泄漏。
(2)分配使用了ThreadLocal,又不再调用get(),set(),remove()方法,那么就会导致内存泄漏,因为这块内存一直存在。
内存泄漏的根源
- 下面我们分两种情况讨论:
(1)key 使用强引用:引用的ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
(2)key 使用弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set、get、remove的时候会被清除。
-
比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set、get、remove的时候会被清除。
-
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
ThreadLocal最佳实践
综合上面的分析,我们可以理解ThreadLocal内存泄漏的前因后果,那么怎么避免内存泄漏呢?
答案就是:每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。
注意:
并不是所有使用ThreadLocal的地方,都在最后remove(),他们的生命周期可能是需要和项目的生存周期一样长的,所以要进行恰当的选择,以免出现业务逻辑错误!但首先应该保证的是ThreadLocal中保存的数据大小不是很大!
(8)从volatile和synchronized的底层实现原理看Java虚拟机对锁优化所做的努力
背景
- java代码首先会编译成java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上进行执行。
- java中所使用的并发机制依赖于JVM的实现和CPU的指令
volatile的两个主要作用
- 禁止指令的重排序优化
- 提供多线程访问共享变量的内存可见性
- volatile使当线程修改一个共享变量时,另外一个线程能读到这个修改的值。volatile是轻量级的synchronized,执行成本更低,因为其不会引起线程上下文的切换和调度。
有volatile变量修饰的共享变量进行写操作的时候会引发了两件事情:
(1)将当前处理器缓存行的数据写回到系统内存。
(2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
这是因为,Java支持多个线程同时访问一个对象或者对象的成员变量,每个线程可以拥有这个变量的拷贝(虽然对象以及成员变量分配的内存是在共享内存中的,但是每个执行的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行,这是现代多核处理器的一个显著特性),所以程序在执行过程中,一个线程看到的变量并不一定是最新的。
我们的处理器为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。
关键字volatile可以用来修饰字段,就是告知程序任何对该变量的访问均需要从共享内存获取(读取时将本地内存置为无效,从共享内存读取),而对它的改变必须同步刷新回共享内存。保证所有线程对变量访问的可见性。
如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
即一是强制把修改的数据写回内存,二是在多处理器情况下使多处理器缓存的数据失效。
Synchronized实现原理
- 同步代码块的实现是通过monitorenter和monitorexit 指令
- 同步方法是依靠方法修饰符上的ACC_SYNCHRONIZED来完成的。
无论采取哪一种方式,本质是对一个对象的监视器(monitor)进行获取,而这个获取过程是排他的,也就是说同一时刻只有一个线程获取到由synchronized所保护对象的监视器。
- synchronized允许使用任何的一个对象作为同步的内容,因此任意一个对象都应该拥有自己的监视器(monitor),当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或同步方法,而没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入BLOCKED状态。
JVM对synchronized的优化
-
synchronized用的锁是存在Java对象头里的,如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果是非数组类型,则用2字宽存储对象头。
-
Java对象头里的Mark Word 里默认存储对象的HashCode、分代年龄和锁标记位
-
32位JVM的Mark Word的默认存储结构如下图所示
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁
1.偏向锁
偏向锁核心思想
- 如果一个线程获得了锁,那么锁就进入了偏向模式,当这个线程再次请求锁时,无需再做任何同步操作。
偏向锁设计初衷
- 锁不仅存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁
偏向锁获取锁流程
-
(1)当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁;
-
(2)如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁);
-
(3)如果没有设置,则使用CAS竞争锁;
-
(4)如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁升级为轻量级锁
对于只有一个线程访问的同步资源场景,锁的竞争不是很激烈,这时候使用偏向锁是一种很好的选择,因为连续多次极有可能是同一个线程请求相同的锁。
但是在锁竞争比较激烈的场景,最有可能的情况是每次不同的线程来请求相同的锁,这样的话偏向锁就会失效,倒不如不开启这种模式,幸运的是Java虚拟机提供了参数可以让我们有选择的设置是否开启偏向锁。
如果偏向锁失败,虚拟机并不会立即挂起线程,而是使用轻量级锁进行操作。
2.轻量级锁
- 轻量级锁只是简单的将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先夺到锁,那么当前线程的轻量级锁就会膨胀为重量级锁。
3.自旋锁
轻量级锁就会膨胀为重量级锁后,虚拟机为了避免线程真实的在操作系统层面挂起,虚拟机还会在做最后的努力–自旋锁。
由于当前线程暂时无法获得锁,但是什么时候可以获得锁时一个未知数。也许在几个CPU时钟周期之后,就可以获得锁。如果是这样的话,直接把线程挂起肯定是一种得不偿失的选择,因此系统会在进行一次努力:他会假设在不就的将来,限额和从那个可以得到这把锁,因此虚拟机会让当前线程做几个空循环(这也就是自旋锁的意义),若经过几个空循环可以获取到锁则进入临界区,如果还是获取不到则系统会真正的挂起线程。
那么为什么锁的升级无法逆向那?
这是因为,自旋锁无法预知到底会空循环几个时钟周期,并且会很消耗CPU,为了避免这种无用的自旋操作,一旦锁升级为重量锁,就不会再恢复到轻量级锁,这也是为什么一旦升级无法降级的原因所在。
4.重量级锁
5.三种锁的优缺点对比
6.线程状态及状态转换
当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:
- Contention List:所有请求锁的线程将被首先放置到该竞争队列
- Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
- Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
- OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
- Owner:获得锁的线程称为Owner
- !Owner:释放锁的线程
7.总结
- 偏向锁,轻量级锁,重量级锁,分别解决三个问题,只有一个线程进入临界区,多个线程交替进入临界区,多线程同时进入临界区。
- synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量