【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

线程池

线程池

相关推荐
RuoyiOffice1 小时前
2026 年开源 BPM/工作流引擎大盘点:Flowable vs Camunda vs Activiti vs Turbo——谁才是企业级首选?
java·spring boot·后端·开源·流程图·ruoyi·anti-design-vue
SamDeepThinking1 小时前
别把业务逻辑塞进存储过程,适当用表驱动法
java·后端·架构
HZY1618yzh1 小时前
洛谷题解:P16304 [蓝桥杯 2026 省 Java C 组] 抽奖活动
java·c++·算法·蓝桥杯
java1234_小锋2 小时前
Spring AI 2.0 开发Java Agent智能体 - Advisors —— 拦截器模式增强AI能力
java·人工智能·spring·ai·spring ai2.0
Komore3152 小时前
商户查询缓存
java·redis·缓存
ch.ju2 小时前
Java程序设计(第3版)第二章——函数的返回值
java
架构源启2 小时前
OpenClaw 只能命令行触发?自研企业微信实现发消息即执行
java·chrome·自动化·企业微信
逻辑驱动的ken2 小时前
Java高频面试考点场景题22
java·开发语言·jvm·面试·职场和发展·求职招聘·春招
小则又沐风a2 小时前
list模拟实现
java·服务器·list