【Java EE】JUC的常见类(Callable、ReentrantLock、Semaphore和CountDownLatch )

JUC的常见类

什么是JUC?

JUC是java.util.concurrent包的简称,它是Java并发编程的库,提供了一套经过精心设计的高性能并发工具。这个包涵盖了锁、同步器、原子变量、线程池、并发集合等方方面面,极大地降低了并发编程的难度。

Callable

为什么需要 Callable?

传统的 Runnable 接口有个明显短板:run() 方法没有返回值,也不能抛出受检异常。例子:

java 复制代码
private static int total =0;
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
        total = sum;  // 只能通过共享变量传出结果
    }
};
Thread t = new Thread(runnable);
t.start();
t.join();  // 必须等待线程结束才能安全读取 total
System.out.println("total = " + total);

这种写法有几个痛点:

  • 需要定义静态成员变量 total 来中转结果
  • 必须调用 join() 等待线程结束,否则可能读到错误值
  • 代码不够内聚,逻辑分散

Callable 接口就是为了解决这个问题而生的。

Callable 与 Runnable 的核心区别⭐

对比项 Runnable Callable
方法签名 void run() V call() throws Exception
返回值 有(泛型指定类型)
异常处理 不能抛出受检异常 可以抛出异常
配合使用 直接交给 Thread 需通过 FutureTask 包装后交给 Thread

Callable + FutureTask 的执行流程

完整流程:

java 复制代码
// 第1步:定义任务(只是定义,并未执行)
Callable<Integer> callable = new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        int result = 0;
        for (int i = 1; i <= 100; i++) {
            result += i;
        }
        return result;  // 直接返回结果
    }
};

// 第2步:包装成 FutureTask
FutureTask<Integer> futureTask = new FutureTask<>(callable);

// 第3步:交给线程执行
Thread t = new Thread(futureTask);
t.start();

// 第4步:获取结果(可能阻塞)
System.out.println(futureTask.get());  // 输出 5050
  • Callable 只是定义任务,此刻代码并未真正执行
  • FutureTask 像一个容器/取餐小票,既包装了任务,又能在未来取出结果
  • 任务的真正执行,仍需要搭配 Thread 对象调用 start()

FutureTask.get() 的阻塞机制

get 操作就是获取到 FutureTask 的返回值。这个返回值就来自于 Callablecall 方法。
get 可能会阻塞,如果当前线程执行完毕,get 拿到返回结果。

如果当前线程还没执行完毕,get 会一直阻塞,直到执行完毕。
包装
交给
调用get
Callable
FutureTask
Thread
拿到结果 ,可能发生阻塞

ReentrantLock

ReentrantLock vs synchronized 对比⭐

对比维度 synchronized ReentrantLock
本质 Java关键字(JVM内部C++实现) Java标准库中的类(java.util.concurrent.locks
加锁/解锁 代码块自动控制,退出即释放 手动调用 lock() / unlock()
释放保障 JVM保证一定释放 必须在 finally 中释放,否则容易遗漏
公平锁 只支持非公平锁 默认非公平,构造参数 true 可指定公平锁
尝试加锁 不支持,死等 tryLock() 尝试加锁,失败立即返回 false
等待通知 wait() / notify() Condition 类,功能更强大

简单场景
需要尝试加锁
需要公平锁
需要超时等待
需要精准唤醒
需要加锁
功能需求
synchronized
ReentrantLock
优点:自动释放

代码简洁

不用担心死锁
优点:功能更丰富

灵活性更高

a)本质不同:关键字 vs 类

java 复制代码
// synchronized 是语言层面的关键字
synchronized (obj) {
    // 临界区代码
}

// ReentrantLock 是标准库提供的类
ReentrantLock locker = new ReentrantLock();
locker.lock();
try {
    // 临界区代码
} finally {
    locker.unlock();
}

synchronized 由 JVM 底层 C++ 实现,而 ReentrantLock 是一个纯 Java 类。

