JAVA的线程安全问题

一.线程安全的概念

线程安全是指当多个线程同时访问某个对象、方法或变量时,系统仍然能保持正确的行为和数据一致性,无需调用者进行额外的同步协调。它是多线程编程的基石,用于防止因并发操作导致的数据混乱、计算结果错误或程序崩溃等问题,想准确给出⼀个线程安全的确切定义是复杂的。

但我们可以这样认为:

如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的

二.观察线程不安全

示例代码:

public class Demo1 { //这里定义一个int类型的变量 private static int count = 0; public static void main(String[] args) throws InterruptedException { //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + count); } }

当上述代码执行后,得到的结果并不是预期中的10万,而是一个5万多的值,多执行几次该代码,每次得到的结果都不同,这便是采用多线程编程引发的线程安全问题

三.导致线程安全问题出现的原因

那么为什么会导致出现线程安全问题呢,主要可分为以下几种情况

1.线程调度是随机的(主要原因)

这是线程安全问题的罪魁祸首,随机调度使⼀个程序在多线程环境下,执行顺序会存在很多的变数. 程序猿必须保证在任意执行顺序下,代码都能正常工作.但是这是操作系统的底层逻辑,我们无法去改变

2.两个或多个线程针对同一个变量进行修改操作

两个或多个线程针对同一个变量进行修改操作时,如果没有适当的同步机制,就必然会引发数据竞争,导致程序行为出现不确定性错误

就针对上述线程不安全的例子来说,t1线程和t2线程同时对count变量进行累加修改操作,但由于count++这个操作它并不是"原子性"的。它看起来是一步,但在计算机底层实际分为三步:

(1)读取当前count值到寄存器(load)

(2)对寄存器中的值进行修改(add)

(3)将寄存器中的值重新写回内存中(save)

但由于操作系统的调度是随机的,可能会导致以下的情况发生,假设初始值 count = 5

时间点线程t1的操作线程t2的操作内存中count的值
1读取 count=55
2计算 5+1=6读取 count=55
3计算 5+1=65
4写回 66
5写回 66

最终结果:两个线程都执行了一次自增,但最终 count = 6,而不是正确的 7线程B的写入覆盖了线程A的写入,导致一次更新“丢失”

3.原子性

原子性 是并发编程中的一个核心概念,指一个或多个操作要么全部成功执行,要么完全不执行,中间不会被打断,也不会被其他线程看到中间状态

简单来说就好比银行转账,从账户A扣款100元,向账户B加款100元。原子性要求这两个操作必须一起完成。如果只扣了A的钱但没加到B上,就是原子性被破坏,会导致数据不一致。或者说上面的count++操作,count++ 在底层需要三步(读→改→写)。原子性则是要求这三步像一步一样执行,其他线程看不到中间过程。

4.内存可见性

内存可见性指在多线程环境中,当一个线程修改了共享变量后,其他线程能够立即、可靠地看到最新值的能力。这是并发编程中除原子性外的另一核心挑战,因为现代计算机的多级缓存架构和编译器的指令重排序优化可能导致一个线程的修改滞留在本地缓存,而其他线程仍读取到旧值或处于不一致中间状态的数据。

5.解决线程安全问题的方法

为了解决线程不安全问题,在这里引入一个新的概念""

上述示例代码的优化版本:

public class Demo2 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Object locker = new Object(); //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized (locker) { count++; } } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized (locker) { count++; } } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + count); } }

这时代码运行的结果就是预期中的10万了

四.什么是"锁"

1.锁的基本概念

