【Java】线程安全问题
一.线程安全概念
实际结果和预期结果不一样,就是线程不安全
多线程带给我们效率提升的同时,也为我们带来了风险,因为多线程的抢占式执行,带来的随机性。
count++看起来是一行代码,实际上对应了三个cpu指令
++操作本质上要分为三步
1.load指令:先把内存中的count值读取到CPU的寄存器中
2. add指令:把CPU寄存器里的数值+1操作
3.save指令:把寄存器中的内容保存回内存上
我们想使用两个线程将一个变量同时增加5000次
此时两个线程的并发执行,就相当于两组load add save指令并发执行。所以这两组指令的执行顺序存在了许多可能性。
package Thread;
public class Demo1{private static int count=0;public static void main(String[] args)throws InterruptedException{Object obj=new Object();Thread t1=new Thread(()->{for(int i=0;i<5000;i++){count++;}});Thread t2=new Thread(()->{for(int i=0;i<50000;i++){count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
每运行一次,结果都是不同的,如下所示,这是我分别运行三次的结果



推演图(有很多种可能性)

一个线程的load必须在一个线程的save之后
二.线程安全问题原因和如何解决线程安全问题
最根本的原因: 抢占式执行,随机调度
我们上述线程代码之所以不安全,因为涉及到我们两个线程同时去修改一个相同的变量。
一个线程修改一个变量,安全
多个线程读取同一个变量,安全
多个线程修改多个不同的变量,安全
1.根本:操作系统对于线程的调度是随机的,抢占式执行
操作系统的底层设定,咱们左右不了
2.多线程同时修改同一个变量
和代码的结构直接相关,可以通过调整代码结构,规避一些线程不安全的代码
(不够通用,有的情况下,有时需要多线程修改同一个变量)
3.修改操作不是原子的
(java中解决线程安全问题,最主要的方案)
计算机中的锁和生活中的锁,是同样的概念,互斥/排他
加锁,让不是原子的,打包成一个原子的操作
一旦把锁加上了,其他人要想加锁,就得阻塞等待。
(eg:上厕所关门)不允许暴力拆锁
加锁操作,不是把线程锁死到 cpu 上,禁止这个线程被调度,而是禁止其他线程重新加这个锁,避免其他线程的操作在当前线程执行过程中,插队
在代码中如何实现呢?如下所示
用synchronized(){}将count++包裹起来

然后再写一个locker对象,更改后的代码块如下所示
Thread t1=new Thread(()->{for(int i=0;i<50000;i++){synchronized(locker){count++;}}});Thread t2=new Thread(()->{for(int i=0;i<50000;i++){synchronized(locker){count++;}}});两个线程,针对同一个对象加锁,才会产生互斥效果,(一个线程加上锁了,另一个线程就得阻塞等待,等到第一个线程释放锁,才有机会)
运行结果为

三.Synchronized监管锁monitor lock
在 Java 中,synchronized 关键字的底层实现依赖于监视器锁(Monitor Lock),它是一种基于 JVM 内置的同步机制,用于保证多线程环境下共享资源的原子性、可见性和有序性。当使用 synchronized 时若出现异常,错误信息中常提到 “监视器锁”(如 IllegalMonitorStateException),这与它的特性和使用规则直接相关。
使用锁的时候抛出一些异常,可能就会看到监视器锁这样的报错信息
监视器锁(Monitor Lock)的核心特性
1. 可重入性(Reentrancy)
- 定义:一个线程已经获取了某个对象的监视器锁后,再次请求该对象的锁时可以直接获取(无需重新竞争),即允许同一线程多次持有同一把锁。
- 作用:避免死锁(例如递归调用中,线程不会因重复获取自己已持有的锁而阻塞)。
代码示例
(线程调用 methodA 获取锁后,调用 methodB 时可直接重入,无需等待。)
package Threadtest;
//synichronized 的可重入性代码举例class Counter2 {private int count = 0;public void add() {synchronized (this) { // 第一把锁:thiscount++;}}public int get() {return count;}}public class demo3 {public static void main(String[] args) throws InterruptedException {Counter2 counter = new Counter2();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized(counter){counter.add();}}});t1.start();t1.join();System.out.println("count = " + counter.get());}}2. 其他关键特性
- 独占性:同一时间只能有一个线程持有监视器锁,其他线程尝试获取时会被阻塞(进入等待队列)。
- 释放机制:锁的释放是自动的 —— 当同步代码块执行完毕、抛出未捕获异常或执行
wait()时,线程会释放锁。 - 关联对象:监视器锁与具体对象绑定(
synchronized修饰非静态方法时锁是this,修饰静态方法时锁是类对象Class)。
四.死锁的出现原因
两次加锁
死锁(间接与监视器锁相关)
死锁代码示例是在synichronized可重入性的代码示例上的做的修改,可以通过观察两者的区别,更好的学习死锁
触发原因:多个线程相互持有对方需要的锁,且都不释放,导致无限阻塞。
本质:监视器锁的独占性导致资源竞争无法化解。
代码触发死锁,线程就卡住了
构成死锁的原因
(1)锁是互斥的
(2)锁不可抢占/剥夺
注:1/2都是锁的基本特性,所以只能通过破坏3/4打破死锁
(3)请求和保持--保持锁状态的前提下,再请求另一把锁
(4)循环等待
死锁是怎么解决的
(1)加锁的时候,不要嵌套,即避免锁嵌套(打破3
(2)约定加锁顺序(打破4,如何约定,下文中有举例解释
(3)银行家算法

class Counter2 {private int count = 0;// 新增第二把锁private final Object lock2 = new Object();public void add() {synchronized (this) { // 第一把锁:thissynchronized (lock2) { // 第二把锁:lock2count++;}}}public int get() {return count;}
}public class Demo19 {public static void main(String[] args) {Counter2 counter = new Counter2();// 线程1:先拿this锁,再等lock2锁Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});// 线程2:先拿lock2锁,再等this锁Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (counter.lock2) { // 先获取lock2锁synchronized (counter) { // 再尝试获取this锁// 此处可执行任意操作(如空逻辑),关键是锁的顺序}}}});t1.start();t2.start();// 等待线程执行(实际死锁时此代码不会执行到,但用于演示结构)try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("count = " + counter.get());}
}死锁经典问题:哲学家就餐问题
描述 5 位哲学家围坐在圆桌旁,每人左右各有一根筷子,思考时放下筷子,就餐时需要同时拿起左右两根筷子。若每位哲学家同时拿起左手筷子,再等待右手筷子,会导致所有人都持有一根筷子并等待另一根,形成死锁。

解决该问题的方案(破坏死锁条件)
死锁的 4 个必要条件:互斥、持有并等待、不可剥夺、循环等待。只需破坏其中一个即可避免死锁,以下是两种经典方案:
方案 1:固定筷子获取顺序(破坏 “循环等待”)
让所有哲学家先拿编号小的筷子,再拿编号大的筷子,避免循环等待。
方案 2:最多允许 4 位哲学家同时拿筷子(破坏 “持有并等待”)
限制同时拿筷子的哲学家数量,保证至少有一位能拿到两根筷子,用完后释放资源。
五.什么是原子性?
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
不保证原子性会给多线程带来什么问题?
一个线程正在对一个变量操作,中途其他线程插进来了,对这个操作造成了打断,可能会造成结果的错误。这和线程的抢占式调度有关,如果不是抢占式,就算不是原子性,也问题不大。
六.内存可见性
ava内存模型(JMM): java虚拟机规定了java内存模型。
目的: 屏蔽各种硬件和操作系统的内存访问差异,实现java程序在各平台下都达成一致的并发效果
这个时候代码就会出现问题。
七.指令重排序
指令重排序实际上也是编译器优化,简单的来说,就是我们把一个东西写的太烂了,JVM.CPU指令集会对其进行优化。
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价
八.内存可见性
什么是可见性
可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到
共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量
volatile如何实现内存的可见性
当编译器优化处bug,使用这个关键字修饰的变量,就属于“易失”,必须每次重新读取内存
深入来说:通过加入内存屏障和禁止重排序优化来实现的
对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
对volatile变量执行读操作时,会在读操作前加入一条load屏障指令
总结 volatile 核心作用:
- 可见性:一个线程修改
volatile变量后,其他线程能立即看到最新值。 - 有序性:禁止指令重排,保证代码执行顺序与预期一致。
- 不保证原子性:不能替代锁解决多线程并发修改问题。
public class VolatileDemo {// 用 volatile 修饰共享变量,保证多线程可见性private static volatile boolean flag = false;public static void main(String[] args) {// 线程1:等待 flag 变为 truenew Thread(() -> {System.out.println("线程1启动,等待 flag 变为 true...");while (!flag) {// 循环等待,直到 flag 被修改}System.out.println("线程1检测到 flag 为 true,退出循环");}).start();// 主线程:休眠1秒后修改 flagtry {Thread.sleep(1000); // 确保线程1先启动} catch (InterruptedException e) {e.printStackTrace();}flag = true;System.out.println("主线程已将 flag 修改为 true");}
}九.synchronized和volatile比较
volatile不需要加锁,比synchronized更轻量级,不会阻塞线程
从内存可见性角度讲,volatile读相当于加锁,volatile写相当于解锁
synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性;
