9年Java开发,多线程的坑我踩得最多。有些类你用了十年,以为它线程安全,结果线上崩了才发现根本不是。今天聊四个"我以为线程安全"的经典陷阱。
一、SimpleDateFormat:并发环境下"时间乱跳"
现象:线上突然出现ParseException,或者时间完全错乱
java
ini
// 错误写法:全局共享一个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
typescript
// 方案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
ini
// 错误写法:多线程共用一个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()方法内部有多个步骤:
- 检查容量
- 扩容(如果需要)
- 赋值
- 修改size
多线程同时执行,可能出现:
- 两个线程同时读到相同的位置,互相覆盖
- 扩容时另一个线程还在写,数组越界
解决方案(3选1)
java
arduino
// 方案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
ini
// 错误写法:多线程共用一个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
arduino
// 方案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:
| 操作 | HashMap | ConcurrentHashMap |
|---|---|---|
| 单线程 | 快 | 稍慢 |
| 多线程 | 不安全 | 安全 |
| 扩容 | 可能死循环(JDK7) | 安全扩容 |
记住:多线程用ConcurrentHashMap,不要有任何犹豫。
四、双重检查锁(DCL):"看似完美,实则漏洞百出"
场景:单例模式的"标准写法"
java
csharp
// ❌ 错误的多重检查锁
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会拆成三步:
- 分配内存空间
- 初始化对象
- 将instance指向内存地址
指令重排序后可能变成: 1 → 3 → 2
线程A执行到3(instance已非null,但对象还没初始化),线程B进来判断instance != null,直接返回未初始化的对象,使用时崩溃。
解决方案(3种)
java
csharp
// 方案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;
// 自带线程安全、序列化安全
}
推荐度排序:
- 枚举(最简单)
- 静态内部类(经典)
- volatile双重检查锁(面试常问)
五、其他"我以为线程安全"的坑
坑1:StringBuilder vs StringBuffer
java
ini
// StringBuilder:线程不安全,但单线程最快
StringBuilder sb = new StringBuilder();
// StringBuffer:线程安全(方法加synchronized),但慢
StringBuffer sb = new StringBuffer();
原则: 单线程用StringBuilder,多线程用StringBuffer或自己加锁。
坑2:volatile不能保证原子性
java
csharp
// ❌ 错误:以为volatile能让count++线程安全
private volatile int count = 0;
public void increment() {
count++; // 不是原子操作!等于 count = count + 1
}
count++分三步: 读→加→写,volatile只保证可见性,不保证原子性。
解决方案:
java
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
arduino
SimpleDateFormat用ThreadLocal,
ArrayList并发用CopyOnWrite,
HashMap多线程换Concurrent,
双重检查锁记得加volatile,
volatile只管可见不管原子,
原子操作找Atomic来帮忙。
八、互动一下
你因为SimpleDateFormat出过线上问题吗?
HashMap死循环听说过没见过?评论区聊聊👇
下期预告: 避坑5------数据库的"我以为走了索引"(索引失效、隐式转换、回表、深分页)
我是小李,9年Java,产假中持续输出。点个赞,收藏防丢❤️