跳到主要内容 Java 线程安全:概念、原因与解决方案 | 极客日志
Java java 算法
Java 线程安全:概念、原因与解决方案 Java 线程安全指多线程环境下代码运行结果符合预期。主要问题源于随机调度、数据竞争、原子性缺失及内存可见性。通过 synchronized 实现互斥和原子性,volatile 保证可见性,死锁需打破循环等待条件解决。
氛围 发布于 2026/3/15 更新于 2026/4/18 3 浏览一、线程安全的概念
线程安全 是指当多个线程同时访问某个对象、方法或变量时,系统仍然能保持正确的行为和数据一致性 ,无需调用者进行额外的同步协调。它是多线程编程的基石,用于防止因并发操作导致的数据混乱、计算结果错误或程序崩溃 等问题。
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
二、观察线程不安全
示例代码:
public class Demo1 {
;
InterruptedException {
(() -> {
( ; i < ; i++) {
count++;
}
});
(() -> {
( ; i < ; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println( + count);
}
}
private
static
int
count
=
0
public
static
void
main
(String[] args)
throws
Thread
t1
=
new
Thread
for
int
i
=
0
50000
Thread
t2
=
new
Thread
for
int
i
=
0
50000
"count 为:"
当上述代码执行后,得到的结果并不是预期中的 10 万,而是一个 5 万多的值,多执行几次该代码,每次得到的结果都不同,这便是采用多线程编程引发的线程安全问题。
三、导致线程安全问题出现的原因 那么为什么会导致出现线程安全问题呢,主要可分为以下几种情况。
1. 线程调度是随机的(主要原因) 这是线程安全问题的罪魁祸首 ,随机调度使一个程序在多线程环境下,执行顺序会存在很多的变数。程序员必须保证在任意执行顺序 下,代码都能正常工作。但是这是操作系统的底层逻辑,我们无法去改变。
2. 两个或多个线程针对同一个变量进行修改操作 当两个或多个线程针对同一个变量进行修改操作时 ,如果没有适当的同步机制,就必然会引发数据竞争 ,导致程序行为出现不确定性错误 。
就针对上述线程不安全的例子来说,t1 线程和 t2 线程同时对 count 变量进行累加修改操作,但由于 count++ 这个操作它并不是原子性 的。它看起来是一步,但在计算机底层实际分为三步:
(1)读取 当前 count 值到寄存器(load)
(2)对寄存器中的值进行修改 (add)
(3)将寄存器中的值重新写回 内存中(save)
但由于操作系统的调度是随机的,可能会导致以下的情况发生,假设初始值 count = 5。
时间点 线程 t1 的操作 线程 t2 的操作 内存中 count 的值 1 读取 count=5 5 2 计算 5+1=6 读取 count=5 5 3 计算 5+1=6 5 4 写回 6 6 5 写回 6 6
最终结果 :两个线程都执行了一次自增,但最终 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 ();
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++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count 为:" + count);
}
}
四、什么是"锁"
1. 锁的基本概念 锁 是并发编程中实现线程同步 的核心机制,用于控制多个线程对共享资源的访问顺序 ,确保同一时间只有一个(或固定数量)的线程能执行特定代码段或访问特定数据。
2. 锁的核心作用
(1) 实现互斥:保证临界区代码一次只能被一个线程执行 场景 锁的类比 说明 单间厕所 门锁 一人进去锁门,其他人等待 会议室 钥匙/门禁卡 只有持卡人才能进入开会 游戏手柄 手柄本身 只有拿着手柄的人能操作角色
(2) 保证原子性:将非原子操作包装成原子操作 就好像前面的示例代码,再加锁之前,因为 count++ 操作不是原子性的,在线程调度过程中会产生线程安全问题,但对于修改后的代码来说,线程的运行逻辑就会改变,还是假设初始值 count = 5
时间点 线程 t1 的操作 线程 t2 的操作 内存中 count 的值 1 获取到锁 (lock) 5 2 读取 count=5(load) 5 3 计算 5+1=6(add) 获取锁,发现已经被占用,开始阻塞等待 (lock) 4 t2 加锁失败,放弃 cpu,进入阻塞状态,操作系统又调度给 t1(或者其他线程) 5 写回 6 (save) 6 6 释放锁 (unlock) 6 7 获取到锁,阻塞结束,读取 count=6(load) 8 计算 6+1=7(add) 9 写回 7 (save) 10 释放锁 (unlock)
修改后的代码在执行时,t2 执行 lock 开始尝试获取锁,但由于此时锁已经被线程 t1 获取了,所以此时 t2 线程获取锁失败,会进入阻塞等待状态,直到 t1 线程释放锁,t2 线程获取到锁,t2 线程才会继续执行。
3. synchronized 关键字
3.1 核心本质 synchronized 通过在对象头中设置标记来实现基于监视器(Monitor)的锁机制 ,确保同一时间只有一个线程能执行特定代码。
3.2 基本形式 进入大括号即为加锁,出了大括号即为解锁,小括号里填的是一个 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 ();
Thread t1 = new Thread (() -> {
for (int i = 0 ; i < 50000 ; i++) {
c.add();
}
});
Thread t2 = new Thread (() -> {
for (int i = 0 ; i < 50000 ; i++) {
c.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
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 {
Thread t1 = new Thread (() -> {
for (int i = 0 ; i < 50000 ; i++) {
add();
}
});
Thread t2 = new Thread (() -> {
for (int i = 0 ; i < 50000 ; i++) {
add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
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("t2 线程获取到 locker2 这个锁" );
sleep(1000 );
synchronized (locker1) {
System.out.println("t2 线程获取到 locker1 这个锁" );
}
}
});
t1.start();
t2.start();
}
}
时间点 线程 t1 的操作 线程 t2 的操作 锁状态 t0 启动 启动 locker1=空闲,locker2=空闲 t1 获取 locker1 获取 locker2 locker1=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("t2 线程获取到 locker1 这个锁" );
sleep(1000 );
synchronized (locker2) {
System.out.println("t2 线程获取到 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 synchronized 保证原子性 ❌ 不保证(除了单次读/写) ✅ 完全保证 保证可见性 ✅ 完全保证 ✅ 完全保证 保证有序性 ✅ 禁止重排序 ✅ 保证串行执行 适用场景 状态标志、一次性发布 复合操作、临界区 性能开销 小 较大 阻塞行为 不会阻塞 会阻塞线程
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online