首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >【Java EE初阶 --- 多线程(初阶)】线程安全问题

【Java EE初阶 --- 多线程(初阶)】线程安全问题

作者头像
optimistic_chen
发布2026-01-14 20:16:04
发布2026-01-14 20:16:04
150
举报

线程不安全的原因

根本原因

多线程优点很明显,大大提高了线程的运行效率,但是它也有一个巨大的隐患:线程是并发执行的,而且调度是随机的(根本原因)。也就是说,随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数 程序猿必须保证 在任意执⾏顺序下 , 代码都能正常⼯作。

看下面的代码,利用多线程求和,我们的想法是两个线程各自增5000,最后结果就是10000,但是我们多次运行发现,每一次执行的结果都不同。

在我们看来count++是一个递增操作,但是在CPU眼里,它有三个指令:

1. load -> 把内存中count数据读到 CPU寄存器 2. add -> 在指定寄存器中进行+1操作 3. save -> 把寄存器中数据写回到内存


CPU在执行这三条指令时,随时会触发线程的随机调度。。。

在这里插入图片描述
在这里插入图片描述

非原子性操作

我依稀记得,原子性这个特性是数据库中的东西,具有不可再分的特性。

如果修改操作只是对应到一个CPU指令,不会出现“一条指令执行一半”的情况,就可以认为是原子的;相反如果对应多个CPU指令,就不是原子的。


随机到正确的调度

在这里插入图片描述
在这里插入图片描述

随机到错误的调度

在这里插入图片描述
在这里插入图片描述

经过讨论:

如果两个线程load到的数据都是0,意味着一定会少加一次。 必须严格一个load到0,一个load到1,结果才正确;也就是一个线程的load必须在另一个线程的save后面

多个线程修改同一个变量

如果一个线程修改一个变量---- 可以 如果多个线程,不是同时修改同一个变量— 可以 如果多个线程修改不同变量— 可以 如果多个线程读取同一个变量— 可以

在这里插入图片描述
在这里插入图片描述

内存可见性

如果某一个线程针对共享变量值修改,能够及时被其他线程看到

在这里插入图片描述
在这里插入图片描述

此时虽然输入了非0的值,但是t1线程还没有执行完。 由运行结果可知,t2线程已经修改,但是t1线程没有读到已经修改后的值,这里就出现了bug.

解决线程安全问题

内存可见性问题

为什么会出现内存不能及时读取这种问题?我们来看看Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型的解释(翻译后):

每个线程,有一个自己的”工作内存(work memory)“,同时这些线程共享同一个“主内存(main memory)”,当一个线程循环进行上述变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里。由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变换。


如果学过计算机组成原理,这里稍微解释一下就会很清楚:工作内存不是内存,而是CPU的寄存器主内存才是我们平时所说的内存。文档中之所以没有明确使用“寄存器”,而是用work memory来描述,主要是为了“一次编译,到处运行”。⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果。


在语法中,引入 volatile 关键字,通过这个关键字来修饰变量,编译器对这个变量的读取操作,就不会轻易被优化到寄存器中…(因为会得“易变”)

在这里插入图片描述
在这里插入图片描述

volatile关键字只是解决了内存可见性问题,解决原子性问题还需要加锁来完成。

加锁(打包原子性)

根本原因是操作系统的底层设计,无法改变… 主要解决方案,通过加锁(封装)一个原子特性,让不是原子的操作,打包为一个原子操作。

针对之前count++的操作,先给t1中count++加锁,等待t1计算完毕,再计算t2中的count++(给t2同样加锁)。

在这里插入图片描述
在这里插入图片描述

这里加锁比较抽象,不是禁止线程被调度走,而是禁止其他线程重新加这个锁,避免其他线程的插队。

加锁\解锁

加锁 \ 解锁 本身是操作系统提供的api,大多数都是对这样的api进行封装。 但是Java是通过synchronized 这样的关键字,来实现类似效果

代码语言:javascript
复制
synchronized(){//加锁
    //代码块
    //count++
}//解锁
锁对象

在Java中,任何一个对象都能叫做“锁”

在这里插入图片描述
在这里插入图片描述

