synchronized
关键字提供了一种独占式的加锁方式,用来控制多个线程对共享资源的互斥访问。它可以保证在同一时刻只有一个线程在执行该段代码,同时它还可以保证共享变量的内存可见性。
synchronized
的获取和释放锁由 JVM
实现,用户不需要显示的获取和释放锁,非常方便。但是当线程尝试获取锁的时候,如果获取不到锁该线程会一直阻塞。
在早期版本中, synchronized
是一个重量级锁,效率低下。但从 JDK1.6
开始,从 JVM
层面对 synchronized
引入了各种锁优化技术,例如:自旋锁、适应性自旋锁、锁消除、锁粗化、轻量级锁和偏向锁等,大大减少了锁操作的开销。
使用 synchronized
实现同步有同步方法块、同步方两种方式。
作用于代码块时,括号中可以是指定的对象,也可以是 Class 对象。
// 锁的是指定的对象实例 public void test1 (){ synchronized(this) { // ··· } } 复制代码
// 锁定是指定的类对象 public void test2 (){ synchronized(Test.class) { // ··· } } 复制代码
作用于方法时,锁的是当前的对象实例。
public synchronized void test3(){ // ··· } 复制代码
作用于静态方法,锁的是类对象。
public synchronized static void test4(){ // ··· } 复制代码
HotSpot
虚拟机的对象头分为两部分信息:
Mark Word
:用于存放对象自身的运行时数据,如哈希码、 GC
分代年龄、锁类型、锁标志位等信息,这部分数据在 32
位和 64
位虚拟机中分别为 32
和 64 bit
。它是实现轻量级锁和偏向锁的关键。 Class Metadata Address
:用于存储指向方法区对象类型数据的指针,如果是数组,还会有一个额外的部分用于存放数组长度。 Mark Word
被设计为一个非固定的数据结构以便存储更多的信息,它会根据对象的状态复用自己的存储空间。例如,在 32
位的 HotSpot
虚拟机中,各种状态下对象的存储内容如下:
每个 Java
对象都有一个 Monitor
对象与之关联,它被称为管程(监视器锁),前面的表格中,锁状态为重量级锁时,指针就指向 Monitor
对象的起始地址。当一个 Monitor
被某个线程持有后,便处于锁定状态。在 HotSpot
虚拟机的源码实现中, ObjectMonitor
对象相关属性有:
_count
:计数器; _owner
:指向持有 ObjectMonitor
对象的线程; _WaitSet
:等待池; _EntryList
:锁池; 多个线程访问同步代码时,首先会进入 _EntryList
锁池中被阻塞,当线程获取到对象的 Monitor
后,就会把 _owner
指向当前线程,同时 Monitor
中的 _count
计数器加一。如果线程调用 wait
方法, _owner
就被恢复为 null
, _count
计数器减一,同时该线程就会进入 _WaitSet
等待池中。
当线程执行完毕,将对应的变量复位,以便其他线程获取 Monitor 锁。
synchronized
有四种状态:无锁、偏向锁、轻量级锁和重量级锁。随着对锁的竞争逐渐激烈,锁的状态进行升级。
public class SynchronizedTest { public void test1() { synchronized (this) { // ··· } } } 复制代码
使用 javap -c -v
对 SynchronizedTest.class
进行反汇编:
可以看到,在同步代码块的开始位置插入 monitorenter
指令,在结束位置插入 monitorexit
指令,而且必须保证每一个 monitorenter
都有一个 monitorexit
与之对应。
synchronized
便是通过 Monitor
获取锁的。当线程执行到 monitorenter
指令时,将会尝试获取 Monitor
所有权。当计数器为 0
,则成功获取;获取后将锁计数器置为 1
。在执行 monitorexit
指令时,将锁计数器置为 0
。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
public class SynchronizedTest { public synchronized void test1() { // ··· } } 复制代码
使用 javap -c -v
对 SynchronizedTest.class
进行反汇编:
可以看到,被同步的方法也仅是被翻译成普通的方法调用和返回指令。在 JVM
字节码层面并没有任何特别的指令来实现 synchronized
修饰的方法。
但是在 Class
文件的方法表中将方法的 flags
字段中的 ACC_SYNCHRONIZED
标志位置为 1
,表示该方法是同步方法。在执行方法时,线程就会持有 Monitor
对象。
JDK1.6
对锁引入了大量的优化,如自旋锁、自适应自旋锁、锁消除、锁粗化、轻量级锁、偏向锁等技术来减少锁操作的开销。
在实现同步互斥时,如果获取锁失败,就会使当前线程阻塞,但线程的挂起和恢复都需要在内核态和用户态之间转换,对系统的性能影响很大。许多情况下共享数据的锁定状态持续时间不会很长,切换线程不值得。
自旋锁就是让线程在请求共享数据的锁时执行一个忙循环(自旋),如果能够很快获得锁,就避免其进入阻塞状态。
自旋等待虽然避免了线程切换的开销,但它要求多处理器,而且要占用处理器时间。如果锁占用时间过长,那么反而会消耗更多的资源。因此,对自旋等待的时间必须进行限制,另外自旋的次数也不能过多,默认为 10
次,可使用 -XX:PreBlockSpin
参数修改。
JDK1.6
中引入了自适应的自旋锁,它的自旋时间由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
锁消除是指虚拟机的即时编译器在运行时,如果代码要求同步,但检测发现不可能存在共享数据竞争时,那么就进行锁消除。
锁消除主要根据逃逸分析,如果判断在一段代码中,堆上的所有数据都不会逃逸出去,那就可以将它们认为是线程私有的,也就无须进行同步加锁。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁也是出现循环体中,那么即使没有数据竞争,频繁地加锁解锁也会导致不必须的性能消耗。
锁粗化指的就是如果虚拟机探测到这样的情况,那就将加锁同步的范围扩展(粗化)到整个操作序列的外部。
偏向锁是在无竞争的情况下消除整个同步,也就是减少同一线程获取锁的代价。它的思想是这个锁会偏向于第一个获得它的线程,如果接下来该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
当锁对象第一次被线程获取时,锁进入偏向模式,同时 Mard Word 的结构也变为偏向锁结构。锁标志位为“01”,同时使用 CAS
操作把获取到这个锁的线程的 ID
记录在对象的 Mark Word
中,如果 CAS
操作成功,这个线程以后每次进入这个锁相关的同步块时,都可以不用再进行任何同步操作。
不适用于锁竞争比较激烈的多线程场合。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定状态或者轻量级锁状态。
轻量级锁是相对于使用操作系统互斥量实现的传统锁而言的。偏向锁运行在一个线程进入同步块时,如果有第二个线程加入锁竞争,则偏向锁就会升级为轻量级锁。它适用于线程交替执行的场景。
在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机将先在当前线程的栈帧中建立一个名为锁记录( Lock Record
)的空间,用于存储锁对象目前 Mark Word
的拷贝。如下图,左侧是一个线程的虚拟机栈,右侧是一个锁对象:
然后,虚拟机将使用 CAS
操作尝试将对象的 Mark Word
更新为指向 Lock Record
的指针,并将 Lock Record
里的 owner
指针指向对象的 Mark Word
。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象的 Mark Word
的锁标志位转变为“00”,即表示对象处于轻量级锁定状态。多线程堆栈和对象头的状态如下:
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果已指向则说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,如果没有指向则说明这个锁对象已经被其他对象抢占了。
如果有两条以上的线程争用同一个锁,那轻量级锁就要膨胀为重量级锁,锁标志变为“10”, Mark Word
中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。
对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁使用 CAS
操作避免了重量级锁使用互斥量的开销,提升了程序同步的性能。
偏向锁、轻量级锁的状态转化及对象 Mark Word
的关系如下: