本篇是关于从kernel层面分析synchronized、volatile,进大厂必备硬核小伎俩系列的第二篇。在上一篇中主要介绍了一些关于synchronized、volatile相关的底层相关的概念。本文主要介绍内存屏障的概念、分析jvm锁升级的过程等。而关于volatile的实现细节及DCL单例是否需要加volatile修饰将在下一篇进行介绍。
内存屏障
内存屏障用白话说就是在两个指令之间加入一个屏障,前面的指令不执
行完,后面的指令是不允许执行的。内存屏障分为操作系统级别和jvm
级别的屏障。
OS内存屏障
操作系统级别的内存屏障在x86系统上是支持sfence、lfence、mfence原语来实现内存屏障功能的。
- sfence:sfence之前的写操作必须在sfence之后的写操作执行之前完成。
- lfence:lfence之前的读操作必须在sfence之后的读操作执行之前完成。
- mfence:mfence之前的读写操作必须在mfence之后的读写操作之前完成。
这三个指令不是所有的操作系统都支持的,如果不支持此指令的操作
系统,那么在操作系统层面就是通过lock指令来用锁总线的方式。总
之支持指令的用指令来实现内存屏障,不支持指令的用锁总线的方式
来实现内存屏障。
jvm内存屏障
jvm级别的内存屏障分loadload,loadstore,storeload,storestore。
- loadload:在对加volatile关键字的变量i进行读操作时,在读i之前的所有读操作必须全部完成,才能进行对i的读取。也就是在i之前加入了loadload指令。
- loadstore:在对加volatile关键字的变量i进行写操作时,在给i赋值之前的所有读操作必须全部完成,才能进行对i赋值。也就是在i之前加入了loadstore指令。
- storeload:在对加volatile关键字的变量i进行读操作时,在读取i之前的所有写操作必须全部完成,才能进行对i进行读取。也就是在i之前加入了storeload指令。
- storestore:在对加volatile关键字的变量i进行写操作时,在给i赋值之前的所有写操作必须全部完成,才能进行对i进行赋值操作。也就是在i之前加入了storestore指令。
这样还很混乱,看下面的例子:
int a;
volatile int b;
a = 5;
// 插入storestore屏障
b = 6;
// 插入storeload屏障,保障b完成赋值完全可见,才执行if操作
if (b == 6) {
// 插入loadload指令
a = 4;
}
synchronized锁升级
synchronized在jdk1.6之前是重量级锁,也就是说没有锁升级的过程,只要给对象加锁,那么直接通过想内核空间申请锁,从用户态转内核态是很消耗资源的,所以jdk1.6之后采用了锁升级的机制。
锁升级过程:无锁-》给对象加synchronized,如果开启偏向锁,那么先会升级为偏向锁,否则直接升级为轻量级锁-》有线程竞争同一把锁的情况,那么如果当前是偏向锁,那么会升级为轻量级锁;-》当竞争到一定程度(有一个线程自旋10次或竞争的线程数超过内核数的2分之一,操作系统也会自己判断是否需要升级)时,就会升级为重量级锁。
锁升级的过程会通过对象头的markword来记录,以下是对象的不同状态markword的变化情况(64位操作系统),重点可关注后两列(是否为偏向锁和锁标识位):
下面我们来通过JOL工具来来观察对象头,分别分析锁升级的过程。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
- 无锁
public class TestNewObj {
public static void main(String[] args) {
TestNewObj testNewObj = new TestNewObj();
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
}
}
前两行8个字节共64位代码markword,可以看到无锁的情况下,锁标识位上为01,代表无锁状态。
-
偏向锁
同样的代码,我们开启偏向锁(-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0),观察输出,后三位为101,代表偏向锁,也就是如果我们开启偏向锁,那么默认创建对象时就是偏向锁,其实这个是匿名偏向锁,因为可以观察到markword中没有记录线程id。 -
存在竞争的偏向锁:
public class TestNewObj {
public static void main(String[] args) {
TestNewObj testNewObj = new TestNewObj();
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
synchronized (testNewObj) {
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
}
}
}
仍然开启偏向锁,执行上面的代码段,分别输出两次改对象的对象头,首次实例化后markword锁标识位为101,其余位上都是0,当给该对象加锁后,markword锁标识位为101,但是markword中会记录线程id,这个就是正常的偏向锁。
- 禁用偏向锁
public class TestNewObj {
public static void main(String[] args) {
TestNewObj testNewObj = new TestNewObj();
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
synchronized (testNewObj) {
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
}
}
}
可以看到上面是禁用了偏向锁,分别输出两次对象头信息,观察到,初始化对象时,对象时无锁状态,当给对象加锁后,再次输出对象头,可以观察到该对象头中锁标识位上是00,代表轻量级锁,也就是说当禁用偏向锁后,存在给对象加锁,对象就直接升级为轻量级锁,不会再升级为偏向锁了。
- 存在锁竞争,升级为重量级锁
public class TestNewObj {
public static void main(String[] args) {
final TestNewObj testNewObj = new TestNewObj();
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
new Thread(new Runnable() {
@Override
public void run() {
synchronized (testNewObj) {
try {
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
synchronized (testNewObj) {
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
}
}
}).start();
}
}
可以观察上面的代码段和输出,当存在两个线程同时竞争同一把锁后,第二个线程输出的对象头中,锁标记位为10,即重量级锁。也就是当存在竞争后,会随即升级为重量级锁。
- 锁不能降级
public class TestNewObj {
public static void main(String[] args) throws InterruptedException {
final TestNewObj testNewObj = new TestNewObj();
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
new Thread(new Runnable() {
@Override
public void run() {
synchronized (testNewObj) {
try {
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
synchronized (testNewObj) {
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
}
}
}).start();
Thread.sleep(5000);
System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
}
上面图输出结果是上面代码段的最后一个输出,应该说两个线程都执行结束后,已经释放对象锁后,观察对象头中的markword的锁标记位仍然为10(重量级锁),说明锁不可降级。
synchronized的jvm实现。
我们先看下加了synchronized修饰的两个代码片段及对应的汇编码:
public class TestLock {
public synchronized void lock(){
}
}
public class TestLock {
Object o = new Object();
public void lock(){
synchronized (o) {
}
}
}
观察上面两个代码段和对应的汇编码,分别是给方法加锁和锁实例对象,汇编指令是不同的。
-
给对象加synchronized:在汇编指令中会生成monitorenter和两个monitorexit指令,也就是说在jvm层面给对象加同步锁,是通过加入monitorenter来给代码块进行加锁控制的,monitorexit是执行完同步代码块后用于释放对象锁的指令,那么为什么会有两个monitorexit呢?那就是说第一个monitorexit是正常执行完同步代码块后的释放锁指令,第二个monitorexit是当同步代码块抛出异常时,用来释放锁的指令。也就是为什么synchronized修饰的同步代码块抛出异常时也会释放锁的原因了。
-
synchronized修饰方法:我们看到synchronized修饰方法时,会在汇编码的方法描述符中生成ACC_SYNCHRONIZED指令,这个指令其实也就是表明是在方法上加了synchronized修饰,其实底层也是调用了monitorenter和monitorexit指令来实现的加锁。
synchronized在OS上的实现
上面说了synchronized在jvm中的实现,那么在os上是如何实现的呢?在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。
ObjectMonitor() {
_header = NULL;
_count = 0; //锁计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
最后
下一篇将接着本篇讲解,会介绍volatile底层实现原理及DCL单例是否需要加volatile修饰等相关底层实现。如果本篇对你有用,欢迎点赞、关注、转载,由于水平有限,如有问题请留言。