文章目录
1. 概述
在多线程并发编程中 synchronized
一直是元老级角色,很多人都会称呼它为 重量级锁 ,但是,随着 Java SE 1.6
版本对 synchronzied
进行了各种优化之后,有些情况它并不那么重了。本文将详细介绍 Java SE 1.6
中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁, 以及锁的存储结构和升级过程.
2. 实现同步的基础
Java中的每个对象都可以作为锁,具体变现为以下3中形式:
- 对于普通同步方法, 锁是当前实例对象。
- 对于静态同步方法, 锁是当前类的Class对象。
- 对于同步方法块, 锁是synchronized括号里配置的对象。
一个线程试图访问同步代码块时,,必须获取锁。在退出或者抛出异常时, 必须释放锁。
3. 实现方式
JVM 基于进入和退出Monitor
对象来实现方法同步 和 代码块同步,但是两者的实现细节不一样:
- 代码块同步 :通过
monitorrenter
和montorexit
指令实现的。- 同步方法 :通过
ACC_SYNCHRONIZED
修饰。
4. 对象头
在 HotSpot 虚拟机
中,对象在内存中的布局可以分为三块区域:对象头、实例数据 和 对其填充。
对象头 包括两部分:MarkWord 和 类型指针。
如果是数组对象的话,对象头还有一部分是存储数组的长度。
多线程下 synchronized
的加锁就是对同一个对象的对象头中的 MarkWord
中的变量进行 CAS 操作 。
4.1. MarkWord
MarkWord :用于存储对象自身的运行时数据,如 HashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向锁ID 等等。
占用内存大小与虚拟机位长一致(32位 JVM --> MarkWord 是32位,64位 JVM --> MarkWord 是64位)。
4.2. 类型指针
类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。
4.3. 对象头的长度
长度 | 内容 | 说明 |
---|---|---|
32 / 64 bit | MarkWord | 存储对象的HashCode 或 锁信息 等 |
32 / 64 bit | Class MetaData Address | 存储对象类型数据的指针 |
32 / 64 bit | Array Length | 数组的长度(如果当前对象是数组) |
如果是数组对象的话,虚拟机用3
个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头;如果是普通对象的话,虚拟机用2
个字宽存储对象头(32/64bit + 32/64bit)。
5.优化后synchronized锁的分类
锁级别从低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁可以升级,但是不能降级。即:无所 --> 偏向锁 --> 轻量级锁 --> 重量级锁 方向是单向的。
6. 锁的升级
6.1. 偏向锁
偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,,这样可以省略很多开销.。假如有两个线程来竞争该锁话,那么偏向锁就失效了,,进而升级成轻量级锁了。
为什么要这样做呢?
因为经验表明,其实大部分情况下, 都会是同一个线程进入同一块同步代码块的。 这也是为什么会有偏向锁出现的原因。
在 JDK 1.6 中,偏向苏的开关是默认开启的,适用于只有一个线程访问同步块的场景。
偏向锁的加锁:
当一个线程访问同步块并获取锁时,会在锁对象的对象头
和栈帧
中的锁记录里存储锁偏向的线程ID
,以后该进程
进入和退出同步块时不需要进行CAS操作
来加锁和解锁,只需要简单的测试一下锁对象的对象头的MarkWord
里是否存储着当前线程的偏向锁(线程ID是当前线程)
,如果测试成功,表示线程已经获得了锁;如果测试失败,则需要测试一下 MarkWord
中偏向锁的标识是否设置成1(
表示当前是偏向锁),如果没有设置,则使用CAS
竞争锁,如果设置了,则尝试使用CAS
将锁对象的对象头的偏向锁指向当前线程。
偏向锁的撤销:
偏向锁使用了一种等到竞争
出现才释放锁
的机制,所以当其他线程
尝试竞争偏向锁时,持有偏向锁的线程才会释放锁
。偏向锁的撤销需要等到全局安全点(在这个时间点上没有正在执行的字节码)
。首先会暂停持有偏向锁的线程
,然后检查持有偏向锁的线程是否存活
,如果线程不处于活动状态
,则将锁对象的对象头设置为无锁状态
;如果线程仍然活着
,则将对象的对象头中的 MarkWord
和 栈中的锁记录
要么重新偏向于其他线程
要么恢复到无锁状态
,最后唤醒暂停的线程(释放偏向锁的线程)。
总结
偏向锁在 Java 1.6 及更高版本中默认启用的,但是它在程序启动几秒钟之后才会激活。可以使用 -XX:BiasedLockingStartupDelay=0
参数来关闭偏向锁的启动延迟,也可以使用 -XX:-UseBiasedLocking=false
参数来关闭偏向锁,那么程序会直接进入轻量级锁状态。
6.2. 轻量级锁
当出现有两个线程来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀, 升级为轻量级锁。
轻量级锁加锁
线程在执行同步块之前,JVM
会先在当前线程的栈帧
中创建用户存储锁记录的空间,并在对象头中的 MarkWord
复制到锁记录
中,然后线程尝试使用 CAS
将对象头中的 MarkWord
替换为指向锁记录的指针
。如果成功,当前线程获得锁;如果失败,表示其它线程竞争锁,当前线程便尝试使用自旋
来获取锁,之后再来的线程,发现是轻量级锁,就开始进行自旋
。
轻量级锁解锁
轻量级锁解锁时,会使用原子的 CAS
操作将当前线程的锁记录
替换为对象头
,如果成功,表示没有竞争发生;如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
总结
总结一下加锁解锁过程,有线程A
和线程B
来竞争对象c
的锁(如: synchronized©{} ), 这时线程A
和线程B
同时将对象c的MarkWord
复制到自己的锁记录
中, 两者竞争去获取锁, 假设线程A
成功获取锁, 并将对象c的对象头中的线程ID(MarkWord中)
修改为指向自己的锁记录的指针
, 这时线程B
仍旧通过CAS
去获取对象c
的锁, 因为对象c的MarkWord
中的内容已经被线程A
改了, 所以获取失败。 此时为了提高获取锁的效率, 线程B
会循环去获取锁, 这个循环是有次数限制的,如果在循环结束之前CAS
操作成功, 那么线程B
就获取到锁,如果循环结束依然获取不到锁, 则获取锁失败, 对象c的MarkWord
中的记录会被修改为重量级锁
, 然后线程B
就会被挂起, 之后有线程C
来获取锁时, 看到对象c的MarkWord
中的是重量级锁的指针
, 说明竞争激烈, 直接挂起。
解锁时, 线程A
尝试使用CAS
将对象c的MarkWord
改回自己栈中复制的那个MarkWord
, 因为对象c中的MarkWord
已经被指向为重量级锁
了, 所以CAS
失败。 线程A
会释放锁并唤起等待的线程, 进行新一轮的竞争。
6.3. 锁的比较
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步代码方法的性能相差无几 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的相应速度 | 如果始终得不到锁竞争 | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |
7. 总结
首先要明确一点是引入这些锁是为了提高获取锁的效率, 要明白每种锁的使用场景, 比如偏向锁适合一个线程对一个锁的多次获取的情况; 轻量级锁适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况。