这个锁的类型不重要,重要的是,是否有多个线程尝试针对这同一个对象加锁。 (这种时候才会产生互斥),如果是不同的锁对象,不会有互斥效果,线程安全没有解决。(一个对象只有一个用途比较好)

使用synchronized修饰方法

锁任意对象

代码语言:javascript
复制
private Object locker = new Object();
    public void method() {
        synchronized (locker) {
        }
    }

锁当前对象

代码语言:javascript
复制
private Object locker = new Object();
    public void method() {
        synchronized (this) {
        }
    }

直接修饰普通方法:锁的Demo对象

代码语言:javascript
复制
public class Demo {
    public synchronized void methond() {
    }
}

修饰静态方法:锁的Demo类的对象

代码语言:javascript
复制
public class Demo {
    public synchronized static void methond() {
    }
}

总结: 多个线程针对同一个对象加锁,才会产生互斥(锁冲突)。 synchronized修饰普通方法,相当于给this加锁 synchronized修饰静态方法,相当于给类对象加锁

监视器锁monitor lock

比较少见

代码语言:javascript
复制
Lock locker=new locker();
locker.lock();
.....
locker.unlock();
//容易忘记unlock;

可重入性

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

观察上面两个代码块,我们发现对add()方法加了两次锁。 我们分析一下,针对第一次加锁,可以成功(锁没有人使用);第二次加锁,锁对象已经被占用,那么第二次加锁就会阻塞等待


这个时候为了要解除阻塞等待(继续执行),只能使第一次加锁被释放;但是第一次加锁要释放必须执行到红色方框,为了执行到红色方框又必须执行第二次解锁,此时就陷入了死循环(死锁)。


反转来了:当你运行此程序时,发现它结果完全正确。 因为Java中的 synchronized 是可重⼊锁(内在), 因此没有上⾯的问题.

在这里插入图片描述
在这里插入图片描述

值得注意的是,这里可重入特性有一个前提:所有锁都掌握在同一个线程手中,也就是锁对象内部知道当前线程是哪一个,如果有非当前线程来加锁,还是会造成阻塞等待


<举个例子>小帅去追小美交朋友,小美同意了,那么小美就相当于被小帅加锁,之后小帅一直对小美“交朋友”(懂得都懂),小帅不会被拒之门外;但是如果有小丑来找小美交朋友,那他就得等待小帅和小美分手,他才有机会....

站在JVM的视角,如何才知道哪个 “{” 是真正的加锁、哪个 “}” 是真正的解锁呢?

在可重⼊锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息. • 如果某个线程加锁的时候, 发现锁已经被⼈占⽤, 但是恰好占⽤的正是⾃⼰(“{”), 那么仍然可以继续获取到锁, 并让计数器⾃增. • 解锁(”}“)的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

死锁

第一种死锁:一个线程,一把锁,连续加锁两次(正常会造成死锁,synchronized有可重入特性避免出现死锁) 第二种死锁:两个线程,两把锁,每个线程获得一把锁后,都想要对方的锁。


众所周知,核潜艇中的”小男孩“要发射需要舰长和副舰长两人的钥匙,战争中,如果收到命令发射核弹,需要正副舰长拿出钥匙同时启动。

如果舰长收到命令,在有自己钥匙的前提下,还需要副舰长的钥匙。

代码语言:javascript
复制
Thread t1=new Thread(()->{
            //舰长拿出钥匙
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //尝试拿到副舰长的钥匙
                synchronized (locker2){
                    System.out.println("t1线程拿到两个锁");
                }
            }
        });
在这里插入图片描述
在这里插入图片描述

如果副舰长收到命令,在有自己钥匙的前提下,还需要舰长的钥匙。

代码语言:javascript
复制
Thread t2=new Thread(()->{
            //副舰长拿出钥匙
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //尝试拿到舰长的钥匙
                synchronized (locker1){
                    System.out.println("t2线程拿到两个锁");
                }
            }
        });
在这里插入图片描述
在这里插入图片描述

运行程序发现,两个线程都以BLOCKED(竞争锁)阻塞了

