Lesson 22 · 并发编程

线程安全设计模式:不可变对象、Thread-safe Singleton、生产者-消费者

中级·#并发·#设计模式

第 1 站

线程安全不只是加锁——而是设计

"面试问到线程安全,很多人第一反应就是 synchronized。但真正的高手知道——最好的线程安全,是从设计上就不需要加锁。"

加锁(synchronizedReentrantLock)只是解决线程安全的"最后手段"。锁意味着竞争,竞争意味着上下文切换和性能损耗,用不好还会带来死锁。Doug Lea 说过:"线程安全的最高境界不是把锁用好,而是让共享可变状态尽量少出现。"

本文将覆盖 5 种核心模式:不可变对象、单例 5 种写法、生产者-消费者、线程安全集合选型、编码军规。

面试官真正在考什么?

不是考你会不会写 synchronized,而是考你有没有设计并发程序的思维:能否从架构层面减少共享状态?能否选择合适的工具而不是万物皆锁?

第 2 站

不可变对象:天生线程安全的设计

如果对象创建后状态永远不能改变,那么无论多少线程同时访问它都不会有线程安全问题——没有"写"操作,就不存在数据竞争。JDK 中 StringIntegerLocalDate 都是不可变类。

自定义不可变类——四条规则
public final class OrderSnapshot {           // 规则 1:final 类,禁止继承
    private final String orderId;                // 规则 2:所有字段 private final
    private final BigDecimal totalAmount;
    private final LocalDateTime createTime;
    private final List<String> items;

    public OrderSnapshot(String id, BigDecimal amt,
                        LocalDateTime time, List<String> items) {
        this.orderId = id;
        this.totalAmount = amt;
        this.createTime = time;
        this.items = List.copyOf(items);     // 规则 3:防御性拷贝
    }

    // 规则 4:只提供 getter,不提供 setter
    public String getOrderId()          { return orderId; }
    public BigDecimal getTotalAmount()  { return totalAmount; }
    public List<String> getItems()     { return items; } // List.copyOf 返回不可变 List
}
不可变 = 线程安全的本质原因

线程安全的本质是防止多线程同时修改共享状态。如果状态根本不能修改,就不存在"同时修改"的问题。不可变对象可被任意多线程同时读取,无需任何同步——这是零开销的线程安全。

第 3 站

单例模式 5 种写法:从入门到最佳实践

写法一:饿汉式

Eager——类加载即创建
public class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    private EagerSingleton() {}
    public static EagerSingleton getInstance() { return INSTANCE; }
}  // 优点:简单可靠 | 缺点:可能浪费内存

写法二:懒汉式

Lazy——每次都加锁
public class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() {}
    public static synchronized LazySingleton getInstance() {
        if (instance == null) instance = new LazySingleton();
        return instance;
    }
}  // 优点:延迟加载 | 缺点:每次调用都加锁,性能差

写法三:双重检查锁(DCL)

DCL——面试最常考
public class DclSingleton {
    private static volatile DclSingleton instance; // 必须 volatile!
    private DclSingleton() {}
    public static DclSingleton getInstance() {
        if (instance == null) {                     // 第一次检查(无锁)
            synchronized (DclSingleton.class) {
                if (instance == null)                 // 第二次检查(有锁)
                    instance = new DclSingleton();
            }
        }
        return instance;
    }
}
为什么 DCL 必须用 volatile?

new DclSingleton() 字节码层面分三步:① 分配内存 → ② 构造函数初始化 → ③ 引用指向地址。JIT 可能将 ②③ 重排为 ①③②。线程 A 执行到 ③(引用非 null)但还没 ②(未初始化),线程 B 直接返回半初始化对象——NPEvolatile 禁止此重排序。

写法四:静态内部类

Holder Pattern——优雅推荐
public class HolderSingleton {
    private HolderSingleton() {}
    private static class Holder {
        private static final HolderSingleton INSTANCE = new HolderSingleton();
    }
    public static HolderSingleton getInstance() { return Holder.INSTANCE; }
}  // 延迟加载 + ClassLoader 保证线程安全 + 无锁

