前言
synchronized在JDK1.6的优化之后,性能上有了较大的提升。
synchronized优化的内容是锁升级机制。
- 偏向锁、自旋锁、重量级锁
- 锁升级条件、过程
- 底层实现
各类锁的含义
JDK1.6之后,synchronized的锁机制按照开销从小到大排列,依次可分为:偏向锁、自旋锁(轻量级锁)和重量级锁。
重量级锁
- JDK1.6之前,synchronized只有一种锁机制,即重量级锁。
- 获取锁失败的线程会进入阻塞(Blocked)状态,目标锁的释放会唤醒这些线程。
- 线程的阻塞和唤醒都需要通过系统调用,由操作系统来完成,其中涉及到用户态与内核态的切换,开销很大。这也是通常来说重量级锁性能较低的原因。
自旋锁
- 通过CAS操作来获取锁,如果CAS操作成功,则获取到锁,如果失败,则继续自旋尝试获取锁。
- 获取锁失败的线程不会阻塞,仍然继续自旋尝试获取锁。
- 自旋锁的加锁和解锁不涉及线程状态的变化,CAS操作也不要系统调用,因此,相比于重量级锁,节省了系统调用的开销。
偏向锁
- 偏向锁只存在于仅有一个线程请求锁的情况下,一旦超过一个线程请求锁,则锁升级为自旋锁。
- 偏向锁仅在该线程第一次获取锁时,通过CAS操作在锁上记录线程标识,之后再尝试获取锁时,如果发现锁上的线程标识和本线程相符,直接进入同步代码块,无需再通过CAS操作加锁。退出同步代码块也无需释放锁操作。
- 引入偏向锁的原因是:Hotspot作者经研究发现,大多数情况下,锁不仅不存在多线程竞争,还总是由同一个线程多次获得,因而通过引入偏向锁,能够让这种情况下的加锁解锁操作开销进一步降低。
思考
偏向锁一定比自旋锁高效吗?
不一定,偏向锁是针对无竞争且每次是由同一个线程获取锁的情况的优化。如果明确知道存在竞争,那么偏向锁的设置和撤销就是无意义的消耗,不如直接升级为自旋锁。
自旋锁一定比重量级锁高效吗?
不一定,重量级锁的开销在于加锁和解锁过程的系统调用开销,对于重量级锁,加锁失败的线程进入Blocked状态,不再占用CPU资源。
而对于自旋锁,加锁失败的线程一直处于Runnable状态,会一直自旋再次尝试获取锁。
因此,如果竞争很激烈(即有很多线程尝试获取锁),或同步代码段执行的时间非常长(即加锁失败的线程需要长时间自旋等待),那么加锁失败线程的自旋就会消耗大量CPU资源,也就得不偿失了。
上面的两个问题就引出了锁升级的概念。
锁升级
synchronized锁升级过程如下图所示:
偏向锁功能在JVM中有两个相关参数可以配置:
-XX:-UseBiasedLocking=ture
:是否使用偏向锁,默认开启。-XX:BiasedLockingStartupDelay=4000
:偏向锁功能开启延时,默认4000,即JVM启动4秒后,才开启偏向锁功能。- 之所以默认配置了4秒的开启延时,是因为,在JVM启动过程中,明确知晓存在很多竞争,为了避免偏向锁撤销和锁升级带来的无意义的消耗,因此延时一段时间,等JVM启动完成后,才开启偏向锁功能。
一个锁对象new出来之后,如果未开启偏向锁功能,那么这就是一个普通对象,而如果开启了偏向锁功能,此时锁上没有任何一个线程标识,称之为匿名偏向。
普通对象加锁后,即成为自旋锁;匿名偏向锁加锁后,成为偏向锁。
偏向锁不可重偏向,一旦有第二个线程尝试对偏向锁加锁,偏向锁便撤销,并升级为自旋锁。
如果竞争加剧,自旋锁升级为重量级锁。
何为竞争加剧:
- 某个线程自旋次数超过指定值,可通过
-XX:PreBlcokSpin=10
配置,默认值为10。 - 或者自旋等待该锁的进程超过指定值,一般为CPU核数的一半。
- 自适应自旋(Adapative Self Spinning):两个条件的阈值由JVM自己动态控制。
- 某个线程自旋次数超过指定值,可通过
底层实现
了解上面锁的类型和升级过程之后,我们可能还会有疑问:
为什么synchronized可以将任何对象作为锁?
怎么区分一个锁对象是何种类型的锁?
偏向锁的线程标识存的是什么?存在哪儿?
synchronized可重入是如何实现的?
……
这就需要了解Synchronized更底层的实现了。
java对象内存布局
首先了解一下HotSpot实现中,一个java对象(Object)在内存中的布局。
内存布局
如下表所示,一个对象在内存中通常可以分为以下4部分:mark word、class pointer、instance data和内存对齐。
内容 | 大小 | 备注 |
---|---|---|
mark word | 8字节 | |
class pointer | 4字节 | 指向对象所属的类 |
instance data | 对象中的成员所占的空间 | |
内存对齐 | 0~7字节 | 8字节对齐 |
其中mark word中的内容和锁紧密相关。
mark word
mark word占用8字节空间,其中的内容随着对象状态的不同而变化,如下表所示:
可以看到根据一个对象的最后3位,就能判断出此对象当前是普通对象、正在被垃圾回收还是处于某种锁状态。
可重入
可重入锁是指:同一个线程可以多次对同一把锁执行加锁操作,而不会引起死锁。
synchronized锁必须是可重入的,否则某些java特性就会引发死锁,比如:父类中有个synchronized方法,子类重写了该方法,也是synchronized方法,子类的方法中又通过super调用了父类的该方法,相当于对同一把锁加锁了两次,如果synchronized不支持重入,那么这种情况就会引发死锁。
那么,synchronized是如何实现可重入的呢?
关键在于记录两个东西:
- 线程的标识,否则无法判断是否是同一个线程加锁;
- 加锁的次数,因为需要对应次数的解锁才能释放锁。
接下来我们就来看看不同的锁分别是如何实现的:
偏向锁
- mark word中就记录了线程指针
- 不需要记录加锁次数,因为偏向锁不存在释放锁的概念,一旦有其他线程尝试拿锁,就会升级为自旋锁。
自旋锁
通过Lock Record来实现重入。
Lock Record在Hot Spot源码中的class名称是
BasicLock
,其中只有一个成员_displaced_header
,用于备份该对象加锁前的mark word。Lock Record 和 加锁的对象 成对以
BasicObjectLock
的结构存储在线程栈中,每加一次锁,都会向栈中存入一个Lock Record。从mark word的内存布局可以看到,当锁标志是00时,mark word中记录了Lock Record的指针,再次加锁时会通过
is_lock_owned
函数判断对象mark word中的Lock Record指针是否在本线程的栈中,如果是,则表示锁是本线程持有的。自旋锁并没有通过记录加锁的次数来实现解锁和加锁次数的对应。自旋锁通过以下方法实现:
第一次加锁时,会把对象的mark word中的原内容备份到Lock Record的
displaced header
中,并通过CAS的方式修改mark word,将Lock Record的地址存入mark word中。之后,当同一个线程再次加锁时,会生成新的Lock Record放入栈中,但是
displaced header
中存的是NULL
,也不会再修改mark word。解锁时,如果从栈中弹出的Lock Record中的
displaced header
中存的是NULL,就表示锁还没有解完,锁仍然还由本线程持有。直到弹出的Lock Record中,
displaced header
中内容不为NULL,将displaced header
中的内容还原到对象的mark word中,锁成功释放。
重量级锁
- 重量级锁通过ObjectMonitor实现。
- ObjectMonitor中记录了锁的持有线程
_owner
和重入的次数_recursions
。 - 此外,ObjectMonitor还备份了对象加锁前的mark word,即
_header
。
HashCode
一旦某个对象计算过identity hashcode,该值就会存储在对象的mark word中,下次再需要获取该对象的hashcode时,就直接返回mark word中记录的hashcode。
但是从markword的布局图中,我们看到,对象被加锁后,mark word中就不再存有hashcode了,难道加锁的对象就没有hashcode了吗?
答案显然是否定的。
- 对于自旋锁和重量级锁,hashcode可以从LockRecod或者ObjectMonitor中的
displaced header
中读出。- 而对于偏向锁,实际上偏向锁和hashcode不能共存,一旦对处于偏向锁状态的对象计算hashcode,如果偏向锁未被持有,那么会变为普通对象,如果偏向锁被持有了,则膨胀为自旋锁或重量级锁。
实验验证
想要通过实验验证锁的升级过程,就需要能够直观的看到一个对象在内存中的原始数值,通过 ClassLayout
类可以将一个java对象在内存中的原始数据以方便观察的格式打印出来。使用方法也很简单:
引入mvn依赖:
1
2
3
4
5<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>在源码中输出:
1
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
普通对象 加锁 -> 自旋锁
代码如下:
1 | public class T01_Synchronized { |
分别打印了一个Object实例在加锁前后的内存布局。
输出如下:
1 | java.lang.Object object internals: |
我们看到打印出的lock对象的内存布局,每行展示了4字节数据,
VALUE
一栏分别以16进制、2进制和10进制三种方式打印出了值。前两行共8字节,就是mark word的值,打印是按低字节在前的顺序打印的,因此第一个字节的bit0~1就是锁标志。
可以看到加锁前,锁标志是 0 1
,偏向锁位是 0
,其他位都是0,即这是一个普通对象,之所以不是匿名偏向锁,是因为程序刚运行时,偏向锁功能还未启用。
加锁后,锁标志变成了0 0
,即自旋锁,且其他位已经有数据,其中存储的是Lock Record的地址。
再次加锁,程序仍然能继续执行,证明synchronized是可重入的,且mark word中存储的Lock Record地址仍然是初次加锁的Lock Record地址,并没有变。
匿名偏向 加锁 -> 偏向锁
1 | public class T01_Synchronized { |
输出如下:
1 | java.lang.Object object internals: |
这次首先sleep 5秒,待偏向锁功能启用后,再new一个Object,打印加锁前后,该对象的内存布局。
可见,加锁前,锁标志为:0 1
,偏向锁位为:1
,markword中其余位均为0,为匿名偏向状态。
加锁后,mark word 中记录了线程指针(JavaThread *
)。
偏向锁与hashcode
1 | public class T01_Synchronized { |
首先产生一把匿名偏向锁,接着对该对象计算hashcode,观察前后对象内存布局;
再生成一把偏向锁,加锁后计算该对象的hashcode,观察前后对象布局。
输出如下:
1 | java.lang.Object object internals: |
根据上面结果可见:
- 偏向锁加锁前,计算hashcode,锁标志位不变,仍为
0 1
,偏向锁位由1
变为0
,从偏向锁变为了普通对象,且mark word中确实存储了hashcode值。加锁后,锁标志位变为0 0
,即自旋锁。 - 偏向锁加锁后,计算harshcode,锁标志位变为
1 0
,膨胀成重量级锁。
源码实现
java代码
1 | public class T01_Synchronized { |
字节码
通过jclasslib可以看到main函数的字节码如下(jclasslib的使用可移步《class文件格式-插件jclasslib》):
1 | new #2 <java/lang/Object> |
可以看到 synchronized
锁住的同步代码段前后分别插入了一条monitorenter
和monitorexit
指令。
可以注意到monitorexit
指令有两条,这是因为如果在同步代码段发生了异常,会自动释放锁。
JVM实现
Lock Record数据结构
在hotspot源码(版本:hotspot-37240c1019fd
)中,我们首先来看一下Lock Record的数据结构:
src\share\vm\runtime\basicLock.hpp
中:
1 | class BasicObjectLock VALUE_OBJ_CLASS_SPEC { |
可以看到 BasicObjectLock 中包含了 加锁对象_obj
和 Lock Record _lock
,_lock
中则包含了mark word的备份_displaced_head
。
再往下我们就能看到,BasicLock *
就是自旋锁加锁时,实际存入mark word中的指针。
偏向锁
偏向锁的加锁过程就是MacroAssembler::biased_locking_enter
函数,每个硬件平台有其各自的实现,如x86平台的实现在src\cpu\x86\vm\macroAssembler_x86.cpp
文件中。
自旋锁
自旋锁的加锁过程为ObjectSynchronizer::slow_enter
,在src\share\vm\runtime\synchronizer.cpp
中:
1 | void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) { |
可以看到,加锁流程为:
- 判断mark word,如果是普通对象,先将mark word存入lock record的displaced_header中,再通过cas操作将lock record的地址存入mark word中,如果成功,则加锁成功,如果失败,就膨胀为重量级锁。
- 如果判断mark word已经是自旋锁,那么通过
is_lock_owned
判断mark word中的lock record地址是否在本线程的栈中,如果在,则可重入,加锁成功;如果不在,膨胀为重量级锁。
从源码来看,似乎并未看到很多文章中描述的竞争加剧的条件,而是一旦自旋锁加锁失败,直接膨胀为重量级锁。