b)加锁解锁机制不同

这是两者使用上最重要的区别:

  • synchronized:进入代码块自动加锁,退出代码块自动释放,JVM 保证不会忘记释放。
  • ReentrantLock :需要手动 lock()unlock()一旦忘记调用 unlock(),就会造成死锁
java 复制代码
// ❌ 危险写法:unlock 不在 finally 中
locker.lock();
count++;              // 万一这里抛异常,unlock 永远不执行!
locker.unlock();

// ✅ 正确写法:unlock 必须在 finally 中
locker.lock();
try {
    count++;
} finally {
    locker.unlock();  // 无论如何都会释放锁
}

c)tryLock() ------ 尝试加锁,不阻塞

这是 synchronized 完全不具备的能力:

java 复制代码
ReentrantLock locker = new ReentrantLock();

// 尝试加锁,成功返回 true,失败立即返回 false,不会阻塞等待
if (locker.tryLock()) {
    try {
        // 拿到锁,执行业务
    } finally {
        locker.unlock();
    }
} else {
    // 没拿到锁,做其他事情,而不是傻等
    System.out.println("锁被占用,先干别的");
}

synchronized 没抢到锁就阻塞死等 ,而 tryLock() 让你有了自己决定下一步做什么的空间。

d)公平锁支持

ReentrantLock 提供了公平锁的实现,默认是非公平的。

java 复制代码
// 非公平锁(默认):不保证等待最久的线程先获取锁,吞吐量高
ReentrantLock locker1 = new ReentrantLock(false);
// 或直接
ReentrantLock locker1 = new ReentrantLock();

// 公平锁:按照请求顺序排队获取锁,公平但性能稍低
ReentrantLock locker2 = new ReentrantLock(true);
锁类型 获取顺序 性能 synchronized
非公平锁 不保证先到先得 更高 仅此一种
公平锁 严格先到先得 略低 不支持

e)Condition ------ 更强大的等待通知机制

synchronized 配合的是 wait() / notify(),但 notify() 只能随机唤醒一个等待线程,无法指定唤醒哪个。

ReentrantLock 搭配的 Condition 类,可以实现精准唤醒

java 复制代码
ReentrantLock locker = new ReentrantLock();
Condition condition = locker.newCondition();

// 线程A:等待某个条件
locker.lock();
try {
    while (条件不满足) {
        condition.await();   // 类似 wait()
    }
    // 条件满足,执行业务
} finally {
    locker.unlock();
}

// 线程B:满足条件后,精准唤醒线程A
locker.lock();
try {
    // 修改条件
    condition.signal();      // 类似 notify(),但可以指定唤醒对象
} finally {
    locker.unlock();
}

一个 ReentrantLock 可以创建多个 Condition 对象,实现多路精准唤醒,这是 wait/notify 做不到的。

Semaphore

什么是信号量?

信号量的核心思想就是一句话:

如果是普通的 N 的信号量,就可以限制同时有多少个线程来执行某个逻辑。

它本质上是一个计数器,用来控制同时访问某个资源的线程数量。

核心概念:PV 操作⭐

信号量有两个核心操作,

P操作 (acquire) V操作 (release)
申请资源,计数器-1 计数器为0时阻塞等待 释放资源,计数器+1 唤醒等待的线程

生活比喻:停车场

java 复制代码
// 停车场只有 4 个车位
Semaphore semaphore = new Semaphore(4);

semaphore.acquire();  // P操作:开进一辆车,剩余车位 -1
try {
    // 车停在停车场内(访问受限资源)
} finally {
    semaphore.release();  // V操作:开走一辆车,剩余车位 +1
}

结果
线程申请
初始状态
许可证 +1
🗳️ 许可证 ×4
acquire()

拿走1张
acquire()

拿走1张
acquire()

拿走1张
acquire()

拿走1张
acquire()