写法五:枚举(Joshua Bloch 推荐)

Enum——Effective Java 推荐
public enum EnumSingleton {
    INSTANCE;
    public void doSomething() { System.out.println("Working..."); }
}
// EnumSingleton.INSTANCE.doSomething();
// 线程安全 + 防反射 + 防序列化 + 代码极简
单例模式 5 种写法对比 写法 线程安全 延迟加载 防反射/序列化 性能 饿汉式 ✔ ClassLoader ✘ 类加载即创建 ● 可被反射破坏 ★★★★★ 懒汉式 ✔ synchronized ✔ 延迟 ✘ 可被破坏 ★★☆☆☆ DCL ✔ volatile+锁 ✔ 延迟 ✘ 可被破坏 ★★★★☆ 静态内部类 ✔ ClassLoader ✔ 延迟 ● 可被反射破坏 ★★★★★ 枚举 ★ ✔ JVM 保证 ✘ 类加载即创建 ✔ 天然防护 ★★★★★ ★ = Joshua Bloch《Effective Java》推荐
图 1 单例模式 5 种写法多维度对比——枚举是综合最优解
面试回答策略

"不需要延迟加载选饿汉式;需要延迟加载且 JDK 允许用枚举——天然防反射、防序列化;框架限制不能用枚举时用静态内部类。DCL 容易写错 volatile,生产环境谨慎使用。"

第 4 站

生产者-消费者:BlockingQueue 的经典协作

生产者负责生产数据放入缓冲区,消费者从缓冲区取出处理。缓冲区满时生产者阻塞,空时消费者阻塞。BlockingQueue 把这个同步逻辑封装好了:

Producer queue.put(data) 满时阻塞 ArrayBlockingQueue(100) D1 D2 D3 head ← ———— → tail Consumer queue.take() 空时阻塞 put() 满时阻塞 | take() 空时阻塞 | 线程安全,无需额外同步
图 2 生产者-消费者模型——BlockingQueue 作为线程安全缓冲区
完整生产者-消费者示例
public class ProducerConsumerDemo {
    private static final BlockingQueue<String> queue =
            new ArrayBlockingQueue<>(100);  // 有界队列!

