黄磊的博客 - 并发编程
type: channel
link: https://www.huangleicole.com/tag/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/
items: 并发编程的陷阱：六年里，我见过的那些诡异的线程安全问题
items: Java并发编程的“坑”与“术”：六年实战经验总结的避坑指南
items: 超越synchronized：深入剖析AQS及其在CountDownLatch中的应用
items: Java并发编程实战：从ThreadLocal到CompletableFuture的深度解析

并发编程的陷阱：六年里，我见过的那些诡异的线程安全问题
type: item
date: 2024-12-12T16:25:00+08:00
description: AI摘要 作者以六年踩坑经历，揭示并发“幽灵”：SimpleDateFormat共享状态、Check-then-Act空档、迭代器隐式fail-fast、volatile误当原子锁。教训：单线程测试不可信，共享可变必审访问路径，优先使用JUC工具，理解内存语义，方得线程安全。 此内容根据文章生成，仅用于文章内容的解释与总结 如果说编程是教会计算机思考，那并发编程就是教会它“一心多用”。而让我真正对并发产生敬畏的，不是书本上的概念，而是在自己代码里撞见的那些诡异瞬间。它们不像生产事故那样轰轰烈烈，却像程序世界里悄无声息的“幽灵”，一次次挑战我对代码确定性的信仰。我记得早期，我天真地以为线程安全就是synchronized。直到我踩进一个又一个陷阱，才明白并发问题的诡异在于：它绝大多数时候都运行良好，就像一颗藏在代码深处的定时炸弹，只等一个完美的、难以复现的条件组合来引爆。陷阱一：看似“局部”的变量，却引来了“全局”的灾难场景： 一个简单的日期格式化工具类。我写了一个public static String formatDate(Date date)方法，内部使用了SimpleDateFormat实例。在我的单元测试里，百分百通过。但在一个压测脚本中，偶尔会出现格式化后的日期字符串错乱，甚至抛出NumberFormatException。排查过程：轻敌阶段： 首先怀疑是测试数据问题，反复检查，无果。困惑阶段： 在IDE里单步调试，一切正常。这让我无比沮丧。顿悟时刻： 当我用多个线程同时调用这个工具类时，幽灵出现了。日期串了，时间错了。我这才意识到，SimpleDateFormat内部维护了一个Calendar对象用于计算。当线程A正在格式化一个日期，还没完成时，线程B闯了进来，重置了Calendar对象，然后把自己的日期塞了进去。最终，A线程拿到的就是被B“污染”后的结果。教训：我犯了一个经典错误：认为没有“写”操作就是线程安全的。我误以为format方法只是“读”传入的Date对象。但实际上，SimpleDateFormat的内部状态（那个Calendar对象）在格式化过程中被剧烈地“写”改了。任何一个有状态（尤其是可变状态）的对象，如果被多个线程共享，就必须考虑线程安全问题。解决方案：最直接： 每次调用都new SimpleDateFormat()。性能差，但简单有效。线程隔离： 使用ThreadLocal<SimpleDateFormat>，为每个线程分配一个独立的实例。这是性能和安全的良好折衷。拥抱新时代： 换成Java 8引入的DateTimeFormatter，它被明确设计为不可变且线程安全的。陷阱二：“检查-执行”的致命间隙场景： 实现一个简单的缓存MyCache。逻辑是：如果缓存里有，直接返回；如果没有，就去数据库查，然后放入缓存。我写了类似这样的代码：public Object getData(String key) { Object data = cache.get(key); if (data == null) { // 检查通过，认为需要加载 data = loadDataFromDB(key); // 这是一个耗时的操作 cache.put(key, data); // 执行放入操作 } return data; }在低并发下完美工作。但模拟高并发访问时，数据库连接池偶尔会报超限错误。排查过程：日志显示，对于同一个key，在极短的时间内，loadDataFromDB这个耗时操作被执行了多次。这太反直觉了！我的if (data == null)检查是干什么用的？幽灵再现：想象一下，线程A和线程B同时调用getData(“key1”)。A检查缓存，key1不存在。A开始执行耗时的loadDataFromDB(“key1”)。就在A查询数据库的这段时间里，线程B也来检查缓存。此时A还没把结果放进去，所以B也检查到key1不存在。B也开始了loadDataFromDB(“key1”)。结果就是，数据库被同一个查询轰炸了两次，缓存里也被重复放入了两次（虽然结果一样，但浪费了资源）。教训：这就是典型的检查-执行（Check-then-Act） 竞态条件。在“检查”（data == null）和“执行”（cache.put）之间，存在一个时间窗口，其他线程可以趁虚而入。synchronized可以解决，但粒度太粗，影响性能。解决方案：使用ConcurrentHashMap的putIfAbsent方法，或者更优雅的computeIfAbsent方法。它能保证对于同一个key，加载数据的函数只被执行一次。这才是真正的原子操作。public Object getData(String key) { return cache.…

Java并发编程的“坑”与“术”：六年实战经验总结的避坑指南
type: item
date: 2023-07-07T20:15:00+08:00
description: AI摘要 六年踩坑总结：SimpleDateFormat共享致错乱、synchronized组合非原子、volatile不保证++安全；用ThreadLocal/DateTimeFormatter、ConcurrentHashMap原子方法、Atomic类即可避坑，敬畏并发、多线程测试、代码审查是铁律。 此内容根据文章生成，仅用于文章内容的解释与总结 写了几年Java，尤其是开始接触稍具规模的项目后，并发代码就成了绕不开的坎。它不像空指针异常那么“耿直”，直接崩溃给你看。它更像一个幽灵，大部分时间安静无害，但会在某个你意想不到的时刻，给你带来一些诡异、难以复现的问题。这篇博客，就是我六年来在平常开发中，对并发编程从“畏惧”到“理解”再到“谨慎使用”的一些总结。咱们不搞高深理论，就聊实实在在的“坑”和解决问题的“术”。坑一：那个看似“高效”的全局日期格式化器场景：刚开始学Spring，知道要减少对象创建，利用IoC容器的单例特性。于是，我“聪明”地定义了一个全局的SimpleDateFormat来格式化日志时间：@Component public class DateUtil { public static final SimpleDateFormat GLOBAL_FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); } // 在某个Service里到处用 String logTime = DateUtil.GLOBAL_FORMATTER.format(new Date());问题发现：在本地用main方法测试，一点问题没有。直到有一次，我写了个简单的多线程单元测试，想模拟一下并发日志记录。结果日志里的时间戳时不时就乱套了，出现了2023年变成1923年的诡异情况，甚至偶尔抛出了NumberFormatException。为啥是坑？SimpleDateFormat的内部维护了一个Calendar对象实例，它在执行format或parse方法时，会频繁地修改这个Calendar的内部状态（比如先clear，再set时间）。当多个线程同时调用同一个SimpleDateFormat实例时，线程A刚set好值，还没等输出，线程B就来个clear，数据就乱套了。这被称为非线程安全。解决之术：放弃治疗（最简单）： 每次用时在方法内部new SimpleDateFormat()。对于大多数不极端频繁调用的场景，这点开销完全可以接受，换来的是代码的清晰和安全。线程隔离（经典方案）： 使用ThreadLocal为每个线程绑定一个独立的实例，空间换时间，兼顾性能和安全。public class DateUtil { private static final ThreadLocal<SimpleDateFormat> threadLocalFormatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); public static String format(Date date) { return threadLocalFormatter.get().format(date); } // 重要！对于Web应用等使用线程池的场景，用完后最好主动remove，防止内存泄漏。 public static void clear() { threadLocalFormatter.remove(); } }拥抱新时代（最佳实践）： 直接使用Java 8引入的DateTimeFormatter。它是不可变对象，天生线程安全。public class DateUtil { // 放心大胆地声明为public static final public static final DateTimeFormatter FORMatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static String format(LocalDateTime dateTime) { return dateTime.format(FORMatter); } }坑二：synchronized锁得住代码，但锁不住粗心场景：在实现一个简单的内存缓存时，我写了下面这样的代码：public class SimpleCache<K, V> { private final Map<K, V> cache = new HashMap<>(); public synchronized void put(K key, V value) { cache.put(…

超越synchronized：深入剖析AQS及其在CountDownLatch中的应用
type: item
date: 2021-06-28T12:55:00+08:00
description: AI摘要 文章以“主线程等待所有工作线程”需求为引，对比synchronized的局限，深入解析AQS核心原理、CLH队列、共享/独占模式，并逐行剖析CountDownLatch如何利用AQS的state与模板方法实现高效等待与唤醒，最终给出支持超时的自定义同步器实战，强调理解设计哲学优于死记API。 此内容根据文章生成，仅用于文章内容的解释与总结 在四年的Java开发生涯中，我曾认为synchronized就是并发编程的全部。直到遇到那个让我夜不能寐的需求：实现一个自定义的线程协同工具。正是这次经历，让我走进了AQS的世界。一、从synchronized的局限性说起去年在公司消息推送系统中，我需要实现这样一个功能：​主线程等待所有工作线程完成消息预处理后，才能开始批量推送​。我的第一版实现使用了synchronized：public class SimpleSync { private int workerCount; private final Object lock = new Object(); public void await() throws InterruptedException { synchronized(lock) { while (workerCount > 0) { lock.wait(); } } } public void countDown() { synchronized(lock) { if (workerCount > 0) { workerCount--; } if (workerCount == 0) { lock.notifyAll(); } } } }这段代码虽然能用，但在高并发场景下暴露了两个问题：​无法超时等待​：主线程可能永久阻塞​性能瓶颈​：所有线程竞争同一把锁正是这些痛点，促使我去探索更优秀的解决方案。二、AQS是什么？为什么说是并发包的核心？AQS（AbstractQueuedSynchronizer）是JDK并发包的基石。Doug Lea在设计时就定下目标：​提供一个框架，用于实现依赖于先进先出（FIFO）等待队列的阻塞锁和相关同步器​。2.1 AQS的核心理念AQS使用一个int类型的成员变量state来表示同步状态，通过内置的FIFO队列来完成资源获取线程的排队工作。这种设计巧妙地将同步器分解为两个维度：public abstract class AbstractQueuedSynchronizer { // 同步状态 private volatile int state; // 等待队列的头节点 private transient volatile Node head; // 等待队列的尾节点 private transient volatile Node tail; }2.2 模板方法模式的应用AQS使用了经典的模板方法模式，将具体的同步操作留给子类实现：// 尝试获取独占锁 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } // 尝试释放独占锁 protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }这种设计让AQS既可以实现独占锁（如ReentrantLock），也可以实现共享锁（如CountDownLatch）。三、深入AQS的CLH队列实现AQS的核心在于其队列管理机制，这直接决定了线程调度的公平性。3.1 Node节点：队列的基础单元static final class Node { // 节点状态 volatile int waitStatus; // 前驱节点 volatile Node prev; // 后继节点 volatile Node next; // 节点对应的线程 volatile Thread thread; // 指向下一个等待节点 Node nextWaiter; }每个Node节点代表一个等待线程，通过waitStatus标识线程状态（CANCELLED、SIGNAL、CONDITION等）。3.2 入队操作：真实的排队场景让我通过一个真实案例来说明入队过程。在订单处理系统中，多个线程同时竞争处理权限：public class OrderProcessor { private final Sync sync = new Sync(); public void processOrder(String orderId) { sync.acquire(1); // 尝试获取锁 try { // 处理订单…

Java并发编程实战：从ThreadLocal到CompletableFuture的深度解析
type: item
date: 2020-11-02T16:48:00+08:00
description: AI摘要 文章通过微服务上下文传递问题，系统解析ThreadLocal原理、内存泄漏风险与最佳实践，并深入演示CompletableFuture的链式、并行、异常及超时处理，结合性能测试给出IO/计算密集型线程池配置建议，总结生产环境监控与优雅关闭要点。 此内容根据文章生成，仅用于文章内容的解释与总结 一、引言：一个微服务上下文传递的难题在微服务架构中，我们经常需要在不同线程间传递用户上下文（如用户ID、请求ID等）。最初我们使用静态Map来存储，结果遇到了严重的数据错乱问题：// ❌ 错误示例：静态Map存储上下文 public class UserContextHolder { private static final Map<String, Object> context = new ConcurrentHashMap<>(); public static void set(String key, Object value) { context.put(key, value); } public static Object get(String key) { return context.get(key); } }在多线程环境下，多个请求会相互覆盖上下文数据。这让我深刻认识到：​共享可变状态是并发编程万恶之源​。二、ThreadLocal：线程安全的局部变量2.1 ThreadLocal的核心原理ThreadLocal为每个线程提供独立的变量副本，避免了线程间的数据竞争。ThreadLocal基本使用import java.util.concurrent.TimeUnit; public class ThreadLocalDemo { // 创建ThreadLocal实例 private static final ThreadLocal<String> userContext = new ThreadLocal<>(); private static final ThreadLocal<Integer> requestIdContext = ThreadLocal.withInitial(() -> 0); public static void main(String[] args) throws InterruptedException { // 创建并启动多个线程 Thread thread1 = new Thread(new UserRequest("用户A", 1001), "Thread-1"); Thread thread2 = new Thread(new UserRequest("用户B", 1002), "Thread-2"); Thread thread3 = new Thread(new UserRequest("用户C", 1003), "Thread-3"); thread1.start(); thread2.start(); thread3.start(); // 等待所有线程执行完成 thread1.join(); thread2.join(); thread3.join(); System.out.println("所有线程执行完成"); } // 用户请求处理类 static class UserRequest implements Runnable { private final String userName; private final int requestId; public UserRequest(String userName, int requestId) { this.userName = userName; this.requestId = requestId; } @Override public void run() { try { // 设置线程局部变量 userContext.set(userName); requestIdContext.set(requestId); // 模拟业务处理 processRequest(); } finally { // 重要：必须清理ThreadLocal，防止内存泄漏 userContext.remove(); requestIdContext.remove(); } } private void processRequest() { // 获取当前线程的上下文数据 String currentUser = userContext.get(); Integer currentRequestId = requestIdContext.get(); System.out.println(Thread.currentThread…