还想拿...
✅ 执行
✅ 执行
✅ 执行
✅ 执行
⏳ 阻塞等待
release() 归还

信号量退化为互斥锁⭐

当许可数为 1 时,信号量退化为互斥锁(二元信号量)

java 复制代码
Semaphore semaphore = new Semaphore(1);  // 只有 1 个许可

这就等价于一把锁:

java 复制代码
// 这两个写法效果相同:

// 写法1:Semaphore 当互斥锁
semaphore.acquire();
count++;
semaphore.release();

// 写法2:synchronized
synchronized (lock) {
    count++;
}

执行流程示意:
count 变量 线程2 🔒 许可证(1个) 线程1 count 变量 线程2 🔒 许可证(1个) 线程1 初始有 1 张许可证 ... 交替执行 100000 次 ... acquire() 申请许可 ✅ 拿到许可 count++ (50000次中的第1次) release() 归还许可 acquire() 申请许可 ✅ 拿到许可 count++ (50000次中的第1次) release() 归还许可 acquire() 申请许可 ✅ 拿到许可 count++ (第2次) release() 归还许可 acquire() 申请许可 ✅ 拿到许可 count++ (第2次) release() 归还许可

信号量的广义理解⭐

信号量是一个广义的锁
🔢 信号量 Semaphore

广义的锁
new Semaphore(1)
new Semaphore(2)
new Semaphore(N)
🔒 互斥锁

(二元信号量)
同一时刻只有 1 个线程

可以访问资源
等价于 synchronized
🔐 限流器

(通用信号量)
同一时刻最多 2 个线程

可以访问资源
🔐 限流器

(通用信号量)
同一时刻最多 N 个线程

可以访问资源

CountDownLatch:倒计时协调器

CountDownLatch 是什么?⭐

三步核心流程:

  1. 构造方法指定参数,描述拆成了多少个任务。
  2. 每个任务执行完毕之后,都调用一次 countDown() 方法。
  1. 主线程中调用 await() 方法,等待所有任务执行完毕。


    构造方法

new CountDownLatch(N)
子任务完成

调用 countDown()

计数器 -1
计数器 == 0 ?
await() 返回

主线程继续执行

它就像一个倒计数器------初始设定一个数字,每完成一个任务减 1,减到 0 时,等待的线程就被唤醒继续执行。

CountDownLatch与线程池⭐

java 复制代码
// 第1步:创建倒计数器,初始值 = 10(表示有 10 个子任务)
CountDownLatch latch = new CountDownLatch(10);

// 第2步:创建线程池(4 个线程执行 10 个任务)
ExecutorService executor = Executors.newFixedThreadPool(4);

// 第3步:提交 10 个子任务
for (int i = 0; i < 10; i++) {
    int id = i;
    executor.submit(() -> {
        System.out.println("子任务开始执行: " + id);
        Thread.sleep(1000);                    // 模拟耗时操作
        System.out.println("子任务结束执行: " + id);
        latch.countDown();                     // ⬅ 完成一个,计数器 -1
    });
}

// 第4步:主线程等待,直到计数器归零
latch.await();  // 阻塞,直到 10 个任务全部完成
System.out.println("所有任务执行完毕");
executor.shutdown();

执行流程图:
线程池(4线程) CountDownLatch(10) 主线程 线程池(4线程) CountDownLatch(10) 主线程 loop 10 次 new CountDownLatch(10) 提交 10 个子任务 await() ⏳ 阻塞等待 执行子任务 countDown() 计数器 -1 ✅ 计数器归零,唤醒主线程 打印 "所有任务执行完毕"

方法 调用者 作用 特点
new CountDownLatch(N) 主线程 设定倒计数的初始值 N = 子任务数量
countDown() 子线程 计数器 -1 原子操作,线程安全
await() 主线程 阻塞等待计数器归零 归零后立即返回

关键特性

1. CountDownLatch一次性使用

