Synchronized深入解读
Synchronized
在1.6之间只是重量级锁,因为会有线程的阻塞与唤醒操作,这个操作是借助操作系统的系统调用来实现的(Linux下就是利用了Pthread
的mutex
来实现),由于涉及到用户态到内核态上下文的切换,因此开销较大。1.6之后由于引入了大量锁优化策略,因此性能大大提升。
1、实现原理
每个对象都有一个monitor
对象与之关联,该monitor
对象在HotSpot
中是C++实现的,叫ObjectMonitor
,是管程的实现。其构造如下:
上面比较重要的字段有:
recursions
:锁重入次数,如果当前持有锁线程再次尝试获锁,会增加重入次数;释放锁时减少,直至为0
_owner
:持有锁线程,表明哪个线程持有锁对象
_cxq
(阻塞队列,很多文章也叫ContentList
):没拿到锁的线程会存放到该队列中进行阻塞
EntryList
(为了避免cxq
尾部竞争过大而引入的预备队列):当前线程释放锁之前会讲_cxq
中一部分线程放入该队列,然后再该队列中选出新的线程来持有锁(唤醒线程让其重新获锁)。
WaitSet
:等待队列,用来存放调用了wait()方法的线程
那么Java对象如何与monitor
对象相关联?这其实利用了Java对象头,回顾Java对象的结构:对象头、实例数据和对齐填充。
主要关注对象头,由两部分构成:MarkWord
和KClass pointer
(数组还有数组长度)。在锁的不同状态下,对象头状态如下:
(关于锁的不同状态后面会讲)
因此,在重量级锁的状态下,通过对象头中的monitor
指针将锁对象与monitor
关联起来。
那么获取锁具体是指什么操作?其实就是通过CAS
操作将ObjectMonitor
对象中的_owner
设置为当前线程,设置成功就表示获锁成功!下面详细讨论获锁解锁的整个流程:
》获锁
- 如果当前线程通过
CAS
操作将ObjectMonitor
对象中的_owner
设置为当前线程,获锁成功,然后判断是否是重入的,是的话重入字段加1; - 如果
CAS
失败,那么线程会再尝试一次CAS
,通过自适应自旋来等待锁释放(一直尝试获取); - 自适应自旋获取失败,包装成
ObjectWaiter
对象,并设置对象的状态(TS_CXQ
); - 死循环中使用
CAS
操作将上述对象加入_cxq
中,成功后退出循环(这是由于可能会有多个线程争用队列尾部);其实这里在CAS
失败后还会进行一次获锁操作挣扎一下 - 已经加入阻塞队列,在阻塞前再次尝试获取锁,不行会通过
park()
方法调用mutex
锁住当前线程,直到被唤醒(再次获锁)
总流程图:
》解锁
解锁分为两种情况:
-
重入次数不为0:此时锁不会释放,直接减少重入值即可
-
重入次数等于0:此时锁需要释放,解锁线程会唤醒之前等待的线程
这里会根据不同的情况(QMode属性)去唤醒(调用
unpark()
方法)_cxq
或EntryList
队列中的阻塞线程。
总流程图:
》补充:wait()
和notify()
方法
这两个方法比较简单,基于的是monitor
中的双向链表WaitSet
等待集合。前者将当前线程加入该集合,后者将集合头部的节点根据不同策略放入阻塞队列(两个中的一个)的头部或尾部,然后唤醒。
2、使用细节
首先,Synchronized
关键字可以修饰代码块、实例方法和静态方法,本质上都是作用于对象(Class对象/实例对象)。其中代码块作用于括号中的对象,实例方法作用于当前的实例对象,而静态方法作用于当前的类。
-
当用
Synchronized
修饰代码块时此时编译得到的字节码会有
monitorenter
和monitorexit
这两个指令。前者会试图去进行获锁操作;后者会进行解锁操作。 -
当用
Synchronized
修饰方法时此时虽然没有上面两个指令,但会在方法的访问标记上增加
ACC_SYNCHRONIZED
字段,该字段作用于上面类似:在进入方法时进行获锁操作,退出时进行解锁操作。
3、锁优化
在jdk1.6
之后,Java对锁进行了大量优化,包括锁消除、锁粗化、以及锁的不同形态,虽然引申出了众多概念,但一个基本的思想要清楚:锁的优化本质还是为了应对不同场景下的需求,达到效率最大化。
下面逐一进行介绍。
1)锁消除
锁消除指即时编译器对检测到不可能存在共享数据竞争的锁进行消除。主要判定依据来源于逃逸分析,如果判断一段代码中堆上的所有数据都只被一个线程访问,就可以当作栈上的数据对待,认为它们是线程私有的而无须同步。
2)锁粗化
原则需要将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中进行同步,这是为了使等待锁的线程尽快拿到锁。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之内的,即使没有线程竞争也会导致不必要的性能消耗。因此如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把同步的范围扩展到整个操作序列的外部。
3)锁的自旋
从之前的内容可以知道,由于锁的底层会进行系统调用,会有比较大的开销,所以提出了一个自旋方案来进行优化。自旋其实就是空转CPU,执行一些无意义的指令(halt),从而不让出CPU,等待锁的释放。
自适应自旋 :由于自旋时间难以确定,因此根据以往经验来选择自旋次数,就叫自适应自旋。
4)偏向锁
偏向锁的出现是为了解决这样一类场景:一开始只有一个线程来持有锁,其他线程没来进行竞争,因此频繁的CAS
操作并无必要。
回顾前面的MarkWord
:
偏向锁就是第二行的状态,如果当前锁对象支持偏向锁(1),那么会通过CAS
操作,讲当前线程的地址(上面的线程ID)记录到MarkWord
中,并将标记字段的最后三位设置为101。
之后如果有线程请求这把锁,只需判断标志字段最后三位是否为101,以及线程ID是否对应当前的线程地址即可判断偏向,如果偏向成功,则可以直接返回,否则会膨胀成轻量级锁。
5)轻量级锁
偏向锁考虑了线程不竞争的情况,那么如果线程轻微竞争(交替用锁),那么其实也没有使用重量级锁的必要。因此提出了轻量级锁来应对这一情况。
轻量级锁依靠的只有锁对象头中的MarkWord
,连monitor
对象都不需要。
轻量级锁执行逻辑如下:
-
如果当前处于无锁状态,会在当前栈帧中划出一块区域:
LockRecord
,然后将锁对象的MarkWord
拷贝一份到该区域中的dhw
结构中。然后通过CAS
把锁对象头指向这个LockRecord
-
如果当前处于有锁状态,并且当前线程持有锁,那么会在
dhw
结构中放入null
值,表明重入一次;如果当前线程不持有锁,进行CAS
操作,如果失败,则进行锁膨胀这里大部分参考资料中说的是如果轻量级锁获锁失败会进行一段时间的自旋,但就参考的文章资料(最后给出)来看,博主通过分析源码发现并没有这一操作,因此还是以代码为主。
-
如果线程需要解锁,会取
dhw
结构中的MarkWord
换回到锁对象头,但若获取的是null
,表明还在重入,所以直接返回,否则利用CAS
进行交换,如果失败说明此时有竞争存在,会直接膨胀成重量级锁。
总体流程:
小结
Synchronized
锁机制由JVM
实现,根据不同的情况利用对象头和外部对象monitor
实现了线程同步机制,是Java锁机制的重要组成部分(还有类库层面的Lock锁,后续补充)。
本篇文章参考了一些资料,列举如下:
-
Java并发编程的艺术
-
Yes的练级攻略:Synchronized的一个点…(主要参考对象,内容硬核,建议关注)