Java并发编程五-Java并发机制的底层实现原理-synchronized
参考文献
- <<Java并发编程艺术>>
- <<Java语言规范-基于Java SE 8>>
- http://www.itabin.com/synchronized-lock/
引言
-
Java编程语言提供了多种用于线程通信的机制.这些方法中最基础的就是同步(
synchronization
),它是使用监视器(monitor
)实现的.Java中每个对象都与一个线程可以锁定或解锁的监视器相关联.在任何时刻,只有一个线程可以持有某个监视器上的锁.任何其他试图锁定该监视器线程都将阻塞,直到它们可以获得该监视器上的锁. -
线程T可以多次锁定某个特定的监视器,而每个解锁操作都会抵消一次锁定操作的效果.(可重入锁)
synchronized
-
synchronized
语句是对对象的引用,即基于对象实现,然后它试图执行在该对象的监视器上的锁定动作,并且在锁定动作成功完成之前,不会执行下一步动作.在锁定动作执行之后,synchronized
语句体被执行.如果该语句体执行结束,无论是正常结束还是异常结束,都会在相同的监视器上执行解锁动作. -
synchronized
的底层实际上就是依靠的Monitor
.Monitor
被翻译为监视器或管程,每个 Java 对象都可以关联一个Monitor
对象,如果使用synchronized
给对象上锁(重量级)之后,该对象头的 Mark Word(标记信息,包含锁,GC等标记信息) 中就被设置指向Monitor
对象的指针.- 对象内置锁
ObjectMonitor
流程:-
监控区部分(
Entry Set
),停留在这个区域的线程由于还没有获得对象操作权限的原因,依然停留在synchronized
同步块以外,具体来说就是synchronized(Object)
这句代码的位置.处于“Entry Set”区域的线程,其线程状态被标识为BLOCKED
-
对象操作权持有区(
The Owner
),对于一个特定对象的Object Monitor
控制来说,一个时间点最多有一个线程处于这个区域.它的状态变为RUNNABLE
. -
所有曾经获得过锁,但是由于其它必要条件不满足而需要
wait
的时候,线程就进入了对象锁的wait set
区域 .它的状态变为WAITING
- 在
wait set
区域的线程获得Notify/notifyAll
通知的时候,随机的一个Thread(Notify)
或者是全部的Thread(NotifyALL)
从对象锁的wait set
区域进入了Entry Set
中.
- 在
-
在当前拥有锁的线程释放掉锁的时候,处于该对象锁的
Entry Set
区域的线程都会抢占该锁,但是只能有任意的一个Thread
能取得该锁,而其他线程依然在Entry Set
中等待下次来抢占到锁之后再执行.
-
- 对象内置锁
-
ObjectMonitor.hpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//ObjectMonitor.hpp
ObjectMonitor() {
// 记录无锁状态的Mark Word
_header = NULL;
_count = 0;
// 等待锁的线程个数
_waiters = 0,
// 线程重入次数
_recursions = 0;
// 指向的对象头
_object = NULL;
// 指向线程或者Lock Record,即指向了当前持有锁线程
_owner = NULL;
// 调用wait()方法后等待锁的队列,即条件变量的等待队列
_WaitSet = NULL;
// 等待队列的锁
_WaitSetLock = 0 ;
_Responsible = NULL ;
// 下一个被唤醒的线程
_succ = NULL ;
// ObjectWaiter 队列
_cxq = NULL ;
FreeNext = NULL ;
// ObjectWaiter 队列,即同步队列
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ };
// 指向下一个节点
ObjectWaiter * volatile _next;
// 指向上一个节点
ObjectWaiter * volatile _prev;
// 线程
Thread* _thread;
// 线程状态
volatile TStates TState;
public:
ObjectWaiter(Thread* thread);
};header
: 记录无锁状态的Mark Word
,同Lock Record
的Displaced Mark Word
字段ObjectWaiter
: 一个双向链表结构对象,封装了线程的信息,包括当前线程的状态EntryList
: 当多个线程同时访问同步代码时,这些因自旋到达次数后还没有竞争到锁的线程就会进入阻塞,将封装成ObjectWaiter
结点进入EntrySet
双向链表(EntryList
);而获取到锁的线程,即获取到锁对象的Monitor
,Monitor
依赖于操作系统底层的Mutex Lock
,当线程获取到Mutex Lock
时,其他线程将不能获取到Mutex Lock
.WaitSet
: 当线程调用wait()
方法,该线程将主动释放Mutex Lock
,并且进入阻塞,将封装成ObjectWaiter
结点进入WaitSet
双向链表,等待下一次被其他线程调用notify()
或者notifyAll()
唤醒开始继续竞争锁;owner
: 加锁和释放锁是通过owner
,即指向了持有锁的线程的Lock Record
或者当前持有锁线程recursions
: 线程重入次数,即表示Synchronized
支持可重入object
: 指向了对象头cxq
: cxq队列存储的是指向enter函数的时候因为锁已经被其他线程占有而阻塞的线程(单向链表)succ
: 下一个被唤醒线程
-
synchronized
方法在被调用时会自动执行锁定动作,它的方法体在该锁定动作成功完成之前,是不会被执行的.如果该方法是实例方法,那么它会锁定与在其上调用该方法的实例(即,在该方法体执行期间被称为this
的对象)相关联的监视器.1
2
3
4
5
6
7
8public synchronized void testThread(){
}
// <==>
public void testThread(){
synchronized(this){
}
} -
如果该方法是
static
的,那么它会锁定与表示定义该方法的类的Class
对象相关联的监视器.如果该方法体执行结束,无论是正常结束还是异常结束,都会在相同的监视器上执行解锁动作.1
2
3
4
5
6
7
8public synchronized static void testThread(){
}
// <==>
public void testThread(){
synchronized(Test.class){
}
} -
JVM
基于进入和退出Monitor
对象来实现方法同步和代码块同步.代码块同步是使用monitorenter
和monitorexit
指令实现的. -
monitorenter
指令是在编译后插入到同步代码块的开始位置,而monitorexit
是插入到方法结束处和异常处,JVM
要保证每个monitorenter
必须有对应的monitorexit
与之配对.任何对象都有一个monitor
与之关联,当且一个monitor
被持有后,它将处于锁定状态.线程执行到monitorenter
指定时,将会尝试获取对象对应的monitor
的所有权,即尝试获得对象的锁.
锁消除
-
锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除.
-
举个例子让大家更好理解.
1
2
3
4
5
6public String test(String s1, String s2){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(s1);
stringBuffer.append(s2);
return stringBuffer.toString();
}-
上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来.
-
test 方法中三个变量s1, s2, stringBuffer, 它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的.
-
我们都知道 StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为
锁消除
.1
2
3
4
5
6
7
8StringBuffer.class
// append 是同步方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
-
Java对象头
-
synchronized
用的锁是存在Java对象头里的.-
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头;
1
2
3
4
5|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------| -
如果对象是非数组类型,则用2个字宽存储对象头.在32位虚拟机中,1字宽等于4字节,即32
bit
.1
2
3
4
5|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
-
-
Java对象头有三部分组成:
Mark Word
,指向类的指针,数组长度(只有数组对象才有);长度 内容 说明 32/64bit Mark Word
存储对象的 hashCode
或锁信息等32/64bit Class Metadata Address
存储到对象类型数据的指针 32/64bit Array Length
数组的长度(如果当前对象是数组) - Java对象头里的
Mark Word
里默认存储对象的HashCode
,分代年龄和锁标记位.
对象的HashCode(25bit) 对象分代年龄(4bit) 是否是偏向锁(1bit) 锁标志位(2bit) 锁状态 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| CMS过程用到的标记信息 | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------| - Java对象头里的
锁的状态
- 在JavaSE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态.这几种状态会随着竞争情况逐渐升级,锁可以升级但不能降级.意味着偏向锁升级成轻量级锁后不能降级成偏向锁.
- 这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率.
biased_lock | lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
偏向锁(Biased Locking
)
- HotSpot的作者经研究发现,大多数情况下,锁不仅不存在多线程竞争(没有竞争,就自己这个线程),而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁.即只有一个线程进入临界区,偏向锁.
- 当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的
Mark Word
里是否在存储着指向当前线程的偏向锁.如果测试成功,表示当前线程已经获得了锁.如果测试失败,则需要在测试一下Mark Word
中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程.- Java偏向锁(
Biased Locking
)是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁. - 偏向锁的实现是通过控制对象
Mark Word
的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入.
- Java偏向锁(
- 一个对象创建时,如果开启了偏向锁(在JDK6和JDK7默认开启),对象的Mark Word的值为
Ox05
即最后三位为101,此时他的thread
,epoch
,age
都是0;- 但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态;如果想避免延迟,可加VM参数
-XX:BasicLockingStartupDelay=0
来禁用延迟; - 如果没有开启偏向锁,那么对象创建后,
Mark Word
值为Ox01
即最后三位为001,这时它的hashcode
,age
都为0,第一次用到hashcode
时赋值; - 禁用偏向锁,添加VM参数:
-XX:UserBaisedLocking=false
- 但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态;如果想避免延迟,可加VM参数
偏向锁的撤销
- 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁.
- 偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码).
- 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;
- 如果线程仍然活着,拥有偏向锁的栈会被执行遍历偏向对象的锁记录,栈中的锁记录和对象头的
Mark Word
要么重新偏向于其他线程,要么恢复要无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程.
- 触发撤销偏向锁的方法
- 调用对象
hashCode
方法- 调用了对象的
hashCode
但偏向锁的对象Mark Word
中存储的是线程id,如果调用hashCode
会导致偏向锁被撤销 ;- 轻量级锁会在锁记录中记录
hashCode
- 重量级锁会在锁记录中记录
hashCode
- 轻量级锁会在锁记录中记录
- 调用了对象的
- 其他线程使用对象
- 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁;
- 调用
wait
-notify
- 调用对象
轻量级锁(Lightweight Locked
)
-
当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁.即多个线程交替进入临界区,轻量级锁.
-
场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(可以认为没有竞争),那么就可以使用轻量级说来优化.
轻量级锁加锁
- 线程在执行同步块之前,
JVM
会现在当前线程的栈帧中创建用于存储记录的空间,并将对象头的Mark Word
复制到锁记录中,官方称为Displaced Mark Word
,然后线程尝试使用CAS将对象头中的Mark Word
替换为指向锁记录的指针.- 如果成功,当前线程获得锁,如果失败,表示其他线程在竞争锁,当前线程便尝试使用自旋来获得锁.
轻量级锁解锁
- 轻量级锁解锁,会使用原子的CAS操作将
Displaced Mark Word
替换回对象头,如果成功,则表示没有竞争发生.如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁. - 因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态.
- 当锁处于这个状态,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的竞争锁.
轻量级锁加锁以及膨胀流程
-
创建锁记录(
Lock Record
)对象,每个线程的栈帧都会包含一个锁记录对象,内部可以存储锁定对象的mark word
(不再一开始就使用Monitor
); -
让锁记录中的
Object reference
指向锁对象(Object),并尝试用cas
去替换Object中的mark word
,将此mark word
放入lock record
中保存; -
如果
CAS
替换成功,则将Object的对象头替换为锁记录的地址和状态 00(轻量级锁状态),并由该线程给对象加锁; -
如果一个线程在给一个对象加轻量级锁时,
CAS
替换操作失败(因为此时其他线程已经给对象加了轻量级锁),此时该线程就会进入锁膨胀过程; -
此时便会给对象加上重量级锁(使用Monitor)
- 将对象头的
Mark Word
改为Monitor
的地址,并且状态改为01(重量级锁) - 并且该线程放入入
EntryList
中,并进入阻塞状态BLOCKED
- 将对象头的
-
当Thread-0退出同步块解锁时,使用
CAS
将Mark Word
的值恢复给对象头,失败,这时会进入重量级解锁流程,即按照Monitor
地址找到Monitor
对象设置Owner为null,唤醒EntryList
中BLOCKED
线程;
重量级锁( Heavyweight Locked)
- 如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为
重量级锁
,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞. - 升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态.
- 在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁
- 多个线程同时进入临界区,重量级锁
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,适用自旋会消耗CPU | 追求响应时间同步块执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应速度缓慢 | 追求吞吐量,同步块执行速度较长 |