AI摘要
如果说编程是教会计算机思考,那并发编程就是教会它“一心多用”。而让我真正对并发产生敬畏的,不是书本上的概念,而是在自己代码里撞见的那些诡异瞬间。它们不像生产事故那样轰轰烈烈,却像程序世界里悄无声息的“幽灵”,一次次挑战我对代码确定性的信仰。
我记得早期,我天真地以为线程安全就是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.computeIfAbsent(key, k -> loadDataFromDB(k));
}陷阱三:隐式的迭代器陷阱
场景: 一个后台任务,需要遍历一个List,并根据条件删除一些元素。我写出了这样的代码:
for (String item : list) { // 这里使用了增强for循环(语法糖,底层是Iterator)
if (shouldRemove(item)) {
list.remove(item); // 在遍历过程中直接删除
}
}在单线程测试里,删得干干净净。但当一个线程遍历,另一个线程同时向这个List添加元素时,程序在测试中偶尔会神秘地抛出ConcurrentModificationException。
幽灵的根源:
增强for循环背后使用的是Iterator。Iterator在工作时,会期望它正在遍历的集合结构不发生“意外改变”。它内部有一个计数器modCount,记录集合的修改次数。当Iterator被创建后,它会记住当前的modCount值。在每次next()调用时,它会检查当前的modCount是否和它记住的值一致。如果不一致(说明有其他线程或方式修改了集合结构),它就认为发生了“并发修改”,立即抛出异常。
教训:
- 任何非线程安全的集合类(如
ArrayList,HashMap),在被一个线程迭代时,如果被另一个线程修改(增、删),都可能导致ConcurrentModificationException。 - 即使在单线程中,用
Iterator遍历时直接调用集合的remove()方法也会抛出此异常,必须使用Iterator.remove()。
解决方案:
- 遍历前复制:
List<String> copy = new ArrayList<>(list);然后遍历copy,操作原始的list。简单,但内存开销大。 - 使用线程安全集合: 比如
CopyOnWriteArrayList。它在修改时(增、删)会复制整个底层数组,遍历操作在旧数组上进行,因此不会受修改影响。适用于读多写少的场景。 - 显式加锁: 在遍历和修改的代码块前后加同一把锁,保证互斥。
陷阱四:volatile的错觉——它不保证原子性
场景: 一个简单的计数器,private volatile int count = 0;,有多个线程执行count++。我天真地以为加了volatile,就能保证最终结果是正确的。
幽灵的数字:
跑完测试,结果值总是小于理论值。为什么?count++这个操作,看起来是一行代码,但底层是三个步骤:
- 从主内存读取
count的当前值到线程工作内存。 - 在工作内存中给这个值加1。
- 将新值写回主内存。
volatile只能保证变量的可见性(一个线程修改后,新值立即对其他线程可见)和禁止指令重排序。但它不保证复合操作的原子性。
幽灵再现:
线程A和B同时读取count,值都是0。
- A计算
0+1=1。 - B也计算
0+1=1。(因为B在读的时候,A还没把1写回去) - A和B分别将1写回主内存。
结果,两次++操作,count只增加了1。
教训:volatile不是万能的。对于count++这种“读-改-写”复合操作,需要用synchronized或ReentrantLock保证原子性,或者直接使用AtomicInteger。
结语:敬畏之心
并发编程,最大的陷阱,是“想当然”的思维惰性。
- 不要相信你的单线程测试。 并发问题在99.9%的时间里都会伪装得很好。
- 养成条件反射: 看到共享的可变对象,立刻思考它的访问路径。
- 优先使用高级工具: 多用
java.util.concurrent包下的类(ConcurrentHashMap,AtomicXXX,CountDownLatch等),它们由大师编写,远比我们自己实现的可靠。 - 理解底层原理: 明白
synchronized、volatile、final的内存语义,才能真正用好它们。
与并发幽灵的斗争,是一场心智的磨砺。它逼着你用最严苛的眼光去审视每一行代码,去思考时间线上无数种可能的交错。这种训练所带来的严谨,最终会渗透到你编程的每一个角落,让你成长为一个更可靠的工程师。