 是并发编程中实现线程同步的核心机制,用于控制多个线程对共享资源的访问顺序,确保同一时间只有一个(或固定数量)的线程能执行特定代码段或访问特定数据。

2.锁的核心作用
(1)实现互斥:保证临界区代码一次只能被一个线程执行

类比说明

场景锁的类比说明
单间厕所门锁一人进去锁门,其他人等待
会议室钥匙/门禁卡只有持卡人才能进入开会
游戏手柄手柄本身只有拿着手柄的人能操作角色
(2)保证原子性:将非原子操作包装成原子操作

就好像前面的示例代码,再加锁之前,因为count++操作不是原子性的,在线程调度过程中会产生线程安全问题,但对于修改后的代码来说,线程的运行逻辑就会改变,还是假设初始值 count = 5

时间点线程t1的操作线程t2的操作内存中count的值
1获取到锁(lock)5
2读取 count=5(load)5
3计算 5+1=6(add)获取锁,发现已经被占用,开始阻塞等待(lock)5
4t2加锁失败,放弃cpu,进入阻塞状态,操作系统又调度给t1(或者其他线程)5
5写回6 (save)6
6释放锁(unlock)6
7获取到锁,阻塞结束,读取 count=6(load)6
8计算 6+1=7(add)6
9写回7 (save)7
10释放锁(unlock)7

修改后的代码在执行时,t2执行lock开始尝试获取锁,但由于此时锁已经被线程t1获取了,所以此时t2线程获取锁失败,会进入阻塞等待状态,直到t1线程释放锁,t2线程获取到锁,t2线程才会继续执行

3.synchronized关键字
3.1核心本质

synchronized 通过在对象头中设置标记来实现基于监视器(Monitor)的锁机制,确保同一时间只有一个线程能执行特定代码。

3.2基本形式
synchronized (obj) { //一系列代码逻辑 }

进入大括号即为加锁,出了大括号即为解锁,小括号里填的是一个Object类型的对象

注意:

(1)synchronized关键字并不关心()里填的是什么对象,只关心当两个或多个线程()里填的是一个对象时,这些线程才会产生"锁竞争"或者说"锁排斥"

(2)加锁并不意味着"禁止线程调度",而是禁止其他线程插队,也就是说当线程1在执行锁内的逻辑时,是可以被调度走的,但是其他线程想要申请这个锁时,会进行阻塞等待

(3)与其它编程语言不同,synchronized关键字更稳健,不管在锁内的代码逻辑中出现return或者throw,都会确保执行unlock即释放锁,避免因为忘记释放锁引起的线程安全问题

3.3三种使用方式

(1)直接修饰代码块:明确指定锁哪个对象,如前面的示例代码

(2)把synchronized加到实例方法上,此时就是给this加锁

class Count { public int count = 0; synchronized public void add() { count++; } } public class Demo2 { public static void main(String[] args) throws InterruptedException { Count c = new Count(); //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c.add(); } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c.add(); } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + c.count); } }

(3)synchronized还可以修饰一个静态方法

public class Demo3 { private static int count = 0; synchronized private static void add() { count++; } public static void main(String[] args) throws InterruptedException { //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { add(); } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { add(); } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + count); } }
3.4可"重入"锁

synchronized 同步块对同⼀条线程来说是可重入的,不会出现自己把自己锁死的问题

理解"把自己锁死" ⼀个线程没有释放锁,然后又尝试再次加锁. // 第一次加锁,加锁成功 lock(); // 第二次加锁,锁已经被占用,阻塞等待,按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待.直到第一次的锁被释放,才能获取到第二个锁.但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作.这时候就会死锁

但是:Java 中的synchronized是可重入锁,因此没有上⾯的问题.

五.死锁问题

1.基本概念

死锁是多线程编程中最严重的问题之一,指两个或多个线程永久阻塞,互相等待对方持有的资源,导致程序无法继续执行

示例代码:

public class Demo4 { private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { throw new RuntimeException(e); } } public static void main(String[] args) { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); sleep(1000); synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); sleep(1000); synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); } } }); t1.start();t2.start(); } }

死锁过程分析:

时间点线程t1的操作线程t2的操作锁状态
t0启动启动locker1=空闲, locker2=空闲
t1获取 locker1获取 locker2locker1=t1持有, locker2=t2持有
t2休眠1秒休眠1秒双方各持有一把锁
t3醒来,尝试获取 locker2(但被t2持有) → 阻塞等待醒来,尝试获取 locker1(但被t1持有) → 阻塞等待互相等待对方释放锁
t4无限期等待locker2无限期等待locker1死锁形成
2.死锁产生的四个必要条件
条件在代码中的体现
1. 互斥synchronized 确保每个锁一次只能被一个线程持有
2. 持有并等待t1持有locker1等待locker2,t2持有locker2等待locker1
3. 不可剥夺Java锁不能被强制抢占,只能主动释放
4. 循环等待t1 → 等待 t2 的locker2,t2 → 等待 t1 的locker1
3.解决死锁的方法

死锁产生有四个必要条件,打破其中任意一个,便可以解除死锁

(1)"锁"是互斥的(这是锁的基本特点),对于synchronized关键字来说是解决不了的