java 复制代码
// ❌ CountDownLatch 是一次性的,计数器归零后不能重置
latch.await();  // 等待通过
latch.await();  // 第二次调用立即返回(已经归零了),不会重新倒计数

如果需要可重用,应该用 CyclicBarrierPhaser

2. 一对多等待 vs 多对一等待

java 复制代码
// 场景1:主线程等待多个子线程(一对多)
CountDownLatch latch = new CountDownLatch(5);
// 5 个子线程各执行 countDown()
// 主线程 await()
// → 最常用的场景

// 场景2:多个线程等待一个信号(多对一)
CountDownLatch startSignal = new CountDownLatch(1);
// 5 个子线程都调用 await()
// 主线程调用 countDown()
// → 5 个子线程同时开始执行(发令枪模式)

3. 计数器归零后,await 永远不会阻塞

java 复制代码
CountDownLatch latch = new CountDownLatch(3);
latch.countDown();
latch.countDown();
latch.countDown();
// 归零了
latch.await();  // 立即返回,不会阻塞
latch.await();  // 再调用也是立即返回
latch.await();  // 调用多少次都是立即返回

线程安全集合类

多线程使用 List

多线程使用 List
方式1:手动加锁

synchronized / ReentrantLock
方式2:包装

Collections.synchronizedList()
方式3:写时拷贝

CopyOnWriteArrayList

方式 读性能 写性能 适用场景
手动加锁 一般 一般 通用
synchronizedList 一般 一般 通用
CopyOnWriteArrayList ⭐极高 差(每次复制全数组) 读多写少

a)手动加锁

java 复制代码
// 自己用 synchronized 或 ReentrantLock 保护
synchronized (list) {
    list.add("item");
}

前面 synchronizedReentrantLock 章节已经详细讨论过,不再展开。

b)Collections.synchronizedList

java 复制代码
List<String> list = Collections.synchronizedList(new ArrayList<>());
  • 标准库提供的线程安全包装
  • 关键操作(addgetremove 等)上都自动加了 synchronized
  • 本质上就是对方法套了一层 synchronized

c)CopyOnWriteArrayList(写时拷贝)

核心策略是写时复制。 工作原理:
① 复制
② 修改
③ 引用原子切换
切换前
切换后
旧数组

A,B,C,D

副本

A,B,C,D

新数组

A,X,C,D

读线程始终读引用指向的数组

时间线序列图(读线程视角)
数组引用 写线程 读线程2 读线程1 数组引用 写线程 读线程2 读线程1 初始指向 A, B, C, D 引用仍指向旧数组 整个过程中不会读到 A, X, C, D 这种修改一半的数据 读取 返回 B ✅ ① 复制整个数组 副本 A, B, C, D 读取(写操作进行中) 返回 C ✅ 读到的是旧数组 ② 副本修改为 A, X, C, D 读取 返回 D ✅ 还是旧数组 ③ 原子替换引用 读取 返回 X ✅ 读到新数组了

适用场景:读多写少

eg.服务器配置热加载(reload)

java 复制代码
// 服务器正在运行,需要修改配置
// 正常来说,修改配置文件需要重启服务器
// 但很多服务器提供 reload 功能

// 配置以数组/哈希形式存在内存中
// 其他线程频繁读取配置(只读不写)
// reload 时:
//   ① 创建新的数组/哈希,加载新配置
//   ② 加载完毕,替换引用(原子操作)
//   ③ 读线程要么读到旧配置,要么读到新配置,不会读到一半新一半旧

多线程使用队列(阻塞队列)

阻塞队列 BlockingQueue
ArrayBlockingQueue

基于数组实现
LinkedBlockingQueue

基于链表实现
PriorityBlockingQueue

基于堆,带优先级
TransferQueue

最多只包含一个元素

