1. synchronized 简介
在上一篇博文 JAVA 内存模型 与 volatile关键字 中原子性问题分析时有如下代码:
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
num++;
}
}
});
thread.start();
}
Thread.sleep(1000);
System.out.println(num);
}
通过volatile修饰 num (屏蔽可见性问题)后,如果计算正常的话,应该是 20000 ,但是多次运行结果都不是 20000,且每次的结果都不一致。
那为什么会出现这样的问题的呢?
在多线程并发执行时,volatile还没来得及加锁,就有多个线程将 num 加载到寄存器中进行计算,然后其中某个线程才加锁成功,将cpu缓存中的 num 失效,但是已经加载到寄存器中的 num 不会被失效,仍会被计算并写回内存,这也就导致了 num 最终值和我们预想的不一致!
那这种数据安全性问题该如何解决呢?
既然在多线程情况下会出现数据安全性问题,那么存在数据安全问题的共享资源 每次只允许一个线程访问,自然也就避免了这样的问题。JAVA 提供的 synchronized 关键字,为我们提供了单线程访问的共享资源的功能。
2. Synchronized 该如何使用呢?
2.1 修饰静态方法
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(() -> {
getNumStatic();
});
thread.start();
}
Thread.sleep(1000);
System.out.println(num);
}
// 修饰静态方法
public synchronized static int getNumStatic(){
for (int i1 = 0; i1 < 1000; i1++) {
num++;
}
return num;
}
当 synchronized 修饰静态方法时,加锁的对象是当前的class类,所以无论哪个线程去访问该方法,都去竞争同一把锁。这意味着如果该类存在两个业务不相干的由 synchronized 修饰的方法时,其他线程将不能访问另一个方法。
2.2 修饰实例方法
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
java obj = new java();
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(() -> {
obj.getNumStatic();
});
thread.start();
}
Thread.sleep(1000);
System.out.println(num);
}
public synchronized int getNumStatic(){
for (int i1 = 0; i1 < 1000; i1++) {
num++;
}
return num;
}
当 synchronized 修饰实例方法时,锁定的时当前 new 出来的 obj 对象。不同线程已同一个对象实例访问时,竞争的是同一把锁。
如下写法会导致 synchronized 失效
public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(() -> {
// 不同线程竞争的锁对象不是同一个,导致竞争关系失效。
java obj = new java();
obj.getNumStatic();
});
thread.start();
}
Thread.sleep(1000);
System.out.println(num);
}
public synchronized int getNumStatic(){
for (int i1 = 0; i1 < 1000; i1++) {
num++;
}
return num;
}
2.3 修饰代码块
public static Object object = new Object();
public int getNumStatic(){
for (int i1 = 0; i1 < 1000; i1++) {
synchronized (object){
num++;
}
}
return num;
}
当 synchronized 修饰代码块时,加锁的对象是 synchronized 后面的 object,需确保object的全局唯一性,否则会失效。
2.3.1 变种-修饰 this
public int getNumStatic(){
for (int i1 = 0; i1 < 1000; i1++) {
synchronized (this){
num++;
}
}
return num;
}
当 synchronized 修饰 this时,加锁对象为调用该方法的实例对象。不同线程需对同一实例对象访问,否则一样会失效
3. synchronized 加锁原理
synchronized 是基于 JVM 内置锁实现的,通过进入与退出 Monitor(监视器锁) 对象实现。synchronized 是重量级锁,在JDK1.5之后才被优化。
3.1 监视器锁-Monitor
监视器锁是基于操作系统底层的Mutex lock(互斥锁)实现的,这里就涉及 用户态→内核态→用户态 的切换,所以它是一个重量级锁。
每个对象都会与一个 Monitor 对象关联,当 Monitor 对象被持有时,该对象也就被锁定了。
监视器锁-Monitor 在 JVM 中是通过 进入/退出 Monior对象实现的。虽然不同的JVM虚拟机实现有所不同,但是都可以通过 MonitorEnter / MonitorExit 指令完成进入/退出操作。
- MonitorEnter:每个对象都是一个监视器锁(Monitor)。当monitor被占用时就会处于锁定状态,线程执行 monitorenter指令时尝试获取monitor的所有权
- 当monior的进入数为0时,则该线程进入monior对象,并将进入数设置为1。该线程就是该对象的持有者
- 如果该线程已经持有该对象,再次进入时,进入数+1.
- 如果其他线程已经持有该对象,则阻塞等待。
- MonitorExit:只有只有该对象的线程才能执行MonitorExit指令,执行MonitorExit指令后,进入数-1,直到进入数为0时,释放锁。
Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),即锁状态是被记录在每个对象的对象头(Mark Word)中。
3.2 Java 对象头
在HotSpot虚拟机中,对象在内存的存储主要分为3个部分:对象头,实例数据,对其填充区域。
- 对象头类型
- 普通对象包含:Mark Word(运行时类信息)、Klass Pointer(类对象指针-指向方法区)
- 数组对象包含:Mark Word、Klass Pointer、Array Length(数组长度)
锁状态被记录在Mark Word(运行时类信息)中,如图
4. synchronized 锁的膨胀升级
在jdk1.5之前 synchronized 是一个重量级锁,所有由 synchronized 修饰的代码,都会直接通过对monior对象的 MonitorEnter / MonitorExit 指令操作进行加锁,这导致“用户态和内核态”的来回切换,效率很低。
jdk1.5之后,Oracle对synchronized进行了优化,加入了锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
大致流程:无锁状态 → 偏向锁 → 轻量级锁 → 重量级锁。
详细过程如下图:
4.1 锁粗化
当 JVM 感知到一系列连续操作都是对同一个对象进行加锁时,此时没有其他线程竞争,频繁的对同一个对象进行加锁解锁操作非常消耗性能,JVM 对锁的范围进行扩大(从第一个加锁操作到最后一个加锁操作)。
public int getNumStatic(){
for (int i1 = 0; i1 < 1000; i1++) {
// 循环对同一个对象加锁
synchronized (object){
num++;
}
}
return num;
}
会被优化为类似代码
public int getNumStatic(){
synchronized (object){
for (int i1 = 0; i1 < 1000; i1++) {
num++;
}
}
return num;
}
4.2 锁消除
当 JVM 通过逃逸分析判断某一代码块不要加锁时,会将JVM阶段将锁去除。
4.3 偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价(在锁切换时会涉及到一些CAS操作,比较耗时)而引入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量不必要锁申请的操作,从而优化程序性能。
在多线程竞争激烈的情况下,开启偏向锁意味着每次加锁之前都要通过CAS操作修改锁的偏向线程,这样也就得不偿失了。
默认开启
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
4.4 轻量级锁
在偏向锁失败的情况下,JVM 会升级到轻量级锁。轻量级锁认为:大多数情况下,在一个线程执行期间,不存在锁竞争。(线程交替执行)
如果出现大量线程竞争同一把锁的情况,轻量级锁也就会失效。
假设存在1000个锁同一时刻竞争同一个锁,线程平均执行时间 1ms。
- 如果此时直接升级为重量级锁,完全没有必要。
- 如果将其他线程挂起,然后需要时唤醒也非常耗时。 所以JVM 引入了自旋锁。
4.5 自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,JVM 会让等待线程进行几次空循环,以期轻量级锁会被释放,再由循环中的线程占有。一般不会无限循环下去,会指定循环次数,如果在循环次数中无法获取锁,锁就会升级为重量级锁。