Skip to content

基础集合与并发问题

ArrayList 是线程安全的吗?

不是。ArrayList 没有任何同步机制,多线程并发操作会产生两类问题:

  • 数据丢失:两个线程同时 add(),都读到相同 size,各自写入同一位置,其中一个被覆盖
  • 数组越界:扩容时 size 和 elementData.length 不一致,可能抛 ArrayIndexOutOfBoundsException
java
// 线程不安全 — 结果通常小于 2000
List<Integer> list = new ArrayList<>();
// 两个线程各 add 1000 次,最终 list.size() 可能是 1800、1950…

// 三种安全替代方案:
// 1. 加锁(粒度大,性能差)
List<String> sync = Collections.synchronizedList(new ArrayList<>());

// 2. CopyOnWriteArrayList(写时复制,读多写少场景)
List<String> cow = new CopyOnWriteArrayList<>();

// 3. 局部变量 + 最后汇总(无竞争,最快)

CopyOnWriteArrayList 每次写都复制整个数组,写性能差但读无锁,适合"读多写极少"的场景,如事件监听器列表。

HashMap 在多线程下有什么问题?

三大问题,严重程度从高到低:

问题版本原因
无限循环/CPU 100%JDK 7并发 resize 时链表形成环,get() 陷入死循环
数据丢失7 & 8两线程同时 put 到同一 bucket,一个被覆盖
数据不可见7 & 8没有 volatile,其他线程可能看不到最新值
java
// JDK 8 改用尾插法解决了成环问题,但数据丢失仍存在
// 生产环境绝对不能用 HashMap 做并发容器!

// 正确替代:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

JDK 7 的 HashMap 并发扩容会死循环,JDK 8 修了死循环但数据不安全问题依然存在,多线程必须用 ConcurrentHashMap。

头插法为什么会成环?

JDK 7 的 HashMap 扩容使用头插法迁移链表节点。并发 resize 时,两个线程各自持有旧链表的引用,头插法会反转链表顺序,最终形成环形链表:

初始状态

T1 暂停

T2 完成扩容

T1 第1步

T1 第2步

T1 第3步成环

JDK 8 改用尾插法,保持链表原始顺序,不会再形成环。但并发 put 丢数据的问题仍然存在。

ConcurrentHashMap 是干嘛的?

ConcurrentHashMap 是线程安全的高性能哈希表,核心思路是缩小锁的粒度,避免整个 map 加一把大锁。

JDK 7:分段锁(Segment)

把数组分成 16 个 Segment,每个 Segment 独立加锁。最多 16 个线程并发写,互不干扰。

JDK 8:CAS + synchronized

锁粒度缩小到单个桶(Node)。插入空桶用 CAS 无锁,有碰撞才 synchronized 锁住链表头节点。

ConcurrentHashMap 结构

java
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 原子性的复合操作(重要!不要自己 get 再 put)
map.putIfAbsent("key", 1);
map.computeIfAbsent("key", k -> expensiveCompute(k));
map.merge("key", 1, Integer::sum); // 原子累加

size() 不保证精确(并发修改中),get() 不需要加锁(读操作无锁)。

什么是死锁?怎么避免?

死锁是两个或多个线程互相持有对方需要的锁,导致永久阻塞。

四个必要条件

同时满足才会发生:

  • 互斥:资源同时只能被一个线程持有
  • 持有并等待:持有锁 A 的同时等待锁 B
  • 不可剥夺:锁不能被强制夺走,只能主动释放
  • 循环等待:线程 A 等 B,线程 B 等 A,形成环

触发死锁演示

触发死锁

按顺序获锁演示

按顺序获锁

避免死锁的四种方法

1. 固定加锁顺序(推荐)

所有线程都按相同顺序获取锁,从根源上打破循环等待。最简单最推荐。

java
// 不管哪个线程,永远先获取 lock1,再获取 lock2
synchronized (lock1) {
    synchronized (lock2) {
        // 安全操作
    }
}

2. tryLock 超时

获取锁失败时主动退出并释放已持有的锁,打破"持有并等待"条件。

java
// ReentrantLock 方案
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
    try {
        if (lock2.tryLock(1, TimeUnit.SECONDS)) {
            try {
                // 安全操作
            } finally { lock2.unlock(); }
        }
    } finally { lock1.unlock(); }
}

3. 减少锁粒度

缩小 synchronized 块的范围,或用 ConcurrentHashMap 代替手动加锁,减少同时持有多把锁的机会。

4. 死锁检测工具

生产环境用 jstack 命令或 JVM 自带的 ThreadMXBean 检测死锁,输出线程持锁信息。