小蔡学Java

深入解析Synchronized锁底层原理

2024-02-02 20:37 1079 0 JVM / JUC Java多线程synchronized / lock

一、概述

java Synchronized锁是Java提供的,底层依赖JVM实现的单机锁。可用于对实例方法、静态方法以及代码块进行加锁,用于保 护多线程环境下对共享资源访问的安全性

二、底层原理分析

java Synchronized锁依赖于Java对象的内存布局,因此,需要了解Java对象的内存布局。

2.1 Monitor对象

每个对象自从创建起,都会关联一个锁对象,即 Monitor对象【管程\监视器】。每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。该对象在Java虚拟机中,由C++实现的,具体表现为:

	ObjectMonitor,具体的数据结构为:
	ObjectMonitor() {
		_header       = NULL;
		_count        = 0;     
		_waiters      = 0,
		_recursions   = 0;
		_object       = NULL;
		_owner        = NULL;  // _owner指向持有ObjectMonitor对象的线程
		_WaitSet      = NULL;  // 处于wait状态的线程,会被加入到_WaitSet
		_WaitSetLock  = 0 ;
		_Responsible  = NULL ;
		_succ         = NULL ;
		_cxq          = NULL ;
		FreeNext      = NULL ;
		_EntryList    = NULL ;  // 处于等待锁block状态的线程,会被加入到该列表
		_SpinFreq     = 0 ;
		_SpinClock    = 0 ;
		OwnerIsThread = 0 ;
	  }
	具体字段解释:
	- _owner: 指向拥有ObjectMonitor对象的线程
	- _WaitSet: 存放处于wait状态的的线程队列
	- _EntryList: 存放处于等待锁block状态的线程队列
	- _recursions:锁的重入次数
	- _count:用来记录该线程获取锁的次数

工作原理:

  1. 当多个线程同时访问一段同步代码时。首先假设线程A会进入EntryList队列中,会判断owner是否为空,主要通过CAS操作(比较和交换,比较新值和旧值的不同)。如果 owner为null ,直接把其赋值,指向自己owner=self,同时把可重入次数recursions=1count+1获取锁成功。如果self=cur,说明是当前线程,锁重入了,recursions++即可。线程A进入owner区域,然后执行同步方法块
  2. 若线程B来获取锁。首先会放入EntryList队列中。然后去判断锁是否被占用,此时线程A正在使用该锁,那么会一直放在EntryList队列中,直到线程A释放锁,所有EntryList队列中竞争锁是非公平的;
  3. 若持有monitor的线程调用wait()方法,将释放当前持有的monitorowner变量恢复为null,count自减1 ,同时该线程 进入WaitSet集合中等待被唤醒
  4. 线程从WaitSet集合中被唤醒notifyall后,会放入到 EntryList队列中,参与锁的竞争

2.2 锁分类以及升级流程

2.2.1 偏向锁

背景: 在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。轻量级锁需要多次CAS操作,偏向锁来减少CAS的操作次数。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了降低获取所得代价,引入偏向锁。

大致流程: 当一个线程访问同步块并获取锁时,会在 对象头和栈帧中的锁记录里存储锁偏向的线程ID ,以后该线程在进入和退出同步块时 不需要进行CAS操作来加锁和解锁

获取锁、释放锁以及锁升级的流程:

  1. 获取锁:当线程1访问代码块并获取锁对象后,会在 对象头和栈帧中记录偏向锁的threadID 。以后,线程1再次获取锁时,直接比较当前threadID和对象头中的threadID是否一致;如果一致,不需要CAS加锁、解锁

  2. 释放锁:采用只有线程竞争时才会释放锁的机制 【没有竞争时,解锁后线程ID仍会存在对象头中】 。偏向锁的撤销需要等待全局安全点 (在这个时间点上没有正在执行的字节码)

    • 首先暂停拥有偏向锁的线程,(1)判断拥有偏向锁的线程是否存活;若 没有存活,锁对象状态被重置为无锁状态

    • 若存活,(2)查找线程1的栈帧信息,是否需要继续持有这个锁。若 需要,等待全局安全点,在安全点暂停持有偏向锁的线程1,撤销偏向锁,升级为轻量级锁(锁的升级); 若不需要,将锁对象的状态设为无锁状态,重现偏向新的线程

2.2.2 轻量级锁

背景: 在没有多线程竞争的前提下, 减少传统的重量级锁使用操作系统互斥量产生性能消耗。

