目录
[1. 线程安全问题产生的原因](#1. 线程安全问题产生的原因)
[1.1 【根本】操作系统对于线程的调度是随机的;](#1.1 【根本】操作系统对于线程的调度是随机的;)
[1.2 多个线程同时修改同一个变量;](#1.2 多个线程同时修改同一个变量;)
[1.3 修改操作不具有原子性的;](#1.3 修改操作不具有原子性的;)
[1.4 内存可见性问题;](#1.4 内存可见性问题;)
[1.5 指令重排序问题。](#1.5 指令重排序问题。)
[2. 解决线程安全问题](#2. 解决线程安全问题)
[2.1 synchronized 关键字结构](#2.1 synchronized 关键字结构)
[2.2 锁对象](#2.2 锁对象)
[2.3 synchronized 关键字修饰方法](#2.3 synchronized 关键字修饰方法)
[3. synchronized 的特性](#3. synchronized 的特性)
[3.1 互斥](#3.1 互斥)
[3.2 可重入](#3.2 可重入)
[4. 死锁](#4. 死锁)
[4.1 构成死锁的场景](#4.1 构成死锁的场景)
[4.2 形成死锁的必要条件](#4.2 形成死锁的必要条件)
[4.3 如何避免死锁](#4.3 如何避免死锁)
对于线程不安全,我们可以理解为:出现 Bug,即多线程运行环境下代码运行的结果不符合我们的预期。
线程不安全示例:
java
public class notSafe {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
System.out.println("t1结束");
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
System.out.println("t2结束");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
ps:必须要有
t1.join(); t2.join();如果没有这两个 join,线程还没自增,就开始打印,结果很大可能打印出" count = 0"。
对于上面的代码我们预期结果应该是 1万,但是实际输出结果小于预期值。
原因如下:
操作系统在处理 count++; 时,实际上对应了 3 个 CPU 指令,
-
load,把内存中的 count 值加载到 CPU 的寄存器中;
-
add,把寄存器中的内容 +1;
-
save,把寄存器中的内容保存回内存中。
因为操作系统对线程的调度是随机的(抢占式执行),执行 3 个指令时不一定是连续的、一口气执行完毕的。执行顺序有可能是,执行 1 -> 被调度走 -> 调度回来 -> 执行 2 -> 执行 3,也有可能是 执行 1,2 -> 被调度走 -> 调度回来 -> 执行 3......
1. 线程安全问题产生的原因
1.1 【根本】操作系统对于线程的调度是随机的;
1.2 多个线程同时修改同一个变量;
以下的情况不会产生线程安全问题:
① 一个线程修改一个变量;
② 多个线程,但没有同时修改同一个变量;
③ 多个线程,修改不同变量;
④ 多个线程,读取同一个变量。(只取值,不修改)
1.3 修改操作不具有原子性的;
原子性在数据库的事务特征中提到过。
在线程中,我们可以把一段代码想象成一个独立房间,每个线程是要进入这个房间的人。如果没有任何机制保证,A 进入房间之后,还没出来,B 是可以闯进房间,触犯 A 在房间里的隐私。这就是不具备原子性的情况。
如果修改操作只对应到一个 CPU 指令,就可以认为是原子的;但如果对应到多个 CPU 指令,就不是原子的。像 ++、--、+=、-= 等操作涉及多个 CPU 指令,因此这些修改操作都不是原子的。
1.4 内存可见性问题;
1.5 指令重排序问题。
对于内存1.4 和 1.5,放到 线程安全(2),这篇文章中进行讨论。
2. 解决线程安全问题
针对 1.2 的问题,我们可以 调整代码的结构,比如上面提及的示例,调整 join() 的位置:

但调整代码结构的方案,不是通用方法,有些情况下的开发需求就是需要多个线程修改同一个变量。
2.1 synchronized 关键字结构
Java 中解决线程的问题主要方案是 加锁,具体方法是使用 synchronized 关键字,结构如下:
java
synchronized( 锁对象 ){
// 要执行的保护逻辑
}
真正解决线程安全问题的不是 synchronized,而是 合适的代码块 以及 合适的锁对象。
2.2 锁对象
在 Java 中,任何一个对象,都可以作"锁",但是只有多个线程的锁对象是同一个,才会产生 互斥 的效果,才能解决线程安全问题。
互斥:某个线程执行到某个对象的 synchronized 代码块时,其他线程如果也执行到同一个对象的 synchronized 就会阻塞等待。
进入 synchronized 修饰的代码块,相当于加锁;
退出代码块,相当于解锁。
反例:

如果想获取当前线程作为锁对象,又想达到互斥的效果,正确的做法可以如下:

但是上述的做法在实际开发过程中,并不推荐,且整体看来代码非常别扭。一般来说,编程的原则是一个对象只有一个用途。因此推荐专门创建一个对象作为锁。
例如 Object locker = new Object();
此外,需要注意 synchronized 的放置位置,如果将 fori 循环包含在代码块里面,线程1执行 5w 次的过程中,线程2一直在阻塞等待,这种写法总的执行时间远远比只写 count++; 要长,相当于完全串行。
2.3 synchronized 关键字修饰方法
java
class Counter{
public int count = 0;
public synchronized void add(){
count++;
}
// add 相当于 add2,锁当前对象
public void add2(){
synchronized (this){
}
}
public int get(){
return count;
}
synchronized public static void func1(){
}
// func2 等效于 func1
public static void func2(){
synchronized (Counter.class){
// ...
}
}
}
public class safeMethod {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + counter.get());
}
}
1、如果将 count++; 包装成 add() 方法,使用 synchronized 修饰该方法,就相当于针对 this 进行加锁。【this 是指调用该方法的实例,在示例代码中是指 Counter 对象 counter】
在 main 中,两个线程共享同一个 Counter 对象,当它们同时调用 counter.add() 时,它们会去竞争 同一个锁(即 counter 对象内部的 moniter,监视器,JVM 术语,使用锁的过程中如果抛出异常,可能会看到 "moniter locker" 这样的信息)
2、但是 static 修饰的方法,即静态方法,属于类本身不属于任何实例,因此没有 this 引用。此时 synchronized 修饰静态方法就相当于针对 该类的对象(即 Counter.class) 进行加锁。
3. synchronized 的特性
3.1 互斥
当一把锁被某个线程占用时,其他线程会进行阻塞等待,一直等到之前的线程解锁之后,由操作系统"唤醒"一个新线程来获取这把锁。注意,上一个线程解锁之后,下一个线程并不是立即就能获取到锁,而是由操作系统来调度唤醒。
假设有 A B C 三个线程同一把锁,A 先获取到锁,此时 B、C就只能在阻塞队列中排队等待。但是 A 释放锁之后,虽然可能 B 比 C 先来,但是 B 不一定先获得到锁,而是和 C 重新竞争。
3.2 可重入
可理解为锁上加锁再加锁的情况,不会出现死锁的问题。
java
synchronized (locker){
synchronized (locker){
// ...
}
}
死锁:一个线程还没释放锁,又尝试再次加锁。第一次加锁成功,第二次进行加锁时,锁已经被占用,阻塞等待,直到第一次的锁被释放才能获取。但是理论上,只有出了第一个 synchronized 的 } 才会释放锁,因此第二个 synchronized 始终等不到第一个释放锁,由此会形成死锁。而这样的锁称为不可重入锁。
Java 中的 synchronized 是可重入锁,所以上面的问题并不存在。那么 JVM 是如何确保什么时候才能真正释放锁呢?
引出面试经典问题:如何自己实现一个可重入锁?
可重入锁的内部,包含了"线程持有者"和"计数器"两个信息。
1、如果某个线程加锁的时候,记录当前是哪个线程持有的锁,后续每次加锁,都进行判定,并让计数器自增;
2、解锁的时候计数器递减为0的时候,才能真正释放锁。
4. 死锁
4.1 构成死锁的场景
① 一个线程一把锁,连续上锁多次;(即不可重入锁)
② 两个线程两把锁,每个线程分别获取不同的锁之后(未释放),再尝试获取对方的锁;
③ N 个线程 M 把锁。
第②种场景示例:
java
public class deadLock2 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
// 获取锁1
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 尝试获取锁2
synchronized (locker2){
System.out.println("t1 两个锁都获取到");
}
}
});
Thread t2 = new Thread(() -> {
// 获取锁2
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 尝试获取锁1
synchronized (locker1){
System.out.println("t2 两个锁都获取到");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}


如果不加 sleep() ,结果可能不是出现死锁,可能是 t1 一下子获取到两把锁,这时候 t2 还没加载好,自然无法构成死锁的条件。因此加上 sleep() 是为了确保两个线程都拿到各自的锁之后执行一段时间,再尝试拿起对方的锁。
由此又引出经典面试题:手写一个出现死锁的代码(如示例所示)。
对于第③种情况,是属于经典的哲学家进餐问题:
5 个沉默寡言的哲学家围坐在圆桌前,每人面前一盘意面。叉子放在哲学家之间的桌面上。(5 个哲学家,5 根叉子)
所有的哲学家都只会在思考和进餐两种行为间交替。哲学家只有同时拿到左边和右边的叉子才能吃到面,而同一根叉子在同一时间只能被一个哲学家使用。每个哲学家吃完面后都需要把叉子放回桌面以供其他哲学家吃面。只要条件允许,哲学家可以拿起左边或者右边的叉子,但在没有同时拿到左右叉子时不能进食。
假设面的数量没有限制,哲学家也能随便吃,不需要考虑吃不吃得下。

离开哲学餐桌,回到 Java 王国,哲学家对应着线程,餐具对应着锁。每个线程需要拿起其中两把锁才能更好的运作,否则会造成死锁的情况。
4.2 形成死锁的必要条件
① 锁是互斥的;
② 锁是不可抢占(不可剥夺)的;
线程1拿到锁,线程2也尝试获取这把锁,那么线程2必须阻塞等待,不能直接抢过来。
③ 请求和保持;
一个线程拿到锁1之后,不释放锁1的前提下,获取锁2,就会构成死锁。(如果先放下锁1,再去获取锁2,则不会构成死锁。)
④ 循环等待 。
多个线程多把锁之间的等待过程,形成一种头尾相接的循环等待资源关系。
4.3 如何避免死锁
根据不可重入的情况,避免死锁的方法之一是不要嵌套锁,而是改为并列的锁。但是实际开发环境中有些情况确实需要拿到多个锁再进程某项操作的。
就哲学家就餐问题,解决的思路就是 对加锁的顺序做出约定------每个线程加锁的时候,永远是先获取序号小的锁,再获取序号大的锁。

例如,将锁进行编号,每个线程加锁时只能先获取比它小的锁,因此1号小人拿不到任何锁,只能阻塞等待,2号小人拿到①号锁,3号小人拿到②号锁......5号拿到④时1号还在阻塞等待,也就给机会给5号小人拿到⑤号锁,从而运行。
当5号小人"就餐"结束,就会解锁,将④号和⑤号锁释放掉。由此一来,4号小人就有机会获得④号锁,从而运行......
因此回到 4.1 中②构成死锁的代码,按照解决死锁的思路:先获取序号小的锁再获取序号大的锁,那么 t2 应该先获取 locker1,再去获取 locker2,就不会构成死锁的情况,代码也就正常运行出预期结果。