(2)"锁"不可被抢占,这对于synchronized关键字来说也是解决不了的

(3)持有并等待,即t1线程在持有locker1这个锁的前提下,还想要获取locker2这个锁,解决办法是在编写代码时尽量避免出现锁的嵌套

(4)打破循环等待,就需要约定加锁的顺序,即把锁进行编号,约定任何一个线程在需要加多把锁时,都按照锁编号从小到大的顺序来加锁

​//修改后的代码,锁编号解决死锁问题 public class Demo4 { private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { throw new RuntimeException(e); } } public static void main(String[] args) { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); sleep(1000); synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); } } }); Thread t2 = new Thread(() -> { synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); sleep(1000); synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); } } }); t1.start();t2.start(); } } ​

六.内存可见性问题

1.内存可见性导致的线程安全问题

实例代码:

2.原因分析

通过上述代码可以发现当我们在t2线程中修改了flag的值,按预期来说t1线程应该检测到flag非0,从而结束线程,但结果是t1线程并没有结束,这便是内存可见性导致的线程安全问题

内存可见性问题,本质上是因为编译器优化引起的,在JAVA的程序员中,有的大佬代码写的非常简洁明了,更加高效,但是有的小白的代码逻辑比较低效,为了更高效的运行,大佬们便想了个办法,就是在编译器中加入"优化机制",即小白写出了一串代码逻辑,在编译器编译的时候就会自动分析这一串代码,在保持代码逻辑不变的前提下,自动修改代码内容,让代码变得更高效

那么上述代码为什么会出现问题,从ti线程的while循环语句,站在cpu的角度来看可分为两个步骤,(1)从内存中读取flag的值储存到寄存器中(load),(2)比较寄存器中的值和0是否相同,若相同便继续执行,不相同使用跳转语句跳转到某个位置,在执行时因为第一步读内存操作的开销比第二步比较的开销大的多,在执行了多次以后(输入操作之前的时间足够让这个循环执行上万次),编译器就发现flag每次读到的值好像都是一样的,且编译器也没有发现flag在哪里有修改(虽然t2线程里有修改flag的值的操作,但是编译器无法判断另一个线程的执行时机),于是编译器做了一个大胆的决定,就把load操作优化掉了,以后只从寄存器/缓存中读取flag的值,此时t2线程修改flag的值,t1线程就感知不到了。

从JMM的角度来说,在JAVA的进程中。每个线程都会有一份工作内存(work Memory),同时这些线程还共享一份主内存(main Memory),而当一个线程进行修改和读取操作时候

修改:先把数据从主内存拷贝到工作内存,对工作内存进行操作,再写回主内存

读取:先把数据从主内存拷贝到工作内存,再从工作内存中读取

内存可见性:

t1线程while循环判定的是工作内存里的值

t2线程修改的是主内存里的值,由于t1工作内存是主内存数据的副本,导致修改主内存,不会影响t1工作内存里的值

3.volatile 关键字

为了解决上述问题JAVA就引入了volatile 关键字

3.1 基本应用

volatile 是 Java 提供的一种轻量级的同步机制,用于确保变量的可见性和禁止指令重排序,但它不保证原子性。它常用来修饰某个变量,此时编译器就知道这个变量"易变",后续在针对这个变量的读写操作时就不会涉及优化操作了

修改后的代码:

3.2 volatile 不保证原子性

volatile 和 synchronized 有着本质的区别.synchronized能够保证原⼦性,volatile保证的是内存可⻅ 性,因此在前面的问题中使用volatile是无法解决线程安全问题的,volatile适用于内存可见性导致的线程安全问题

volatile vs synchronized

特性volatilesynchronized
保证原子性❌ 不保证(除了单次读/写)✅ 完全保证
保证可见性✅ 完全保证✅ 完全保证
保证有序性✅ 禁止重排序✅ 保证串行执行
适用场景状态标志、一次性发布复合操作、临界区
性能开销较大
阻塞行为不会阻塞会阻塞线程

Read more

“现在的AI就像1880年的笨重工厂!”微软CSO斯坦福泼冷水:别急着造神

“现在的AI就像1880年的笨重工厂!”微软CSO斯坦福泼冷水:别急着造神

