Java线程同步:从多线程协作到银行账户安全
前言:当多线程成为双刃剑
在单核CPU时代,多线程曾是“伪并行”的代名词;如今,面对多核处理器与分布式系统的浪潮,真正的并行计算已成为Java高并发编程的基石。
然而,线程在共享资源时的不确定性,如同一场没有红绿灯的十字路口交通——竞态条件(Race Condition)、死锁(Deadlock)、内存可见性(Memory Visibility)问题频发。如何让多个线程安全有序地协同工作?这正是线程同步(Thread Synchronization)的核心使命。本文将带你穿透synchronized、Lock、CAS等技术的迷雾,构建线程安全的铜墙铁壁。
一、线程同步简介
线程同步(Thread Synchronization)是一种协调多个线程对共享资源的访问的机制,确保在同一时刻只有一个线程能够操作特定的共享数据。其核心目标是防止因并发操作导致的数据不一致问题。
典型场景:
- 银行账户扣款:两个线程同时修改账户余额,可能导致最终结果错误。
- 生产者-消费者模型:需要协调生产者与消费者之间的数据交换。
- 任务协作:多个线程需要按特定顺序执行操作。
二、多线程带来的问题
在多线程程序中,常见的问题主要来自于线程对共享资源的访问。当多个线程并发操作共享资源时,可能会出现以下问题:
- 竞态条件(Race Condition) :当多个线程并发地对共享资源进行修改时,可能会出现执行顺序的不确定性,导致数据的状态不一致。例如,两个线程同时对一个共享变量进行自增操作,可能导致最终结果不如预期。
- 死锁(Deadlock) :多个线程在执行过程中相互等待对方释放资源,导致程序陷入无限等待的状态,无法继续执行。
- 活锁(Livelock) :与死锁类似,活锁发生在多个线程相互响应对方的动作,虽然程序没有完全停止,但也无法正常进行。
- 饥饿(Starvation) :某些线程由于系统资源分配不均,长时间得不到执行,导致程序不公平地分配CPU资源。
举例:
多线程可以充分利用多核 CPU 的计算能力,那多线程难道就没有一点缺点吗?有。
多线程很难掌握,稍不注意,就容易使程序崩溃。我们以在路上开车为例:
在一个单向行驶的道路上,每辆汽车都遵守交通规则,这时候整体通行是正常的。『单向车道』意味着『一个线程』,『多辆车』意味着『多个 job 任务』。
单线程顺利同行
如果需要提升车辆的同行效率,一般的做法就是扩展车道,对应程序来说就是『加线程池』,增加线程数。这样在同一时间内,通行的车辆数远远大于单车道。
多线程顺利同行
然而车道一旦多起来,『加塞』的场景就会越来越多,出现碰撞后也会影响整条马路的通行效率。这么一对比下来『多车道』就比『单车道』慢多了。
多线程故障
防止汽车频繁变道加塞可以在车道间增加『护栏』,那在程序的世界里该怎么做呢?
1. 竞态条件(Race Condition)
竞态条件是指多个线程对共享资源的访问顺序影响程序的最终结果。例如,以下代码模拟了两个线程同时修改一个银行账户的余额:
package org.example;public class UnsafeAccount {private int balance = 1000;public void withdraw(int amount) {// 人为添加延迟,放大竞态条件窗口if (balance >= amount) {try {Thread.sleep(100); // 模拟耗时操作,增加其他线程介入的机会} catch (InterruptedException e) {e.printStackTrace();}balance -= amount;System.out.println(Thread.currentThread().getName() + " 取款 " + amount + ",余额: " + balance);} else {System.out.println(Thread.currentThread().getName() + " 余额不足");}}public static void main(String[] args) throws InterruptedException {UnsafeAccount account = new UnsafeAccount();Thread t1 = new Thread(() -> account.withdraw(600), "线程1");Thread t2 = new Thread(() -> account.withdraw(600), "线程2");t1.start();t2.start();// 等待两个线程执行完毕t1.join();t2.join();System.out.println("最终余额: " + account.balance);}
}
最终余额可能为-200
问题根源:balance >= amount
的条件判断与balance -= amount
的操作非原子性,中间插入其他线程操作导致数据错误。
竞态条件示例的逐步解析
场景复现:
两个线程同时调用 withdraw(600)
方法,初始余额为1000元。以下是导致余额为-200的关键执行流程:
-
线程1(T1)启动
- 读取
balance
值:1000 - 检查条件
balance >= 600
→ 通过 - 准备执行
balance -= 600
(但尚未完成)
- 读取
-
线程2(T2)同时启动
- 在T1修改
balance
前,T2也读取balance
值:1000 - 检查条件
balance >= 600
→ 通过 - 准备执行
balance -= 600
- 在T1修改
-
操作交错执行
- T1 完成操作:
balance = 1000 - 600 = 400
- T2 继续执行:由于它之前读取的
balance
是1000,仍会执行balance = 1000 - 600 = 400
- 但实际上此时
balance
已经是400,T2的操作覆盖了T1的结果,最终balance = 400 - 600 = -200
(假设业务允许透支)
- T1 完成操作:
关键问题:
-
非原子操作:
balance -= amount
并非一步完成,实际包含三步:- 读取当前
balance
值 - 计算新值(原值 - amount)
- 将新值写回
balance
- 读取当前
-
线程切换时机:若多个线程在步骤1和步骤3之间切换,会导致共享数据被覆盖。
可视化流程:
时间线 | 线程1操作 | 线程2操作
---------------|--------------------------|--------------------------
t1 | 读取 balance=1000 |
t2 | | 读取 balance=1000
t3 | 计算 balance=1000-600=400 |
t4 | | 计算 balance=1000-600=400
t5 | 写入 balance=400 |
t6 | | 写入 balance=400-600=-200
修复方案:
通过同步机制(如 synchronized
)保证操作的原子性:
public class Account {private int balance = 1000;// 添加synchronized关键字public synchronized void withdraw(int amount) {if (balance >= amount) {balance -= amount; // 现在线程安全}}
}
修复后的执行流程:
- 线程1(T1) 先获得锁,完成全部操作(读→计算→写),释放锁。
- 线程2(T2) 必须等待锁释放后,才能进入方法。此时
balance
已经是400,条件balance >= 600
不成立,操作被拒绝。 - 最终余额:400(符合预期)。
总结:
竞态条件的本质是多个线程对共享资源的非原子操作的交错执行。解决方法是:
- 加锁(如
synchronized
、ReentrantLock
)确保操作的原子性。 - 使用原子变量(如
AtomicInteger
)替代基础类型。 - 设计无状态对象或 线程封闭(如
ThreadLocal
)避免共享。
2. 死锁
public class DeadlockExample {public static void main(String[] args) {// 定义两个锁对象Object lockA = new Object();Object lockB = new Object();// 线程1:先获取lockA,再请求lockBThread t1 = new Thread(() -> {synchronized (lockA) {System.out.println(Thread.currentThread().getName() + " 持有 lockA,等待 lockB...");try {Thread.sleep(100); // 模拟耗时操作} catch (InterruptedException e) {e.printStackTrace();}synchronized (lockB) {System.out.println(Thread.currentThread().getName() + " 持有 lockA 和 lockB");}}}, "Thread-1");// 线程2:先获取lockB,再请求lockAThread t2 = new Thread(() -> {synchronized (lockB) {System.out.println(Thread.currentThread().getName() + " 持有 lockB,等待 lockA...");try {Thread.sleep(100); // 模拟耗时操作} catch (InterruptedException e) {e.printStackTrace();}synchronized (lockA) {System.out.println(Thread.currentThread().getName() + " 持有 lockB 和 lockA");}}}, "Thread-2");// 启动线程t1.start();t2.start();}
}
运行结果与死锁分析
-
线程执行顺序:
- 线程1 先获取
lockA
,然后休眠100ms,接着尝试获取lockB
。 - 线程2 先获取
lockB
,然后休眠100ms,接着尝试获取lockA
。
- 线程1 先获取
-
死锁发生条件:
- 互斥:
lockA
和lockB
不能同时被两个线程持有。 - 不可抢占:线程必须主动释放锁。
- 循环等待:线程1等待线程2持有的
lockB
,线程2等待线程1持有的lockA
。 - 持有并等待:两个线程都持有部分锁。
- 互斥:
-
典型输出(死锁发生时) :
Thread-1 持有 lockA,等待 lockB... Thread-2 持有 lockB,等待 lockA...
- 程序卡住,无后续输出,两个线程互相等待对方释放锁。
如何检测死锁?
-
使用
jstack
命令:-
在终端运行
jstack <进程ID>
,查看线程状态:Found one Java-level deadlock: ============================= "Thread-2":waiting to lock monitor locked by "Thread-1", "Thread-1":waiting to lock monitor locked by "Thread-2",
-
-
代码中主动检测死锁(可选):
ThreadMXBean tmBean = ManagementFactory.getThreadMXBean(); long[] deadlockedThreads = tmBean.findDeadlockedThreads(); if (deadlockedThreads != null && deadlockedThreads.length > 0) {System.out.println("死锁已发生!"); }
关键点总结
问题 | 原因 | 解决方案 |
---|---|---|
死锁 | 线程1持有 lockA 等待 lockB ,线程2持有 lockB 等待 lockA | 避免交叉请求锁,或按固定顺序请求锁 |
互斥 | 锁对象只能被一个线程持有 | 使用 ReentrantLock 支持超时机制 |
不可抢占 | 线程必须主动释放锁 | 使用 tryLock() 显式尝试获取锁 |
改进方案(避免死锁)
// 固定顺序请求锁(例如:总是先获取 lockA,再获取 lockB)
Thread t3 = new Thread(() -> {synchronized (lockA) {try { Thread.sleep(100); } catch (InterruptedException e) {}synchronized (lockB) { /* ... */ }}
});
Thread t4 = new Thread(() -> {synchronized (lockA) {try { Thread.sleep(100); } catch (InterruptedException e) {}synchronized (lockB) { /* ... */ }}
});
通过统一锁请求顺序,避免循环等待,彻底消除死锁风险。
三、Java线程同步的实现方式
Java 提供了多种线程同步机制
1. synchronized
关键字
(1)方法级同步
-
实例方法:锁定当前对象(
this
)。public synchronized void withdraw(int amount) {if (balance >= amount) {balance -= amount;} }
-
静态方法:锁定类的
Class
对象。public static synchronized void staticMethod() {// 类级同步 }
(2)代码块级同步
-
对象锁:指定任意对象作为锁。
private Object lock = new Object(); public void withdraw(int amount) {synchronized (lock) {if (balance >= amount) {balance -= amount;}} }
-
类锁:锁定类的
Class
对象。public void method() {synchronized (YourClass.class) {// 类级同步} }
(3)synchronized
的特性
- 隐式锁:由 JVM 自动管理加锁和释放。
- 可重入性:同一个线程可以多次获取同一把锁。
- 互斥性:同一时刻只有一个线程可以持有锁。
(4)示例:银行账户同步
之前示例未同步结果:
优化解决:
package com.example.thread;public class UnsafeAccount {private int balance = 1000;public synchronized void withdraw(int amount) {// 人为添加延迟,放大竞态条件窗口if (balance >= amount) {try {Thread.sleep(100); // 模拟耗时操作,增加其他线程介入的机会} catch (InterruptedException e) {e.printStackTrace();}balance -= amount;System.out.println(Thread.currentThread().getName() + " 取款 " + amount + ",余额: " + balance);} else {System.out.println(Thread.currentThread().getName() + " 余额不足");}}public static void main(String[] args) throws InterruptedException {UnsafeAccount account = new UnsafeAccount();Thread t1 = new Thread(() -> account.withdraw(600), "线程1");Thread t2 = new Thread(() -> account.withdraw(600), "线程2");t1.start();t2.start();// 等待两个线程执行完毕t1.join();t2.join();System.out.println("最终余额: " + account.balance);}
}
输出:最终余额为 400
,避免了线程竞争导致的负数问题。
这段代码中使用了 synchronized
关键字,这意味着在同一时间只有一个线程能够执行 withdraw
方法。
具体来说:
- 当一个线程调用
withdraw
方法时,它会获得Account
对象的锁,其他任何线程在这段时间内尝试调用这个withdraw
方法都会被阻塞,直到第一个线程完成并释放锁。 - 在你的代码示例中,如果
t1
线程正在执行withdraw
方法,t2
线程必须等待,直到t1
线程执行完毕并释放Account
对象的锁,然后t2
才能开始执行withdraw
方法。
这样做的目的就是为了确保对 balance
的修改是线程安全的,避免了因并发访问导致的状态不一致问题。如果没有 synchronized
,两个线程可能会同时检查和修改 balance
,从而导致余额计算错误。
2. ReentrantLock
显式锁
(1)基本用法
import java.util.concurrent.locks.ReentrantLock;public class LockExample {private final ReentrantLock lock = new ReentrantLock();private int sharedResource;public void updateResource(int value) {lock.lock(); // 显式获取锁try {sharedResource = value;} finally {lock.unlock(); // 确保释放锁}}
}
(2)高级特性
-
尝试加锁:非阻塞获取锁。
if (lock.tryLock()) {try {// 临界区代码} finally {lock.unlock();} }
-
超时加锁:在指定时间内尝试获取锁。
if (lock.tryLock(1, TimeUnit.SECONDS)) {try {// 临界区代码} finally {lock.unlock();} }
-
条件变量(Condition) :替代
wait/notify
,实现更细粒度的线程协作。ReentrantLock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition();public void waitForData() {lock.lock();try {while (data.isEmpty()) {notEmpty.await(); // 等待数据}} finally {lock.unlock();} }
(3)示例:生产者-消费者模型
public class ProducerConsumer {private final ReentrantLock lock = new ReentrantLock();private final Condition notFull = lock.newCondition();private final Condition notEmpty = lock.newCondition();private Queue<Integer> queue = new LinkedList<>();private static final int CAPACITY = 10;public void produce(int item) throws InterruptedException {lock.lock();try {while (queue.size() >= CAPACITY) {notFull.await(); // 等待队列不满}queue.add(item);notEmpty.signal(); // 通知消费者} finally {lock.unlock();}}public int consume() throws InterruptedException {lock.lock();try {while (queue.isEmpty()) {notEmpty.await(); // 等待队列非空}int item = queue.remove();notFull.signal(); // 通知生产者return item;} finally {lock.unlock();}}
}
3. volatile
关键字
(1)特性
- 可见性:确保多个线程对变量的修改立即对其他线程可见。
- 禁止指令重排序:防止编译器优化导致的执行顺序变化。
- 不保证原子性:仅适用于单线程写、多线程读的场景。
(2)示例:状态标志位
public class FlagExample {private volatile boolean isRunning = true;public void stop() {isRunning = false;}public void runTask() {while (isRunning) {// 执行任务}}
}
4. 原子类(java.util.concurrent.atomic
)
(1)原理
基于 CAS(Compare-And-Swap) 实现无锁并发,通过硬件指令(如 CAS
)确保原子性。
(2)常用类
AtomicInteger
:原子整数操作。AtomicReference
:原子对象引用操作。AtomicLong
:原子长整型操作。
(3)示例:无锁计数器
import java.util.concurrent.atomic.AtomicInteger;public class Counter {private AtomicInteger count = new AtomicInteger(0);public void increment() {count.incrementAndGet(); // 原子自增}public int getCount() {return count.get();}
}
5. wait
/ notify
/ notifyAll
(1)基本用法
-
wait()
:释放锁并进入等待状态,直到其他线程调用notify
或notifyAll
。 -
notify()
:随机唤醒一个等待的线程。 -
notifyAll()
:唤醒所有等待的线程。
(2)示例:线程协作
public class WaitNotifyExample {public synchronized void waitMethod() {try {System.out.println("线程 " + Thread.currentThread().getName() + " 进入 wait");wait(); // 释放锁并等待System.out.println("线程 " + Thread.currentThread().getName() + " 被唤醒");} catch (InterruptedException e) {e.printStackTrace();}}public synchronized void notifyMethod() {notify(); // 唤醒一个线程System.out.println("线程 " + Thread.currentThread().getName() + " 调用 notify");}public static void main(String[] args) {WaitNotifyExample example = new WaitNotifyExample();Thread t1 = new Thread(() -> example.waitMethod(), "Thread-1");Thread t2 = new Thread(() -> {try {Thread.sleep(1000);example.notifyMethod();} catch (InterruptedException e) {e.printStackTrace();}}, "Thread-2");t1.start();t2.start();}
}
四、同步机制对比与选择
机制 | 特性 | 适用场景 | 优点 | 缺点 |
---|---|---|---|---|
synchronized | 隐式锁,可重入,互斥访问 | 简单同步需求 | 使用简单,JVM优化 | 无法控制锁粒度,性能较低 |
ReentrantLock | 显式锁,支持超时、尝试加锁、条件变量 | 复杂并发场景 | 灵活性高,性能优化 | 需手动管理锁,易出错 |
volatile | 保证可见性,禁止指令重排序 | 单写多读的状态标志 | 性能高 | 不保证原子性 |
原子类 | 基于CAS的无锁操作 | 高并发计数、更新操作 | 无需加锁,性能高 | 仅适用于简单数据类型 |
wait /notify | 线程间协作,需在同步代码块中调用 | 生产者-消费者、任务等待 | 实现线程通信 | 需谨慎处理条件检查,易出现虚假唤醒 |
五、最佳实践与注意事项
- 减少锁粒度:尽量缩小同步代码块范围,减少锁竞争。
- 避免死锁:按固定顺序获取锁,或使用超时机制。
- 使用高阶并发工具:优先考虑
java.util.concurrent
包中的线程安全类(如ConcurrentHashMap
)。 - 警惕隐式锁泄漏:确保
finally
块中释放锁。 - 优先选择原子类:在适用场景下使用无锁操作提升性能。
六、总结
Java线程同步是多线程编程的核心技能,合理选择同步机制可以显著提升程序的并发性能和稳定性。以下是关键总结:
- 简单场景:优先使用
synchronized
或原子类。 - 复杂协作:选择
ReentrantLock
和Condition
。 - 状态标志:使用
volatile
确保可见性。 - 避免过度同步:合理设计锁粒度,减少性能开销。
通过深入理解这些机制,开发者可以编写出高效、可靠的多线程程序,应对高并发场景的挑战。