一、 偏向锁、轻量级锁、重量级锁
1、不存在无锁到偏向锁的概念
延迟偏向后,默认开启偏向锁状态,对象的锁状态是匿名偏向状态,此时虽然是偏向锁状态,但是并不偏向任何线程,仅仅表示可以偏向,如果偏向了线程之后,是不会变回无锁状态的,一直是偏向锁状态,如果偏向锁升级为轻量级锁,那么会进行偏向锁撤销。
2、偏向锁撤销
当偏向锁升级为其他锁时,会进行偏向锁撤销,先成为无锁状态,再升级,偏向锁撤销会先进入安全点。
如果此时对象没有锁定(例如没有进入同步代码块执行了hashcode),那么对象会恢复到无锁状态,用来保存hashcode。如果此时对象锁定了,那么会升级为轻量级锁。如果对象锁定了,且在同步代码块中调用hashcode方法,那么会直接升级为重量级锁。
3、禁用偏向锁状态
4、轻量级锁不存在自旋
5、重量级锁存在自旋
为了避免线程阻塞,会有很多次自旋,重量级锁和轻量级锁在释放锁时都会变为无锁状态
6、mark word 里面的线程id
mark word中保存的的线程id,不是java中的线程,而是操作系统的线程地址。
7、轻量级锁保存hashcode
在轻量级锁状态,会在线程的栈空间创建一个锁记录(Lock Record),在第一次创建锁记录时,会直接把mark word 复制到锁记录中,并通过cas把对象的mark word 修改为指向栈内的直接,这个也是线程加锁的过程,在解锁时,再把这个mark word 通过cas把对象的mark word修改会无锁状态。
8、轻量级锁到重量级锁
如果在轻量级锁状态产生激烈的竞争,那么会直接膨胀,获取monitor,如果获取monitor对象成功,会cas修改mark word指向创建的monitor对象,解锁也会变为无锁状态,重量级锁清掉montior对象是通过GC,这个过程是需要时间的。
9、无锁状态到重量级锁
在无锁状态升级到轻量级锁时,线程会尝试cas修改mark word来指向栈空间,如果修改失败了,那么说明产生了竞争,此时会执行膨胀逻辑,直接从无锁状态膨胀到重量级锁。
10、锁的重入
轻量级锁的重入:在轻量级锁加锁的时候,线程会把mark word保存到线程栈中的锁记录(Lock Record)中,会把mark word指向线程栈中第一个锁记录,Lock Record中有两个对象,BasicLock中保存了mark word,BasicObjectLock对象中保存了Object属性指向mark word锁对象,当锁重入的时候,会再创建一个锁记录,这个锁记录会在栈顶,其中的mark word是null,锁对象会指向新创建的锁记录,当释放第一个锁后,栈顶的锁记录出栈,mark word会重新指向下一个栈顶的锁记录。
重量级锁的重入:会在monitor对象中的一个字段中记录重入的次数。
二、偏向锁的批量重偏向和批量撤销
从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
1、原理
以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后 (默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后, 对于该class的锁,直接走轻量级锁的逻辑。
2、应用场景
批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。 批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
设置JVM参数-XX:+PrintFlagsFinal,在项目启动时即可输出JVM的默认参数值。
intx BiasedLockingBulkRebiasThreshold = 20 //默认偏向锁批量重偏向阈值intx BiasedLockingBulkRevokeThreshold = 40 //默认偏向锁批量撤销阈值
我们可以通过-XX:BiasedLockingBulkRebiasThreshold 和 - XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值。
注意:时间-XX:BiasedLockingDecayTime=25000ms范围内没有达到40次,撤销次数清为0, 重新计时。
3、总结
批量重偏向和批量撤销是针对类的优化,和对象无关。
偏向锁重偏向一次之后不可再次重偏向。
当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类 的新实例对象使用偏向锁的权利。
三、自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
Java 7 之后不能控制是否开启自旋功能。
注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)。
四、锁粗化
假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
StringBuffer buffer = new StringBuffer();/*** 锁粗化*/public void append(){buffer.append("aaa").append(" bbb").append(" ccc");}
上述代码每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次 append方法时进行加锁,最后一次append方法结束后进行解锁。
五、锁消除
锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
‐XX:+EliminateLocks // 开启锁消除(jdk8默认开启)‐XX:‐EliminateLocks // 关闭锁消除
1 public class LockEliminationTest {2 /**3 * 锁消除4 * ‐XX:+EliminateLocks 开启锁消除(jdk8默认开启)5 * ‐XX:‐EliminateLocks 关闭锁消除6 * @param str17 * @param str28 */public void append(String str1, String str2) {StringBuffer stringBuffer = new StringBuffer();stringBuffer.append(str1).append(str2);}public static void main(String[] args) throws InterruptedException {LockEliminationTest demo = new LockEliminationTest();long start = System.currentTimeMillis();for (int i = 0; i < 100000000; i++) {demo.append("aaa", "bbb");}long end = System.currentTimeMillis();System.out.println("执行时间:" + (end ‐ start) + " ms");}}
StringBuffer的append是个同步方法,但是append方法中的 StringBuffer 属于一个局部变量,不可能从该方法中逃逸出去,因此其实这过程是线程安全的,可以将锁消除。
测试结果: 关闭锁消除执行时间4688 ms 开启锁消除执行时间:2601 ms
六、逃逸分析(Escape Analysis)
逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。
使用逃逸分析,编译器可以对代码做如下优化:
1)同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
2)将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
3)分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
jdk6才开始引入该技术,jdk7开始默认开启逃逸分析。在Java代码运行时,可以通过JVM参数指定是否开启逃逸分析:
‐XX:+DoEscapeAnalysis //表示开启逃逸分析 (jdk1.8默认开启)‐XX:‐DoEscapeAnalysis //表示关闭逃逸分析。‐XX:+EliminateAllocations //开启标量替换(默认打开)‐XX:+EliminateLocks //开启锁消除(jdk1.8默认开启)
七、hotspot源码分析
1、轻量级锁加锁过程
首先判断是不是无锁状态,如果是无锁状态,就执行拷贝mark word操作,然后cas修改mark word执行lock record,如果成功直接返回,如果失败就直接膨胀为重量级锁。
如果不是无锁状态,判断是否重入,如果是重入操作,在入栈一个markword为null的lock record到本地线程栈。
如果不是重入操作,那么就需要膨胀为重量级锁,在膨胀前,设置Displaced Mark Word为一个特殊值,代表该锁正在用一个重量级锁的monitor。
锁的膨胀过程,不是为了获取重量级锁,只是为了拿到montior对象,重量级锁的加锁逻辑在ObjectMontior::enter方法中,膨胀完成后会返回ObjectMontior对象,在膨胀过程中,会有大量的自旋,首先自旋加CAS,判断当前是否为重量级锁状态,即mark word的锁标志位为10,如果已经是重量级锁,获取指向ObjectMontior的指针直接返回。如果不是重量级锁,检查是否处于膨胀过程中(其他线程正在膨胀),如果是,就调用ReadStaleMark方法进行等待,方法执行完毕后再检查,ReadStableMark方法中会调用spin,yield,park释放cpu资源,避免活锁的方法,释放cpu资源或者线程挂起,yield在某些平台上几乎没有效果,所以会加上park(1),避免活锁一致没有比较完美的方案。
每个对象只能有一个montior对象,所以如果有别的线程正在创建montior对象,则需要等待这里是在while循环中使用yield释放时间片,并每次循环检查是否膨胀完成,如果yield大于16次,直接park(1),也可以理解为一种自旋,这是种避免活锁的方式,因为线程都在运行,但是没有做事情,长期这种状态就是活锁。
如果不是在膨胀过程中,判断当前是否是轻量级锁状态,即锁标志位为00,开始膨胀,创建ObjectMontior对象,并初始化对象中的属性赋初值,CAS设置状态为膨胀中,如果CAS失败则释放monitor重试,设置ObjectMontior的_heard,_owner和_object,最后将锁对象mark word设置为重量级锁状态。
如果不是轻量级锁,即无锁状态,CAS设置锁对象mark word为重量级锁,如果CAS失败则释放monitor重试,调用omAlloc分配一个ObjectMonitor对象,初始化,设置_header为mark word,_owner字段为null,_object字段为锁对象,设置锁对象头的mark word为重量级锁状态,指向分配的ObjectMonitor对象。
2、重量级锁加锁过程
重量级锁是在Monitor对象的enter方法中,首先通过CAS尝试获取锁,就是将Monitor对象的_owner指针指向当前线程,如果CAS成功直接返回。如果CAS失败,判断是否是当前线程获取锁,如果是则表示重入锁,可重入次数_recursions加1后返回。如果不是当前线程获取锁,判断当前线程是否是之前持有轻量级锁的线程,由轻量级锁膨胀且第一次调用enter方法,那么当前线程cur变量就指向当前线程的Lock Record的指针,重入次数加1,设置_owner为当前线程(之前_owner是指向Lock Record的指针)。
如果以上都不满足,自旋获取锁,这样做是为了减少执行操作系统同步操作带来的开销,开始自适应自旋逻辑(trySpin方法),通过CAS尝试获取锁,获取锁成功则返回,如果一直失败达到一定的自旋次数(会自动调整)或者自旋256次时检查是否进入安全点,如果进入安全点,满足其中一个条件则停止自旋。
如果自旋结束获取锁失败,再开启一个循环,因为要保证一定要获取锁,循环中首先再次尝试获取锁,如果没有获取到,再开启自适应自旋,尝试获取锁,如果此次的自旋获取锁失败,则进入_cxq队列等待被唤醒,将当前线程封装到node节点中,类型是ObjectWaiter,设置状态为TS_CXQ,CAS循环将node节点插入到_cxq队列的头部,如果入队失败,在这期间还会再尝试获取锁,如果获取锁失败,继续再次将node插入到_cxq队列的头节点,入队成功后,跳出这层CAS循环,保证并发条件下一定能够入队成功。
入队成功后,进入循环(1),在挂起之前会再次尝试获取锁,如果失败,调用park挂起当前线程,此处是重量级锁最大的额开销,系统调用会涉及到用户态和内核态的切换,在liunx系统中,会调用pthread_cond_wait或pthread_cond_timewait挂起线程。线程被唤醒时有可能在_cxq队列也有可能在EntryList队列中,被唤醒后首先会尝试获取锁,如果仍然失败,则再次进入自适应自旋,如果自适应自旋没有获取到锁,调用内存屏障保证内存可见性,因为多线程调用的话要保证被其他线程修改的变量的可见性,当前线程还在循环(1)中继续执行直到获取锁跳出循环,将当前线程的node从_cxq获取EntryList中移除。
3、重量级锁释放锁
判断_owner是否是当前线程,如果不是判断是否其他线程占用该锁,如果是则返回解锁失败。如果是当前线程,判断重入次数是否为0,如果不为0,则减1后返回,如果为0,将_owner设置为null,(这里有非公平锁的优化,如果某个线程正在自旋抢占锁,则会抢占成功,这种策略会优先保证通过自旋抢占的线程获取锁,而其他处于等待队列中的线程则靠后)加入一个内存屏障,让修改立即生效。
