1. 为什么我们需要重新审视多线程编程
在当今这个多核处理器普及的时代,多线程编程已经从高级技能变成了每个开发者必备的基础能力。但有趣的是,我接触过的绝大多数开发者(包括我自己早期)对多线程的理解都停留在表面——知道要加锁,知道要避免竞态条件,但一到实际项目中就频频踩坑。
最近我在重构一个老项目时,发现当初写的多线程代码简直惨不忍睹:有过度同步导致的性能问题,有死锁隐患,甚至还有完全没必要的线程创建。这促使我系统性地重新梳理了多线程知识体系,下面就把这次"复习"的收获分享给大家。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多线程基础:从概念到现实
2.1 线程的本质是什么
线程本质上就是一条执行路径。当我们在代码中创建新线程时,操作系统会为这个线程分配独立的栈空间,但与其他线程共享进程的堆内存和全局变量。这个特性带来了巨大的效率优势,但也埋下了数据竞争的隐患。
举个例子,假设我们有一个银行账户类:
java复制class BankAccount {
private int balance;
public void deposit(int amount) {
balance += amount;
}
public void withdraw(int amount) {
balance -= amount;
}
}
在单线程环境下,这个类工作得很好。但在多线程环境下,如果两个线程同时调用deposit方法,可能会出现:
- 线程A读取balance(假设为100)
- 线程B也读取balance(还是100)
- 线程A计算100+50=150
- 线程B计算100+30=130
- 线程A写入150
- 线程B写入130
最终余额变成了130,而不是预期的180。
2.2 现代CPU架构对多线程的影响
现代CPU的架构特性让多线程编程更加复杂:
- 多级缓存:每个CPU核心有自己的缓存,导致内存可见性问题
- 指令重排序:编译器和CPU会优化指令执行顺序
- 内存屏障:需要显式控制内存访问顺序
这些硬件特性意味着,即使你的代码逻辑看起来正确,在实际运行时仍可能出现意想不到的行为。比如著名的"双重检查锁定"问题:
java复制class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
这段代码在早期Java版本中是有问题的,因为new操作可能被重排序,导致其他线程看到未完全初始化的实例。
3. 同步机制深度解析
3.1 锁的选用策略
选择正确的锁类型对性能和正确性都至关重要:
| 锁类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| synchronized | 简单同步块 | 使用简单,自动释放 | 功能有限,不可中断 |
| ReentrantLock | 需要高级功能时 | 可中断,可定时,公平性可选 | 需手动释放 |
| ReadWriteLock | 读多写少 | 读操作完全并行 | 写操作会阻塞所有读 |
| StampedLock | 极端读多写少 | 乐观读不阻塞写 | API复杂 |
我在实际项目中的经验法则是:
- 先用synchronized,满足需求就不换
- 需要高级功能时换ReentrantLock
- 确认是读多写少场景再用ReadWriteLock
- 只有性能测试证明有必要时才考虑StampedLock
3.2 避免死锁的实用技巧
死锁的四个必要条件:
- 互斥条件
- 占有并等待
- 不可抢占
- 循环等待
在实践中,我总结出以下预防死锁的方法:
- 锁顺序法:全局规定锁的获取顺序。比如在转账场景中,总是先锁ID小的账户。
java复制void transfer(Account from, Account to, int amount) {
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
// 转账逻辑
}
}
}
- 锁超时法:使用tryLock而不是lock,设置合理的超时时间。
java复制Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// 业务逻辑
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
- 开放调用:在调用外部方法时不持有锁。这是最容易忽视的一点。
4. 并发工具类的实战应用
4.1 CountDownLatch vs CyclicBarrier
这两个工具经常被混淆,但它们的设计目的完全不同:
- CountDownLatch:一个线程等待其他多个线程完成工作
- 不可重用
- 计数减到0时释放所有等待线程
- 典型场景:启动服务时等待所有组件初始化完成
java复制// 主线程等待5个工作线程完成
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
// 工作代码
latch.countDown();
}).start();
}
latch.await(); // 等待所有工作完成
- CyclicBarrier:多个线程互相等待到达屏障点
- 可重用
- 所有线程到达屏障后执行回调函数
- 典型场景:并行计算的分阶段处理
java复制// 5个线程在屏障处等待彼此
CyclicBarrier barrier = new CyclicBarrier(5, () -> {
System.out.println("所有线程到达屏障");
});
for (int i = 0; i < 5; i++) {
new Thread(() -> {
// 阶段1工作
barrier.await();
// 阶段2工作
}).start();
}
4.2 CompletableFuture的现代并发编程
Java 8引入的CompletableFuture彻底改变了异步编程的方式。它最大的优势是可以构建复杂的异步操作流水线:
java复制CompletableFuture.supplyAsync(() -> queryFromDatabase()) // 异步查询数据库
.thenApplyAsync(data -> transformData(data)) // 异步转换数据
.thenAcceptAsync(result -> sendToAPI(result)) // 异步发送结果
.exceptionally(ex -> {
System.err.println("处理失败: " + ex.getMessage());
return null;
});
在实际项目中,我常用以下模式:
- 批量异步任务:
java复制List<CompletableFuture<Result>> futures = requests.stream()
.map(request -> CompletableFuture.supplyAsync(() -> process(request), executor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()))
.thenAccept(results -> { /* 处理所有结果 */ });
- 超时控制:
java复制CompletableFuture.supplyAsync(() -> longRunningTask())
.completeOnTimeout(defaultValue, 1, TimeUnit.SECONDS);
5. 并发编程的性能陷阱与优化
5.1 伪共享问题与解决
伪共享(False Sharing)是多线程性能的隐形杀手。它发生在多个线程频繁修改位于同一缓存行的不同变量时,导致缓存行无效化,引发严重的性能下降。
示例代码:
java复制class Counter {
volatile long count1; // 与count2很可能在同一个缓存行
volatile long count2;
}
解决方案:
- 填充法(Java 7及之前):
java复制class Counter {
volatile long count1;
long p1, p2, p3, p4, p5, p6, p7; // 填充
volatile long count2;
}
- 使用@Contended注解(Java 8+):
java复制class Counter {
@sun.misc.Contended
volatile long count1;
volatile long count2;
}
5.2 线程池调优实战
创建线程池时,这些参数需要特别注意:
java复制ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, // 常驻线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 空闲线程存活时间
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(queueCapacity), // 工作队列
new ThreadFactory() { // 线程工厂
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("worker-" + t.getId());
return t;
}
},
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
我的经验参数设置原则:
- CPU密集型任务:核心线程数 = CPU核心数 + 1
- IO密集型任务:核心线程数 = CPU核心数 * 2
- 队列容量:根据业务特点设置,通常100-10000
- 拒绝策略:
- AbortPolicy:默认策略,直接抛出异常
- CallerRunsPolicy:由调用线程执行任务
- DiscardPolicy:静默丢弃任务
- DiscardOldestPolicy:丢弃队列中最老的任务
重要提示:永远不要使用Executors.newFixedThreadPool或newCachedThreadPool,因为它们隐藏了关键参数设置,容易导致OOM。
6. 现代并发编程模式
6.1 无锁编程与CAS
Compare-And-Swap (CAS) 是无锁编程的基础。Java中的Atomic类就是基于CAS实现的:
java复制AtomicInteger counter = new AtomicInteger(0);
// 线程安全的自增
int oldValue, newValue;
do {
oldValue = counter.get();
newValue = oldValue + 1;
} while (!counter.compareAndSet(oldValue, newValue));
CAS的优点是完全没有锁开销,但在高竞争场景下可能导致CPU空转。我的使用建议:
- 低竞争场景:优先使用CAS
- 中等竞争:尝试LongAdder
- 高竞争:考虑锁或其他同步机制
6.2 不可变对象模式
不可变对象天生线程安全,是最简单的并发编程方案。设计不可变类的要点:
- 所有字段final
- 类本身final
- 没有setter方法
- 如果包含可变对象,返回防御性拷贝
示例:
java复制public final class ImmutablePerson {
private final String name;
private final List<String> hobbies;
public ImmutablePerson(String name, List<String> hobbies) {
this.name = name;
this.hobbies = Collections.unmodifiableList(new ArrayList<>(hobbies));
}
public List<String> getHobbies() {
return hobbies; // 由于是不可变列表,可以直接返回
}
}
7. 多线程调试与问题定位
7.1 线程转储分析
当遇到死锁或线程卡顿时,线程转储(Thread Dump)是最直接的诊断工具。获取方法:
- Linux/Mac:
kill -3 <pid> - Windows: Ctrl+Break (在运行窗口)
- JDK工具:
jstack <pid>
分析线程转储的关键点:
- 查找BLOCKED状态的线程
- 查看线程持有的锁和等待的锁
- 注意"deadlock"关键词
7.2 并发单元测试技巧
测试并发代码特别具有挑战性。我常用的方法:
- 使用CountDownLatch控制线程执行顺序
- 在测试中注入随机延迟
- 使用专门的测试框架如JCStress
示例:
java复制@Test
public void testConcurrentAccess() throws InterruptedException {
final BankAccount account = new BankAccount();
final int threads = 100;
final CountDownLatch startLatch = new CountDownLatch(1);
final CountDownLatch endLatch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
try {
startLatch.await();
account.deposit(1);
} finally {
endLatch.countDown();
}
}).start();
}
startLatch.countDown();
endLatch.await();
assertEquals(threads, account.getBalance());
}
8. 实际项目中的经验教训
在多年的多线程编程实践中,我积累了一些宝贵的经验:
-
锁粒度要尽可能小:只锁必要的部分,但要注意原子性需求。我曾经优化过一个系统,仅仅通过缩小锁范围就将吞吐量提高了3倍。
-
避免在锁内执行IO操作:这会导致锁持有时间过长,严重影响系统吞吐量。一个常见的错误模式:
java复制synchronized(lock) {
Result result = callExternalService(); // 网络IO
process(result);
}
- 线程局部变量的正确使用:ThreadLocal可以避免同步,但要小心内存泄漏。特别是使用线程池时,一定要记得remove:
java复制private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 使用后清理
try {
dateFormat.get().format(date);
} finally {
dateFormat.remove();
}
- 监控线程池状态:在实际生产环境中,一定要监控线程池的关键指标:
- 活跃线程数
- 队列大小
- 拒绝任务数
- 完成任务数
- 不要过度使用多线程:有时候单线程+批处理的性能反而更好,特别是在任务间有强依赖或共享大量数据时。我见过一个案例,将多线程改为单线程批处理后,性能提升了40%,因为消除了同步开销。
