Java 线程问题:线程死锁与线程安全
在 Java 多线程编程中,线程死锁与线程安全是两个最核心、也是最容易被忽视的问题。一旦处理不当,你的程序可能会陷入无限等待,或者出现数据错乱、崩溃等严重故障。这篇文章将用最直白的方式,帮你彻底理解这两个问题的本质,并掌握实际的解决方法。
一、线程死锁:程序永远的"死循环"
1.1 什么是线程死锁
线程死锁指的是两个或多个线程互相持有对方需要的资源,同时又等待对方释放资源,导致所有线程都无法继续执行的状态。想象一个场景:两个人过独木桥,A 刚走到桥中间,B 也走到桥中间,两人都不愿意让步,结果谁也过不去——这就是死锁的直观比喻。
死锁一旦发生,程序中涉及的线程会永远处于阻塞状态,不会自动恢复。你只能通过重启程序来解决问题,这对于生产环境来说是灾难性的。
1.2 死锁产生的四个必要条件
死锁的发生必须同时满足以下四个条件,缺一不可。理解这四个条件是预防死锁的关键:
| 条件 | 说明 | 能否破坏 |
|---|---|---|
| 互斥 | 资源一次只能被一个线程使用 | 不能(这是资源本身的特性) |
| 请求与保持 | 线程持有至少一个资源,同时等待其他资源 | 能 |
| 不可剥夺 | 资源在释放前,不能被强行夺走 | 能 |
| 循环等待 | 存在一个线程等待环 | 能 |
1.3 死锁代码示例
下面是一个典型的死锁案例。两个线程分别持有不同的锁,同时等待对方持有的锁:
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1: 持有锁A,等待锁B");
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("线程1: 获得锁B");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2: 持有锁B,等待锁A");
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("线程2: 获得锁A");
}
}
});
thread1.start();
thread2.start();
}
}
运行这段代码,你会发现程序永远停在那里,没有任何输出——这就是死锁。线程1持有lockA并等待lockB,线程2持有lockB并等待lockA,形成了一个循环等待链。
1.4 如何检测和预防死锁
检测死锁的方法:
- 使用 JConsole 或 jvisualvm:JDK 自带的图形化工具,可以实时查看线程状态,识别死锁。
- 使用 jstack 命令:在命令行执行
jstack <pid>,会输出线程堆栈信息,如果存在死锁,会明确显示 "Found one Java-level deadlock"。
预防死锁的策略:
- 破坏请求与保持条件:线程必须一次性申请所有需要的资源,要么全给,要么全不给。
- 破坏不可剥夺条件:当线程申请新资源失败时,必须释放已持有的所有资源。
- 破坏循环等待条件:对所有资源进行编号,线程必须按照编号顺序申请资源,不能反向或跳跃申请。
修改后的无死锁代码(按顺序申请):
public class NoDeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
// 先申请 lockA,再申请 lockB
synchronized (lockA) {
System.out.println("线程1: 持有锁A");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("线程1: 获得锁B");
}
}
});
Thread thread2 = new Thread(() -> {
// 同样先申请 lockA,再申请 lockB
synchronized (lockA) {
System.out.println("线程2: 持有锁A");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("线程2: 获得锁B");
}
}
});
thread1.start();
thread2.start();
}
}
二、线程安全:多线程环境下的数据正确性
2.1 什么是线程安全
线程安全是指多个线程访问同一个对象或数据时,程序总能产生正确的结果。换句话说,无论操作系统如何调度这些线程,无论它们是同时执行还是交替执行,程序的最终状态都应该是可预期的。
举个反例:假设你有一个银行账户,余额为1000元。两个线程同时从该账户取钱,如果没有同步机制,可能出现以下情况:
初始余额: 1000
线程A读取余额: 1000
线程B读取余额: 1000
线程A计算并写入: 1000 - 500 = 500
线程B计算并写入: 1000 - 300 = 700 (错误!覆盖了线程A的结果)
最终余额: 700 (实际应扣800,余额200)
这就是典型的线程不安全导致的数据竞争。
2.2 线程不安全的后果
线程不安全可能表现为以下几种形式:
| 现象 | 描述 | 示例 |
|---|---|---|
| 数据覆盖 | 一个线程的操作结果被另一个线程覆盖 | 银行账户余额计算错误 |
| 脏读 | 读取到未提交的数据 | 事务中的不可重复读 |
| 死循环 | 由于状态不一致,循环条件永远为真 | 计数器永远无法达到预期值 |
| 程序崩溃 | 异常状态导致程序异常终止 | 空指针、数组越界 |
2.3 如何实现线程安全
Java 提供了多种机制来保证线程安全,从简单到复杂依次为:
2.3.1 使用 synchronized 关键字
synchronized 是最基础的同步机制,它可以修饰方法或代码块,确保同一时刻只有一个线程能执行被修饰的代码:
public class Counter {
private int count = 0;
// synchronized 方法
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
synchronized 的特点包括:自动获取锁和释放锁、可重入(同一线程可多次获取同一把锁)、对阻塞线程公平处理。
2.3.2 使用 ReentrantLock
ReentrantLock 是 synchronized 的增强版,提供了更灵活的锁控制:
import java.util.concurrent.locks.ReentrantLock;
public class AdvancedCounter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock(true); // 公平锁
public void increment() {
lock.lock(); // 加锁
try {
count++;
} finally {
lock.unlock(); // 必须在 finally 中释放
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
ReentrantLock 的优势在于:可尝试获取锁(tryLock())、可设置公平锁、支持Condition条件变量。
2.3.3 使用线程安全类
Java 并发包提供了大量线程安全的类,直接使用这些类可以简化开发:
| 线程安全类 | 用途 | 注意事项 |
|---|---|---|
AtomicInteger |
原子整数操作 | 适用于单一变量的原子操作 |
ConcurrentHashMap |
高性能并发 Map | 不能锁定整个Map时使用 |
CopyOnWriteArrayList |
读多写少的列表 | 写操作会复制整个数组 |
BlockingQueue |
阻塞队列 | 适用于生产者-消费者模式 |
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作,无需同步
}
public int getCount() {
return count.get();
}
}
2.3.4 使用 ThreadLocal
ThreadLocal 为每个线程提供独立的变量副本,各线程之间互不影响:
public class ThreadLocalDemo {
private static final ThreadLocal<Integer> count = ThreadLocal.withInitial(() -> 0);
public void increment() {
count.set(count.get() + 1);
}
public int getCount() {
return count.get();
}
}
ThreadLocal 适用于:保存线程私有的配置信息、实现线程级别的单例模式、避免在方法间传递参数。
2.4 线程安全设计原则
在实际开发中,遵循以下原则可以有效避免线程安全问题:
- 最小化共享:尽量不要在多线程间共享可修改的状态,使用不可变对象或局部变量。
- 明确的可变边界:明确区分哪些状态是线程共享的,哪些是线程私有的。
- 优先使用高级并发工具:优先使用
ExecutorService、ConcurrentHashMap、BlockingQueue等并发工具类,而不是直接使用低级的synchronized。 - 保持同步域小而简单:同步代码块应尽可能小,逻辑应尽可能简单,避免在同步块内调用可能阻塞的方法。
三、实战:综合案例
下面是一个综合运用线程安全和死锁避免策略的完整示例——模拟一个简单的转账系统:
import java.util.concurrent.locks.ReentrantLock;
import java.util.HashMap;
import java.util.Map;
public class BankTransferSystem {
static class Account {
private final String id;
private double balance;
private final ReentrantLock lock = new ReentrantLock();
Account(String id, double balance) {
this.id = id;
this.balance = balance;
}
boolean transferTo(Account target, double amount) {
// 获取两把锁的顺序:按 id 排序,避免死锁
Account first = this.id.compareTo(target.id) < 0 ? this : target;
Account second = this.id.compareTo(target.id) < 0 ? target : this;
first.lock.lock();
second.lock.lock();
try {
if (this.balance < amount) {
System.out.println("余额不足,转账失败");
return false;
}
this.balance -= amount;
target.balance += amount;
System.out.println("从 " + this.id + " 转账 " + amount
+ " 到 " + target.id + " 成功");
System.out.println(this.id + " 余额: " + this.balance
+ ", " + target.id + " 余额: " + target.balance);
return true;
} finally {
second.lock.unlock();
first.lock.unlock();
}
}
}
public static void main(String[] args) {
Map<String, Account> accounts = new HashMap<>();
accounts.put("A", new Account("A", 1000));
accounts.put("B", new Account("B", 1000));
accounts.put("C", new Account("C", 1000));
Runnable transferTask = () -> {
String[] ids = {"A", "B", "C"};
for (int i = 0; i < 10; i++) {
String from = ids[(int)(Math.random() * 3)];
String to = ids[(int)(Math.random() * 3)];
while (from.equals(to)) {
to = ids[(int)(Math.random() * 3)];
}
double amount = Math.random() * 200 + 50;
accounts.get(from).transferTo(accounts.get(to), amount);
}
};
Thread[] threads = new Thread[5];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(transferTask);
threads[i].start();
}
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("所有转账完成");
}
}
这个示例展示了几个关键点:按照固定顺序获取锁来避免死锁、使用细粒度锁减少锁竞争、确保锁一定在 finally 块中释放。
四、关键要点回顾
- 死锁的四个必要条件是互斥、请求与保持、不可剥夺、循环等待,预防死锁需要破坏其中至少一个条件
- 按固定顺序申请资源是避免死锁最简单有效的方法
- 线程安全保证多线程环境下数据的正确性,核心是控制对共享资源的并发访问
- 根据场景选择合适的同步机制:简单场景用
synchronized,复杂场景用ReentrantLock,性能敏感场景考虑无锁算法

暂无评论,快来抢沙发吧!