前言
synchronized
作为java关键字,在多线程并发编程中一直是一个很重要的角色,java SE 1.6之前其一直被人称为重量级锁,但是1.6对synchronized
进行了各种优化,有些情况下它并没有那么重了。
synchronized 应用方式
利用synchronized
实现同步的基础: java中的每一个对象都可以作为锁,具体表现为以下三种形式。
- 对于普通同步方法,锁住的是当前实例对象
- 对于静态同步方法,锁住的是当前类的class对象
- 对于同步方法块,所示
synchronized
括号里配置的对象
JVM规范中可以看到Synchronized
在JVM里的实现原理,JVM基于进入和进出Monitor对象来实现方法同步和代码块同步,但是两者的实现细节不一样。
- 代码块同步
使用
monitorenter
和monitorexit
指令实现的。monitorenter
在编译后插入到同步代码块的开始位置,而monitorexit
是插入到方法结束处和异常处,JVM保证每个monitorenter
必须有monitorexit
与值配对。任何对象都有一个monitor与之关联。当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。 - 方法同步
细节在JVM规范里没有详细说明。但是方法的同步同样可以使用
monitorenter
和monitorexit
指令实现
对象的内存布局
对象在内存中存储的布局可以分为三块区域:对象头(Header)
,实例数据(Instance Data)
和对齐填充(Padding)
。下图为非数组类型的对象在HotSpot虚拟机中的内存结构(数组对象的对象头会多出一个字宽存储数组的长度):
理解Java对象头与Monitor
synchronized
用的锁是存在Java对象头里的:
- 如果对象是数组类型,则虚拟机用3个字宽(word)存储对象头。
- 如果对象是非数组类型,则用2个字宽存储对象头。
32位虚拟机中,1个字宽等于4个字节,即32bit。64位虚拟机中,1个字宽等于64bit,如下表:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的HashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储对象类型数据的指针 |
32/32bit | Array length | 如果对象是数组类型,则多出此32位保留数组的长度 |
Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下表所示:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的HashCode | 对象的分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化
。在32位虚拟机下,Mark Word可能会变化为以下4种数据,如下图:
在64位虚拟机下,Mark word的变化结构有如下几种:
|------------------------------------------------------------------------------|---------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|---------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:0 | lock:01| 正常 |
|------------------------------------------------------------------------------|---------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:01| 偏向锁 |
|------------------------------------------------------------------------------|---------|
| ptr_to_lock_record:62 | lock:00| 轻量级锁 |
|------------------------------------------------------------------------------|---------|
| ptr_to_heavyweight_monitor:62 | lock:10| 重量级锁 |
|------------------------------------------------------------------------------|---------|
| | lock:11| GC标志 |
|------------------------------------------------------------------------------|---------|
锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态
、偏向锁状态
、轻量级锁状态
和重量级锁状态
。这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。
1.偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时:
- 首先在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否 存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁)。如果没有设置(值位0),则使用CAS竞争锁;如果设置了(值为1),则尝试使用CAS将对象头的偏向锁指向当前线程。
(1) 偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
(2) )关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如 有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程 序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:- UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
2. 轻量级锁
(1) 轻量级锁加锁
线程在执行同步块之前,会执行以下操作:
- JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,
- 并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
- 然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2) 轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是 两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
3. 锁的优缺点对比
参考《java并发编程的艺术》-方腾飞