总结一下死锁出现的原因:

  1. 锁是互斥的,一个线程拿到锁后,另一个线程再尝试获得锁,必须要阻塞等待(锁的基本特性
  2. 锁是不可剥夺的,一个线程拿到锁后,另一个线程再尝试获得锁,不能直接去抢,必须要阻塞等待(锁的基本特性)
  3. 请求和保持,一个线程拿到锁后,不释放锁1的情况下,去获得锁2 (嵌套)
  4. 循环等待,多个线程,多个锁之间构成”循环“
避免死锁

结合死锁出现的原因,1和2都是锁的基本特性,无法改变,那么只有对3和4下功夫了。

3.请求和保持:一个线程拿到锁后,不释放锁1的情况下,再由一个线程去获得锁2(并列) 4.循环等待:对加锁的顺序做出约定

Java标准可中的线程安全类

在这里插入图片描述
在这里插入图片描述

虽然有加锁来保证线程安全,但是万事没有绝对,而且加锁是要付出相应的代价(代码可能因为锁竞争,造成阻塞等待,影响程序执行效率)

Java中String虽然没有加锁,但是它内部具有不可修改的特性,使其天然的保证了线程安全。

线程协调

我们知道**,线程之间是抢占式执行的**,如果我们程序员不加以干涉,我们很难得知线程之间执行的先后顺序。程序员对线程的设计都是具有逻辑顺序的,这时候就需要我们合理的协调多个线程之间的执行先后顺序。


回想之前有关等待的部分: join是等另一个线程彻底结束,才继续执行,不符合我们的需求。 锁对于等待随机性太复杂,也不符合需求。

wait(等待)

wait方法的作用: 1. 使当前线程代码的线程进行等待 2. 释放当前锁(这就意味着wait方法必须和synchronized配合使用,因为要解锁的前提是当前对象为加锁状态) 3. 满足一定条件被唤醒,重新尝试获取这个锁

代码语言:javascript
复制
Object object=new Object();
        synchronized (object){
            object.wait();
        }

详细的操作是:

在这里插入图片描述
在这里插入图片描述

这里等待其他线程逻辑执行完毕,就可以使用notify方法唤醒wait,当前线程继续执行。

notify(通知)

notify的作用; 1. 同步使用在等待代码块中,通知 等待该对象的对象锁的其他线程,使它们重新获取该对象的对象锁。 2. 如果有多个等待线程,那么随机挑选出有wait状态的线程 3. 注意:在notify方法结束后,当前线程线程不会立刻释放该对象锁,要等执行notify的线程执行完毕后才会释放对象锁

代码语言:javascript
复制
Object locker=new Object();
        Thread t1=new Thread(()->{
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException();
                }
            }
        });
代码语言:javascript
复制
Thread t2=new Thread(()->{
            Scanner scan=new Scanner(System.in);
            scan.next();//等待输入:约等于阻塞
            synchronized (locker){
                locker.notify();
            }
        });
在这里插入图片描述
在这里插入图片描述

如果不是同一个对象,那么两个线程无法沟通,就不能相互作用

notifyAll

代码语言:javascript
复制
Thread t3=new Thread(()->{
            Scanner scan=new Scanner(System.in);
            scan.next();//等待输入:约等于阻塞
            synchronized (locker){
                locker.notifyAll();//一次性唤醒所有wait线程
            }
        });

注意: 虽然是同时唤醒 2 个线程, 但是这 2 个线程需要同时加锁.只有其中一个才能成功加锁,所以并不是同时执⾏, ⽽仍然是有先有后的执⾏.

完结

可以点一个免费的赞并收藏起来~ 可以点点关注,避免找不到我~ ,我的主页:optimistic_chen 我们下期不见不散 ~ ~ ~

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-01-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 线程不安全的原因
    • 根本原因
    • 非原子性操作
    • 多个线程修改同一个变量
    • 内存可见性
  • 解决线程安全问题
    • 内存可见性问题
    • 加锁(打包原子性)
    • 加锁\解锁
      • 锁对象
  • 使用synchronized修饰方法
    • 监视器锁monitor lock
    • 可重入性
    • 死锁
      • 避免死锁
  • Java标准可中的线程安全类
  • 线程协调
    • wait(等待)
    • notify(通知)
    • notifyAll
  • 完结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档