基于本文回答
0
评论

Java并发同步器与StampedLock详解

知识点图片

本文总结了Java四个核心并发工具:

  • Semaphore:控制同时访问资源的线程数量。
  • CountDownLatch:用于一次性等待,一个线程等待多个任务完成。
  • CyclicBarrier:用于多线程同步,互相等待到达屏障点。
  • StampedLock:通过乐观读优化读写锁性能。

在 Java 并发编程中,SemaphoreCyclicBarrierCountDownLatch 都是重量级的同步器,它们位于 java.util.concurrent 包(通常简称 JUC)。StampedLock 是一个较为现代的、用于优化读写锁性能的锁机制,位于 java.util.concurrent.locks 包。

下面我将详细介绍它们各自的用途、工作原理以及使用场景。


1. Semaphore (信号量)

Semaphore(信号量)用于控制同时访问特定资源的线程数量。它维护了一个内部计数器,这个计数器表示可用资源的许可数量。

核心概念

  • 许可 (Permits): 信号量维护的一组许可。线程在访问资源前必须获取许可,在完成后必须释放许可。
  • 资源限制: 如果初始化时设置了 NN 个许可,那么最多只有 NN 个线程可以同时执行获取许可后的代码块。

核心方法

方法 描述
Semaphore(int permits) 创建具有给定许可数量的信号量。
acquire() 尝试获取一个许可。如果没有可用许可,线程将阻塞直到有许可释放。
release() 释放一个许可,将其返回给信号量。这可能会唤醒一个等待中的线程。
tryAcquire() 尝试获取许可,立即返回结果,不阻塞。

工作原理与用途

信号量通常用于 资源池 的管理,例如数据库连接池、限制对高负载服务的并发请求数。

场景示例: 停车场只有 3 个空位,但有 10 辆车要停。

java
// 假设停车场只有 3 个车位
Semaphore parkingLot = new Semaphore(3);

public void enter() {
    try {
        parkingLot.acquire(); // 尝试获取车位
        System.out.println(Thread.currentThread().getName() + " 停车成功,正在停车...");
        Thread.sleep(2000); // 模拟停车时间
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        System.out.println(Thread.currentThread().getName() + " 离开,释放车位。");
        parkingLot.release(); // 释放车位
    }
}

2. CountDownLatch (倒计时门闩)

CountDownLatch 允许一个或多个线程等待,直到在其他线程中执行的一组操作完成为止。它是一个一次性的同步工具。

核心概念

  • 计数器 (Count): 初始化时设置一个正数作为计数器。
  • 等待与递减: 调用 await() 的线程会阻塞,直到计数器减到零。其他线程调用 countDown() 来递减计数器。

核心方法

方法 描述
CountDownLatch(int count) 创建一个初始化计数的门闩。
countDown() 将计数器减 1。
await() 使当前线程等待,直到计数器为零。

工作原理与用途

CountDownLatch 常用于 “主线程等待所有子任务完成” 的场景,如:

  1. 启动一个服务,等待所有必要的外部服务或资源初始化完成。
  2. 启动一个大型计算,将任务拆分为 N 个子任务,主线程等待所有 N 个子任务完成并返回结果。

场景示例: 赛跑比赛,裁判等待所有运动员都准备好。

java
final int numRunners = 5;
// 裁判等待 5 个运动员准备就绪
CountDownLatch startSignal = new CountDownLatch(numRunners);

// 运动员线程:
for (int i = 0; i < numRunners; i++) {
    new Thread(() -> {
        System.out.println("运动员 " + Thread.currentThread().getId() + " 准备好了。");
        startSignal.countDown(); // 准备好,计数减 1
    }).start();
}

// 裁判线程:
startSignal.await(); // 阻塞,直到计数器归零 (所有运动员准备就绪)
System.out.println("所有运动员准备就绪,比赛开始!");

3. CyclicBarrier (循环屏障)

CyclicBarrier(循环屏障)允许一组线程互相等待,直到所有线程都到达一个 共同的屏障点 (Barrier Point) 之后,才能继续执行。

核心概念

  • 循环性 (Cyclic): 一旦所有等待的线程都被释放,CyclicBarrier 可以被重置并再次使用(这也是它与 CountDownLatch 最主要的区别)。
  • 屏障点动作 (Barrier Action): 可以在所有线程达到屏障点后,执行一个可选的 Runnable 任务。

核心方法

