【并发复习】 ---- 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 字节码层面上

  • 修饰代码块时

    编译得到的字节码会有monitorentermonitorexit指令,对应获取锁和释放锁; 实际这两个指令和修饰代码块那个对象相关, 每个对象都有一个monitor对象与之关联,执行monitorenter指令的线程就是试图去获取monitor的所有权,抢到了就成功获取到了锁

    image-20210503203527588

    每个同步对象都有一个**自己的Monitor(**监视器锁),加锁过程如下图所示:

    image-20210503203319616

    monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
    2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
    3. 如果其他线程已经占用了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、数组长度(只有数组才有)

image-20210503182911025

重点关注Mark Word。

64位的Mark Word内存布局

image-20210503183102383

重量级锁: 对象头的锁标记位为10,并且会有一个指针指向monitor对象,所以锁对象和monitor两者就这样关联起来了

image-20210503183224035

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对象组成的队列:cxqEntryListWaitSet,以及两个字段owner(持有锁)Read Thread(竞争候选者)

image-20210503205508647

  • 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。

image-20210503213547613

补充:
每次加锁肯定是在一个方法调用中,而方法调用就是有栈帧入栈,如果是轻量级锁重入的话那么此时入栈的栈帧里面的 dhw 就是 null,否则就是锁对象的 markword。

image-20210503215234028

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、脑图总结

image-20210503222919577

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值