对象是过程的抽象,线程是调度的抽象
并发是一种架构设计:并发不是简单的 “用
Thread
跑个任务”。它是一种宏观的设计决策,应该在架构设计早期就考虑,而不是事后添加。它可以将“做什么”和“何时做”解耦。并发与性能不是等价的:
并发:结构性问题。旨在让程序在同一时间做多件事(更清晰的设计)。
并行:性能问题。旨在让程序在更短的时间内做一件事(提高速度)。
并发可以带来并行,从而提升性能,但这不是唯一目的。并发只有在多个线程或处理器之间能分享大量等待时间的时候管用
并发编写的核心难点:对共享数据的正确访问。不同执行线程对同一数据资源的竞争和协作是万恶之源。
并发防御原则:
单一权责原则 (SRP):并发相关的代码应该有自己独立的开发、修改和调优的生命周期。不要将并发代码与其他业务代码混在一起。
限制数据作用域:共享数据是危险的。严格限制哪些数据可以被共享,并通过精细的同步机制来保护它们。
使用数据副本:尽可能避免共享,使用数据的副本进行处理。
线程应尽可能独立:让每个线程在自己的世界中运行,不与其他线程共享数据。例如,使用
ThreadLocal
变量。
书中提到的经典问题(为什么它难)
竞态条件 (Race Condition):操作的正确性依赖于线程执行的时序。
死锁 (Deadlock):多个线程互相等待对方持有的资源,导致所有线程都无法继续执行。(四个必要条件:互斥、占有且等待、不可抢占、循环等待)
超越《Clean Code》:现代并发最佳实践
以下是基于 Java 生态的一些方法论:
1. 设计原则与模式
- 不可变性 (Immutability):这是最强大的并发“武器”。共享不可变对象是绝对安全的。尽可能将你的类设计为不可变的(使用
final
字段,不提供 setter)。
1 | // 一个线程安全的不可变类 |
- 线程封闭 (Thread Confinement):将数据访问限制在单个线程内。例如,使用局部变量(每个线程有自己的栈)、ThreadLocal
- 使用线程安全的组件 :优先使用现有的高并发组件,而不是自己从头实现。
- Java 并发集合 (java.util.concurrent):永远优先使用
ConcurrentHashMap
而不是自行同步的HashMap
,使用CopyOnWriteArrayList
用于读多写少的场景。它们的性能和数据一致性远优于用Collections.synchronizedList()
包装的集合。 - 同步器 (Synchronizers):熟练使用
CountDownLatch
,CyclicBarrier
,Semaphore
,Exchanger
等高级工具来协调线程间的合作。
- Java 并发集合 (java.util.concurrent):永远优先使用
2. 执行任务与管理线程
- 不要手动管理线程:不要再
new Thread(...).start()
了。 - 使用 Executor 框架:使用
ExecutorService
来管理线程池。它帮你处理了线程的生命周期、调度和资源管理。
1 | // 使用线程池执行任务 |
- 明确任务类型:你的任务是 Runnable(无返回值)还是 Callable(有返回值)?使用 Future 来获取异步任务的结果。
3. 同步与锁
同步最小的代码块:使用
synchronized
时,只锁住必要的部分,以减少线程争用。优先使用 java.util.concurrent.locks:对于更复杂的场景,
ReentrantLock
比synchronized
更灵活,支持尝试锁定、定时锁定、可中断锁定等。了解 Java 内存模型 (JMM):理解
volatile
关键字的作用(保证可见性、禁止指令重排)和局限性(不保证原子性)。它是轻量级的同步机制。
4. 测试与调试
并发代码难以测试:一些错误可能在测试中运行一万次都不出现一次。
将并发代码与非并发代码隔离:以便于针对并发部分进行重点测试。
尝试诱发失败:
在多核处理器上运行测试。
调整线程池大小(比处理器数量多或少),以增加上下文切换和资源争用的概率。
使用
CountDownLatch
等工具“胁迫”代码以产生竞态条件。
总结:一份并发编程清单
首先考虑:我真的需要并发吗?复杂度是否值得带来的性能/结构收益?
遵守 SRP:隔离并发代码。
优先选择不可变对象。
优先使用并发库 (
java.util.concurrent
),而不是自己造轮子。优先使用 Executor 框架,而不是手动管理线程。
尽量缩小同步范围,减少争用。
编写测试,并尝试让测试失败,以证明你的同步是有效的。