大模型仍未对上商业的齿轮? 编译 | 王启隆 来源 | youtu.be/aWqfH0aSGKI 出品丨AI 科技大本营(ID:rgznai100) 现在的硅谷,空气里都飘着一股“再不上车就晚了”的焦躁感。 最近 OpenClaw 风头正旺,强势登顶 GitHub,终结了 React 神话,许多人更是觉得“AI 自己干活赚钱”的日子就在明天了。 特别是在斯坦福商学院(GSB)这种地方,台下坐着的都是成天琢磨怎么用下一个技术风口搞个独角兽出来的狠人。 微软的首席科学官(CSO)Eric Horvitz 被请到了这个几乎全美最想用 AI 变现的礼堂里。作为从上世纪 80 年代就开始搞 AI 的绝对老炮、也是微软技术底座的“扫地僧”,这位老哥并没有顺着台下的胃口,去吹捧下个月大模型又要颠覆什么行业,而是兜头给大家浇了一盆带点学术味的冷水。 他讲了一个挺有画面感的比喻:大家都在聊

By Ne0inhk
Godot被AI代码“围攻”!维护者崩溃发声:“不知道还能坚持多久”

Godot被AI代码“围攻”!维护者崩溃发声:“不知道还能坚持多久”

整理 | 郑丽媛 出品 | ZEEKLOG(ID:ZEEKLOGnews) 当大模型能在几秒钟内生成一段“看起来像那么回事”的补丁时,开源社区却开始付出另一种代价。 最近,开源游戏引擎 Godot 的核心维护团队公开吐槽:他们正被大量“AI 生成的低质量代码”淹没。那些代码往往结构完整、注释齐全、描述洋洋洒洒,但真正的问题是——提交者可能并不理解自己交上来的内容。 这件事,并不是简单的“有人偷懒用 AI 写代码”。它正在触及开源协作最核心的东西:信任。 一场悄无声息的“AI 洪水” 事情的导火索来自一条 Bluesky 讨论帖。 Godot 主要维护者之一、同时也是 Godot 商业支持公司 W4 Games 联合创始人的 Rémi Verschelde 表示,所谓的“AI slop”

By Ne0inhk
诺奖得主辛顿最新访谈:1 万个 AI 可以瞬间共享同一份“灵魂”,这就是为什么人类注定被超越

诺奖得主辛顿最新访谈:1 万个 AI 可以瞬间共享同一份“灵魂”,这就是为什么人类注定被超越

当宇宙级的“嘴炮”遇到降维打击。 编译 | 王启隆 来源 | youtu.be/l6ZcFa8pybE 出品丨AI 科技大本营(ID:rgznai100) 打开最新一期知名播客 StarTalk 的 YouTube 评论区,最高赞的一条留言是这样写的: “我长这么大,第一次看到尼尔·德葛司·泰森(Neil deGrasse Tyson)在一档节目里几乎全程闭嘴,像个手足无措的小学生一样乖乖听讲。” 作为全美最知名的天体物理学家,泰森平时的画风是充满激情、喋喋不休、用宇宙的宏大来震撼嘉宾。但这一次,坐在他对面的那位满头银发、带着温和英音的英国老人,仅仅用最平淡的语气,就让整个演播室陷入了数次令人窒息的沉默。 这位老人是 Geoffrey Hinton。深度学习三巨头之一,2024 年诺贝尔物理学奖得主,被公认为“AI 教父”。 对经常阅读 Hinton 演讲的我来说,这也是比较新奇的一幕—

By Ne0inhk
48小时“烧光”56万!三人创业团队濒临破产,仅因Gemini API密钥被盗:“AI账单远超我们的银行余额”

48小时“烧光”56万!三人创业团队濒临破产,仅因Gemini API密钥被盗:“AI账单远超我们的银行余额”

整理 | 苏宓 出品 | ZEEKLOG(ID:ZEEKLOGnews) 「仅过了 48 小时,一笔 8.2 万美元的天价费用凭空出现,较这家小型初创公司的正常月费暴涨近 46000%。」 这不是假设的虚幻故事,而是一家墨西哥初创公司正在经历的真实危机。 近日,一位名为 RatonVaquero 的开发者在 Reddit 发帖求助称,由于他的 Gemini API 密钥被盗用,原本每月仅约 180 美元(约 1242 元)的费用,在短短 48 小时内暴涨到 82,314.44 美元(约 56.8 万元)。对于这家只有三名开发者的小型创业团队来说,这笔突如其来的账单,几乎等同于灭顶之灾。 “我现在整个人都处在震惊和恐慌之中。”RatonVaquero

By Ne0inhk