并发复习 ---- Synchronized底层原理深入分析
Posted whc__
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发复习 ---- Synchronized底层原理深入分析相关的知识,希望对你有一定的参考价值。
Synchronized底层原理深入分析
Synchronized底层原理深入分析
1、作用范围
Synchronized关键字可以修饰代码块、静态方法、实例方法。本质上都是作用于对象上。
- 代码块: 作用于括号里面的对象
- 实例方法: 作用于当前的实例对象即this
- 静态方法: 作用于当前的类对象
public class Test {
private Object lockObject;
public Test(Object lockObject) {
this.lockObject = lockObject;
}
public void lockObject() {
synchronized(lockObject) { // 作用于lockObject这个对象
// 修饰代码块
}
}
public synchronized void lockInstance() { // 作用于Test这个实例
// 修饰实例方法
}
public static synchronized void lockStatic() { // 作用于Test.class这个类对象
// 修饰静态方法
}
}
2、Synchronized原理是什么?
2.1 字节码层面上
-
修饰代码块时
编译得到的字节码会有
monitorenter
和monitorexit
指令,对应获取锁和释放锁; 实际这两个指令和修饰代码块那个对象相关, 每个对象都有一个monitor对象与之关联,执行monitorenter
指令的线程就是试图去获取monitor的所有权,抢到了就成功获取到了锁每个同步对象都有一个**自己的Monitor(**监视器锁),加锁过程如下图所示:
monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
monitorexit:执行monitorexit的线程必须是对象所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
-
修饰方法时
在方法表结构中的访问标志(access_flags)设置
ACC_SYNCHRONZIED
来实现。当方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED
是否被设置了,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成时释放管程。补充: 类文件结构中组成: 魔数与Class文件的版本、常量池、访问标志.....等 管程是用于多线程互斥访问共享资源, 首先,是互斥访问,即任一时刻只有一个线程在执行管程代码;第二,正在管程内的线程可以放弃对管程的控制权,等待某些条件发生再继续执行。 简单来说, 管程是互斥锁(称之为monitor's lock)与条件变量的配合使用,
2.2 本质上作用于对象上
2.2.1 对象头
对象结构分为: 对象头、实例数据、对齐填充
对象头又分为: Mark Word、Klass Pointer、数组长度(只有数组才有)
重点关注Mark Word。
64位的Mark Word内存布局
重量级锁: 对象头的锁标记位为10,并且会有一个指针指向monitor对象,所以锁对象和monitor两者就这样关联起来了
2.2.2 对象监视器
由上面的对象头中的Mark Word可知, Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向)
所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁
Monitor是由ObjectMonitor实现的,其主要数据结构如下
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
当多个线程发生竞争的时候,synchronized就会膨胀为重量锁,这时会创建一个ObjectMoitor对象,这个对象包含了三个由ObjectWaiter对象组成的队列:cxq、EntryList、WaitSet,以及两个字段owner(持有锁)和Read Thread(竞争候选者)
- cxq和EntryList都是获取锁失败用来存储等待线程的
- WaitSet是Java中调用wait方法进入阻塞的线程
- owner指向当前获取锁的线程
- Read Thread表示从cxq和EntryList中挑选出来去抢锁的线程,但由于是非公平的,所以不一定能抢到锁
在膨胀为重量锁的时候若没有获取到锁,不是立马就阻塞未获取到锁的线程,因其是非公平锁,首先会去尝试加锁,不管前面是否有线程等待(如果是公平锁的话就会判断是否有线程等待,有的话则直接入队睡眠),如果加锁失败,synchronized还会采用自旋的方式去获取锁,JDK1.6之前是默认自旋10次后睡眠,而优化之后引入了适应性自旋,即JVM会根据各种情况动态改变自旋次数。当自旋没有获取到锁,则会将当前线程添加到cxq队列的队首(注意在入队后还会抢一次锁,这就是非公平锁的特点,尽可能的避免调用系统函数进入内核态阻塞)并调用park函数睡眠。
2.3 总结
Synchronized
底层是利用monitor对象,CAS和mutex互斥锁来实现的,内部会有等待队列(waitingQueue)和条件等待队列(waitSet)来存放相应阻塞的线程。(阻塞是由操作系统来完成的)
未竞争到锁的线程存储到等待队列中,获得锁的线程调用wait后便放在条件等待队列中,解锁和notify都会唤醒相应队列中的等待线程来争抢锁。
然后由于阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁。
所以又引入了自适应自旋机制,来提高锁的性能。
3、锁优化
3.1 轻量级锁
为什么存在?
多个线程都是在不同的时间段来请求同一把锁,此时根本就不需要阻塞线程,连monitor对象都不需要,所以就引入了轻量级锁这个概念,避免了系统调用,减少了开销。
底层原理
轻量级锁操作的就是对象头的Mark Word。
如果判断当前处于无锁状态,会在当前线程栈的当前栈帧中划分一块叫LockRecord的区域,然后把锁对象的Mark Word拷贝一份到LockRecord中称之为dhw。然后通过CAS把锁对象头指向这个LockRecord。
补充:
每次加锁肯定是在一个方法调用中,而方法调用就是有栈帧入栈,如果是轻量级锁重入的话那么此时入栈的栈帧里面的 dhw 就是 null,否则就是锁对象的 markword。
3.2 偏向锁
为什么存在?
如果存在一开始一直只有一个线程持有这个锁,也不会有其它线程来竞争,此时频繁的CAS是没有必要的,CAS也是有开销的。
底层原理
如果当前锁对象支持偏向锁,那么就会通过CAS操作: 将当前线程的地址(也叫做唯一ID)记录到markword中,并且将标记字段的最后三位设置为101。
之后有线程请求这把锁,只需要判断 markword 最后三位是否为 101,是否指向的是当前线程的地址。
4、面试系列
4.1 Synchronized是什么?
Synchronized是一种互斥锁,一次只能允许一个线程进入被锁住的代码块,Synchronized是Java的一个关键字,它能够将代码块/方法锁起来。如果Synchronized修饰的是实例方法,对应的锁则是对象实例,如果修饰的是静态方法,对应的锁则是当前类的Class实例,如果修饰的是代码块,对应的锁则是传入Synchronized的对象实例。
4.2 Synchronized原理是什么?
通过反编译可以发现,当修饰方法时,编译器会生成ACC_SYNCHRONIZED关键字用来标识, 当修饰代码块时,会依赖monitorenter和monitorexit指令, 前面说了,无论Synchronized修饰的是方法还是代码块,对应的锁都是一个实例(对象)。
在内存中,对象一般由三部分组成,分别是对象头、对象实例数据和对齐填充。
重点在于对象头,对象头又由几部分组成,但我们重点关注对象头Mark Word的信息就好了,Mark Word会记录对象关于锁的信息。又因为每个对象都会有一个与之对应的monitor对象,monitro对象中存储着当前持有锁的线程以及等待锁的线程队列。
了解Mark Word和monitor对象是理解Synchronized原理的前提。
4.3 Synchronized锁在JDK1.6做了很多优化,了解多少?
在JDK1.6之前是重量级锁,线程进入同步代码块/方法时,monitor对象就会把当前进入线程的ID进行存储,设置Mark Word的monitor对象地址,并把阻塞的线程存储到monitor的等待线程队列中,它加锁是依赖底层操作系统的mutex相关指令实现,所以会有用户态和内核态之间的切换,性能损耗十分明显。
而JDK1.6以后引入偏向锁和轻量级锁在JVM层面实现加锁的逻辑,不依赖底层操作系统,就没有切换的消耗。所以,Mark Word对锁的状态记录一共有4种: 无锁、偏向锁、轻量级锁和重量级锁
4.4 简单说说偏向锁、轻量级锁和重量级锁
偏向锁指的就是JVM会认为只有某个线程才会执行的同步代码(没有竞争的环境)
所以在Mark Word会直接记录线程ID,只要线程来执行代码了,会比对线程ID是否相等,相等则当前线程直接获取得到锁,执行同步代码,如果不相等,则用CAS来尝试修改当前的线程ID,如果CAS修改成功,那还是能获取得到锁,执行同步代码。如果CAS失败了,说明有竞争环境,此时会对偏向锁撤销,升级为轻量级锁。
在轻量级锁状态下,当前线程会在栈帧下创建Lock Record(锁记录空间),Lock Record会把Mark Word的信息拷贝进去,且有个Owner指针指向加锁的对象。
线程执行到同步代码时,则用CAS视图将Mark Word的指向到线程栈帧的Lock Record,假设CAS修改成功,则获取得到轻量级锁。假设修改失败,则自旋(重试),自旋一定次数后,则升级为重量级锁。
简单总结一下: Synchronized锁原来只有重量级锁,依赖操作系统的mutex指令,需要用户态和内核态切换,性能损耗十分明显。重量级锁用到monitor对象,而偏向锁则在Mark Word记录线程ID进行比对,轻量级锁则是拷贝Mark Word到Lock Record,用CAS + 自旋的方式获取。
引入了偏向锁和轻量级锁,就是为了在不同的场景使用不同的锁,进而提高效率。锁只有升级,没有降级。
- 只有一个线程进入临界区,偏向锁
- 多个线程交替进入临界区,轻量级锁
- 多线程同时进入临界区,重量级锁
5、脑图总结
以上是关于并发复习 ---- Synchronized底层原理深入分析的主要内容,如果未能解决你的问题,请参考以下文章
Java并发编程:Synchronized底层优化(偏向锁轻量级锁)
Java高并发编程实战4,synchronized与Lock底层原理
Java高并发编程实战4,synchronized与Lock底层原理