一、前言序章
Java因为实现的是共享数据模型,在多线程操作共享数据时,会引起线程安全问题。Java为了解决线程安全问题,在Jvm层面为我们提供了一把内置锁——synchronized。接下来我将带领大家一起探索synchronized的世界。
二、synchronized基础使用
synchronized是Jvm层面提供的内置锁,基于Monitor机制实现,它是一把重量级的锁,性能较低。在JDK1.5之后进行了重大优化,比如锁粗化、锁消除、偏向锁、轻量级锁、自适应自旋等技术来减少锁的性能开销,经过相关一系列的优化手段之后,和Lock锁的性能基本不相上下。
而在synchronized的使用方面,则是非常简单,具体使用方式如下图所示:
归类之后就是两种使用方式:
1、在方法上使用synchronized
当synchronized作用于实例方法时,锁住的就是实例对象;
当synchronized作用于静态方法时,锁住的就是类对象。
2、在代码块中使用synchronized
当synchronized作用于代码块时,如果不是类对象,那么锁住的就是实例对象;
如果相反,那锁住的就是类对象本身;
另有一个特殊点,就是如果是我们自己new的对象,那么锁住的也是这个对象。
三、synchronized底层原理
当synchronized作用于方法上时,在Jvm指令层面则是通过设置acc_synchronized标志来实现;而在同步代码块中,则是通过monitorenter和monitorexit这两个指令来实现。最终都是依赖操作系统的互斥原语Mutex来实现,被阻塞的线程将被挂起、等待重新调度,而使线程挂起、唤醒会涉及到内核态和用户态之间的切换,性能损耗非常大。
当synchronized作用于方法上:
当synchronized作用于代码块中:
3.1 Java中的Monitor(管程)
管程就是管理共享变量和堆共享变量操作的过程,让它们支持并发。在Java多线程访问共享资源时,会出现原子性、可见性问题,而为了解决这些问题,java就实现了多线程的同步和互斥,用于保证同一时刻只有一个线程可以访问。而这个机制的来源就是Monitor,而对Monitor的具体实现就是MESA模型。
3.2 MESA模型
MESA具体流程就是:当有多个线程竞争共享资源时,只有一个线程可以竞争到,其它竞争失败的线程将进入入口等待队列进行排队等待。而当线程之间需要同步操作时,就会使用条件变量,将锁释放,使自身进入条件等待队列,直到被下一个线程唤醒。
3.3Java实现Monitor机制
Java参考了MESA模型,但是对MESA进行了简化。在Java实现的MESA模型中,只有一个条件变量。
而具体的实现则是依赖JVM中ObjectMonitor,其主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; //线程存放的地方
_WaitSet = NULL; //低啊用wait后,线程将在这等待
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;//多线程竞争失败后将在此排队
FreeNext = NULL ;
_EntryList = NULL ;//存放用于等待锁的线程
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
执行流程如下:
1.当线程竞争锁失败后,会先存放到_cxq队列中(该队列是栈结构);
2.如果_EntryList为空,则将_cxq中的元素按顺序复制到_EntryList,并唤醒第一个线程去持有锁;如果_EntryList不为空,则直接从_EntryList中唤醒第一个线程;
3.当持有锁的线程调用wait(),线程将释放锁并进入_waitSet进行排队等待;
4.调用notify(),将唤醒的线程根据策略存放_cxq或_EntryList中。
3.4 synchronized锁的记录
在JVM中,对象的内存布局为对象头、实例数据、对其填充。
而在对象头中,有一个MarkWord部份,里面用于记录对象运行时所需的数据,其中锁的状态就记录在里面。64位JVM中MarkWord描述如下:
ptr_to_lock_record:在轻量级锁状态下,指向的是线程栈中锁记录。
ptr_to_heavyweight_monitor:在重量级锁状态下,指向的是Monitor的指针。
四、synchronized锁的状态变化
锁的信息记录在MarkWord中,因此我们并不能直接可以观测到锁的变化信息,而需要引入一个JOL工具。锁状态的变化如下图所示:
4.1 偏向锁
偏向锁是为了消除数据在无竞争情况下把整个同步都消除掉,连CAS都不需要做。
当JVM启用了偏向锁模式,新创建的对象的MarkWord的Thread ID为0,此时处于可偏向但是没有偏向任何线程的状态,称为匿名偏向状态。
4.2 偏向锁延迟偏向
虚拟机启动4秒后,才会对新建的对象开启偏向模式。在虚拟机启动过 程,会大量使用synchronized进行加锁操作,但是这些操作大部分都不是偏向锁,为了减少初始化时间,JVM默认开启延迟偏向。
//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking
//启用偏向锁(默认开启)
-XX:+UseBiasedLocking
//此demo可以看到延迟偏向状态
public static void main(String[] args) throws InterruptedException {
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000);
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
}
执行结果:
4.3 轻量级锁
当偏向锁失败后,虚拟机并不会立即进入重量级锁,而是会先升级成轻量级锁,此时MarkWord也会变为轻量级的结构。轻量级锁适用于线程交替执行的场景。
轻量级锁加锁过程:
首先在进入同步代码块前,会复制一份当前对象的markword到线程栈的lock record中,同时更新对象markword的指针指向lock record,然后将lock record中的owner指向当前对象。当轻量级锁重入后,会在当前线程栈中添加一份新的lock record,并且这份lock record中的markword为空,而lock record中的owner指向当前对象的位置。
轻量级锁解锁过程:
线程栈逐步出栈,然后将lock record释放,并更新后面的lock record中的owner值,直到所有lock record都出栈后,对象头markword的将恢复到无锁数据结构。
4.4重量级锁
当线程的竞争非常激烈时,使用CAS修改MarkWord失败,将会膨胀为重量级锁,此时会进行CAS自旋获取锁,如果一定时间内还是获取不到,就只能进行阻塞。
五、synchronized锁优化
5.1偏向锁批量重偏向和批量撤销
当只有一个线程频繁进入同步代码块时,性能损耗可以忽略不计,但是如果有多线程竞争,那么偏向锁就会升级为轻量级锁,而升级锁这个操作只能在safe point中进行,并且会有一定的性能损耗,如果竞争激烈,频繁的升级锁就会使性能下降。于是就引入了批量重偏向和批量撤销机制。
实现的原理:
JVM会在class中维护一个偏向锁撤销计数器——epoch,每当发生一次偏向锁撤销,该计数器便+1,当这个值达到20时,JVM就会进行偏向锁批量重偏向。当达到20阈值之后,如果计数器的值还在累加,并且达到了40时,JVM就认为当前class存在多线程竞争,将该class的偏向锁禁用,后续直接走轻量级锁。
具体的流程如下:
1.每个class都有epoch,每个处于偏向锁状态的对象也有epoch(初始值就是class的epoch)
2.当达到20阈值,将发生一次批量重偏向,会在safe point中去修改class的epoch+1
3.遍历所有线程栈,找到该class中所有正处于加锁状态的偏向锁(此时当前加锁的对象仍处于synchronized代码块中),修改对象的epoch值为class的epoch值
4.下次获取锁时,判断对象的epoch和class的epoch是否相同,不同cas修改Thread ID
批量重偏向和批量撤销总结:
1.批量重偏向和批量撤销针对类的优化,和对象无关
2.偏向锁只能重偏向一次
3.当触发了批量撤销操作后,该类的偏向锁机制将无效,后续创建的对象都是无锁状态
5.2 自适应自旋优化
重量级锁再竞争锁时,会使用自旋来进行优化,如果自旋过程中获取到锁,那么就可以避免线程挂起和唤醒。线程挂起和唤醒都涉及到内核态和用户态之间的切换,性能损耗高。
而在Java6之后自旋都是自适应的,如果上次通过CAS获取到锁,那么这次自旋次数就会增加,如果上次CAS没有获取到锁,这次就会少自旋甚至不自旋。
5.3 锁粗化
如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
StringBuffer buffer = new StringBuffer();
/**
* 锁粗化
*/
public void append(){
buffer.append("aaa").append(" bbb").append(" ccc");
}
比如上面的代码,经过锁粗化后,只会在第一个append("aaa")加锁,会在append("ccc")进行解锁。通过锁粗化,避免不必要的加锁解锁,以提升性能。
5.4 锁消除
锁消除和锁粗化类似,本质上都是减少不必要的加锁、解锁操作。JVM会通过逃逸分析来进行锁消除。
/**
* 锁消除
* -XX:+EliminateLocks 开启锁消除(jdk8默认开启)
* -XX:-EliminateLocks 关闭锁消除
* @param str1
* @param str2
*/
public void append(String str1, String str2) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
public static void main(String[] args) throws InterruptedException {
LockEliminationTest demo = new LockEliminationTest();
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
demo.append("aaa", "bbb");
}
long end = System.currentTimeMillis();
System.out.println("执行时间:" + (end - start) + " ms");
}
JVM会通过逃逸分析去判断一个对象能否被外部访问到。
如果当前对象只是在某个方法内使用,那么对这个对象的操作可以不考虑同步。
如果这个对象不能被其它线程访问,那么对这个对象的操作也可以不考虑同步。