Java线程同步总结
线程同步的关键是保证临界资源访问的原子性和可见性。
一般的解决方案是使用volatile(保证可见性、不一定保证原子性)修饰共享变量,或是加锁(直接保证原子性和可见性)进行线程同步
1. volatile
1.1 可见性实现原理:
汇编层面会向volatile修饰的变量加关键字前缀lock,这个lock前缀的作用有二:
- lock前缀修饰变量的修改不会在缓存停留,会直接回写主存,
- 其他cpu会在总线上嗅探lock修饰的变量是否修改,若变化,直接置cpu本地缓存为不可用,下次使用需要从主存中读取最新的值
1.2 禁止指令重排序:
包括禁止在编译阶段优化(javac优化),以及执行阶段优化(处理器流水线统筹优化)
1.3 为什么不保证原子性
在编译过程中,一条Java代码语句可能被编译为多条JVM指令码
- 如自增语句 i++ 会被编译为 1.load(读变量i的值)、2.increment(变量值加一)、3.store(写回) 三条指令码。
- 用volatile修饰之后,执行store指令时会回写主存,同时其他线程load时需要从主存读取最新的值
- 可能出现的问题:初始值volatile int i = x,两线程同时执行 i++,预期结果为i == x+2。先同时load i 的值为x,然后自增为x+1,再写回,由于一个线程store的时候,另一个线程已经load过了,所以导致两个线程都store x的值为x+1,而不是预期的x+2
证明:
public class VolatileTest {
volatile static int cnt = 0;
volatile static boolean signal = true;
public static void main(String[] args) throws InterruptedException {
VolatileTest main = new VolatileTest();
main.test1();
Thread.sleep(2000);
signal = false;
System.out.println(cnt);
// 第一次 54055473
// 第二次 50769994
// 第三次 57593069
}
private void test1() {
Runnable runnable = () -> {
while (signal) {
cnt++;
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
}
}
1.4 常见面试问题:
- 什么时候只使用volatile,不使用锁就可以做线程同步
- 两个线程,一个用volatile修饰的共享变量i,初始值为0,两个线程分别做50次i++,最后i的值会是100么?
- 举一个场景:不使用volatile,不保证可见性后可能出现的问题
2. 锁相关
2.1 synchronized和lock的区别
- 关键字 vs 接口
- synchronized是一个JVM提供的关键字
- lock是jdk中提供的接口,包括多个方法如 lock() unlock() tryLock()...
- 加锁方式
- synchronized是隐式加锁
- lock是显式加锁,需要手动调用lock()和unlock()
- 作用位置
- synchronized可在方法、静态方法、代码块上加锁
- lock仅支持在代码块上加锁
- 加锁的方式
- synchronized仅支持阻塞加锁
- lock支持非阻塞加锁,如tryLock(),如果获取不到直接返回,不阻塞
- lock支持设置阻塞等待锁的时间,如tryLock(long time),阻塞time后获取不到锁就唤醒
- lock支持等待锁过程中可响应中断,如lockInterruptibly()
- 锁类型
- synchronized是非公平锁
- lock的实现类如reentrantlock可选公平非公平
- 底层实现
- synchronized基于c++的ObjectMonitor实现,其中包含一个同步队列 + 一个等待队列
- lock基于AQS实现,包含一个同步队列 + 多个等待队列(通过newCondition() 创建)
- 等待唤醒机制
- synchronized 通过Object.wait() 和 Object.notify() 完成等待唤醒
- lock 通过创建的Condition对象的 Condition.await() 和 Condition.signal() 方法完成等待和唤醒
- 个性化定制
- synchronized无法定制化
- lock采用模版方法模式,易于根据个人需求定制化
2.2 synchronized锁升级过程
主要关注升级条件,以及如何争抢和释放锁
-
无锁
- 此时对象头markword中锁标识位为001,表示对象未被加锁
-
偏向锁
- 升级为偏向锁
- 线程CAS修改锁标志位从001 -> 101,若成功,则占有偏向锁,此时线程将其id写入对象头markword的hashcode区域,便于后续判断锁是否偏向该线程。(同时要注意,如果代码中有调用对象的hashcode方法,该对象无法做为偏向锁使用)
- 若CAS失败,表明出现争抢,直接升级为轻量级锁
- 争抢锁
- 若锁标志位为101,接着检查锁偏向的线程id是否为当前线程id,若是则直接获取成功,若不是则意味有竞争
- 竞争
- 竞争线程发现锁标志位为101,将markword中记录的对应锁偏向线程执行到安全点后挂起,并检查线程状态
- 若线程未执行临界区资源,则进行线程重偏向(依然是CAS争抢)
- 若线程仍在执行临界区资源,则升级为轻量级锁
- 升级为偏向锁
-
轻量级锁
- 争抢锁
- 通过CAS将对象头中的markword拷贝到栈帧中的lockrecord,并覆盖为lockrecord的地址,同时lockrecord中也存有对象的地址
- 重入:每一次重入,栈帧中就压入一个空lockrecord,这样只有最后一次弹出,才会使用非空的lockrecord释放锁
- 释放
- CAS将lockrecord中的markword还原回去
- 若CAS失败,说明已升级为重量级锁,直接将对象头还原到monitor中防止丢失,同时进行重量级锁释放流程
- 竞争
- 线程自旋CAS尝试获取锁(这里需要再深入了解)
- 若自旋次数过多或竞争线程过多,升级为重量级锁。线程修改锁标志位为重量级锁,并将markword设置为指向monitor的指针
- 争抢锁
-
重量级锁
- ObjectMonitor
- 一个c++实现的对象,java基于该对象实现重量级锁
- 包含一个重入计数器,同步队列entryList,等待队列waitSet,竞争队列cxq等
- 争抢锁
- 执行monitorenter指令,线程CAS尝试把monitor的重入计数器从0变为1
- 若成功则代表占有锁,此时锁重入次数为1,线程把monitor的owner设置为自己
- 若失败,加入同步队列排队
- 等待
- 持有锁的线程主动调用wait()方法,则释放monitor,线程进入waitSet,若被notify()唤醒,加入entryList
- 比如生产者消费者持有同一个锁,多个消费者线程持有锁后发现没有可消费的东西,就进入等待,生产者线程持有锁,生产后再通过notify()方法唤醒消费者线程从waitSet到entryList
- 释放
- 执行monitorexit指令,计数器减一,若减完不为0,表示有重入,若为0则表示释放锁,此时唤醒entryList头部线程
- 唤醒的头部线程和外部线程一同CAS争抢锁,这也体现出synchronized的非公平性
- ObjectMonitor
2.3 什么时候可以仅使用volatile而不使用锁做线程同步
1)满足共享变量修改的原子性
- 变量修改时仅使用简单赋值语句。简单赋值语句是原子性的,如 x = 10,对应指令码只有一条 store x, 10;
2)变量读多写少 - 若是写多读少,应当使用锁,因为使用volatile会导致总线风暴(大量锁总线事件)
2.4 常见面试问题:
- synchronized的非公平性体现在哪里
- reentrantlock的公平和非公平区别在哪里,实现上的区别是什么
- 一个线程访问synchronized临界资源、等待、被唤醒、继续执行的详细过程