队列类型 底层实现 特点
ArrayBlockingQueue 数组 有界,容量固定
LinkedBlockingQueue 链表 可有界可无界
PriorityBlockingQueue 元素有优先级
TransferQueue --- 最多 1 个元素,生产者直接交给消费者

多线程使用哈希表

多线程使用哈希表
Hashtable
ConcurrentHashMap
ConcurrentHashMap
桶3
🔒 锁3
数据
桶2
🔒 锁2
数据
桶1
🔒 锁1
数据
桶0
🔒 锁0
数据
Hashtable
🔒 全局锁 this
桶0
桶1
桶2
桶3
⚠️ Hashtable:不管操作哪个桶

都要先抢这把锁

不同桶之间也互斥
✅ ConcurrentHashMap:操作桶0 只锁桶0

操作桶1 只锁桶1

不同桶之间互不影响

Hashtable ConcurrentHashMap
锁粒度 锁整个表 锁单个桶
不同桶操作 互斥等待 并行执行
锁对象 this 每个链表的头结点

a)Hashtable ------ 简单粗暴的线程安全

多线程同时 put,可能导致数据丢失、链表成环(JDK7)等问题。

java 复制代码
// Hashtable:所有 public 方法都加 synchronized(this)
// 等同于全局一把锁

synchronized(this) {
    put(key1, val1);  // 操作桶1
}
synchronized(this) {
    put(key2, val2);  // 操作桶2(不同桶也要竞争同一把锁!)
}
复制代码
Hashtable 的锁对象是 this(整个表)

┌─────────────────────────┐
│      Hashtable          │
│  ┌──────────────┐       │
│  │  🔒 this     │       │  ← 整个表只有一把锁
│  │  ┌───┐┌───┐  │       │
│  │  │桶0││桶1│  │       │
│  │  └───┘└───┘  │       │  操作桶0 和 桶1 也会互斥
│  │  ┌───┐┌───┐  │       │  即使它们在不同的链表上
│  │  │桶2││桶3│  │       │
│  │  └───┘└───┘  │       │
│  └──────────────┘       │
└─────────────────────────┘

任意两个线程,访问任意的两个不同元素,都会产生锁竞争!

b)ConcurrentHashMap ⭐

ConcurrentHashMap 放弃了全局锁,采用了化整为零的思想。

优化1:锁桶(锁粒度变小)

如果修改的两个元素在不同链表上,本身就不涉及线程安全问题。

直接使用每个链表的头结点作为锁对象就行了。
场景2:操作相同桶
acquire()
acquire()
🔵 线程A
🔒 锁0 桶0
🟠 线程B
✅ 线程A 拿到锁,执行
⏳ 线程B 阻塞等待
同一把锁 → 只有一个能拿到
场景1:操作不同桶
acquire()
acquire()
🔵 线程A
🔒 锁0 桶0
🟠 线程B
🔒 锁1 桶1
✅ 拿到锁,执行
✅ 拿到锁,执行
两把不同的锁 → 并行,无竞争

优化2:CAS 维护 size

想象一个场景:线程 A 正在往 ConcurrentHashMap 里添加元素,此时线程 B 想查询 map.size()

如果没有 CAS,要保证 size 正确,就得这样写:

java 复制代码
// ❌ 低效做法:对整个表加锁才能统计 size
public int size() {
    synchronized (this) {  // 全局锁!和所有写操作互斥
        return count;
    }
}

public void put(K key, V value) {
    synchronized (this) {  // 全局锁!和 size() 互斥
        // 插入元素
        count++;
    }
}

后果 :读大小和写操作互相阻塞,性能极差。这又退化成 Hashtable 了。

CAS(Compare And Swap,比较并交换) 是一条 CPU 原子指令,不用加锁就能实现先检查,再更新的线程安全操作。

CAS 维护 size 就是:修改 size 时不上锁,而是用 CPU 原子指令"比较并交换"。成功了最好;失败了说明别人抢先改了,那就重读新值再试一次。这样读写 size 永远不会阻塞,性能远优于对整个表加锁。

