多线程(二) -- 管程(二) -- Synchronized底层Monitor,轻量级、偏向锁,锁膨胀
Java对象头以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;数组对象:其中 Mark Word 结构为:所以一个对象的结构如下:Monitor(锁)Monitor被翻译为监视器或管程每个java对象都可以关联一个monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Mo
1. Java对象头
以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;
数组对象:
其中 Mark Word 结构为:
- 1 01代表偏向状态
- 00代表轻量级锁,其中的mark word存放的是锁记录
- 10代表重量级锁,其中的mark word存放的是monitor地址
所以一个对象的结构如下:
2. Monitor(锁)
Monitor被翻译为监视器或管程
每个java对象都可以关联一个monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针
monitor结构如下:
- 刚开始时Monitor中的Owner为null
- 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
- 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
3.synchronized原理
示例一段代码:
static final Object lock=new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
对应的main方法中的字节码为:
0 getstatic #2 <com/concurrent/test/Test17.lock> // 取得lock的引用(synchronized开始了)
3 dup // 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
4 astore_1 // 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
5 monitorenter // 将lock对象的Mark Word置为指向Monitor指针
6 getstatic #3 <com/concurrent/test/Test17.counter>// 以下执行counter++操作
9 iconst_1
10 iadd
11 putstatic #3 <com/concurrent/test/Test17.counter>
14 aload_1 // 从局部变量表中取得lock的引用,放入操作数栈栈顶
15 monitorexit // 将lock对象的Mark Word重置,唤醒EntryList
16 goto 24 (+8) // 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return
可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。
3.1 为什么会有两个monitorexit呢?
这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。
3.2 synchronized可重入的原理
重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。
4. 轻量级锁
轻量级锁使用的场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即使语法仍然是synchronized
假设有两个方法同步块,利用同一个对象加锁:
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
对上述代码的加锁步骤进行分析:
-
当线程执行到锁锁住的代码块时,都会在栈帧中创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference地址
-
让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word ,将Mark Word 的值存入锁记录中
-
如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态00(01代表没有锁住,00代表轻量级锁,见上面),而锁记录中存放的就是对象的相关信息,如下所示
-
如果CAS失败,有两种情况
- 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
- 如果是自己的线程已经对用一个对象执行了synchronized进行加锁(即自己执行了synchronized锁重入),那么再添加一条 Lock Record 作为重入的计数
-
当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有重入,这时重置锁记录,表示重入计数减一
-
当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象
- 成功则解锁成功
- 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
5. 锁膨胀(锁升级)
如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。
static object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块
}
}
-
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
-
这时 Thread-1 加轻量级锁失败(交换失败),进入锁膨胀流程
- 即为Object对象申请monitor锁,让object指向重量级锁地址
- 然后自己进入monitor的entryList BOLCKED
-
当Thread-0 退出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程。
锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
6. 自旋优化(反复尝试获取锁)
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁(即避免上下文切换带来的开销)
-
自旋成功的情况:
-
自旋失败的情况:
-
自旋注意点:
- 在java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能
- 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
- Java7之后不能控制是否开启自旋功能
7. 偏向锁
在轻量级的锁中,如果同一个线程对同一个对象进行重入锁时,也需要执行CAS操作,有点浪费时间和性能;
那么java6开始引入了偏向锁来做进一步优化,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了。以后只要不发生竞争,这个对象就归线程所有
7.1 偏向状态
一个对象的创建过程
-
如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 值为0x05即最后三位的值101,这时它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值。
-
偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟
-
注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
-
测试偏向过程:(三次都是101偏向)
-
测试禁用:在上面测试代码运行时在添加VM参数-xx:-UseBiasedLocking禁用偏向锁;(开始是001正常,然后是00轻量级锁,最后又变成001正常)
7.2 撤销偏向锁:
7.2.1 hashcode撤销;
当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁;
因为正常情况下,对象的hash码只有在用的时候才会产生,没有用之前就都是0,第一次调用了对象的hashcode方法时,才会给对象产生hash码,并且填充到对象头的markword中;而如果处于偏向状态,只能存一个线程id,再想存对象的hashcode码就存不下了。
- 轻量级锁会在锁记录中记录hashcode
- 重量级锁会在monitor中记录hashcode
7.2.2 其他线程使用对象
当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
结果:
7.2.3 调用wait/notify
因为只有重量级锁才有wait/nofity机制
7.3 批量重偏向
如果对象被多个线程访问,但是没有竞争,这时候偏向了线程一的对象又有机会重新偏向线程二,即可以不用升级为轻量级锁,重偏向或重置对象的ThreadID;
可这和我们之前做的实验矛盾了呀,其实要实现重新偏向是要有条件的:就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。
7.4 批量撤销
当撤销偏向锁阈值超过40次后,jvm会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
8. 锁消除
看一段代码:代码中有2个方法,都是对x进行++操作,一个对局部对象加了锁,一个就是正常的方法:
执行查看结果:
8.1 原因:
因为jvm中有JIT(即时编译器),会对java字节码进一步优化,比如上述代码中对++操作重复执行变成热点代码,JIT就会过来优化,JIT看到o对象是一个局部变量所以加锁操作并不会被共享,JIT就会把这个加锁操作优化掉,执行时是没有加锁操作的
8.2 关闭锁消除
更多推荐
所有评论(0)