Java多线程基础(3)

Java 多线程基础(3)

作者:没有四次元口袋的蓝胖

日期:2026-06-26

标签:Java, 多线程


一、并发集合

1.1 ConcurrentHashMap ⭐⭐⭐

线程安全的 HashMap,面试必问。

JDK 1.7 实现:Segment 分段锁

  • 把数据分成16个段(Segment),每个段有自己的 ReentrantLock
  • 不同段可以并发访问
  • 并发度默认16

JDK 1.8 实现:CAS + synchronized

  • 放弃分段锁,使用 Node 数组 + CAS + synchronized
  • 锁粒度更细:只锁当前桶(桶为空用 CAS 插入,非空用 synchronized)
  • 引入红黑树优化查询(链表长度 > 8 转红黑树)
java 复制代码
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", 1);
map.get("key");

// 原子操作(线程安全)
map.putIfAbsent("key", 1);           // 不存在才放入
map.computeIfAbsent("key", k -> 1);  // 不存在才计算并放入(推荐)

ConcurrentHashMap 不允许 key 或 value 为 null! 这是设计决策,避免二义性(null 代表不存在还是值就是 null?)。

1.2 CopyOnWriteArrayList

写时复制的线程安全 List:

java 复制代码
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");

原理:

  • 读操作不加锁,直接读原数组
  • 写操作(add/set/remove)加锁,复制一份新数组,修改新数组,再替换引用
  • 读写不互斥,写写互斥

适用场景: 读多写少(如白名单、配置列表、监听器列表)

缺点:

  • 写操作要复制整个数组,内存开销大
  • 数据一致性是最终一致,不是实时的
  • 不适合写频繁的场景

1.3 ConcurrentLinkedQueue

线程安全的无界队列(非阻塞):

java 复制代码
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("A");
queue.offer("B");
String item = queue.poll(); // 取出并移除

使用 CAS 实现,不需要加锁,性能高。适合生产者-消费者场景但不需要阻塞等待时。

1.4 BlockingQueue(阻塞队列)

线程池的核心组件,也是生产者-消费者模式的关键:

实现类 特点
ArrayBlockingQueue 有界数组队列,必须指定容量
LinkedBlockingQueue 可选有界链表队列,默认 Integer.MAX_VALUE
PriorityBlockingQueue 无界优先级队列
DelayQueue 延迟队列,元素到期才能取出
SynchronousQueue 不存储元素,直接手递手传递
java 复制代码
// 常用操作
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
queue.put("A");     // 满了则阻塞
queue.take();       // 空了则阻塞
queue.offer("B", 3, TimeUnit.SECONDS); // 超时尝试
queue.poll(3, TimeUnit.SECONDS);       // 超时尝试

SynchronousQueue 的特殊性:

java 复制代码
SynchronousQueue<String> sq = new SynchronousQueue<>();
// put 会阻塞,直到另一个线程调用 take
// 没有容量,每个 put 必须等待一个 take,反之亦然
// 适合"手递手"传递场景(如 CachedThreadPool 就用它)

1.5 并发集合对比

集合 线程安全 底层实现 特点
ConcurrentHashMap CAS + synchronized 高性能,不允许 null
Hashtable synchronized 全表锁 性能差,不允许 null
Collections.synchronizedMap 包装 + 全表锁 性能差
CopyOnWriteArrayList 写时复制 读多写少场景
ConcurrentLinkedQueue CAS 无锁 非阻塞队列

📌 常见坑点与面试题

Q:ConcurrentHashMap 为什么不允许 null?

并发环境下,get 返回 null 无法区分是"key 不存在"还是"value 是 null"。HashMap 允许 null 是因为单线程可以自行判断,并发场景不行。

Q:ConcurrentHashMap 在 JDK 1.7 和 1.8 的区别?

  • 1.7:Segment 分段锁(ReentrantLock),并发度默认16
  • 1.8:Node 数组 + CAS + synchronized,锁粒度细化到单个桶,引入红黑树优化长链表(>8转树)

Q:CopyOnWriteArrayList 的缺点是什么?

  1. 写操作复制整个数组,内存占用翻倍
  2. 写的数据一致性是最终一致,不是实时的
  3. 只适合读多写少的场景

二、原子类(Atomic)

2.1 为什么需要原子类?

synchronized 虽然能保证原子性,但开销大(涉及线程阻塞、唤醒)。原子类利用 CAS(Compare-And-Swap)实现无锁的线程安全操作,性能更高。

2.2 CAS 原理

复制代码
CAS(内存值V, 预期值A, 新值B)
→ 如果 V == A,则 V = B,返回 true
→ 如果 V != A,说明被别人改了,返回 false(不修改,可以重试)

特点: 比较并交换,一条 CPU 指令完成(cmpxchg),不需要加锁。

