多线程的“我以为线程安全”——SimpleDateFormat、ArrayList、HashMap、双重检查锁

0 阅读5分钟

9年Java开发,多线程的坑我踩得最多。有些类你用了十年,以为它线程安全,结果线上崩了才发现根本不是。今天聊四个“我以为线程安全”的经典陷阱。


一、SimpleDateFormat:并发环境下“时间乱跳”

现象:线上突然出现ParseException,或者时间完全错乱

java

// 错误写法:全局共享一个SimpleDateFormat
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

public static void main(String[] args) throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 100; i++) {
        executor.submit(() -> {
            Date date = sdf.parse("2024-01-01");  // ❌ 线程不安全
            System.out.println(date);
        });
    }
}

可能的结果:

  • ParseException
  • 时间变成 Mon Jan 01 00:00:00 CST 2024 完全不对
  • 甚至 NumberFormatException

为什么?——SimpleDateFormat内部有共享的Calendar

SimpleDateFormat的parse()format()方法会修改内部的Calendar对象。多线程同时修改,状态错乱。

解决方案(4选1)

java

// 方案1:每次创建新实例(简单,但性能差)
public String formatDate(Date date) {
    return new SimpleDateFormat("yyyy-MM-dd").format(date);
}

// 方案2:加锁(性能一般)
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public static synchronized String format(Date date) {
    return sdf.format(date);
}

// 方案3:ThreadLocal(推荐,性能好)
private static final ThreadLocal<SimpleDateFormat> threadLocal = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public static String format(Date date) {
    return threadLocal.get().format(date);
}

// 方案4:用Java 8的DateTimeFormatter(最佳,线程安全)
private static final DateTimeFormatter formatter = 
    DateTimeFormatter.ofPattern("yyyy-MM-dd");
// LocalDate、LocalDateTime都是线程安全的

记住:SimpleDateFormat不是线程安全的,DateTimeFormatter是。能升级就升级。


二、ArrayList:并发add导致“索引越界”或“元素丢失”

现象:多线程往ArrayList添加元素,报IndexOutOfBoundsException,或者size对不上

java

// 错误写法:多线程共用一个ArrayList
List<String> list = new ArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 1000; i++) {
    int finalI = i;
    executor.submit(() -> {
        list.add(String.valueOf(finalI));  // ❌ 线程不安全
    });
}
executor.shutdown();
Thread.sleep(3000);
System.out.println("size: " + list.size());  // 期望1000,实际可能<1000或报错

为什么?——ArrayList没有同步机制

add()方法内部有多个步骤:

  1. 检查容量
  2. 扩容(如果需要)
  3. 赋值
  4. 修改size

多线程同时执行,可能出现:

  • 两个线程同时读到相同的位置,互相覆盖
  • 扩容时另一个线程还在写,数组越界

解决方案(3选1)

java

// 方案1:用Vector(古老,不推荐)
List<String> list = new Vector<>();  // 方法全加锁,性能差

// 方案2:用Collections.synchronizedList(加锁包装)
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 注意:遍历时仍需手动同步
synchronized (list) {
    for (String s : list) { }
}

// 方案3:用CopyOnWriteArrayList(推荐,读多写少场景)
List<String> list = new CopyOnWriteArrayList<>();
// 写操作复制整个数组,适合读多写少

场景选择:

场景推荐原因
读多写少CopyOnWriteArrayList读无锁,写复制
写多读少Collections.synchronizedList写操作不加额外开销
普通替代ConcurrentLinkedQueue考虑用队列代替List

三、HashMap:并发put导致“死循环”(JDK 7)或“数据丢失”

现象:CPU飙到100%,或者get()永远拿不到值

java

// 错误写法:多线程共用一个HashMap
Map<String, String> map = new HashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 1000; i++) {
    int finalI = i;
    executor.submit(() -> {
        map.put("key" + finalI, "value" + finalI);  // ❌ 线程不安全
    });
}

为什么?——JDK 7的头插法导致死循环