使用场景: 如果一个对象虽然有多线程要加锁,但 加锁时间是错开的 (也就是没有竞争),那么可以使用轻量级锁来优化。

使用轻量级锁的时机: 当关闭偏向锁或者多个线程竞争偏向锁导致锁升级为轻量级锁,则会尝试获取轻量级锁,获取锁的流程如下:

  1. JVM在当前线程的栈帧中创建用于存储锁记录的空间,并将 对象头中的Mark Word复制到锁记录中:

  1. 让锁记录中 Object reference 指向锁对象,并尝试用 ** cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录* *。

  1. 如果 cas 替换成功,对象头中存储了 ** 锁记录地址和状态 00 ** ,表示由该线程给对象加锁:

  1. 如果 cas 失败,有两种情况:

● 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入 锁膨胀 过程

● 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数。

【解锁流程】 如下所示:

  1. 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一;

6.当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用CAS将 Mark Word 的值恢复给对象头:

● 成功,则解锁成功

● 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

【锁膨胀流程】: 如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀, 将轻量级锁变为重量级锁

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁;

  1. 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程

● 为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址

● 然后自己进入 Monitor 的 EntryList BLOCKED。

  1. 当 Thread-0 退出同步块解锁时,使用casMark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Ownernull,唤醒 EntryListBLOCKED 线程

2.2.3 重量级锁

缺点:

  1. ObjectMonitor源码时,会发现一些内核函数,对应的线程为park()和upark(),该操作涉及到 用户态和内核态,从用户态切换到内核态是非常消耗资源的
  2. 用户态:程序的运行空间进入到用户运行状态;
  3. 内核态:涉及到IO操作

2.2.3.1 自旋优化-自旋锁

出现原因: 线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力 解决方案: 让线程等待一段时间,不会立即被挂起,看持有锁的线程是否会很快释放锁 优缺点:

  1. 优点: 可以避免线程切换的开销;但占用了处理器CPU的时间,如果持有锁的线程很快就释放了锁,自旋效率是很高的;否则,会消耗掉大量的资源
  2. 缺点: 对自旋次数进行了限制,默认为10次。如果次数到限制,刚刚退出,锁被释放(多等一两次就可以获得锁)是非常遗憾的

2.2.3.2 自旋优化-自适应自旋锁

即自旋的次数不在固定,取决于前一次在同一锁上的自旋时间以及锁的拥有者的状态来决定。如果 自旋成功了,下次自旋的次数就会增加 ,即 jvm会认为自旋获得锁的成功率会很高;反之,对于某个锁,很少有能自旋成功的,在以后自旋时,会相应减少自选的次数。

锁消除:

  1. 如果不存在竞争,为什么还需要加锁?可以将锁消除。
  2. 锁消除依据:逃逸分析的数据支持,如果变量没有逃逸出方法,又因为栈是线程私有的,所以不会存在竞争情况,可以放心清除锁,节省毫无意义的请求锁的时间

锁粗化:

  1. 出现背景:如果发生同一对象进行一系列的加锁解锁操作,会导致不必要的性能消耗
  2. 解决方法:锁粗化,即将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,降低加锁解锁的次数。

2.2.4 各种锁的对比

2.2.5 synchronized 特性

1.可重入性:

Synchronized锁对应的时候有一个计数器,记录下线程获取锁的次数,每次获取锁,计数器加1,每次释放锁,计数器减1,直到计数器为0,锁全部释放。

● 好处:可以避免一些死锁的情况

2.不可中断性:

● 一个线程获取锁之后,另一个线程处于阻塞或者等待状态,前一个线程不释放锁,后一个线程会一直阻塞或者等待,不可以被中断

2.2.6 Synchronized针对同步代码块和同步方法的底层原理

1.同步代码块:

● 对象头会关联到一个monitor对象。进入一个方法的时候执行monitorEnter(同步代码块的开始位置),获取当前对象的一个所有权ownermonitor数值加1,当前这个线程就是monitor的owner,退出的时候对应monitorexit(插入到方法结束处和异常处)。monitorenter和monitorexit是一对,缺一不可。

● 如果已经拥有owner,再次获得锁(可重入),计数器加1,执行monitorexit时,计数器减1;

● 互斥性体现在:是否能够获得monitor的所有权

2.同步方法

● 与同步代码块类似,多了一个标识位ACC_SYNCHRONIZED,一旦执行方法的时候,就会先判断是否存在 标志位,然后ACC_SYNCHRONIZED会隐式的调用monitorentermonitorexit。归根到底,还是monitor的争夺。

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录