CAS 的三大问题:

  1. ABA 问题:值从 A → B → A,CAS 认为没变过
  2. 自旋开销:长时间失败会一直循环,消耗 CPU
  3. 只能保证一个变量的原子操作:多个变量需要用锁或 AtomicReference

2.3 常用原子类

java 复制代码
// 1. AtomicInteger ------ 原子整数
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();   // ++count,返回新值
count.getAndIncrement();   // count++,返回旧值
count.addAndGet(5);        // count += 5
count.decrementAndGet();   // --count
int val = count.get();     // 获取值

// 2. AtomicLong ------ 原子长整型
AtomicLong id = new AtomicLong(0);
id.incrementAndGet();

// 3. AtomicBoolean ------ 原子布尔
AtomicBoolean flag = new AtomicBoolean(false);
flag.compareAndSet(false, true); // CAS 操作

// 4. AtomicReference ------ 原子引用
AtomicReference<User> ref = new AtomicReference<>(new User("张三"));
ref.compareAndSet(oldUser, newUser);

2.4 原子数组

java 复制代码
AtomicIntegerArray arr = new AtomicIntegerArray(10);
arr.getAndIncrement(0);         // arr[0]++
arr.compareAndSet(1, 5, 10);   // arr[1] == 5 则改为 10

2.5 解决 ABA 问题

java 复制代码
// AtomicStampedReference:带版本号的原子引用
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0);
int stamp = ref.getStamp(); // 获取版本号

// CAS 时同时比较值和版本号
boolean success = ref.compareAndSet(1, 2, stamp, stamp + 1);

原理: 每次修改版本号+1,即使值从 A→B→A,版本号已经变了,CAS 能检测出来。

2.6 LongAdder(高并发计数器)

java 复制代码
LongAdder adder = new LongAdder();
adder.increment();
adder.add(10);
long sum = adder.sum(); // 获取总和

比 AtomicLong 性能好的原因:

  • 内部将一个值分散到多个 Cell 中,不同线程写不同的 Cell,减少竞争
  • 最终 sum() 时汇总所有 Cell
  • 牺牲空间换时间,适合高并发统计场景

📌 常见坑点与面试题

Q:CAS 是什么?有什么问题?

CAS = Compare And Swap,比较并交换,是一种无锁算法。问题:

  1. ABA 问题(用 AtomicStampedReference 解决)
  2. 自旋时间长,CPU 开销大
  3. 只能保证一个变量的原子操作

Q:AtomicInteger 的 incrementAndGet 是线程安全的吗?

是的。底层用 CAS + 自旋实现,不需要加锁,比 synchronized 性能更好。

Q:LongAdder 和 AtomicLong 怎么选?

  • 低并发:AtomicLong 够用
  • 高并发统计场景:LongAdder 更好(分散竞争,减少 CAS 冲突)

三、线程局部变量(ThreadLocal)

3.1 什么是 ThreadLocal?

为每个线程提供独立的变量副本,各线程之间互不影响。

java 复制代码
ThreadLocal<String> threadLocal = new ThreadLocal<>();

// 线程A
threadLocal.set("线程A的数据");
System.out.println(threadLocal.get()); // "线程A的数据"

// 线程B
threadLocal.set("线程B的数据");
System.out.println(threadLocal.get()); // "线程B的数据"

3.2 常用方法

java 复制代码
// 设置值
threadLocal.set(value);

// 获取值
Object val = threadLocal.get();

// 删除值(⚠️ 必须手动调用!)
threadLocal.remove();

// 带初始值(方式一:匿名内部类)
ThreadLocal<List<String>> listHolder = new ThreadLocal<List<String>>() {
    @Override
    protected List<String> initialValue() {
        return new ArrayList<>();
    }
};

// 带初始值(方式二:Java 8+ Lambda)
ThreadLocal<SimpleDateFormat> sdfHolder = ThreadLocal.withInitial(
    () -> new SimpleDateFormat("yyyy-MM-dd")
);

3.3 典型应用场景

场景1:保存用户登录信息(Web 开发最常用)

java 复制代码
// 过滤器中设置
public class LoginFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        String token = req.getHeader("token");
        User user = parseToken(token);
        UserContext.set(user); // 存入 ThreadLocal
        try {
            chain.doFilter(req, resp);
        } finally {
            UserContext.remove(); // ⚠️ 请求结束必须清除!
        }
    }
}

// 业务代码中直接获取
public class OrderService {
    public void createOrder() {
        User currentUser = UserContext.get(); // 直接拿到当前用户
        // 不需要层层传递 user 参数
    }
}

// ThreadLocal 工具类
public class UserContext {
    private static final ThreadLocal<User> HOLDER = new ThreadLocal<>();
    public static void set(User user) { HOLDER.set(user); }
    public static User get() { return HOLDER.get(); }
    public static void remove() { HOLDER.remove(); }
}

场景2:SimpleDateFormat 线程安全问题