方法 描述
CyclicBarrier(int parties) 创建一个需要给定数量线程才能通过的屏障。
CyclicBarrier(int parties, Runnable barrierAction) 允许在所有线程到达后执行一个动作。
await() 线程到达屏障点并等待其他线程。如果它是最后一个到达的,则屏障打开,所有等待线程被释放。

工作原理与用途

CyclicBarrier 常用于 分阶段任务迭代计算 中,例如:

  1. 并行数据处理: 将一个大文件切分成多块并行处理,在进入下一阶段处理前,需要等待所有线程完成对当前阶段块的处理。
  2. 多玩家游戏准备: 等待所有玩家进入游戏房间,然后开始游戏。

场景示例: 收集 4 个龙珠才能召唤神龙。

java
final int numDragonBalls = 4;
CyclicBarrier barrier = new CyclicBarrier(numDragonBalls, () -> {
    // 屏障点动作:当所有线程都到达时执行
    System.out.println("\n 四个龙珠集齐!召唤神龙!**");
});

for (int i = 1; i <= numDragonBalls; i++) {
    new Thread(() -> {
        try {
            System.out.println(Thread.currentThread().getName() + " 找到了一个龙珠,到达屏障点。");
            barrier.await(); // 等待其他线程
            System.out.println(Thread.currentThread().getName() + " 开始执行下一阶段任务。");
        } catch (Exception e) {
            // ...
        }
    }, "线程-" + i).start();
}

总结比较 (JUC 同步器)

特性 CountDownLatch CyclicBarrier Semaphore
用途 一个或多个线程等待 其他线程完成任务 一组线程互相等待, 到达同步点 后一起继续。 控制同时访问 共享资源的线程数量
计数器 递减,当减到 0 时释放所有等待线程。 递增(内部实现),当达到设定值时释放所有等待线程,并重置。 表示可用许可数量。acquire() 递减,release() 递增。
可重用性 一次性,计数器归零后无法再次使用。 可循环使用,屏障打开后可以重置。 可重复使用,只要有许可就可以获取。

4. StampedLock (邮戳锁)

StampedLock 是 Java 8 引入的,用于优化 ReentrantReadWriteLock(可重入读写锁)。它提供了一种乐观读机制,显著提升了在多读少写场景下的性能。

核心概念

  • 邮戳 (Stamp): StampedLock 的所有锁定操作都会返回一个 邮戳(long 类型的值)。这个邮戳用于在后续操作中验证或释放锁。
  • 三种锁定模式:
    1. 写锁 (Write Lock): 独占锁,与 ReentrantReadWriteLock 的写锁类似。
    2. 悲观读锁 (Read Lock): 共享锁,与 ReentrantReadWriteLock 的读锁类似。
    3. 乐观读 (Optimistic Read): 最核心的特性。它不阻塞写操作,线程可以“假设”没有写操作发生,并快速读取数据。

核心方法

A. 写锁 (Write Lock)

方法 描述
writeLock() 获取一个独占的写锁,返回一个邮戳。
unlockWrite(long stamp) 释放写锁。

B. 悲观读锁 (Read Lock)

方法 描述
readLock() 获取一个共享的读锁,返回一个邮戳。
unlockRead(long stamp) 释放读锁。

C. 乐观读 (Optimistic Read) - 性能优化关键

  1. tryOptimisticRead(): 尝试获取一个乐观读邮戳。如果当前没有写锁被持有,则返回一个非零邮戳。
  2. validate(long stamp): 在读取完共享变量后,使用这个方法验证邮戳是否仍然有效(即在读取过程中有没有发生写操作)。

乐观读的使用流程

  1. 获取乐观读邮戳:long stamp = lock.tryOptimisticRead();
  2. 读取共享变量。
  3. 验证邮戳:if (!lock.validate(stamp)) { ... }
  4. 如果验证失败,说明在读取过程中被其他线程写入了数据,需要回退到悲观读锁 重新获取数据。
  5. 如果验证成功,则认为读取到的数据是有效的。

为什么使用 StampedLock?

ReentrantReadWriteLock 的读锁是悲观的,当一个线程持有读锁时,其他写线程会被阻塞,直到读锁释放。

StampedLock 的乐观读机制允许写操作在读操作进行的同时发生。如果发现写入操作干扰了读取,则读线程可以优雅地回退到传统的悲观读锁,这大大减少了读线程之间的同步开销,特别适用于读多写少的场景。

注意: StampedLock 不支持重入,并且如果在使用过程中没有正确释放锁或使用了错误的邮戳,可能会导致死锁或其他并发问题,因此使用上比 ReentrantReadWriteLock 更复杂一些。

右滑查看面试常问