AI摘要

作者以六年踩坑经历,揭示并发“幽灵”:SimpleDateFormat共享状态、Check-then-Act空档、迭代器隐式fail-fast、volatile误当原子锁。教训:单线程测试不可信,共享可变必审访问路径,优先使用JUC工具,理解内存语义,方得线程安全。

如果说编程是教会计算机思考,那并发编程就是教会它“一心多用”。而让我真正对并发产生敬畏的,不是书本上的概念,而是在自己代码里撞见的那些诡异瞬间。它们不像生产事故那样轰轰烈烈,却像程序世界里悄无声息的“幽灵”,一次次挑战我对代码确定性的信仰。

我记得早期,我天真地以为线程安全就是synchronized。直到我踩进一个又一个陷阱,才明白并发问题的诡异在于:它绝大多数时候都运行良好,就像一颗藏在代码深处的定时炸弹,只等一个完美的、难以复现的条件组合来引爆。

陷阱一:看似“局部”的变量,却引来了“全局”的灾难

场景: 一个简单的日期格式化工具类。我写了一个public static String formatDate(Date date)方法,内部使用了SimpleDateFormat实例。在我的单元测试里,百分百通过。但在一个压测脚本中,偶尔会出现格式化后的日期字符串错乱,甚至抛出NumberFormatException

排查过程:

  1. 轻敌阶段: 首先怀疑是测试数据问题,反复检查,无果。
  2. 困惑阶段: 在IDE里单步调试,一切正常。这让我无比沮丧。
  3. 顿悟时刻: 当我用多个线程同时调用这个工具类时,幽灵出现了。日期串了,时间错了。我这才意识到,SimpleDateFormat内部维护了一个Calendar对象用于计算。当线程A正在格式化一个日期,还没完成时,线程B闯了进来,重置了Calendar对象,然后把自己的日期塞了进去。最终,A线程拿到的就是被B“污染”后的结果。

教训:
我犯了一个经典错误:认为没有“写”操作就是线程安全的。我误以为format方法只是“读”传入的Date对象。但实际上,SimpleDateFormat的内部状态(那个Calendar对象)在格式化过程中被剧烈地“写”改了。任何一个有状态(尤其是可变状态)的对象,如果被多个线程共享,就必须考虑线程安全问题。

解决方案:

  1. 最直接: 每次调用都new SimpleDateFormat()。性能差,但简单有效。
  2. 线程隔离: 使用ThreadLocal<SimpleDateFormat>,为每个线程分配一个独立的实例。这是性能和安全的良好折衷。
  3. 拥抱新时代: 换成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”)

  1. A检查缓存,key1不存在。
  2. A开始执行耗时的loadDataFromDB(“key1”)
  3. 就在A查询数据库的这段时间里,线程B也来检查缓存。此时A还没把结果放进去,所以B也检查到key1不存在。
  4. B也开始了loadDataFromDB(“key1”)
  5. 结果就是,数据库被同一个查询轰炸了两次,缓存里也被重复放入了两次(虽然结果一样,但浪费了资源)。

教训:
这就是典型的检查-执行(Check-then-Act) 竞态条件。在“检查”(data == null)和“执行”(cache.put)之间,存在一个时间窗口,其他线程可以趁虚而入。synchronized可以解决,但粒度太粗,影响性能。

解决方案:
使用ConcurrentHashMapputIfAbsent方法,或者更优雅的computeIfAbsent方法。它能保证对于同一个key,加载数据的函数只被执行一次。这才是真正的原子操作。

public Object getData(String key) {
    return cache.computeIfAbsent(key, k -> loadDataFromDB(k));
}

陷阱三:隐式的迭代器陷阱

场景: 一个后台任务,需要遍历一个List,并根据条件删除一些元素。我写出了这样的代码:

for (String item : list) { // 这里使用了增强for循环(语法糖,底层是Iterator)
    if (shouldRemove(item)) {
        list.remove(item); // 在遍历过程中直接删除
    }
}

在单线程测试里,删得干干净净。但当一个线程遍历,另一个线程同时向这个List添加元素时,程序在测试中偶尔会神秘地抛出ConcurrentModificationException

幽灵的根源:
增强for循环背后使用的是IteratorIterator在工作时,会期望它正在遍历的集合结构不发生“意外改变”。它内部有一个计数器modCount,记录集合的修改次数。当Iterator被创建后,它会记住当前的modCount值。在每次next()调用时,它会检查当前的modCount是否和它记住的值一致。如果不一致(说明有其他线程或方式修改了集合结构),它就认为发生了“并发修改”,立即抛出异常。

教训:

  1. 任何非线程安全的集合类(如ArrayList, HashMap),在被一个线程迭代时,如果被另一个线程修改(增、删),都可能导致ConcurrentModificationException
  2. 即使在单线程中,用Iterator遍历时直接调用集合的remove()方法也会抛出此异常,必须使用Iterator.remove()

解决方案:

  1. 遍历前复制: List<String> copy = new ArrayList<>(list);然后遍历copy,操作原始的list。简单,但内存开销大。
  2. 使用线程安全集合: 比如CopyOnWriteArrayList。它在修改时(增、删)会复制整个底层数组,遍历操作在旧数组上进行,因此不会受修改影响。适用于读多写少的场景
  3. 显式加锁: 在遍历和修改的代码块前后加同一把锁,保证互斥。

陷阱四:volatile的错觉——它不保证原子性

场景: 一个简单的计数器,private volatile int count = 0;,有多个线程执行count++。我天真地以为加了volatile,就能保证最终结果是正确的。

幽灵的数字:
跑完测试,结果值总是小于理论值。为什么?count++这个操作,看起来是一行代码,但底层是三个步骤:

  1. 从主内存读取count的当前值到线程工作内存。
  2. 在工作内存中给这个值加1。
  3. 将新值写回主内存。

volatile只能保证变量的可见性(一个线程修改后,新值立即对其他线程可见)和禁止指令重排序。但它不保证复合操作的原子性

幽灵再现:
线程A和B同时读取count,值都是0。

  1. A计算0+1=1
  2. B也计算0+1=1。(因为B在读的时候,A还没把1写回去)
  3. A和B分别将1写回主内存。
    结果,两次++操作,count只增加了1。

教训:
volatile不是万能的。对于count++这种“读-改-写”复合操作,需要用synchronizedReentrantLock保证原子性,或者直接使用AtomicInteger

结语:敬畏之心

并发编程,最大的陷阱,是“想当然”的思维惰性。

  • 不要相信你的单线程测试。 并发问题在99.9%的时间里都会伪装得很好。
  • 养成条件反射: 看到共享的可变对象,立刻思考它的访问路径。
  • 优先使用高级工具: 多用java.util.concurrent包下的类(ConcurrentHashMap, AtomicXXX, CountDownLatch等),它们由大师编写,远比我们自己实现的可靠。
  • 理解底层原理: 明白synchronizedvolatilefinal的内存语义,才能真正用好它们。

与并发幽灵的斗争,是一场心智的磨砺。它逼着你用最严苛的眼光去审视每一行代码,去思考时间线上无数种可能的交错。这种训练所带来的严谨,最终会渗透到你编程的每一个角落,让你成长为一个更可靠的工程师。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:并发编程的陷阱:六年里,我见过的那些诡异的线程安全问题
▶ 本文链接:https://www.huangleicole.com/backend-related/83.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