java 复制代码
// SimpleDateFormat 不是线程安全的!
// 用 ThreadLocal 给每个线程一个副本
private static final ThreadLocal<SimpleDateFormat> SDF = ThreadLocal.withInitial(
    () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);

// 使用时
String formatted = SDF.get().format(new Date());

场景3:数据库连接 / Session 管理

java 复制代码
// 每个线程持有自己的数据库连接
private static final ThreadLocal<Connection> connHolder = new ThreadLocal<>();

3.4 原理简述

复制代码
Thread 对象
   └── ThreadLocalMap(Thread 内部类)
          ├── key: ThreadLocal 对象的弱引用
          └── value: 你 set 进去的值(强引用)

每个 Thread 内部有一个 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是你存入的值。调用 threadLocal.set(value) 时,实际是把值存到当前线程的 map 里。

3.5 内存泄漏问题 ⭐⭐⭐

问题: ThreadLocalMap 的 key 是 ThreadLocal 的弱引用 。如果 ThreadLocal 对象被 GC 回收,key 变成 null,但 value 还被 ThreadLocalMap 强引用着,导致 value 无法回收 → 内存泄漏

复制代码
正常情况:ThreadLocal(强引用) → key(弱引用) → value
GC后:    ThreadLocal 被回收 → key=null → value 还在!

解决方案:用完必须调用 remove()!

java 复制代码
try {
    threadLocal.set(value);
    // 业务逻辑
} finally {
    threadLocal.remove(); // ⚠️ 必须清理!
}

在线程池中尤其危险: 线程被复用,如果不清理,上一个任务的值会"泄漏"到下一个任务,导致数据错乱。

3.6 InheritableThreadLocal

父线程的值可以传递给子线程:

java 复制代码
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
itl.set("父线程的值");

new Thread(() -> {
    System.out.println(itl.get()); // "父线程的值"
}).start();

但在线程池中不好用(线程是预先创建好的,不是 new 出来的),推荐用 TransmittableThreadLocal(阿里开源 Ttl 框架)。

📌 常见坑点与面试题

Q:ThreadLocal 为什么会内存泄漏?怎么避免?

ThreadLocalMap 的 key 是弱引用,ThreadLocal 被回收后 key 为 null,但 value 还被强引用。如果线程不销毁(如线程池),value 就一直存在。解决方案:每次用完在 finally 中调用 remove()。

Q:ThreadLocal 和 synchronized 的区别?

  • ThreadLocal:通过"空间换时间",每个线程一份副本,互不干扰
  • synchronized:通过"时间换空间",加锁让线程排队访问
  • 两者目的不同:ThreadLocal 解决数据隔离 ,synchronized 解决数据共享的安全访问

Q:SimpleDateFormat 为什么不是线程安全的?

内部有共享的 Calendar 字段,多个线程同时调用 format() 会互相修改 Calendar 的值。解决方案:用 ThreadLocal 给每个线程一个副本,或者用 Java 8 的 DateTimeFormatter(线程安全)。

Q:线程池中用 ThreadLocal 要注意什么?

线程池中的线程是复用的,任务完成后必须 remove(),否则上一个任务的数据会泄漏到下一个任务。


四、思维导图速览

复制代码
并发工具
├── 并发集合
│   ├── ConcurrentHashMap
│   │   ├── JDK 1.7:Segment 分段锁
│   │   └── JDK 1.8:CAS + synchronized + 红黑树
│   ├── CopyOnWriteArrayList(写时复制,读多写少)
│   ├── ConcurrentLinkedQueue(CAS 无锁队列)
│   └── BlockingQueue
│       ├── ArrayBlockingQueue(有界)
│       ├── LinkedBlockingQueue(可选有界)
│       └── SynchronousQueue(不存储,手递手)
├── 原子类
│   ├── CAS 原理(Compare-And-Swap)
│   ├── AtomicInteger / AtomicLong / AtomicBoolean
│   ├── ABA 问题 → AtomicStampedReference
│   └── LongAdder(高并发计数,分散竞争)
└── ThreadLocal
    ├── 每个线程独立副本
    ├── 典型场景:用户信息、SimpleDateFormat、数据库连接
    ├── 原理:Thread → ThreadLocalMap → (key弱引用, value强引用)
    ├── 内存泄漏 → 必须 remove()
    └── InheritableThreadLocal(父子传递)

五、写在最后

  1. 并发集合:ConcurrentHashMap 的 1.7 vs 1.8 实现区别是高频题,CopyOnWriteArrayList 的写时复制思想要理解
  2. 原子类:理解 CAS 原理和 ABA 问题是关键,LongAdder 的分散竞争思想值得了解
  3. ThreadLocal:内存泄漏的原因和解决方案是必考项,实际开发中保存用户信息是最常见的应用场景
  4. 面试高频排序:ConcurrentHashMap 原理 > ThreadLocal 内存泄漏 > CAS/ABA > CopyOnWriteArrayList 原理