    static class Producer implements Runnable {
        private final String name;
        Producer(String name) { this.name = name; }
        @Override public void run() {
            try {
                for (int i = 0; i < 50; i++) {
                    queue.put(name + "-item-" + i);  // 满时阻塞
                }
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }

    static class Consumer implements Runnable {
        @Override public void run() {
            try {
                while (true) {
                    String data = queue.take();  // 空时阻塞
                    // process(data)...
                }
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }
}
BlockingQueue 实现有界/无界适用场景
ArrayBlockingQueue有界通用生产者-消费者,首选
LinkedBlockingQueue可选(默认无界)高吞吐,但一定要指定容量!
SynchronousQueue容量 0线程间直接交接(CachedThreadPool)
DelayQueue无界定时任务、延迟重试
有界 vs 无界:为什么生产环境推荐有界?

无界队列在生产者速度远大于消费者时会无限增长,最终 OOM。有界队列天然形成反压:队列满时生产者阻塞,被迫降速,系统自动达到平衡。

第 5 站

线程安全集合选型:别再用错容器了

场景推荐避免原因
并发 MapConcurrentHashMapsynchronizedMapCHM 读不加锁;syncMap 全表加锁,差 5-10x
并发 ListCopyOnWriteArrayListVectorCOW 读无锁(读多写少);Vector 每方法 synchronized
并发 QueueConcurrentLinkedQueuesynchronizedList 当队列无锁 CAS,高并发性能远优于包装
并发 SetConcurrentHashMap.newKeySet()synchronizedSet底层就是 CHM
阻塞队列ArrayBlockingQueue自己 wait/notifyJDK 已封装好,自写易出 Bug
核心差异速览
// synchronizedMap:读和读之间也互斥!100 线程读也要排队
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());

// ConcurrentHashMap:100 线程可以同时读,互不影响
ConcurrentMap<String, String> chm = new ConcurrentHashMap<>();
// get()  → 无锁读取(volatile 保证可见性)
// put()  → CAS + synchronized(只锁单个桶节点)

// CopyOnWriteArrayList:写时复制整个数组
List<String> cow = new CopyOnWriteArrayList<>();
cow.add("hello"); // 加锁 → 复制数组 → 修改 → 替换引用
cow.get(0);       // 无锁读 volatile 数组
// 适合:读 10000 次才写 1 次(配置、监听器列表)
选型口诀

Map 选 CHM,List 选 COW(读多写少),Queue 选 ConcurrentLinked,要阻塞就上 BlockingQueue。忘掉 Vector、Hashtable、synchronizedXxx。

第 6 站

线程安全编码六大军规

规则 1:最小化共享状态 —— 能线程局部就不共享,能局部变量就不实例变量。用 ThreadLocal 但记得 remove()

规则 2:优先不可变对象 —— 不可变 = 天生线程安全 = 零同步开销。DTO、VO、配置对象都应是 Immutable。

规则 3:用并发集合代替同步包装 —— ConcurrentHashMap > synchronizedMap,锁粒度更细、算法更优。

规则 4:避免有状态单例 —— 有状态单例 = 定时炸弹。把状态外移到 AtomicInteger 或数据库。

规则 5:永远使用线程池 —— new Thread().start() ✘。手动创建线程无法控制并发数量,高峰期 OOM。

规则 6:缩小同步范围 —— synchronized 块越短越好,绝不在 synchronized 里做 IO。

反面教材 vs 正面写法
// ✘ 反面:在 synchronized 里做 IO
public synchronized void processOrder(Order order) {
    validateOrder(order);              // 需要锁
    updateInventory(order);            // 需要锁
    sendEmail(order.getUserEmail());   // 发邮件可能 2 秒!其他线程干等
}

// ✔ 正面:只锁必要部分
public void processOrder(Order order) {
    synchronized (this) {
        validateOrder(order);
        updateInventory(order);
    }
    sendEmail(order.getUserEmail());   // IO 移到锁外
}
SimpleDateFormat 的坑

SimpleDateFormat 非线程安全!定义为 static 共享,多线程 format()/parse() 会日期错乱。方案:用 Java 8 的 DateTimeFormatter(不可变,线程安全,推荐)。

第 7 站

总结:模式选型与常见陷阱

线程安全设计模式选型指南:

  • 状态不变 → 不可变对象,零开销线程安全
  • 全局唯一实例 → 枚举(最佳)或静态内部类(需延迟加载)
  • 生产-消费解耦 → ArrayBlockingQueue(有界)
  • 并发 Map → ConcurrentHashMap
  • 读多写少 List → CopyOnWriteArrayList
常见陷阱后果正确做法
DCL 忘写 volatile半初始化对象,NPEinstance 必须 volatile
static SimpleDateFormat日期解析错乱用 DateTimeFormatter
无界队列不限容量生产者快于消费者时 OOM始终用有界队列
手动 new Thread()线程数失控 OOM使用线程池
HashMap 多线程使用数据丢失(JDK 8+)换 ConcurrentHashMap
synchronized 里做 IO吞吐量暴跌IO 移到锁外
面试终极回答框架

"线程安全的核心是减少共享可变状态。首先用不可变对象,其次用并发集合(ConcurrentHashMap、BlockingQueue),配合线程池管理生命周期。单例用枚举或静态内部类。真正需要锁时,优先 ReentrantLockReadWriteLock,且锁范围尽可能小。"

下一篇预告:CompletableFuture 异步编程——用链式 API 编排复杂的异步任务流。