JUC P2 可重入锁,JMM
教程:https://www.bilibili.com/video/BV16J411h7Rd
5. 可重入锁(ReentrantLock)
相对于 synchronized 具备的特点:
- 可以打断(一个线程可以取消另外一个线程获取的锁,为了防止死锁且不能打断的情形,
lockInterruptibly()
) - 可以设置超时时间(规定时间内获取不到锁,放弃锁的争抢,执行其他逻辑)
- 可以设置为公平锁(减少饥饿,默认状态下是不公平的)
- 支持多个条件变量(支持多个 WaitSet)
与 synchronized 一样支持可重入(同一个线程对同一把锁多次获取)
用法:
// 获取锁
reentrantLock.lock();
try {
// 临界区。。。
} finally {
// 释放锁
reentrantLock.unlock();
}
Note:
为什么不把reentrantLock.lock();
放到 try { } 中?
- 因为不确定获取锁是否成功,如果获取锁失败,解锁的时候会出现异常
Note:
管程(Monitor)实现区别:
- ReentrantLock 基于 Java 实现的管程
- synchronized 基于 JVM(C++)实现的管程
5.1 可打断
@Slf4j(topic = "c.InitTest")
public class InitTest {
private static final ReentrantLock reentrantLock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
log.debug("尝试获取锁...");
reentrantLock.lockInterruptibly();
} catch (InterruptedException e) {
log.debug("没有获取到锁...");
throw new RuntimeException(e);
}
try {
log.debug("获取锁...");
} finally {
reentrantLock.unlock();
}
}, "t1");
reentrantLock.lock();
t1.start();
TimeUnit.SECONDS.sleep(1);
log.debug("打断 t1...");
t1.interrupt();
}
}
5.2 锁超时
t1 线程若 1 s 后能获取到锁,执行临界区;若 1 s 后不能获取到锁,直接返回 false:
@Slf4j(topic = "c.InitTest")
public class InitTest {
private static final ReentrantLock reentrantLock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("尝试获取锁...");
// 若 1 s 后能获取到锁,执行临界区;若 1 s 后不能获取到锁,直接返回 false
try {
if (!reentrantLock.tryLock(1, TimeUnit.SECONDS)) {
log.debug("没有获取到锁...");
return;
}
} catch (InterruptedException e) {
log.debug("没有获取到锁...");
throw new RuntimeException(e);
}
try {
log.debug("获取到锁");
} finally {
reentrantLock.unlock();
}
}, "t1");
reentrantLock.lock();
log.debug("获取到锁...");
t1.start();
reentrantLock.unlock();
log.debug("释放锁");
}
}
锁超时实现哲学家就餐问题
不会发生死锁:
@Slf4j(topic = "c.InitTest")
public class InitTest {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("c1");
Chopstick c2 = new Chopstick("c2");
Chopstick c3 = new Chopstick("c3");
Chopstick c4 = new Chopstick("c4");
Chopstick c5 = new Chopstick("c5");
new Philosopher("张三", c1, c2).start();
new Philosopher("尼古拉斯·赵四", c2, c3).start();
new Philosopher("王二麻子", c3, c4).start();
new Philosopher("职业法师·刘海柱", c4, c5).start();
new Philosopher("IKUN", c5, c1).start();
}
}
@Slf4j(topic = "c.Philosopher")
class Philosopher extends Thread {
private final Chopstick left;
private final Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
private void eat() {
log.debug("eating...");
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Override
public void run() {
while (true) {
if (left.tryLock()) {
try {
if (right.tryLock()) {
try {
eat();
} finally {
right.unlock();
}
}
} finally {
left.unlock();
}
}
}
}
}
class Chopstick extends ReentrantLock{
private final String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "Chopstick{" +
"name='" + name + '\'' +
'}';
}
}
5.3 公平锁
ReentrantLock 默认是非公平锁(源码):
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
Note:
公平锁一般没有必要,会降低并发度
5.4 支持多个条件变量
synchronized 中也有条件变量:waitSet,当条件不满足时进入 waitSet 等待
ReentrantLock 的条件变量可以支持多个条件变量:也就是说 synchronized 不满足条件的线程只能到一个 waitSet 中,而 ReentrantLock 支持多个 waitSet,唤醒的时候可以根据不同类别的 waitSet 进行唤醒
Note:
synchronized 只有一个休息室,ReentrantLock 有多个休息室。
await()
执行的条件是当前线程已经获得锁await()
执行后,会释放锁,进入 conditionObject (休息室,类似 waitSet)等待await()
的线程被唤醒(或打断(signal)、或超时)会重新竞争锁- 竞争锁成功后,从
await()
后的代码继续执行
Note:
这里省略一个例子:P127
5.5 设计模式
5.5.1 顺序控制
要求两个线程按顺序执行,先执行线程 2,再执行线程 1。
wait / notify 版本
@Slf4j(topic = "c.InitTest")
public class InitTest {
private static final Object lock = new Object();
private static boolean t2runned = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock) {
while (!t2runned) {
try {
lock.wait();
log.debug("1");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (lock) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("2");
t2runned = true;
lock.notify();
}
}, "t2");
t1.start();
t2.start();
}
}
Note:
其实 Join 也能实现类似的效果,比如可以在 t1 线程中调用t2.join()
。
- 有个弊端,假如 t2 在执行完能满足 t1 线程执行的条件后,又执行一些其他的事情,那么 t1 线程就得等 t2 线程执行完全部的事情才能继续执行。
await / signal 版本
@Slf4j(topic = "c.InitTest")
public class InitTest {
private static final ReentrantLock lock = new ReentrantLock();
private static final Condition condition = lock.newCondition();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
lock.lock();
try {
condition.await();
log.debug("1");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}, "t1");
Thread t2 = new Thread(() -> {
lock.lock();
try {
log.debug("2");
condition.signal();
} finally {
lock.unlock();
}
}, "t2");
t1.start();
t2.start();
}
}
park / unpark 版本
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
LockSupport.park();
log.debug("1");
}, "t1");
Thread t2 = new Thread(() -> {
log.debug("2");
LockSupport.unpark(t1);
}, "t2");
t1.start();
t2.start();
}
5.5.2 交替执行
三个线程交替执行输出结果 abcabcabcabcabc
:
- 第一个线程输出 a
- 第二个线程输出 b
- 第三个线程输出 c
wait / notify 版本
@Slf4j(topic = "c.InitTest")
public class InitTest {
private static final Object lock = new Object();
private static int flag = 0;
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
int constI = i;
char output = (char) (i + 'a');
new Thread(() -> {
synchronized (lock) {
for (int j = 0; j < 5; j++) {
// 若当前不是本线程的标记,进入 wait
while (flag != constI) {
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
log.debug("{}", output);
// 修改为下一个线程执行的标记
flag = (flag + 1) % 3;
lock.notifyAll();
}
}
}, "t" + (i + 1)).start();
}
}
}
await / signal 版本
@Slf4j(topic = "c.InitTest")
public class InitTest {
public static void main(String[] args) throws InterruptedException {
AwaitSignal awaitSignal = new AwaitSignal(5);
List<Condition> conditions = new ArrayList<>();
// 添加条件变量
for (int i = 0; i < 3; i++) {
conditions.add(awaitSignal.newCondition());
}
// 三个线程
for (int i = 0; i < 3; i++) {
int constI = i;
new Thread(() -> {
awaitSignal.print("" + (char)('a' + constI), conditions.get(constI), conditions.get((constI + 1) % 3));
}, "t" + (i + 1)).start();
}
// 主线程启动
TimeUnit.SECONDS.sleep(1);
awaitSignal.lock();
try {
conditions.get(0).signal();
} finally {
awaitSignal.unlock();
}
}
}
@Slf4j(topic = "c.AwaitSignal")
class AwaitSignal extends ReentrantLock {
private int loopNumber;
public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
public void print(String msg, Condition cur, Condition next) {
for (int i = 0; i < loopNumber; i++) {
lock();
try {
cur.await();
log.debug(msg);
next.signal();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock();
}
}
}
}
park / unpark 版本
@Slf4j(topic = "c.InitTest")
public class InitTest {
public static void main(String[] args) throws InterruptedException {
ParkVersion parkVersion = new ParkVersion(5);
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 3; i++) {
int constI = i;
threads.add(new Thread(() -> {
parkVersion.print("" + (char) ('a' + constI), threads.get((constI + 1) % 3));
}, "t" + (i + 1)));
}
threads.forEach(Thread::start);
// 主线程启动
TimeUnit.SECONDS.sleep(1);
LockSupport.unpark(threads.get(0));
}
}
@Slf4j(topic = "c.ParkVersion")
class ParkVersion {
private int loopNumber;
public ParkVersion(int loopNumber) {
this.loopNumber = loopNumber;
}
public void print(String msg, Thread next) {
for (int i = 0; i < loopNumber; i++) {
LockSupport.park();
log.debug(msg);
LockSupport.unpark(next);
}
}
}
6. Java 内存模型 JMM
JMM,定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面:
- 原子性:保证指令不会收到线程上下文切换的影响
- 可见性:保证指令不会受到 CPU 缓存的影响
- 有序性:保证指令不会受到 CPU 指令并行优化的影响
6.1 可见性
可见性:一个线程对共享变量的修改,更够及时的被其他线程看到。
先提出两个问题:
第一个问题:下面这段代码会一直执行,为什么?:
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (run) {
int a = 1;
}
}, "t").start();
TimeUnit.SECONDS.sleep(1);
log.debug("停止 t 线程");
run = false;
}
第二个问题:为什么加了锁就更能符合逻辑正常运行呢?
private static boolean run = true;
private static final Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (run) {
synchronized (obj) {
int a = 1;
}
}
}, "t").start();
TimeUnit.SECONDS.sleep(1);
log.debug("停止 t 线程");
run = false;
}
Note:
Java 把内存分为主存(共享信息)和工作内存(线程独享)
- 主内存就是常说的堆区,方法区(元空间)
- 工作内存就是虚拟机栈和本地方法栈,PC 寄存器
- 初始状态:t 线程刚开始从主存读取了 run 的值到工作内存
- 因为 t 线程要频繁从内存中读取 run 的值,JIT 编译器会将 run 的值缓存到自己工作内存中的高速缓存(Cache)中,以减少对主存中 run 的访问,提高效率
- 1s 后主线程修改了 run 的值,并同步至主存,而 t 线程是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
第一个问题可以解答了:一个线程对主内存的数据进行修改,对另外一个线程不可见。
第一个问题如何解决呢,即如何保证一个线程修改主内存的数据对其他线程可见?
volatile
关键字(英文翻译为易变,不稳定的)volatile
只能修饰成员变量和静态成员变量,不能修饰局部变量(因为局部变量线程私有)- 该关键字可以避免线程从自己的工作缓存中查找变量的值,必须到主内存获取值,线程操作
volatile
变量都是直接操作主存
Note:
关于volatile
了解更多可以参考:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
- 可见性实现原理:通过内存屏障实现,若变量被修改后,会立刻将变量由工作内存回写到主存中
- volatile 还有个功能:防止指令重排序(以后细说)
加上 volatile
之后,表示不能再从高速缓存中读取了,每次都从主内存拿:
private static volatile boolean run = true;
6.2 原子性和可见性
原子性:不可被中断的一个或一系列操作
volatile
可以保证在多个线程之间,一个线程对变量的修改对另外一个线程可见。但是不能保证原子性,仅可使用在一个写线程,多个读线程的情况。
若两个线程一个 i++,一个 i–,只能保证每次操作看到最新值,但是不能解决指令交错。
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。 缺点就是相对更加重量,性能相对较低。
Note:
上面第二个问题提前尝试回答一下:加了锁会刷新工作内存,破坏 JIT 即时编译器的优化,因此会重新从主内存拿数据
6.3 终止模式之两阶段种植模式
@Slf4j(topic = "c.InitTest")
public class InitTest {
public static void main(String[] args) throws InterruptedException {
MonitorThread monitorThread = new MonitorThread();
monitorThread.start();
TimeUnit.SECONDS.sleep(5);
monitorThread.stop();
}
}
@Slf4j(topic = "c.MonitorThread")
class MonitorThread {
private Thread monitor;
private volatile boolean stop = false; // 加上 volatile 保证 stop 变量可见性
// 启动监控
public void start() {
monitor = new Thread(() -> {
while (true) {
if (stop) {
log.debug("准备退出......");
break;
}
try {
TimeUnit.SECONDS.sleep(1); // case 1 :该处被打断, 打断标记还是 false
log.debug("执行监控记录"); // case 2 : 该处被打断, 打断标记置为 true
} catch (InterruptedException e) {
}
}
});
monitor.start();
}
// 停止监控
public void stop() {
stop = true;
monitor.interrupt();// 防止线程睡眠, 应该让线程直接退出
}
}
注意:其实该例子中不加 volatile
也是可以的,因为判断语句块中的 log.debug()
语句源码里面会自动加锁,使 JIT 即时编译器不生效 。
- 后来通过实验我发现其实
sleep()
也有类似的效果,大家可以自己尝试一下。
6.4 同步模式之 Balking 犹豫模式
在一个县城发现另一个线程或者本线程已经做了某一件相同的事情,那么本线程就无需再做了,直接结束返回。
下面代码中调用两次 monitorThread.start()
,只会执行一个线程中的内容,因为第二次调用没必要再创建线程了,因为第一次调用已经创建相同的线程任务去执行了:
@Slf4j(topic = "c.InitTest")
public class InitTest {
public static void main(String[] args) throws InterruptedException {
MonitorThread monitorThread = new MonitorThread();
monitorThread.start();
monitorThread.start(); // 第二次调用
TimeUnit.SECONDS.sleep(5);
monitorThread.stop();
}
}
@Slf4j(topic = "c.MonitorThread")
class MonitorThread {
private Thread monitor;
private volatile boolean stop = false; // 加上 volatile 保证 stop 变量可见性
private volatile boolean starting = false;
// 启动监控
public void start() {
// 加上一个标记即可,尽可能让同步代码块短一些
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
monitor = new Thread(() -> {
while (true) {
if (stop) {
log.debug("准备退出......");
break;
}
try {
TimeUnit.SECONDS.sleep(1); // case 1 :该处被打断, 打断标记还是 false
log.debug("执行监控记录"); // case 2 : 该处被打断, 打断标记置为 true
} catch (InterruptedException e) {
}
}
});
monitor.start();
}
// 停止监控
public void stop() {
stop = true;
monitor.interrupt();// 防止线程睡眠, 应该让线程直接退出
}
}
Note:
同步代码块内的可见性可以由 synchronized 保证,同步代码块外的可见性必须由 volatile 保证。
6.5 有序性
JVM 会在不影响正确性的前提下,可以调整语句执行的顺序:
static int i;
static int j;
// 某个线程下
i = ...; // 1
j = ...; // 2
i 和 j 谁先执行对最终结果都不会影响,因此代码真正执行可以是先执行 1 再执行 2,也可以是先执行 2 再执行 1。
这种特性称之为 指令重排,多线程下指令重排会影响正确性。
Note:
为什么要有指令重排?
- 现代 CPU 支持多级指令流水线,可以同时支持
取指令-指令译码-执行指令-内存访问-数据写回
的处理器,CPU 在一个时钟周期内可以同时执行五条指令的不同阶段。流水线技术不能缩短单条指令的执行时间,但变相提高了指令吞吐率。- 因此,在不改变结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令并行。
解决重排序:volatile
关键字
6.6 volatile 原理
volatile
实现的底层原理是内存屏障(Memory Barrier)。
- 对
volatile
变量的写指令之后加入写屏障 - 对
volatile
变量的读指令之前加入读屏障
保证可见性
- 写屏障保证在该屏障之前,对共享变量的改动,都同步到主存当中。
num = 2;
ready = true; // ready 是 volatile 变量
// 写屏障
- 读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中的最新数据。
// 读屏障
if (ready) { // ready 是 volatile 变量
r1 = num + num;
} else {
r1 = 1;
}
保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
不能解决指令交错
- 写屏障只能保证之后的读能读取到最新的结果,不能保证写屏障之前的读
- 有序性只保证本线程内的相关代码不被重排序
双检锁问题
class Singleton {
private static Singleton INSTANCE;
private Singleton() {
}
// 提供一个静态方法,返回实例化对象, 加入同步处理代码块
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
getInstance
方法字节码:
- 17 表示创建对象,将对象引用压入操作数栈
- 20 表示复制一份引用再压入操作数栈
- 21 表示弹出一个对象引用调用构造方法
- 24 表示弹出一个对象引用赋值给
static INSTANCE
Note:为什么会在 new 之后进行 dup?
可以参考:https://blog.csdn.net/dabusiGin/article/details/104701338
一个对象创建在执行构造函数的时候会消耗一个引用
JVM 有可能把把顺序优化为:先执行 24,再执行 21,两个线程并发执行时序图:
这样就会导致 t1 线程还没有进行构造初始化的时候,t2 使用对象导致空指针异常。
解决方案
加上 volatile
关键字:
private static volatile Singleton INSTANCE;
Note:
synchronized
和volatile
有序性的区别:
synchronized
的有序性是持有相同锁的两个同步块只能串行的进入,即被加锁的内容要按照顺序被多个线程执行,但是其内部的同步代码还是会发生重排序,使块与块之间有序可见。(块和块之间的有序性)volatile
的有序性是通过插入内存屏障来保证指令按照顺序执行。不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序。(指令和指令之间的有序性)
6.7 happens-before 规则
happens-before 规定了对共享变量的写操作对其他线程的读操作可见,它是可见性和有序性的一套规则。如果抛开该规则,JMM 并不能保证一个线程对共享变量的写,以及对其他线程的读可见。
- 线程解锁之前对变量的写,对于接下来加锁的其他线程对该变量的读可见
- 线程对
volatile
变量的写,对接下来其他线程对该变量的读可见 - 线程
start()
前对变量的写,对该线程开始后对该变量的读可见 - 线程结束前对变量的写,对其他线程得知他结束后的读可见(比如,
t1.join()
等待结束) - 线程 t1 打断 t2 前对变量的写,对于其他线程得知 t2 被打断后对该变量的读可见
- 对默认值(0,false,null)的写,对其他线程对该变量的读可见
- 传递性,
volatile
变量防止重排序可以影响到其他变量,会把线程内的变量修改都同步到主存
补充图
Note:
存储设备层次结构:图2 来自 https://xiaolincoding.com/os/1_hardware/storage.html#cpu-cache