Java多线程初阶:深入线程安全与同步关键字
文章简述:
大家好!在上一篇《Java多线程初阶:从核心概念到线程操作》中,我们一起了解了线程的基本概念和操作。现在,我们将进入多线程编程中最核心、最重要的:线程安全。这篇笔记会深入探讨线程不安全问题的根源,并详细学习Java提供的两个关键武器——synchronized
和volatile
——是如何帮助我们驯服并发猛兽,编写出健壮、可靠的多线程程序的。
1. 多线程的潜在风险:线程安全问题
当我们踏入多线程的世界,享受并发带来的效率提升时,也必须警惕其伴随而来的风险。其中,线程安全问题是每一位开发者都必须面对和解决的核心挑战。
1.1 一个线程不安全的实例
让我们从一个直观的例子开始。下面的代码创建了两个线程,它们共同对一个共享的计数器 counter
进行累加操作,每个线程各执行 50,000 次 increase
方法。
public class UnsafeThread {
static class Counter {
public int count = 0;
void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
// 等待两个线程都执行完毕
t1.join();
t2.join();
// 理论上,最终结果应该是 100000
System.out.println(counter.count); // 但实际运行结果往往小于 100000
}
}
尝试多次运行这段代码,会发现最终打印出的 counter.count
值几乎每次都不同,而且总是小于预期的 100,000。这个现象就是典型的线程不安全。那么,问题究竟出在哪里呢?
1.2 理解线程安全
在深入探究原因之前,先给“线程安全”一个清晰的定义。虽然学术上的定义可能很复杂,但可以这样通俗地理解:
如果一段代码在多线程环境中执行的结果,与预期它在单线程环境中顺序执行的结果完全一致,那么就可以称这段代码是线程安全的。
反之,如果执行结果出现了偏差,就说明存在线程安全问题。
1.3 探究线程不安全的根源
线程安全问题通常由三个核心因素共同导致:原子性、可见性和有序性。在我们的例子中,这几个因素都有体现。
1.3.1 前提:存在修改共享数据
首先,线程不安全问题只会在“多个线程修改同一个共享变量”的场景下才会发生。在上面的例子中,counter
对象被 t1
和 t2
两个线程共享,而 counter.count
变量被两个线程同时进行修改操作。
小思考:数据为何能被共享?
new Counter()
创建的对象实例是存放在**堆内存(Heap)**中的。堆是所有线程共享的内存区域。因此,t1
和t2
两个线程都可以通过counter
这个引用访问到堆上的同一个Counter
实例,从而修改其中的count
字段。
如果多个线程只是读取共享数据,或者各自修改的是不同的数据,通常是不会产生线程安全问题的。
1.3.2 问题一:操作不具备原子性
原子性(Atomicity) 指的是一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。它就像一个不可分割的整体。
我们可以把一段需要保证原子性的代码想象成一个房间。正常情况下,一个线程(人)进入房间后,应该能不被打扰地做完自己的事再出来。但如果没有加锁机制,当线程 A 正在房间里做事时,线程 B 可能随时会闯进来,打断 A 的操作。这就破坏了原子性。
为了保证原子性,需要给房间加上一把锁。当一个线程进去后,立刻锁上门,其他线程就只能在门外等待,直到里面的线程出来并解锁。这种机制,通常称为“同步互斥”。
回到 count++
操作。这行代码看起来简单,但在 CPU 层面,它并非一个单一指令,而是通常由三个独立的步骤组成:
- Load: 从内存中读取
count
的当前值到 CPU 的寄存器中。 - Add: 在寄存器中对这个值执行 +1 操作。
- Save: 将寄存器中计算出的新值写回到内存中。
思路分析:
count++
的执行过程我们可以将这三条指令的执行过程想象成这样:
load
指令:将内存中的count
值加载到 CPU 寄存器。add
指令:将寄存器中的值加 1。save
指令:将寄存器中的新值写回内存。由于操作系统的抢占式调度,线程的切换可能发生在任何一条指令执行完毕之后。
假设
count
初始值为 0,t1
和t2
两个线程并发执行count++
:
t1
执行load
,读取到count
为 0。t1
执行add
,寄存器中的值变为 1。- 此时发生线程切换!
t2
开始执行。t2
执行load
,由于t1
还没来得及save
,t2
从内存中读取到的count
仍然是 0。t2
执行add
,其寄存器中的值也变为 1。t2
执行save
,将 1 写回内存。此时count
变为 1。- 线程切换回来!
t1
继续执行。t1
执行save
,将它自己寄存器中的值 1 写回内存。count
最终还是 1。可以看到,虽然两个线程都执行了
count++
,但结果只增加了 1,而不是期望的 2。这就是因为count++
这个操作不具备原子性,在执行过程中被中断,导致了数据错误。
关于原子性的小结
- 如果一个操作不具备原子性,在多线程环境下就可能因为执行中途被切换而导致意想不到的结果。
- 在 Java 中,像
=
这样的简单赋值操作通常是原子的(对于long
和double
的非volatile
变量除外)。但复合操作如count++
则不是。 - 解决原子性问题的核心思路就是加锁,确保同一时间只有一个线程能执行这段代码。
1.3.3 问题二:内存可见性
可见性(Visibility) 指的是当一个线程修改了共享变量的值,这个新值能够被其他线程立即得知。如果不能,就说明存在可见性问题。
为了理解可见性问题,需要先了解一下 Java 内存模型(Java Memory Model, JMM)。JMM 是 Java 虚拟机规范中定义的一套标准,它屏蔽了底层不同硬件和操作系统的内存访问差异,旨在让 Java 程序在各种平台上都能表现出一致的并发行为。
JMM 规定:
- 所有线程共享的变量都存储在**主内存(Main Memory)**中。
- 每个线程都有自己独立的工作内存(Working Memory)。
- 当线程需要操作一个共享变量时,它会先将该变量从主内存拷贝一份副本到自己的工作内存中,然后对这个副本进行操作。
- 操作完成后,再将修改后的副本值同步回主内存。
这种机制会导致一个问题:当线程 A 修改了自己工作内存中的变量副本后,如果还没来得及同步回主内存,线程 B 是看不到这个变化的。线程 B 的工作内存中仍然是旧的、未被修改过的值。
-
初始状态下,主内存与两个线程的工作内存中,变量
a
的值都是一致的。
-
当线程 1 修改了
a
的值,它的工作内存中的副本被更新了。但此时,这个更新可能没有被立刻写回主内存,因此线程 2 的工作内存中的值依然是旧的。
这就产生了可见性问题,线程 2 基于一个过时的数据进行操作,很可能导致错误。
追根溯源:为什么需要如此复杂的 JMM?
看到这里,可能会产生两个疑问:为什么要有这么复杂的内存模型?为什么数据要这样拷贝来拷贝去?
JMM 并非真实存在的内存划分
需要澄清一个概念:JMM 中的“主内存”和“工作内存”是抽象概念,并非指物理上真的有这么多块内存。实际上,“主内存”主要对应我们计算机的物理内存(RAM),而“工作内存”则对应了 CPU 的高速缓存(Cache)和寄存器(Register)。拷贝操作是为了极致的性能
这么做的根本原因在于 CPU 的运算速度和内存的读写速度之间存在着巨大的鸿沟。CPU 访问寄存器和高速缓存的速度,要比访问主内存快上成千上万倍。如果一个变量需要被连续读取 10 次,每次都从主内存读取会非常慢。而 JMM 的机制允许 CPU 第一次从主内存读取后,将数据缓存到自己的高速缓存中,后续 9 次直接从缓存读取,效率将得到极大的提升。
那么,既然高速缓存这么快,为什么不全部用它来做内存呢?答案很简单:成本。存储设备的规律通常是:速度越快,单位容量的价格就越昂贵。
值得一提的是,快慢都是相对的。CPU 访问寄存器的速度远快于内存,而内存的访问速度又远快于硬盘。与此对应,CPU 的价格最贵,内存次之,硬盘最便宜。
因此,JMM 的设计正是在追求极致性能与硬件成本之间做出的权衡。但这种优化也带来了副作用,那就是可见性问题。
1.3.4 问题三:指令重排序
有序性(Ordering) 指的是程序代码在执行时的顺序,与我们编写它时所期望的顺序一致。但在现代计算机体系中,为了提升性能,编译器和处理器有时会对指令进行重排序(Reordering)。
来看一个生活中的例子,假设有三个步骤:
- 去前台取 U 盘。
- 回座位写 10 分钟作业。
- 去前台取快递。
在单人(单线程)场景下,为了提高效率,很自然地会优化执行顺序为 1 -> 3 -> 2
,这样只需要跑一趟前台。这种优化在不改变最终结果的前提下是完全合理的。
编译器和 CPU 也会做类似的优化。然而,这种优化的前提是“不改变单线程环境下的程序语义”。在多线程环境中,情况就变得复杂了。编译器和 CPU 很难在编译期或运行时准确预测多线程交互的复杂情况,一个看似无害的重排序,可能就会在并发场景下引发致命的逻辑错误。
重排序是一个涉及编译器优化和 CPU 底层执行原理的复杂话题,此处仅作概念性了解,知道它也是导致线程不安全的一个因素即可。
1.4 如何解决线程不安全问题
了解了问题的根源后,就可以对症下药了。让我们回到最初的 UnsafeThread
例子,看看如何修复它。这里先展示一种解决方案,其背后的原理将在下一节详细解释。
只需要给 increase
方法加上 synchronized
关键字,就能解决问题。
public class SafeThread {
static class Counter {
public int count = 0;
// 使用 synchronized 关键字修饰方法
synchronized void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count); // 结果稳定为 100000
}
}
再次运行代码,会发现无论执行多少次,最终的结果都稳定地输出为 100,000。这说明 synchronized
关键字有效地保证了线程安全。接下来,将深入学习它的工作机制。
2. synchronized:内置的监视器锁
在解决了线程不安全问题的 SafeThread
示例中,仅仅通过添加一个 synchronized
关键字,就让程序的行为恢复了正常。这个关键字是 Java 内置的、用于解决并发问题最核心的工具之一。它也被称为监视器锁(Monitor Lock)。
2.1 synchronized 的核心特性
synchronized
关键字之所以能保证线程安全,是因为它同时具备了我们之前提到的三大特性:原子性、可见性和有序性。
1. 互斥性:保证操作的原子性
synchronized
的首要作用是提供互斥(Mutual Exclusion)。当一个线程进入由 synchronized
修饰的代码块或方法时,它会尝试获取一个与特定对象关联的锁。
- 加锁:成功获取锁的线程可以执行同步代码块内的逻辑。
- 阻塞:此时,如果其他线程也尝试进入同一个对象的
synchronized
区域,它们将会被阻塞(Block),进入等待状态,直到锁被释放。 - 解锁:当持有锁的线程执行完毕并退出同步代码块时,它会自动释放锁。
小思考:锁存在哪里?
synchronized
使用的锁并非凭空产生,它实际上是存储在每个 Java 对象头(Object Header) 中的一部分信息。我们可以粗略地将其理解为,每个对象都自带一个“锁定”状态标记(类似于洗手间的“有人/无人”指示牌)。
- 当状态为“无人”时,线程可以获取锁,并将状态置为“有人”。
- 当状态为“有人”时,其他线程只能在门外排队等待。
小分享:正确使用
synchronized
的关键要想真正解决线程安全问题,仅仅写下
synchronized
是不够的,必须确保:
- 同步代码块范围要合适:加锁的范围需要完整地包裹所有对共享资源进行修改的操作。范围太小,可能漏掉某些操作导致线程不安全;范围太大,则会影响程序性能。
- 锁定的对象要合适:多个线程必须竞争同一把锁,互斥效果才会生效。如果不同线程锁定了不同的对象,它们之间将不会产生竞争,也就无法保证线程安全。
聊聊“非公平锁”:为什么 synchronized 不讲究“先来后到”?针对每一把锁,操作系统内部都会维护一个等待队列。当一个线程持有锁时,其他尝试获取该锁的线程就会进入这个队列中阻塞等待。
当锁被释放时,操作系统会从等待队列中唤醒一个线程来获取锁。但这里有一个重要的特性:
synchronized
是一种非公平锁(Non-fair Lock)。这意味着,等待队列中的线程被唤醒的顺序,并不严格遵循“先来后到”的原则。一个后来的线程完全有可能比一个等待已久的线程更早地获取到锁。
synchronized
的底层通常是基于操作系统的mutex lock
(互斥锁)原语来实现的。
2. 内存刷新:保证内存的可见性
除了保证原子性,synchronized
还能确保内存可见性。JMM 保证了 synchronized
的完整工作流程如下:
- 加锁:线程获得互斥锁。
- 清空工作内存:清空当前线程工作内存中关于共享变量的副本。
- 从主内存加载:从主内存中拷贝共享变量的最新版本到工作内存中。
- 执行代码:在工作内存中执行同步代码块的逻辑。
- 刷新到主内存:将修改后共享变量的值从工作内存刷新回主内存。
- 解锁:释放互斥锁。
这个过程确保了,一旦一个线程在 synchronized
块内修改了数据,这个修改对于后续任何一个获取到同一个锁的线程来说,都是可见的。
3. 可重入性:避免死锁
synchronized
是一种可重入锁(Reentrant Lock)。这意味着,一个已经持有锁的线程,可以再次成功获取同一个锁,而不会被自己阻塞。
一个有趣的场景:“把自己锁死”
想象一下,如果一个锁是不可重入的,会发生什么?
// 假设 lock() 是一个不可重入的锁 // 第一次加锁,成功 lock(); // 第二次尝试加锁,发现锁已被占用(被自己占用),于是进入阻塞等待... lock();
线程会因为等待一个永远不会被释放的锁(因为只有它自己能释放,但它已经阻塞了)而陷入永久的等待,这就是一种死锁。
在实际开发中,方法之间的调用层次可能很深,很容易在无意中形成嵌套加锁的场景。例如:
class Counter { // add() 方法对 this 加锁 public synchronized void add() { // ... } } public void someMethod() { Counter counter = new Counter(); // 外部代码也对 counter 对象加锁 synchronized (counter) { // 在持有锁的情况下,调用另一个同样需要该锁的方法 counter.add(); // 如果 synchronized 不是可重入的,这里就会发生死锁 } }
幸运的是,Java 中的
synchronized
是可重入的,因此我们不必担心上述问题。小分享:可重入锁的实现原理
可重入锁的内部通常维护着两个关键信息:当前持有锁的线程和一个计数器。
- 当一个线程尝试加锁时,如果发现锁已被占用,它会检查持有者是否是自己。
- 如果是自己,那么它依然可以成功获取锁,并将计数器加 1。
- 每次解锁时,计数器会减 1。
- 只有当计数器减到 0 时,锁才会被真正释放,其他线程才有机会获取它。
4. 死锁:多线程编程的噩梦
虽然 synchronized
的可重入性避免了“自己锁死自己”的问题,但在涉及多把锁的场景下,仍然可能遭遇最经典的并发问题——死锁(Deadlock)。
一个生动的死锁比喻:厨房里的僵局
死锁听起来很吓人,但我们可以用一个厨房里的场景来轻松理解它。想象一下,你和一位朋友要在厨房里合作做两道菜:红烧肉和酱油炒饭。
- 资源:厨房里只有一口锅(锁A)和一瓶酱油(锁B)。
- 任务:你需要先用锅,再用酱油;而你的朋友需要先用酱油,再用锅。
现在,僵局开始了:
- 你手速飞快,先把锅拿到了手里(持有了锁A)。
- 几乎在同一时间,你的朋友也启动了,先把酱油拿到了手里(持有了锁B)。
- 接下来,你准备炒肉,发现酱油在朋友那里,于是你只能伸着手等待他用完。
- 而你的朋友准备炒饭,也发现锅在你手里,于是他也只能等着你用完。
此时,你占着锅,等着朋友的酱油;朋友占着酱油,等着你的锅。你们俩都持有对方需要的资源,又都在等待对方先放手,谁也不肯让步。这个互相等待的循环,就是死锁。最终的结果就是,两道菜都做不成,大家一起饿肚子。
一个经典的死锁示例:
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) { // t1 获取 locker1
System.out.println("t1 持有 locker1,尝试获取 locker2...");
try {
Thread.sleep(1000); // 确保 t2 有机会获取 locker2
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) { // 尝试获取 locker2,但此时 locker2 可能已被 t2 持有
System.out.println("t1 成功获取两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) { // t2 获取 locker2
System.out.println("t2 持有 locker2,尝试获取 locker1...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) { // 尝试获取 locker1,但此时 locker1 已被 t1 持有
System.out.println("t2 成功获取两把锁");
}
}
});
t1.start();
t2.start();
}
在这个例子中,t1
持有 locker1
等待 locker2
,而 t2
持有 locker2
等待 locker1
,双方僵持不下,程序将永远挂起。
可以使用 jconsole
等工具来检测和分析死锁。
小思考:去掉
sleep
会怎样?如果去掉代码中的
sleep
,死锁还会发生吗?答案是:不一定,但可能性依然存在。这完全取决于操作系统的线程调度。sleep
的作用是人为地创造一个时间窗口,让死锁的条件更容易稳定复现。如果没有sleep
,很可能t1
会在t2
启动前就连续获取了两把锁,从而避免了死锁。但绝不能依赖这种不确定性来编写代码。
小分享:构成死锁的四个必要条件(高频面试题)
- 互斥条件:一个资源每次只能被一个线程使用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源,在未使用完之前,不能被强行剥夺。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
要避免死锁,必须破坏上述四个条件中的至少一个。最常见的做法是破坏“请求与保持”或“循环等待”条件,例如:
- 一次性申请所有需要的资源。
- 规定所有线程都按相同的顺序来获取锁。
2.2 synchronized 的使用方式
synchronized
关键字可以灵活地应用在不同位置,但其本质始终是锁定一个对象。
1. 修饰实例方法
当 synchronized
修饰一个普通的实例方法时,它锁定的对象是当前实例对象 this
。
public class SynchronizedDemo {
// 等价于 synchronized(this) { ... }
public synchronized void method() {
// 同步代码
}
}
2. 修饰静态方法
当 synchronized
修饰一个静态方法时,它锁定的对象是当前类的 Class
对象(例如 SynchronizedDemo.class
)。因为静态方法不与任何实例关联,所以只能锁定类对象。
public class SynchronizedDemo {
// 等价于 synchronized(SynchronizedDemo.class) { ... }
public synchronized static void method() {
// 同步代码
}
}
3. 修饰代码块
这是最灵活的方式,可以明确指定要锁定的对象。
- 锁定当前实例
this
public class SynchronizedDemo { public void method() { synchronized (this) { // 同步代码 } } }
- 锁定类对象
public class SynchronizedDemo { public void method() { synchronized (SynchronizedDemo.class) { // 同步代码 } } }
- 锁定任意指定对象
public class SynchronizedDemo { private final Object lock = new Object(); public void method() { synchronized (lock) { // 同步代码 } } }
核心原则回顾
无论使用哪种方式,都必须牢记:只有当多个线程竞争同一把锁时,才会发生阻塞和同步。如果它们各自获取了不同的锁对象,那么
synchronized
将形同虚设。
例如,在下面的错误示例中,每个线程都锁定了自己的
Thread
对象(cur
),而不是共享的资源,因此无法解决线程安全问题。// 错误示例:锁定了错误的对象 public class Demo16 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { Thread cur = Thread.currentThread(); for (int i = 0; i < 50000; i++) { synchronized (cur) { // 锁的是 t1 对象 count++; } } }); Thread t2 = new Thread(() -> { Thread cur = Thread.currentThread(); for (int i = 0; i < 50000; i++) { synchronized (cur) { // 锁的是 t2 对象 count++; } } }); // ... } }
正确的做法是让它们锁定一个共享的对象,例如
Demo16.class
或者一个专门的lock
对象。
2.3 Java 标准库中的线程安全类
在 Java 标准库中,许多常用的集合类默认都是线程不安全的,因为它们在设计时优先考虑了单线程环境下的性能,没有内置任何加锁措施。
ArrayList
,LinkedList
HashMap
,TreeMap
HashSet
,TreeSet
StringBuilder
但标准库也提供了一些线程安全的替代品或专门为并发设计的类:
Vector
,Hashtable
:这些是早期的线程安全集合,它们的几乎每个方法都由synchronized
修饰。但由于锁的粒度太大(锁整个集合),性能较差,现在已不推荐使用。StringBuffer
:与StringBuilder
功能类似,但其核心方法是同步的,因此是线程安全的。ConcurrentHashMap
:java.util.concurrent
包下的明星类,是Hashtable
的高度优化版本,通过更精细的锁技术(如分段锁、CAS)大幅提升了并发性能。String
:虽然不加锁,但由于其不可变性(Immutability),它天然就是线程安全的。
性能的权衡
需要认识到,加锁是有性能代价的。锁的竞争可能导致线程阻塞,从而降低程序的执行效率。因此,在没有并发需求时,应优先选择非线程安全的类(如
ArrayList
,StringBuilder
)以获得更好的性能。只有在确实存在多线程共享数据的情况下,才需要考虑使用线程安全的类或手动加锁。
3. volatile:轻量级的同步利器
在 synchronized
提供了重量级的、全面的并发解决方案之后,再来认识一个更轻量级的关键字:volatile
。它专注于解决一个特定的问题——内存可见性。
3.1 volatile 如何保证内存可见性
来看一个经典的可见性问题场景。下面的代码中启动了两个线程:
t1
线程在一个循环中持续检查共享变量counter.flag
的值,只有当flag
不为 0 时才会退出。t2
线程负责接收用户的输入,并将输入的值赋给flag
。
按照直觉,当在 t2
中输入一个非零整数后,t1
的循环应该会立即结束。但实际情况可能并非如此。
import java.util.Scanner;
public class VolatileVisibility {
static class Counter {
// 未使用 volatile 修饰
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
// 为了极致的性能,t1 线程可能会将 counter.flag 的值缓存起来
while (counter.flag == 0) {
// 这个循环体是空的,执行速度极快
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
// t2 修改了 flag,但这个修改可能只存在于 t2 的工作内存中
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
当我们运行这段代码并输入一个非零值后,大概率会发现 t1
线程的循环并不会结束。
思路分析:问题出在哪里?
这正是 Java 内存模型(JMM)中“工作内存”导致的可见性问题。
- 性能优化引发的问题:
t1
线程中的while (counter.flag == 0)
循环执行得非常快。为了追求极致的性能,JIT(即时)编译器会进行优化。它发现循环体内没有修改flag
的操作,就可能做出一个“聪明”的决定:将flag
的值从主内存读一次,然后缓存到自己的高速工作内存(甚至直接缓存到 CPU 寄存器)中。之后,t1
的每次循环都直接从这个高速缓存中读取flag
的值,而不再访问主内存。- 数据不一致:当
t2
线程修改flag
的值时,它首先更新的是自己工作内存中的副本,然后才会(在某个不确定的时间点)将新值写回主内存。结果就是,
t1
一直在读自己的旧缓存,对t2
在主内存中的更新一无所知,从而陷入了死循环。
小分享:为什么加入
Thread.sleep(1)
似乎能“解决”问题?有时会发现,如果在
while
循环中加入一行Thread.sleep(1)
,程序似乎又能正常工作了。这并不是因为sleep
有什么神奇的魔力。sleep
方法会引发线程上下文切换,这给了操作系统一个机会去处理其他任务,其中就可能包括将工作内存的数据同步回主内存。但这是一种非常不可靠的、带有偶然性的行为。绝对不能依赖
sleep
来解决内存可见性问题。
要从根本上解决这个问题,就需要用到 volatile
关键字。它就像一个“圣旨”,向编译器和 CPU 发出明确指令:
- 写操作:当一个线程修改
volatile
变量时,JMM 会强制将这个修改后的值立即刷新回主内存。 - 读操作:当一个线程读取
volatile
变量时,JMM 会强制它每次都从主内存中获取最新值,使工作内存中的缓存失效。
通过这种方式,volatile
牺牲了一部分缓存带来的性能优势,换取了多线程之间数据的强一致性。
只需做如下修改:
static class Counter {
// 使用 volatile 关键字修饰
public volatile int flag = 0;
}
再次运行程序,无论何时输入非零值,t1
线程都能立即感知到变化并结束循环。
3.2 volatile 无法保证原子性
volatile
虽然强大,但它并非万能。它只能保证可见性(和一定程度的有序性),却无法保证原子性。
让我们回到最初那个经典的计数器例子。这次,去掉 synchronized
,并给 count
变量加上 volatile
。
public class VolatileAtomicity {
static class Counter {
// volatile 保证了 count 的修改对其他线程立即可见
public volatile int count = 0;
// 但 count++ 这个操作本身不是原子的
void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 结果仍然不确定,大概率小于 100000
System.out.println(counter.count);
}
}
运行后会发现,结果依然是错误的。volatile
确实保证了每次 t1
修改 count
后,t2
都能立刻看到最新值。但它无法阻止 count++
这个非原子操作(读-改-写)被中途打断。当两个线程同时读取到相同的值(例如 99),然后各自加一,最后都把 100 写回内存时,数据丢失的问题依然会发生。
3.3 synchronized 与 volatile 的对比
synchronized
作为一个“全能型选手”,它在保证原子性的同时,也提供了强大的内存可见性保证。
JMM 规定,在对一个对象**解锁(exit monitor)之前,线程必须把其工作内存中修改过的共享变量同步到主内存中。而在加锁(enter monitor)**时,会清空工作内存,强制从主内存重新加载共享变量的最新值。
因此,只要多个线程锁定的是同一个对象,synchronized
就能确保线程间的可见性。
用 synchronized
来改写最初的可见性示例:
import java.util.Scanner;
public class SynchronizedVisibility {
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (true) {
// t1 在循环中不断地加锁、检查、解锁
synchronized (counter) {
if (counter.flag != 0) {
break;
}
}
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
// t2 在修改前也必须获取同一把锁
synchronized (counter) {
counter.flag = scanner.nextInt();
}
// 解锁时,flag 的修改会被刷新到主内存
});
t1.start();
t2.start();
}
}
总结一下 synchronized
和 volatile
的关键区别:
特性 | volatile | synchronized |
---|---|---|
原子性 | ❌ 不保证 | ✅ 保证 |
可见性 | ✅ 保证 | ✅ 保证 |
有序性 | ✅ 保证(禁止指令重排) | ✅ 保证 |
使用方式 | 修饰变量 | 修饰代码块、方法 |
性能开销 | 较小(不涉及线程阻塞) | 较大(可能引起线程阻塞和上下文切换) |
本质 | 轻量级同步机制,JVM层面实现 | 重量级锁,依赖操作系统互斥量 |
如何选择?
- 当只需要保证共享变量的可见性时,
volatile
是更轻量、更高效的选择。 - 当需要保证一个代码块的原子性(通常是“读-改-写”这类复合操作)时,必须使用
synchronized
。 synchronized
可以完全替代volatile
的功能,但反之不行。
本章小结 (Key Takeaways)
-
理解线程安全:线程安全的核心,是保证多线程环境下的运行结果与单线程顺序执行的结果完全一致。任何偏离预期的行为,都标志着线程安全问题的存在。
-
线程不安全的根源:问题的出现并非偶然,它通常由三个因素共同导致:
- 前提:多个线程对 共享数据 进行了 修改 操作。
- 直接原因:代码操作(如
count++
)不具备 原子性,执行过程可能被中断。 - 深层原因:JMM 导致的 内存可见性 问题,以及编译器/CPU 的 指令重排序。
-
核心解决方案
synchronized
:这是一个“重量级”但功能全面的内置锁。它通过 互斥 来保证代码块的 原子性,同时通过刷新内存的机制确保了 可见性,能一站式解决大部分线程安全问题。 -
轻量级武器
volatile
:它专注于解决 内存可见性 问题,确保一个线程对变量的修改能被其他线程立刻看到。但请务必牢记,volatile
无法保证原子性,不能用于count++
这类复合操作。
需要保证共享变量的可见性时,volatile
是更轻量、更高效的选择。
- 当需要保证一个代码块的原子性(通常是“读-改-写”这类复合操作)时,必须使用
synchronized
。 synchronized
可以完全替代volatile
的功能,但反之不行。