作者:李瑞杰
目前任职于阿里巴巴,资深JVM研究人员
友情提示:
本文内容涉及JVM底层,文章烧脑,请谨慎阅读!
我们可以利用synchronized关键字来对程序进行加锁。它既可以用来声明一个synchronized代码块,也可以直接标记静态方法或者实例方法。
当谈到synchronized时,我们有必要了解字节码中的monitorenter和monitorexit指令。
这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized关键字括号里的引用),作为所要加锁解锁的锁对象。
下面我们将深入了解Synchronized在JVM底层的实现原理。
考察以下的代码:
查看这个代码编译后的字节码,我就直接用下面这张图解释了。
ps:截图截得不太好,下面有点没截到,大家凑合看看:
你可能会留意到,上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。
这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径以及异常执行路径上都能够被解锁。
大家可以看我的注释,自己思考一下,应该都能看懂。
应该注意,如果用synchronized标记方法,你会看到字节码中方法的访问标记包括ACC_SYNCHRONIZED。
该标记表示在进入该方法时,Java 虚拟机需要进行monitorenter操作。
而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行monitorexit操作。
用两张图一看就懂。
可以看到,在0号字节码处就返回了。
这里有人可能问了,
这里没有调用monitorenter和monitorexit指令啊?怎么实现的加锁?
要注意,这里monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。
对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的Class实例。
我们先来介绍Synchronized的重入的实现机理。
可以认为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行monitorenter时,如果目标锁对象的计数器为0,那么说明它没有被其他线程所持有。
Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。
当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为0,代表锁已被释放。
这就是锁的重入的实现机理。
说完了这个实现机理,我们来探究具体的锁实现。
首先谈谈重量级锁,重量级锁是 Java 虚拟机中最为基础的锁实现。
在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。在Linux中,这是通过pthread库的互斥锁来实现的。
此外,这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。
为了尽量避免昂贵的线程阻塞、唤醒操作,Java虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。
如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。
下面我将介绍自适应自旋的概念,刚才说了自旋是什么,但是自旋很耗费资源,所以我们可以根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。
所以Synchronized是否公平这个问题可以休矣,为什么呢?
处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。所以
Synchronized不是公平的
。
我们再介绍轻量级锁,针对多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。
针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。
在介绍轻量级锁的原理之前,我们先来了解一下
Java虚拟机是怎么区分轻量级锁和重量级锁的。
简单的说,对象头中有一个标记字段。它的最后两位便被用来表示该对象的锁状态,其中:
-
00代表轻量级锁
-
01代表无锁(或偏向锁)
-
10代表重量级锁
-
11则跟垃圾回收算法的标记有关。
当进行加锁操作时,Java虚拟机会判断是否已经是重量级锁。
如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。
然后,Java 虚拟机会尝试用 CAS 操作替换锁对象的标记字段。
各位有兴趣可以了解一下JVM的CAS在X86机器上的实现,是汇编指令
lock cmpxhcg
。
这里我简单介绍一下,CAS 是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值。
假设当前锁对象的标记字段为 X…XYZ,Java 虚拟机会比较该字段是否为 X…X01。
如果是,则替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为 00。此时,该线程已成功获得这把锁,可以继续执行了。
如果不是 X…X01,那么有两种可能:
你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录。
当进行解锁操作时,如果当前锁记录的值为 0,则代表重复进入同一把锁,直接返回即可。
若当前锁记录不是0,Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。
如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。
如果不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。
下面我们介绍
偏向锁
,偏向锁针对的是从始至终只有一个线程请求某一把锁。是轻量级锁的更进一步的乐观情况。