本章内容将继续介绍Java并发编程的知识,Synchronized的实现原理、锁的升级、锁的膨胀、对象头、锁的消除、偏向锁、轻量级锁、轻量级锁
何为synchronized
我们知道,synchronized
关键字能够将其修饰的代码块、方法、静态方法变成同步代码。我们在前文中已经介绍过了,使用volatile
关键字修饰能保证变量在内存中的可见性,但不保证操作的原子性,而synchronized
既能保证可见性又能保证原子性与互斥性。
关于保证变量的内存可见性,synchronized
的实现原理是:线程获取到锁,进入synchronized
代码段的时候会强制从堆内存中加载代码段内部变量的值,并且在解锁的时候会将栈内存中变量的最新值写回堆内存,从而保证变量的内存可见性。
关于互斥性,继续往下看!
前置知识:对象头
我们回顾一个经典的八股文:Java中对象的内存布局。
一个对象由三部分组成,对象头、对象的实例内容、对齐填充。其中,对象头包括了这个对象的哈希值、GC分代年龄、锁状态信息、偏向线程ID、偏向时间戳;对齐填充会使整个对象的长度为8的倍数。
但其实,对象头不是同时包含这么多信息的。根据这个对象所处的不同锁状态,对象头存储的是不同的内容:
我们知道 synchronized
会把一个对象当作锁:
synchronized
作用对象为代码段时,若不指定,加锁的对象默认为this
;若指定,如synchronized (obj)
,加锁对象为obj
synchronized
作用对象为实例方法时,加锁的对象为this
。synchronized
作用对象为静态方法时,加锁的对象该类的class
对象。
而每个对象所持有的锁的信息正是存储在对象头中,在后文中我们介绍锁的升级的时候会使用到当前对象头中所存储的信息。
锁的升级(锁的膨胀)
现在让我们看看synchronized
到底是怎么加锁的。
在JDK1.6之前,synchronized
都是重量级锁,但是重量级锁的加锁、解锁、阻塞涉及到用户态到内核态的切换,以及线程上下文的切换,导致性能开销较大。所以引入了锁的升级。
升级过程为:无锁->偏向锁->轻量级锁->重量级锁。并且锁只有升级没有降级。
接下来我将依次介绍各个锁的加锁过程。
偏向锁
假如一个线程需要重复进入一个同步代码段,这个线程会重复的进行加锁和解锁的操作,造成不必要的资源开销,所以JVM引入了偏向锁。
- 若该对象处于无锁状态,当一个线程尝试加锁时会将对象头的无锁状态改为偏向锁状态,并将这个线程的ID记录到对象头中,将Epoch置为1.
- 此时该对象处于偏向锁状态,当其他线程尝试加锁时,会首先检测对象头中的锁的偏向线程是不是自己,若是自己则可以获取锁。这样就省去了有关锁申请的操作。若不是自己JVM会撤销该偏向锁,并且当所有持有锁的线程到达安全点的时候将偏向锁升级为轻量级锁。
- 当某一个类(Class)的偏向锁被撤销到一定次数(默认为20)的时候,JVM会宣布该类往后所有的偏向锁进入下一代,之后该类所有的对象的对象头中Epoch会加一。
同时JVM会遍历查找当前已有的该类的对象,当这些对象处于安全点的时候将他们的Epoch加一。 - 当Epoch达到阈值(默认为40)的时候,JVM会撤销该类的所有偏向锁,在之后的锁的升级的过程中会直接升级为轻量级锁,不会升级为偏向锁。
轻量级锁
轻量级锁与锁的自旋是为了避免线程因为获取不到锁而发生阻塞,进而引发用户态和内核态切换。
在线程试图获取轻量级锁的过程中会自旋,而不是直接阻塞,这样就可以节省不必要的性能开销。
轻量级锁加锁过程如下:
- 当一个线程尝试为一个轻量级锁加锁的时候,会尝试将对象头中的锁记录(Lock Record)使用CAS操作修改为当前线程。
- 若CAS操作成功执行,则获取到锁。
- 若CAS操作失败,说明还有别的进程在竞争该锁,且已经获得了该锁,那么该线程会进行自旋,重新尝试获取锁,当自旋次数达到阈值时,若还没有成功获取到锁该锁会升级为重量级锁。
在轻量级锁的加锁过程中是不会发生ABA问题的,感兴趣的读者可以思考一下。
轻量级锁
由于对该锁的竞争实在是过于激烈,该对象的锁最终会升级为重量级锁,锁对象的对象头会再次发生变化,Mark Word会指向一个监视器(ObjectMonitor)对象,它是一个cpp类,其包含一个队列来登记和管理排队的线程。
重量级锁加锁过程如下:
- 当线程想要获取重量级锁时会执行
ObjectMonitor::enter()
指令,其也是通过CAS操作尝试获取锁,若成功获取锁,ObjectMonitor会将该锁的拥有者设置为该线程。 - 若没有成功获取锁,ObjectMonitor会将该线程阻塞,并加入竞争者队列等待下一次竞争,在操作系统角度,整个过程是基于互斥量mutex进行的操作。
- 当线程执行完毕后会执行
ObjectMonitor::exit()
指令,此时ObjectMonitor会将该锁的拥有者设置为null
,并通知等待队列中的线程,让他们进行竞争。
我们可以发现,在整个锁的升级过程中,在偏向锁和轻量级锁阶段,是没有线程被真正阻塞的,只有在重量级锁阶段才会被阻塞。
由于重量级锁的竞争比较复杂,我这里介绍的是一种比较理想的形式,想要深入研究可见Java面试常见问题:Monitor对象是什么?。
锁的消除
朋友们读到这一节可能会疑惑,锁不是只有升级没有降级吗,锁的消除又是啥?
锁的消除其实是虚拟机另外一种锁的优化,JVM在进行JIT编译时会对对象进行逃逸分析,若逃逸分析发现该对象不会产生线程安全问题会将其持有的锁消除。
让我们回顾一个经典的八股文:Java是所有的对象都储存在堆中吗?
这个分析的过程就涉及到逃逸分析,简单来说就是如果JVM发现一个对象是局部变量,生存周期仅包含了当前的栈帧,JVM就会将该对象储存在栈中,并不会在堆中为其分配内存。
然后我们会发现,如果一个对象的生存周期仅包含当前的栈帧,那么就不会产生线程安全问题是不是,那这个对象加不加锁也没有影响,这时就会进行锁的消除。
不理解的可以回顾JMM。
我们来看一个例子,引自大彻大悟synchronized原理,锁如何升级:
StringBuffer
的append
是一个同步方法,但是在add
方法中的StringBuffer
属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer
不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
public class StringBufferRemoveSync {
public void add(String str1, String str2) {
//StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
//因此sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
for (int i = 0; i < 10000000; i++) {
rmsync.add("abc", "123");
}
}
}