一、多线程带来的的风险---线程安全(重点)
1、观察线程不安全
家观察下是否适用多线程的现象是否⼀致?
同时尝试思考下为什么会有这样的现象发生呢?
java
// 此处定义⼀个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 对 count 变量进⾏⾃增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进⾏⾃增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不⾏的。 线程还没⾃增完, 就开始打印了, 很可能打印出来的 count 就是个 0
t1.join();
t2.join();
// 预期结果应该是10w
System.out.println("count: " + count);
}
2、线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
3、线程不安全的原因
3.1线程调度是随机的(抢占式执行)
这是线程安全问题的罪魁祸首 :线程的调度
随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数
程序猿必须保证在任意执行顺序下,代码都能正常工作

- 核心问题:多线程环境中,修改操作若不是原子的(即操作可被拆分),会引发线程安全问题。
- 解决方案:加锁是 Java 解决线程安全问题的主要方案。通过加锁,可将非原子操作打包成原子操作,确保同一时间只有一个线程能执行该操作。
- 锁的特性:计算机中的锁与生活中的锁概念一致,具有互斥 / 排他性,即同一时间只有一个线程能获取锁并执行被锁保护的代码块。
1. 外部加锁(锁包裹整个 for 循环)
- 执行逻辑 :在
for循环的所有操作(包括i < 5w判断、i++、count++)外围加锁。 - 效果 :整个
for循环的所有步骤都串行执行(同一时间只有一个线程能执行循环内的所有操作)。 - 特点 :
- 线程安全但效率低,因为循环的并发潜力(如
i的判断和自增)被完全限制。 - 适用于循环内所有操作都需要严格串行的场景,但通常不是最优选择。
- 线程安全但效率低,因为循环的并发潜力(如
2. 内部加锁(仅count++处加锁,即题目中的写法)
- 执行逻辑 :仅对
count++这一非原子操作 单独加锁,而for循环的i < 5w判断、i++操作仍可并发执行。 - 效果 :
count++是串行的(保证数据一致性),循环的其他步骤(i的判断和自增)是并发的。- 既保障了
count++的线程安全,又利用了循环的并发潜力,因此执行速度更快。
3. 外部加锁 为什么count++是串行的?
- 锁的作用范围 :
count++被包裹在加锁代码块内(如synchronized块),而锁具有互斥性------ 同一时间只有一个线程能获取锁并执行被保护的代码。 - 因此,所有线程对
count++的操作必须 "排队执行":一个线程执行完count++并释放锁后,下一个线程才能获取锁执行count++,最终实现串行化,避免了多线程同时修改count导致的数据不一致。

从里面加锁到这个外边加锁进行简化

那这个还有一种特殊的情况:
static修饰的方法,是针对类这个本身的,static 修饰的方法,不存在this ,那这个Synchronized 修饰的就是针对类对象加锁

如果说加两个锁呢?
第一次执行加锁成功加锁(锁没有人使用)
第二次执行此时这个锁已经被占用了,此时就会阻塞
注意:想要解锁,需要等待上一个锁使用完解锁,但是这个会一直等待,等待第一次锁释放,但是释放不了,就称之为死锁
死锁是一个非常严重的bug ,使代码执行到这一块之后,就卡住不动了
1. 直接嵌套加锁场景
- 代码中对同一把锁
locker进行了两次synchronized嵌套加锁。此时第一次加锁成功后,第二次加锁会因为锁已被当前线程持有而进入阻塞等待,这种重复加锁是不必要的,却容易在开发中因代码结构问题写出。
2. 方法调用层次较深的加锁场景
- 外层代码对
counter对象加锁,而counter的add方法内部又对this(即counter对象)加锁。当方法调用层次较深时,开发者容易忽略这种间接的重复加锁情况,从而引发锁重入导致的阻塞问题。
怎么解决嵌套死锁问题呢?
Java synchronized的引入了可重入概念,针对这个线程的锁,他已经被使用了,引入了可重入性就不会阻塞了,而是继续往下执行,因为这把锁就是被这个线程所持有的。
其他的线程加锁仍然会阻塞
死锁产生的必要条件
-
互斥访问:资源不能被共享
-
持有并等待:线程持有资源并等待其他资源
-
不可剥夺:资源只能由持有线程释放
-
循环等待:多个线程形成等待环
java
public class demo20 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("他 线程两个锁都捕获到了");
}
}
});
Thread t2 = new Thread(() -> {
synchronized(locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("t2两个线程的两个锁都获取到了");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
这个代码产生了死锁,显示blocked(竞争锁的缘故导致阻塞)

如果不加sleep是否还会出现上述的问题?
- 死锁的可能性降低,但并未完全消失。
原因是:
- 线程调度是抢占式的,若 t1 和 t2 的执行顺序刚好满足 "t1 持
locker1等locker2,t2 持locker2等locker1",则会立即形成死锁; - 但由于没有
sleep强制延迟,线程可能快速执行完锁的获取与释放(例如 t1 获取locker1后,在 t2 获取locker2前就已拿到locker2并释放所有锁,或反之),此时不会死锁。
1. 线程状态与锁持有关系
- 线程
Thread-1处于BLOCKED 状态 ,原因是它在等待获取java.lang.Object@6ae6a90b这把锁,而该锁当前被Thread-0持有。 - 这符合 "线程因竞争锁而阻塞" 的场景,说明
Thread-1在尝试进入某个synchronized代码块时,锁已被Thread-0占用。
2. 堆栈跟踪的锁信息
- 堆栈中显示
Thread-1已锁定java.lang.Object@28c30f8d,说明它在阻塞前已经持有了这把锁,现在又试图获取另一把锁(@6ae6a90b),这是锁竞争 + 多锁交互的典型表现,若处理不当可能引发死锁。
3.2修改共享数据
多 个线程修改同⼀个变量 上面的线程不安全的代码中,涉及到多个线程针对 此时这个 count 变量进行修改. count 是⼀个多个线程都能访问到的"共享数据

3.3修改操作不是原子性

什么是原子性
我们把一段代码想象成⼀个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B是不是也可以进⼊房间,打断A在房间里的隐私。这个就是不具备原子性的。 那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A进去就把门锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原子性了。 有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
⼀条java语句
不⼀定是原子的,也不一定只是一条指令 比如刚才我们看到的n++,其实是由三步操作组成的:
-
从内存把数据读到CPU
-
进行数据更新
-
把数据写回到CPU 不保证原子性会给多线程带来什么问题 如果⼀个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。
这点也和线程的抢占式调度密切相关.如果线程不是"抢占"的,就算没有原子性,也问题不大
3.4 内存的可见性问题
可见性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到.
J**ava内存模型(JMM):**Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果
.• 线程之间的共享变量存在主内存(MainMemory).
• 每⼀个线程都有自己的"工作内存"(WorkingMemory).
• 当线程要读取⼀个共享变量的时候,会先把变量从主内存拷贝到⼯作内存,再从⼯作内存读取数据.
• 当线程要修改⼀个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存. 由于每个线程有自己的总顾总内存,这些工作内存中的内容相当于同⼀个共享变量的"副本".此时修改线 程1的⼯作内存中的值,线程2的工作内存不⼀定会及时变化.
1)初始情况下,两个线程的工作内存内容⼀致.

- ⼀旦线程1修改了a的值,此时主内存不⼀定能及时同步.对应的线程2的⼯作内存的a的值也不⼀定 能及时同步

这个时候代码中就容易出现问题. 此时引入了两个问题:
• 为啥要整这么多内存?
• 为啥要这么麻烦的拷来拷去?
-
为啥整这么多内存? 实际并没有这么多"内存".这只是Java规范中的⼀个术语,是属于"抽象"的叫法. 所谓的"主内存"才是真正硬件角度的"内存".而所谓的"工作内存",则是指CPU的寄存器和⾼速缓存.
-
为啥要这么麻烦的拷来拷去? 因为CPU访问自身寄存器的速度以及高速缓存的速度,远远超过访问内存的速度(快了3-4个数量级, 也就是几千倍,上万倍). 比如某个代码中要连续10次读取某个变量的值,如果10次都从内存读,速度是很慢的.但是如果只是 第一次从内存读,读到的结果缓存到CPU的某个寄存器中,那么后9次读数据就不必直接访问内存了. 效率就大大提高了. 那么接下来问题又来了,既然访问寄存器速度这么快,还要内存干啥??
答案就是⼀个字:贵

值的⼀提的是,快和慢都是相对的.CPU访问寄存器速度远远快于内存,但是内存的访问速度⼜远远快 于硬盘. 对应的,CPU的价格最贵,内存次之,硬盘最便宜
3.5指令重排序引起的线程不安全问题
指令重排序 什么是代码重排序 ⼀段代码是这样的:
-
去前台取下U盘
-
去教室写10分钟作业
-
去前台取下快递 如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按1->3->2的方式执行,也是没问 题,可以少跑⼀次前台。这种叫做指令重排序
编译器对于指令重排序的前提是"保持逻辑发生变化".这⼀点在单线程环境下比较容易判断,但是 在多线程环境下就没那么容易了,多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的 执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是⼀个比较复杂的话题,涉及到CPU以及编译器的⼀些底层工作原理,此处不做过多讨论
死锁的四个必要条件
-
互斥条件 锁的基本性质:一个线程获取锁后,其他线程尝试获取该锁时必须阻塞等待,同一时间锁只能被一个线程持有。(如 Java 的
synchronized锁遵循此特性) -
**不可抢占条件(不可剥夺)**锁的基本特性:线程 1 获取锁后,线程 2 无法强行抢占该锁,只能阻塞等待线程 1 主动释放。
-
请求和保持条件 一个线程持有锁 1 的情况下,不释放锁 1,继续请求锁 2。反例:若线程先释放锁 1 再请求锁 2,可避免死锁(如 "先放下左手筷子,再拿右手筷子")。
-
循环等待条件 多个线程、多把锁之间形成循环依赖的等待链。示例:线程 A 等线程 B 的锁,线程 B 等线程 A 的锁;或线程 A 等 B、B 等 C、C 等 A。
解决办法?
看下面这张图
一个滑稽代表一个线程,筷子是锁,俩筷子就可以继续执行不会产生死锁问题,一个的话就阻塞等待
这是五个滑稽,滑稽1 如果拿着一个筷子,他吃不到这个面条,需要两个筷子,那他拿着只有个筷子,只能等待,此时每一个线程都持有一个筷子,正在等待阻塞,这时候就会产生死锁,这就是我们刚刚所说的死锁问题。
解决办法如下:
我们不妨给他加一个约定,每个线程加锁的时候永远是先获得序号小的锁,在获取序号大的锁。
滑稽1 先不拿锁(筷子)
滑稽2 拿1筷子
滑稽3 拿2筷子
滑稽4 拿3筷子
滑稽5 拿4筷子
滑稽1 此时就在阻塞等待1号筷子
这时候筷子5就空闲了,滑稽5 ,这时候就可以拿走筷子5 ,进行吃面条,吃完就释放筷子
筷子4也空闲了,滑稽4就可以拿筷子4和筷子5,进行吃面条,然后释放筷子
.......滑稽2释放筷子1和筷子2,
此时滑稽1等待到了他的筷子1 就拿起筷子1,然后筷子也是闲置的,此时两个锁都执行,至此所有的滑稽都吃上面条了,没有产生死锁问题
第一种方法:将嵌套锁改为并行锁(破坏 "请求和保持条件")
-
核心逻辑:避免线程在持有锁的同时继续请求新锁。即线程需一次性获取所有需要的锁(并行申请),若无法同时获取,则释放已持有的锁并重新尝试。
-
示例:线程需同时操作锁 A 和锁 B 时,不先拿 A 再拿 B,而是同时申请 A 和 B。若两者都能获取则执行,否则都不拿,避免 "持有 A 等待 B" 的嵌套状态。
-
效果:直接消除 "请求和保持" 条件(不会持有部分锁并等待其他锁),从根源上减少死锁可能。
-
并列的锁就解决这个问题了
-
并列的锁",本质是 **"一次性获取所有需要的锁(并行申请锁)"的策略,核心是破坏 "请求和保持" 条件 **,避免线程在持有部分锁的同时等待其他锁。
定义与逻辑
"并列的锁" 指线程在执行前,一次性尝试获取所有需要的锁;若不能同时获取所有锁,则不持有任何锁,释放已尝试的资源后重新申请。
第二种方法:破坏 "循环等待条件"(如固定锁的获取顺序)
- 核心逻辑:为所有锁定义统一的获取顺序,要求所有线程必须按相同顺序获取锁。
- 示例:规定 "必须先获取编号小的锁,再获取编号大的锁"。若线程 A 和 B 都需要锁 1 和锁 2,则都会先拿 1 再拿 2,不会出现 "A 等 2、B 等 1" 的循环。
- 效果:打破循环等待链,确保等待关系是单向的,避免形成闭环。