假设当前 size = 10,两个线程同时添加元素:
线程2 (期望+1) size (当前=10) 线程1 (期望+1) 线程2 (期望+1) size (当前=10) 线程1 (期望+1) size 变成了 11 最终 size = 12,正确! CAS(10 → 11) 检查:当前是10吗? ✅ 是10,更新为11,成功! CAS(10 → 11) 检查:当前是10吗? ❌ 已经是11了,不是10,失败! 重新读取 size = 11 CAS(11 → 12) 检查:当前是11吗? ✅ 是11,更新为12,成功!

整个过程没有加锁 ,但 size 从 10 正确地变成了 12。

优化3:渐进式扩容(化整为零)⭐

✅ 渐进式扩容
触发扩容
创建新数组

标记'正在扩容'
每次 put / get 时

顺手搬几个桶
多次操作后

全部搬完
搬运期间:

只锁当前搬运的桶

其他桶正常读写
❌ 传统扩容
触发扩容
锁住整张表
一口气搬完所有元素
释放锁
搬运期间:

所有 put / get 全部阻塞

如果元素很多 → 长时间等待

用时间线的形式对比两种扩容方式对线程的影响
0 0 0 0 0 0 0 0 0 0 0 0 0 🔒 全表加锁 所有线程阻塞 🔒 锁桶0 搬A ✅ 桶0-3 可访问 🔒 锁桶1 搬B ✅ 桶0,2,3 可访问 🔒 锁桶2 搬C ✅ 桶0,1,3 可访问 🔒 锁桶3 搬D ✅ 全部桶可访问 ✅ 其他线程可操作 ❌ 传统扩容 ✅ 渐进式扩容 扩容期间线程阻塞时间对比

java 复制代码
// ❌ 传统扩容的逻辑(伪代码)
void resize() {
    lock(整个表);                // 锁所有桶
    newTable = new Table[新长度];  // 建新表
    for (每个元素 in 旧表) {
        搬运到新表;                // 一口气全搬
    }
    table = newTable;             // 切换引用
    unlock(整个表);               // 搬完才放锁
}
// 问题:元素多 → 锁持有时间长 → 所有线程阻塞

// ✅ ConcurrentHashMap 渐进式扩容(伪代码)
void put(key, value) {
    if (正在扩容) {
        帮忙搬一个桶();           // 顺手搬一点!
    }
    // 正常执行 put 逻辑
}

void get(key) {
    if (正在扩容) {
        帮忙搬一个桶();           // 读操作也帮忙搬!
    }
    // 正常执行 get 逻辑
}
// 好处:每次只锁一个桶搬,搬完就释放,其他桶不受影响

原子类

原子类CAS

线程池

线程池

相关推荐
树若逝花若殇2 分钟前
基于Spring AOP优雅记录请求响应日志
java·spring boot
Yyyyyy~29 分钟前
【java】数组的定义和使用
java
麻辣布丁30 分钟前
java集合篇面试模拟
java·开发语言·面试
niucloud-admin31 分钟前
JAVA V6 多商户商城 开发文档——插件开发规范
java·开发语言
嘟哩DuliDuli44 分钟前
创作 Agent 的任务状态该如何保存
android·java·javascript
wdfk_prog1 小时前
lely-canopen-rtt:为什么推荐在 RT-Thread 上使用 Lely CANopen 构建主站
java·运维·缓存·docker·容器
Token掘金室1 小时前
JSON模式结构化输出报错
java·服务器·json
秋饼1 小时前
Spring AI 2.0 接入 DeepSeek V4.1 Flash 生产级实战
java·ai·技术分享·后端开发
IT枫斗者枫哥1 小时前
Java 分批导出仍然 OOM?用 32 MiB 堆复现三种 CSV 写法
java
shehuiyuelaiyuehao1 小时前
算法43,外观数列,模拟算法+双指针
java·算法