- 核心设计原则:这些是指导我们思考和设计并发程序的根本思想。
- 常用设计模式:这些是针对特定并发问题的、可复用的解决方案。
- 实践与工具:结合 Java 并发工具包,谈谈如何应用这些原则和模式。
核心设计原则
并发编程的复杂性远超单线程,因此必须遵循一些基本原则来避免灾难性的后果。

安全性原则
目标:确保程序在并发环境下始终表现出正确、可预期的行为。
-
原子性
- 定义:一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行,经典例子:银行转账,从A账户扣款并转入B账户,这两个操作必须作为一个整体,不能只执行一半。
- Java中的实现:
synchronized关键字:可以确保一个代码块或方法的原子性。java.util.concurrent.atomic包下的原子类(如AtomicInteger,AtomicReference):利用硬件指令(如 CAS)实现原子操作。Lock接口(如ReentrantLock)的lock()和unlock()之间的代码块。
-
可见性
- 定义:当一个线程修改了一个共享变量的值,其他线程能够立即得知这个修改,由于 Java 内存模型 的存在,线程有自己的工作内存,变量的值需要从主内存同步到工作内存,修改后再写回主内存,可见性问题就发生在这个同步过程中。
- Java中的实现:
volatile关键字:确保了新值能立即同步到主内存,并且每次使用前都会从主内存刷新,它不保证原子性,但保证了可见性和禁止指令重排序。synchronized关键字:在解锁前,必须把共享变量的最新值刷新到主内存;在加锁时,会清空工作内存,从主内存重新加载共享变量。final关键字:被final修饰的字段,在构造函数完成之后,它的值对于其他所有线程都是可见的。
-
有序性
(图片来源网络,侵删)- 定义:程序执行的顺序按照代码的先后顺序执行,编译器和处理器为了优化性能,可能会对指令进行重排序,在单线程中,重排序不会影响结果,但在多线程中,可能会导致意想不到的问题。
- Java中的实现:
volatile关键字:禁止指令重排序。synchronized关键字:一个变量在同一个时刻只允许一条线程对其进行lock/unlock操作,这使得持有同一个锁的两个同步块只能串行地进入,从而保证了有序性。happens-before原则:这是 JMM 中最重要的原则,定义了内存可见性的保证,前一个操作( unlock )的happens-before于后一个操作( lock )。
活性原则
目标:确保并发程序能够“动起来”,不会因为死锁、饥饿等问题而永远阻塞。
-
避免死锁
- 定义:两个或多个线程因争夺资源而造成的一种互相等待的僵局,若无外力作用,它们都将无法向前推进。
- 产生条件(四个必须同时满足):
- 互斥条件:一个资源每次只能被一个线程使用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不可剥夺条件:线程已获得的资源,在未使用完之前,不能被强行剥夺。
- 循环等待条件:存在一种线程资源的循环等待链。
- 预防策略:
- 破坏请求与保持条件:一次性申请所有需要的资源。
- 破坏不可剥夺条件:允许线程强行剥夺其占用的资源。
- 破坏循环等待条件:对资源进行排序,所有线程按固定顺序申请资源(最常用且有效的方法)。
-
避免饥饿
- 定义:一个线程由于无法获得所需的资源,导致一直无法执行。
- 原因:通常是因为锁的“不公平性”。
synchronized是非公平的,一个线程可能长时间获取不到锁。 - 解决方案:使用公平锁,如
ReentrantLock(true),但公平锁会牺牲一定的吞吐量。
-
避免活锁
(图片来源网络,侵删)- 定义:线程没有阻塞,但都在互相谦让,导致谁也无法继续执行,比如两个人在狭路相逢,都想给对方让路,结果来回躲闪,谁也过不去。
- 解决方案:引入随机性或等待时间,让一方“坚持”一下。
性能与可伸缩性原则
目标:在保证安全性和活性的前提下,尽可能提高程序的吞吐量和响应速度。
-
减少锁的粒度
- 思想:锁的范围越小,竞争就越少,性能就越好,不要用一个巨大的锁来保护所有数据,而是为不同的数据段使用不同的锁。
- 示例:
ConcurrentHashMap使用分段锁(在 Java 8 中优化为CAS + synchronized),只对哈希冲突的桶进行加锁,而不是整个表。
-
减少锁的持有时间
-
思想:在
synchronized块或lock()中只执行必要的同步代码,尽量将耗时操作(如 I/O、复杂计算)移到同步块之外。 -
示例:
// 错误示例:锁持有时间过长 synchronized (lock) { list.add(item); // 假设这个操作很快 doSomeSlowIO(); // 耗时的 I/O 操作 } // 正确示例:减少锁持有时间 synchronized (lock) { list.add(item); } doSomeSlowIO();
-
-
避免锁的粗化
-
思想:JVM 会进行锁粗化优化,将连续的、对同一个对象的锁操作合并成一个更大范围的锁,但在某些场景下,我们可能需要相反的操作,即显式地缩小锁的范围,尤其是在循环中。
-
示例:
// 错误示例:在循环内加锁 for (int i = 0; i < 1000; i++) { synchronized (lock) { list.add(i); } } // 正确示例:将锁移到循环外(锁粗化) synchronized (lock) { for (int i = 0; i < 1000; i++) { list.add(i); } }注意:这个例子展示了 JVM 的优化方向,但程序员应编写清晰、逻辑正确的代码,过度依赖优化有时会适得其反。
-
-
读写分离
- 思想:对于“读多写少”的场景,使用读写锁,允许多个线程同时读取共享数据,但写操作是互斥的。
- 实现:
java.util.concurrent.locks.ReadWriteLock。
常用设计模式
并发模式是解决特定并发问题的经典方案。
线程池模式
- 目标:重用已创建的线程,避免频繁创建和销毁线程带来的性能开销,控制系统中并发线程的数量,防止耗尽系统资源。
- 核心角色:
- 任务:实现了
Runnable或Callable接口的对象。 - 任务队列:用于存放等待执行的任务,通常是
BlockingQueue。 - 工作线程:从任务队列中取任务并执行。
- 线程池管理器:创建和管理线程池。
- 任务:实现了
- Java 实现:
java.util.concurrent.Executor框架,核心是ThreadPoolExecutor。- 固定大小线程池:
Executors.newFixedThreadPool(int nThreads) - 单例线程池:
Executors.newSingleThreadExecutor() - 缓存线程池:
Executors.newCachedThreadPool()(线程数量可动态增长) - 定时任务线程池:
Executors.newScheduledThreadPool(int corePoolSize)
- 固定大小线程池:
生产者-消费者模式
- 目标:将“数据的创建者”(生产者)和“数据的处理者”(消费者)解耦,它们通过一个共享的缓冲区(队列)进行通信。
- 优点:
- 解耦:生产者和消费者无需知道彼此的存在。
- 平衡负载:生产者可以快速生产,消费者可以慢慢消费,反之亦然,队列起到了缓冲作用。
- 并发控制:通过队列的容量,可以限制生产者的速度,防止系统过载。
- Java 实现:
- 核心:
java.util.concurrent.BlockingQueue(如ArrayBlockingQueue,LinkedBlockingQueue)。 - 生产者
put()数据到队列,消费者take()数据,这两个操作都是阻塞的,完美契合模式需求。
- 核心:
Immutable Object (不可变对象) 模式
- 目标:创建一个一旦创建后,其状态就不能被修改的对象。
- 优点:
- 线程安全:无需同步,因为对象的状态不可变,自然就不会有数据竞争。
- 简单可靠:无需担心对象在多线程环境下被意外修改。
- Java 实现:
- 将类声明为
final,防止被继承。 - 所有字段声明为
private final。 - 不提供任何修改对象状态的方法(如
setXXX)。 - 如果需要返回一个“修改后”的对象,返回一个新的对象实例。
- 示例:
String,Integer等包装类。
- 将类声明为
Balking Pattern (犹豫模式)
- 目标:当某个线程发现某个状态不符合执行条件时,就立即放弃执行,而不是等待。
- 适用场景:一个操作只需要在特定状态下执行一次,或者由特定线程执行。
- 经典示例:
java.util.Properties的store()和load()方法,如果底层的OutputStream或InputStream已经被修改过(比如通过setProperty),再次调用store()会直接返回,因为数据已经“脏”了,不应该覆盖。 - 实现要点:通常使用
synchronized块来检查和修改状态,确保状态检查的原子性。
Thread-Per-Message Pattern (线程 per 消息 模式)
- 目标:为每个请求(消息)分配一个独立的线程来处理它。
- 优点:并发处理能力强,请求处理速度快,因为主线程不会被阻塞。
- 缺点:资源消耗大,如果请求量巨大,可能会导致系统崩溃。
- 适用场景:需要快速响应的 Web 服务器,每个 HTTP 请求由一个线程处理。
Worker Thread Pattern (工作者线程模式)
- 目标:与线程池模式非常相似,它有一组固定的工作线程,从一个任务队列中获取任务并执行。
- 与线程池模式的区别:更侧重于任务的“排队”和“分配”过程,线程池是实现工作者线程模式的一种常用技术。
- 示例:Web 服务器中的请求处理池。
Two-Phase Termination Pattern (两阶段终止模式)
- 目标:安全地终止一个正在运行中的线程。
- 问题:直接调用
Thread.stop()方法已被废弃,因为它不安全,会导致线程持有的锁被突然释放,造成数据不一致。 - 解决方案:
- 第一阶段(请求终止):设置一个“终止标志”(如
volatile boolean isShutdown),工作线程在每次循环开始时检查这个标志,如果为true,则准备退出。 - 第二阶段(清理资源):工作线程在退出前,执行必要的清理操作(如关闭文件、网络连接等)。
- 第一阶段(请求终止):设置一个“终止标志”(如
- 实现要点:必须使用
volatile或synchronized来确保终止标志的可见性。
实践与工具
理解了原则和模式,最终要落实到 Java 的具体 API 上。
-
java.util.concurrent包:这是 Java 并发编程的基石,提供了大量高质量的并发工具。- 原子类:
AtomicInteger,AtomicReference等,用于实现无锁算法。 - 并发集合:
ConcurrentHashMap,CopyOnWriteArrayList,ConcurrentLinkedQueue,它们内部使用了复杂的算法(如 CAS、分段锁、写时复制)来保证线程安全和高性能。 - 同步器:
CountDownLatch,CyclicBarrier,Semaphore,Phaser,它们是用于协调多个线程之间合作的强大工具。CountDownLatch:让一个或多个线程等待其他线程完成一组操作。CyclicBarrier:让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,所有线程才会继续执行。Semaphore:控制同时访问某个特定资源的线程数量。
Executor框架:如前所述,是管理线程池的核心。
- 原子类:
-
java.util.concurrent.locks包:Lock接口:比synchronized更灵活,提供了可中断的锁获取、尝试非阻塞获取锁、超时获取锁等功能。ReadWriteLock:读写锁,适用于读多写少的场景。StampedLock:Java 8 引入,是ReadWriteLock的一个改进,提供了乐观读模式,在读竞争远大于写竞争时性能更好。
| 层面 | 关键点 | |
|---|---|---|
| 设计原则 | 安全性 | 原子性、可见性、有序性,是并发正确性的基石。 |
| 活性 | 避免死锁、饥饿、活锁,确保程序能运行下去。 | |
| 性能 | 减少锁粒度、减少锁持有时间、读写分离,提升吞吐量。 | |
| 设计模式 | 线程池 | 重用线程,控制并发,提升资源利用率。 |
| 生产者-消费者 | 通过队列解耦,平衡负载。 | |
| 不可变对象 | 天然线程安全,无需同步。 | |
| 两阶段终止 | 安全地停止线程。 | |
| 实践工具 | J.U.C 包 | Executor、ConcurrentHashMap、CountDownLatch、Lock 等是实现上述原则和模式的利器。 |
在编写并发代码时,应始终遵循“最小化同步范围”和“优先使用并发工具而非同步块”的原则,先有清晰的并发模型(遵循原则),再选择合适的模式或工具去实现它,这样才能构建出健壮、高效的并发应用。
