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对象实例,它在执行formatparse方法时,会频繁地修改这个Calendar的内部状态(比如先clear,再set时间)。当多个线程同时调用同一个SimpleDateFormat实例时,线程A刚set好值,还没等输出,线程B就来个clear,数据就乱套了。这被称为非线程安全

解决之术:

  1. 放弃治疗(最简单): 每次用时在方法内部new SimpleDateFormat()。对于大多数不极端频繁调用的场景,这点开销完全可以接受,换来的是代码的清晰和安全。
  2. 线程隔离(经典方案): 使用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();
        }
    }
  3. 拥抱新时代(最佳实践): 直接使用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)虽然每个步骤内部是同步的,但整个组合对于外部调用者来说并不是一个原子操作。在getput之间,锁被释放了,其他线程可以趁虚而入。

解决之术:

  1. 真正的原子化: 将整个复合操作在同一把锁的保护下完成,确保中间不释放锁。

    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;
        }
    }
  2. 使用并发容器: 直接使用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));
        }
    }

    这是更优雅、性能更好的解决方案。ConcurrentHashMapcomputeputIfAbsent等方法保证了单个键上操作的原子性。

坑三: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的操作。

解决之术:

  1. 使用真正的原子类: 对于这种简单的原子操作,应该使用java.util.concurrent.atomic包下的类,如AtomicInteger

    public class Counter {
        private final AtomicInteger count = new AtomicInteger(0);
    
        public void increment() {
            count.incrementAndGet(); // 底层通过CAS操作保证原子性
        }
    }
  2. 理解volatile的正确场景: volatile非常适合做状态标志位,如boolean shutdownRequested,一个线程写,其他线程读,这种简单的赋值操作是原子的,volatile保证了修改能立刻被其他线程看见。

总结

对待并发编程,一定要有“敬畏之心”。

  • 不要“我觉得”: 想当然地认为代码没问题,多线程环境下的行为往往反直觉。
  • 工具要用对: synchronizedvolatileLock、原子类、并发容器,每个都有其明确的适用场景。用错工具,就像用螺丝刀去敲钉子,既费劲又危险。
  • 多写测试: 一定要编写多线程单元测试,并尝试高并发场景下的压力测试,很多坑在低并发下是暴露不出来的。
  • 代码审查: 多人互相审查代码,特别是并发相关的部分,非常有效。别人很容易发现你意识不到的盲点。

并发编程是一个深水区,但也是从中级迈向高级的必经之路。希望我踩过的这些坑,能成为你前进路上的垫脚石。共勉!

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:Java并发编程的“坑”与“术”:六年实战经验总结的避坑指南
▶ 本文链接:https://www.huangleicole.com/experience_summary/72.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

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