JDK 7:  扩容时使用头插法,多线程下可能形成环形链表,get()陷入死循环,CPU 100%。

JDK 8+:  改用尾插法,不会死循环,但仍会:

  • 数据丢失(两个线程同时put,后一个覆盖前一个)
  • size不准
  • 链表结构被破坏

解决方案

java

// 方案1:用Hashtable(古老,不推荐)
Map<String, String> map = new Hashtable<>();  // 全表锁,性能差

// 方案2:用Collections.synchronizedMap(加锁包装)
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());

// 方案3:用ConcurrentHashMap(推荐,首选)
Map<String, String> map = new ConcurrentHashMap<>();
// 分段锁/JDK 8+ CAS+synchronized,性能好,线程安全

ConcurrentHashMap vs HashMap:

操作HashMapConcurrentHashMap
单线程稍慢
多线程不安全安全
扩容可能死循环(JDK7)安全扩容

记住:多线程用ConcurrentHashMap,不要有任何犹豫。


四、双重检查锁(DCL):“看似完美,实则漏洞百出”

场景:单例模式的“标准写法”

java

// ❌ 错误的多重检查锁
public class Singleton {
    private static Singleton instance;
    
    public static Singleton getInstance() {
        if (instance == null) {           // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {   // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么错?——指令重排序

instance = new Singleton() 不是原子操作,JVM会拆成三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将instance指向内存地址

指令重排序后可能变成:  1 → 3 → 2

线程A执行到3(instance已非null,但对象还没初始化),线程B进来判断instance != null,直接返回未初始化的对象,使用时崩溃。

解决方案(3种)

java

// 方案1:volatile禁止重排序(最经典)
public class Singleton {
    private static volatile Singleton instance;  // 加上volatile
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

// 方案2:静态内部类(推荐,简单且线程安全)
public class Singleton {
    private Singleton() {}
    
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

// 方案3:枚举(最简洁,Joshua Bloch推荐)
public enum Singleton {
    INSTANCE;
    // 自带线程安全、序列化安全
}

推荐度排序:

  1. 枚举(最简单)
  2. 静态内部类(经典)
  3. volatile双重检查锁(面试常问)

五、其他“我以为线程安全”的坑

坑1:StringBuilder vs StringBuffer

java

// StringBuilder:线程不安全,但单线程最快
StringBuilder sb = new StringBuilder();

// StringBuffer:线程安全(方法加synchronized),但慢
StringBuffer sb = new StringBuffer();

原则:  单线程用StringBuilder,多线程用StringBuffer或自己加锁。


坑2:volatile不能保证原子性

java

// ❌ 错误:以为volatile能让count++线程安全
private volatile int count = 0;

public void increment() {
    count++;  // 不是原子操作!等于 count = count + 1
}

count++分三步:  读→加→写,volatile只保证可见性,不保证原子性。

解决方案:

java

// 用AtomicInteger
private AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();

// 或加锁
private int count = 0;
synchronized void increment() { count++; }

六、总结速查表

类/场景线程安全?替代方案
SimpleDateFormat❌ 不安全DateTimeFormatter / ThreadLocal
ArrayList❌ 不安全CopyOnWriteArrayList / synchronizedList
HashMap❌ 不安全ConcurrentHashMap
StringBuilder❌ 不安全StringBuffer / 自己加锁
volatile count++❌ 不原子AtomicInteger / synchronized
双重检查锁(无volatile)❌ 有bug加volatile / 静态内部类 / 枚举

七、一句话口诀

text

SimpleDateFormat用ThreadLocal,
ArrayList并发用CopyOnWrite,
HashMap多线程换Concurrent,
双重检查锁记得加volatilevolatile只管可见不管原子,
原子操作找Atomic来帮忙。

八、互动一下

你因为SimpleDateFormat出过线上问题吗?

HashMap死循环听说过没见过?评论区聊聊👇


下期预告:  避坑5——数据库的“我以为走了索引”(索引失效、隐式转换、回表、深分页)


我是小李,9年Java,产假中持续输出。点个赞,收藏防丢❤️