Synchronized 解析
本章讲解如下:
- Synchronized 关键字的使用?
- Synchronized 关键字在使用中锁的变化,及其升级过程?
- Synchronized 的底层实现?
- 锁的分类有些?
- 锁优化有哪些?
Synchronized
简介
在 Jdk1.5 之前 Synchronized 是一个重量级锁,但在 1.6 之后 Synchronized 经过优化后会经过一系列锁升级才会成为重量级锁。而 Synchronized 用的锁是存储在锁对象的对象头
中。
特性
Synchronized 可以解决并发编程中的三个问题分别是:原子性(CPU 分片), 有序性(编译器指令重拍),可见性(CPU 缓存)
- 原子性:确保线程互斥的访问同步代码
- 有序性:有效解决重排序问题
- 可见性:保证共享变量的修改能够及时可见
使用
Synchronized 可以使用任意非 null
的对象来作为锁, 这些锁
被称为对象监视器(ObjectMonitor)
。
Synchronized
可以修饰静态方法(Class 实例)、实例方法(this 实例)、对象实例(括号范围)
,归根结底它能上锁的资源只有一类:就是对象
1 |
|
实现原理
Synchronized 是通过对象内部的一个叫做监视器锁(moniter)
来实现的,而监视器锁本质依赖OS 底层的 Mutex Lock
(互斥锁)来实现的。且 OS 实现线程的切换需要从用户态切换到内核态,成本太高,所以将这种依赖 Mutex Lock 实现的锁称为重量级锁。
任意对象都有一个Monitor与之关联,当且一个Monitor被持有后其关联的对象将处于锁定状态。Synchronized 就是基于进入
和退出
Monitor 对象来实现的。
Monitor
简介
在 Java 中任何对象都有可能成为 Monitor,当一个对象被 new 出来后都会携带一把看不见的锁:Monitor 锁
结构
而 Monitor 的在 HotSpot 中是由 ObjectMonitor
实现的,其结构在objectMonitor.hpp
中定义
其包含了三个非常的字段:WaitSet 和 EntryList 和 Owner。
- _WaitSet:用于存放处于 wait 状态的线程
- _EntryList:用于存放处于 blocked 状态的线程
- _owner:用于存储获取到锁的线程
- _count:记录个数
执行流程
Monitor锁的获取流程紧紧围绕着 _WaitSet、_EntryList、_owner
三个字段展开;大致步骤如下:
- 当线程尝试获取锁时,会被包装成 ObjectWaiter,并进入 EntryList 中等待,判断 EntryList 是否为空
- 为空:直接占领 Owner,执行同步代码块
- 不为空:则和之前的线程一起等待
- 当线程持有 Monitor 时有两个选择:
- 正常执行完代码块的代码,释放监视器
- 执行一般等待某个条件的出现,调用
wait()
进入 wait 状态,释放 _owner , count 减 1 ;并进入 WaitSet 中等待条件满足被唤醒。
- 当被唤醒后,继续获取监视器执行代码
流程图
一个线程只有在持有Monitor时才能调用wait()
方法进入_WaitSet 队列
,而处于_WaitSet 队列的线程只有再次获得监视器才能退出等待状态。
线程如何出入Monitor?
JVM 使用 monitorenter
和 monitorexit
两个字节码指令来进入和退出 Monitor 对象
线程如何找到 Monitor?
Monitor对象存在于每个Java对象的对象头
Mark Word中(存储的指针的指向)。
总结
Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait
等方法会使用到Monitor 锁对象,所以必须在同步代码块中使用。
monitorenter
和monitorexit
上面将了Synchronized 的底层是通过进入
和退出
Monitor 对象来实现的,那么线程是如何进入和退出 Monitor 对象呢?
示例
先使用 javap -c -s -v -l
来对一个编译好的只有一个同步方法的类进行反编译,结果如下:
代码
1 |
|
结果如下图:
从图中可以看出 synchronized
底层使用 monitorenter
和 monitorexit
两个指令完成。
使用 monitorenter 来进入 monitor 对象,而 monitorexit 来退出 monitor 对象,而第二个 monitorexit 是为了防止程序出现异常锁不释放。
当使用Synchronized加锁出现异常会不会释放锁?会释放,这里可以证明这点
monitorenter
该 monitorenter
用于开启对monitor
的监控, 并获取 monitor 的所有权,只有到获取到来 monitor 的所有权此 monitor 才会锁定。
执行monitorenter
的线程获取 monitor 的流程:
- 如果monitor的计数条目为0,则线程进入monitor,并将计数加 1;然后该线程是monitor所有者。(获取到锁)
- 如果线程已经拥有计数器,那么它会重新进入monitor,增加计数条目。(可重入锁)
- 如果另外一个线程拥有此monitor,则线程会被阻塞,直到monitor的计数条目变为 0,则再次获取monitor所有权。(为获取到锁,但尝试获取锁)
monitorexit
而 monitorexit
指令用于释放monitor的所有权, 执行monitorexit
的线程必须是monitor的所有者。该线程减少monitor的条目计数。结果,如果条目计数的值为零,则线程退出monitor,并且不再是其所有者。其他被阻止进入monitor的线程也可以尝试这样做。
特点: 一个monitorenter
指令可以和多个monitorexit
指令结合使用。
可重入性
Synchronized 先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一. 即通过计数器实现的 .
当Synchronized修饰实例方法时会在方法标识上添加了ACC_SYNCHRONIZED
, 当线程池调用此方法时会去检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。
1 |
|
反编译结果:
在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
对象内存布局
知道了Synchronized 通过 monitorenter 和 monitorexit 来进入和退出 Monitor 来实现同步的功能,那么问题来了
monitorenter 和 monitorexit 是如何找到 Monitor对象的呢?
答案是对象头
,要知道对象头在哪里就必须了解 Java 对象内存布局。
对象布局
在 Java 中对象在内存中的布局大致分为三部分: 对象头,实例数据, 对齐填充
。
实例数据:存放类的属性信息和父类的属性信息
对齐填充:JVM 要求对象
起始地址
必须是 8 字节的整数倍对象头中存储了:对象自身的运行时数据(Mark Word)、类型指针(Class Point)
- 如果对象是数组类型则对象头还会存储:数组长度
- 对象头在 32 位和 64 位操作系统分别占用 32bit 和 64bit
对象头
对象头分为两部分:Mark Word
和 Class Pointer
。我们重点关注 Mark Word。
Class Pointer 即指向当前对象的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Mark Word
而Mark Word 存储了哈希码
、分代年龄
、锁标志位
、偏向线程ID
、偏向时间
戳等信息。
在 OpenJdk 的 Hotspot 源码的 markOop.hpp 文件中对 Mark Word 进行了描述
结果如下图:
在对象的最后两个 bit 为存储了锁的标识位, 默认是 01 即正常对象, 而随着锁等级的不同。存储的数据如下列表:
锁状态 | 锁标识位 | 存储内容 |
---|---|---|
无锁 | 001 | 对象 HashCode(如果有调用) |
偏向锁 | 101 | 持有线程的线程ID |
轻量级锁、自旋锁 | 00 | 指向持有线程栈帧中的 Lock Record 指针 |
重量级锁 | 10 | 指向互斥量(向需要向内核申请锁)的指针 |
而 monitorenter 和 monitorexit 操作的 monitor 就是对象在内存中的对象头
(通过指针)
Lock Record
线程要想获取到锁,则必须和对象头中的 Mark Word 建立关联,而这个关联就是通过 Lock Record 来实现的。
Lock Record 顾名思义为锁记录
, 它主要存在于线程的栈帧中,每个线程都有属于自己的 Lock Record 列表。
主要用于存储对象头中的 Mark Word 的拷贝
,且每个Lock Record 同一时间只能与一个 Mark Word 关联;标识该对象锁被当前线程持有。
Lock Record 中有个 owner
字段用于存放拥有该锁的线程的唯一标识
或锁对象的 Mark Word
。
总结
上面的内容大致讲述了 Synchronized 的底层实现,以及 Lock Record 和 Mark Word 和 Monitor 的关系。
Lock Record 通过存储对象头中 Mark Word 的拷贝来建立关联,并开始抢占 Monitor 锁。
获取成功则修改 Mark Work 中的锁标志位,并修改其中的指针。
获取失败则进行阻塞(ObjectMonitor 的_EntreyList)。
锁优化
由于 Jdk 在 1.5 之前Synchronized是非常重的锁,在 1.6 之后对Synchronized进行了优化和调整,加入了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略
。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking
来禁用偏向锁。
自旋锁
为什么会有自旋锁?
线程的阻塞和唤醒需要CPU从用户态转为核心态,而频繁的阻塞唤醒非常消耗 CPU 资源。
简介
所谓自旋锁就是当一个线程尝试获取锁时,如果该锁被其他线程占用,则该线程就一直循环
检测锁是否被释放,而不是进行睡眠
或挂起
状态。
适用场景
自旋锁适用于锁保护的临界区很小的情况,所谓临界区就是指访问共享资源
的程序片段
,当临界区很小时,锁占用的时间很短。为什么?
因为自旋锁是占用 CPU 资源的,如果临界区太大,执行时间长,则 CPU 资源会被占用很长时间,典型占着茅坑不拉屎。
在 Jdk1.6 自旋锁默认开启,自旋的默认次数为 10
,可通过-XX:PreBlockSpin
指令修改,但 Jvm加入了自适应
,可自适应性的修改自旋次数。
自适应自旋
如果自旋成功,则自旋的次数会增加,反之如果失败,则会减少自旋的次数,避免过度浪费 CPU 资源。
锁消除
在某些情况下当同步代码中的对象不存在被多个线程竞争时,JVM会对这些同步锁进行锁消除。
示例代码
1 |
|
在 main 方法 lock_clean 对象没有逃逸出 main 方法,所以 JVM 认为不存在竞争关系。
方法生成的字节码
1 |
|
锁消除的依据是逃逸分析的数据支持
锁粗化
在使用 Synchronized 时我们一般会加在临界区很小的地方,缩短锁的占用时间,等待锁的线程尽可能快的获取锁。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗化:就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
偏向锁
为什么会有偏向锁?
因为在大多数程序中,锁不仅不存在多线程竞争,而且总是被同一个线程多次获得,为了让线程获取锁的代价更低,加入了偏向锁。
特性
偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
获取流程
当锁对象被线程持有时,Mark Word 存储了持有锁对象的线程的线程ID,所以偏向锁的获取就是:线程通过 CAS 操作不断的比对并修改锁对象的 Mark Word 中存储的线程 ID。
大致流程如下:
- 步骤1:检查锁对象 Mark Word 偏向标识是否为
1
=1
:不可偏向:说明其他线程正在拥有锁,执行步骤 2!=1
:可偏向:直接将当前线程 ID 存储到锁对象的 Mark Word中并将偏向标识设置为 1,执行步骤 5
- 步骤2:判断存储的线程 ID 是否与当前线程 ID 匹配
- 匹配,则执行步骤 5
- 不匹配,则执行步骤 3
- 步骤 3:通过 Cas 操作将 Mark Word 中的线程 ID 替换成自己的线程 ID
- 成功:执行步骤 5
- 失败:执行步骤 4
- 步骤 4:竞争失败,说明有多线程竞争,当到达全局安全点,将获取锁的线程挂起,偏向锁升级为轻量级锁,阻塞的线程在安全点继续竞争
- 步骤 5:执行同步代码块
释放流程:偏向锁撤销
偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。
但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。
流程如下:
- 步骤 1:暂停拥有锁对象的线程,并判断锁对象是否处理锁定状态
- 否:恢复到无锁状态,偏向标识位设置为
0
; - 是:执行步骤 2
- 否:恢复到无锁状态,偏向标识位设置为
- 步骤 2:挂起持有锁的线程,并在线程栈帧中创建 Lock Record 记录,并将其指针拷贝到锁对象的 Mark Word 的中,将锁标识位改为
00
,升级为轻量级锁
。
偏向锁关闭
在 Jdk1.6 和 1.7后默认开启,想要关闭偏向锁可使用-XX:-UseBiasedLocking=false
指令。
轻量级锁
为什么会有轻量级锁?
当偏向锁存在多线程竞争时会升级为轻量级锁,偏向锁是为了某些单线程执行同步代码块的场景下使用,而轻量级锁会通过自旋的方式获取锁,不会阻塞。
使用场景
轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。
获取流程
大致流程如下:
- 步骤 1:判断锁对象的状态
- 无锁状态执行步骤 2
- 偏向锁状态:则挂起持有锁对象的线程,执行步骤 2(对应偏向锁升级轻量级锁的过程)
- 步骤 2:在当前线程的栈帧中创建 Lock Record(锁记录)空间,用于存储锁对象的 Mark Word
- 步骤 3:拷贝锁对象的 Mark Word 到当前线程的 Lock Record 中
- 步骤 4:通过 CAS 操作替换锁对象的 Mark Word 中存储的 Lock Record ,并把 MarkWord 的指针设置到 LockRecord 的
owner
字段中。- 成功:更新锁标识位为
00
,标识当前锁状态为轻量级锁 - 失败:执行步骤 5
- 成功:更新锁标识位为
- 步骤 5:当 CAS 更新失败后,Jvm 查看锁对象的 MarkWord 中的 Lock Record 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,可直接进入同步块执行。否则说明多个线程竞争,进入自旋,若自旋结束依旧未获取到锁,轻量级锁就要膨胀为重量级锁,锁标志位变为 10,锁对象的 MarkWord 中存储指向重量级锁的指针,当前线程以及后面等待的线程进入阻塞状态。
步骤 2:
步骤 4:
释放流程
当持有锁对象的线程执行完同步代码块时将通过 CAS 操作将锁对象的 Mark Work 中存储的 Lock Record 指针置为 NULL。如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁。
注意
对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁膨胀为重量级锁流程
为什么轻量级锁会在 MarkWord 中保存 LockReacord 记录?
一方面可以用于 CAS 比较,其次在升级为重量级锁时,持有锁的线程和等待的线程都会被阻塞,便于唤醒线程。
总结
关于不同锁的使用及其有点场景如下:
锁 | 优点 | 缺点 | 场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不用消耗时间 | 锁存在竞争会带来额外的锁撤销消耗 | 单线程访问同步代码块 |
轻量级锁 | 竞争的线程不会长时间阻塞 | 自旋浪费 CPU | 追求响应时间,代码块执行快 |
重量级锁 | 不会自旋消耗 CPU | 线程阻塞响应时间慢 | 代码块执行时间长 |
推荐文章