一、锁升级
在1.6之前java中不存在只存在重量级锁,这种锁直接对接底层操作系统中的互斥量(mutex),这种同步成本非常高,包括操作系统调用引起的内核态与用户态之间的切换。线程阻塞造成的线程切换等。因此在jdk 1.6中将锁分为四种状态:由低到高分别为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
1. 偏向锁。
什么是偏向锁呢?为什么要引入偏向锁呢?
偏向锁是如果一个线程获取到了偏向锁,在没有其他线程竞争的情况下,如果下次再执行该同步块时则只需要简单判断当前偏向锁所偏向的对象是否是当前线程,如果是则不需要再进行任何获取锁与释放锁的过程,直接执行同步块。
至于为什么引入偏向锁,是因为经过JVM的开发人员大量的研究发现大多数时候都是不存在锁竞争的,通常都是一个线程在使用锁的时候没有其他线程来竞争,然而每次都要进行加锁和解锁就会额外增加一些没有必要的资源浪费。为了降低这些浪费,JVM引入了偏向锁。
a) 偏向锁的获取以及升级过程如下:
当一个线程在执行同步块时,它会先获取该对象头的MarkWord,通过MarkWord来判断当前虚拟机是否支持偏向锁(因为偏向锁是可以手动关闭的),如果不支持则直接进入轻量级锁获取过程。如果支持,则判断当前MarkWord中存储的ThreadID是否指向当前线程,如果指向当前线程,则直接开始执行同步块。如果没有指向当前线程,则通过CAS将对象头的MarkWord中相应位置替换为当前线程ID表示当前线程获取到了偏向锁,如果CAS成功,同时将偏向锁置为1,执行同步块;若CAS失败,则表示存在多个线程竞争,当达到全局安全点(safepoint)的时候,暂停获得偏向锁的线程,撤销偏向锁(将偏向锁置为0,并且将ThreadID置为空),然后将锁升级为轻量级锁,之后恢复刚暂停的线程,则刚刚CAS失败的线程通过自旋的方式等待轻量级锁被释放。
偏向锁适用于没有线程竞争的同步场所。
但它并不一定总是对程序有利,如果程序中大多数锁都存在竞争,那么偏向锁模式就显得赘余。因此偏向锁可以通过一些虚拟机参数进行手动关闭的。
2. 轻量级锁。
什么是轻量级锁?为什么引入轻量级锁?
**轻量级锁是当一个线程获取到该锁后,另一个线程也来获取该锁,这个线程并不会被直接阻塞,而是通过自旋来等待该锁被释放,所谓的自旋就是让线程执行一段无意义的循环。**当然如果该循环长时间执行也会带来非常大的资源浪费。因此这段自旋通常都是规定次数的,比如自旋100次啊等等,但是如果在第101次锁释放了呢,岂不是很可惜,因此在JDK1.6中JVM加入了自适应自旋,通过之前获取锁所等待的时间来增加或者减少循环次数。那么如果直到自旋结束该锁还未被释放,那么此时轻量级锁膨胀为重量级锁,将后面的线程全部阻塞,还有一种情况,如果线程2正在自旋等待线程1释放锁,此时线程3也来竞争锁,那么这时该轻量级锁膨胀为重量级锁将等待线程全部阻塞。
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
a) 轻量级锁的加锁、释放、以及膨胀过程?
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
轻量级锁适用的场景为:少量线程竞争锁对象,且线程持有锁的时间不长,追求相应速度的场景。
但是如果存在大量的锁竞争,轻量级锁的效率会比传统重量级锁会更慢,因为最终都是进入阻塞状态,但轻量级锁还额外进行了CAS自旋操作。
3. 重量级锁。
重量级锁是如果多个线程同时竞争锁,只会有一个线程得到这把锁,其他线程获取锁失败不会和轻量级锁进行自旋等待锁被释放,而是直接阻塞没有获取成功的线程。重量级锁的实现与对象内部的monitor监视器息息相关。monitor在虚拟机中实际实现是ObjectMonitor。通过JVM顶级基类oopDesc类中的一个成员oopMark类型的子对象去调monitor()方法来获取到ObjectMonitor对象,而重量级锁就是通过获取ObjectMonitor这把监视器锁来实现的具体实现细节请参考我上一篇博客:synchronized的实现原理
4.优缺点对比
二、锁优化
1. 自旋锁。
下面介绍为什么引入自旋锁,原因是这样的,Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,这样是很浪费效率的,因此在JDK1.6中对锁进行了一系列优化其中就包括自旋锁,比如存在这样一个情况当一个线程刚刚进入阻塞状态,这个锁就被其他线程释放了,对于这个线程来说得被唤醒,又从内核态转换到用户态,为了解决这种情况,于是就引入了自旋锁:
**自旋锁是这样的,在一个线程获取锁失败,它并不会立即阻塞线程,而是通过一段无意义的循环,进行尝试获取锁状态。**当然如果长时间进行这样无意义的循环对于CPU的浪费也是非常巨大的,因此JVM对于自旋是有次数规定的。比如循环100次啊等等。可是有存在这样一种情况,如果100次还是没有获取到锁,当前线程被阻塞,可是就在101次的时候这把锁被释放了,此时是不是很可惜呀!
但是没关系,为了解决这种问题JVM团队又引入了自适应自旋,自适应自旋是这样的,此时获取这把锁的自旋此时就不是固定的被写死的,而是一种动态的,它可以通过之前这把锁的获得情况来自动的选择增加自旋此处或者减少自旋次数,如果之前有成功获取这把锁的线程,那么JVM会认为这把锁是能够被获取的,此时会自适应的增加一些自旋次数,当然如果之前没有一个线程成功获取这把锁,JVM为了避免无意义的循环带来的资源浪费,会选择减少自旋次数,或者说不去自旋,而直接阻塞。
2. 锁粗化
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
3. 锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
参考文章
https://blog.csdn.net/tongdanping/article/details/79647337