AI摘要
写了几年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(key, value);
}
public synchronized V get(K key) {
return cache.get(key);
}
// 问题方法!
public synchronized V getAndUpdate(K key, Function<V, V> updater) {
V value = get(key); // 1. 获取值
if (value != null) {
V newValue = updater.apply(value); // 2. 基于旧值计算新值(非原子操作!)
put(key, newValue); // 3. 放回缓存
}
return value;
}
}我想实现一个“获取并更新”的操作,比如给某个计数加一。我觉得每个方法都加了synchronized,万无一失。
问题发现:
代码审查时,一位同事指出:假设线程A和线程B同时调用getAndUpdate来给同一个key的值+1。A执行到第2步,拿到了值1,此时CPU时间片用完,切换到B。B也完整地执行完了所有步骤,将值更新为2。然后A继续执行第3步,它基于自己拿到的旧值1计算出2,又覆盖了B的结果。最终结果变成了2,而不是预期的3。
为啥是坑?
我错误地认为,把多个同步方法组合起来调用,依然是一个原子操作。但实际上,getAndUpdate方法内部的三个步骤(get, calculate, put)虽然每个步骤内部是同步的,但整个组合对于外部调用者来说并不是一个原子操作。在get和put之间,锁被释放了,其他线程可以趁虚而入。
解决之术:
真正的原子化: 将整个复合操作在同一把锁的保护下完成,确保中间不释放锁。
public V getAndUpdate(K key, Function<V, V> updater) { synchronized (this) { // 使用更明确的同步块,锁住整个复合操作 V value = get(key); if (value != null) { V newValue = updater.apply(value); put(key, newValue); } return value; } }使用并发容器: 直接使用
ConcurrentHashMap,并利用其提供的原子方法。public class SimpleCache<K, V> { private final ConcurrentMap<K, V> cache = new ConcurrentHashMap<>(); // 使用compute等原子方法 public V getAndUpdate(K key, Function<V, V> updater) { return cache.computeIfPresent(key, (k, v) -> updater.apply(v)); } }这是更优雅、性能更好的解决方案。
ConcurrentHashMap的compute、putIfAbsent等方法保证了单个键上操作的原子性。
坑三:volatile的错觉——它不保证原子性
场景:
学习volatile关键字时,我知道它能保证可见性。于是我写了一个多线程累加的程序:
public class Counter {
private volatile int count = 0;
public void increment() {
count++; // 操作:读 -> 改 -> 写
}
public int getCount() {
return count;
}
}我以为count++这个操作因为volatile的存在而变得原子了。
问题发现:
跑个测试,开10个线程,每个线程加1000次,结果永远小于10000。
为啥是坑?volatile只解决了两件事:1. 可见性;2. 禁止指令重排序。但它不保证复合操作的原子性。count++看似一行代码,在JVM层面是三个指令:读取当前值、值加1、写回新值。线程A可能在读完值后被挂起,线程B完成了完整的加1操作并写回(由于可见性,A会看到B更新后的值),但当A恢复后,它是在自己之前读到的旧值基础上加1,然后写回,这就覆盖了B的操作。
解决之术:
使用真正的原子类: 对于这种简单的原子操作,应该使用
java.util.concurrent.atomic包下的类,如AtomicInteger。public class Counter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 底层通过CAS操作保证原子性 } }- 理解
volatile的正确场景:volatile非常适合做状态标志位,如boolean shutdownRequested,一个线程写,其他线程读,这种简单的赋值操作是原子的,volatile保证了修改能立刻被其他线程看见。
总结
对待并发编程,一定要有“敬畏之心”。
- 不要“我觉得”: 想当然地认为代码没问题,多线程环境下的行为往往反直觉。
- 工具要用对:
synchronized、volatile、Lock、原子类、并发容器,每个都有其明确的适用场景。用错工具,就像用螺丝刀去敲钉子,既费劲又危险。 - 多写测试: 一定要编写多线程单元测试,并尝试高并发场景下的压力测试,很多坑在低并发下是暴露不出来的。
- 代码审查: 多人互相审查代码,特别是并发相关的部分,非常有效。别人很容易发现你意识不到的盲点。
并发编程是一个深水区,但也是从中级迈向高级的必经之路。希望我踩过的这些坑,能成为你前进路上的垫脚石。共勉!