Java中CountDownLatch与Semaphore比较

在 Java 多线程中,线程之间的有效协调对于确保正确同步和防止数据损坏至关重要。两种常用的线程协调机制是CountDownLatch和Semaphore。在本教程中,我们将探讨CountDownLatch和Semaphore之间的差异,并讨论何时使用它们。

什么是CountDownLatch?
CountDownLatch使一个或多个线程能够优雅地暂停,直到完成一组指定的任务。它通过递减计数器直到其达到零来进行操作,这表明所有先决任务都已完成。

什么是Semaphore
信号量Semaphore是一种同步工具,通过使用许可来控制对共享资源的访问。与CountDownLatch相比,信号量许可可以在整个应用程序中多次释放和获取,从而允许对并发管理进行更细粒度的控制。

CountDownLatch和Semaphore的区别
计数机制

  • CountDownLatch从初始计数开始运行,随着任务完成该计数会递减。一旦计数达到零,等待的线程就会被释放。
  • 信号量Semaphore维护一组许可,其中每个许可代表访问共享资源的权限。线程获取访问资源的许可并在完成后释放它们。

可复位性

  • 信号量许可可以多次释放和获取,从而实现动态资源管理。例如,如果我们的应用程序突然需要更多的数据库连接,我们可以释放额外的许可来动态增加可用连接的数量。
  • 而在CountDownLatch中,一旦计数达到零,就无法重置或重新用于另一个同步事件。它专为一次性用例而设计。

动态许可计数

  • 可以使用acquire()和release()方法在运行时动态调整信号量许可。这允许动态更改允许同时访问共享资源的线程数量。
  • 另一方面,一旦用计数初始化CountDownLatch ,它就保持固定并且在运行时不能更改。

公平

  • 信号量支持公平的概念,确保等待获取许可的线程按照它们到达的顺序(先进先出)获得服务。这有助于防止高争用场景中的线程饥饿。
  • 相比之下,CountDownLatch没有公平概念。它通常用于一次性同步事件,其中线程执行的特定顺序不太重要。

用例

  • CountDownLatch通常用于协调多个线程的启动、等待并行操作完成或在继续主任务之前同步系统的初始化等场景。例如,在并发数据处理应用中,CountDownLatch可以确保在数据分析开始之前完成所有数据加载任务。
  • 另一方面,信号量适合管理对共享资源的访问,实现资源池,控制对代码关键部分的访问,或限制并发数据库连接的数量。例如,在数据库连接池系统中,Semaphore可以限制并发数据库连接的数量,以防止数据库服务器不堪重负。

性能

  • 由于CountDownLatch主要涉及递减计数器,因此它在处理和资源利用方面产生的开销最小。
  • 信号量在管理许可方面带来了开销,特别是在频繁获取和释放许可时。每次调用acquire()和release()都会涉及额外的处理来管理许可计数,这可能会影响性能,尤其是在高并发的情况下。


目的:

  • CountDownLatch:同步线程直到一组任务完成
  • 信号量:控制对共享资源的访问

计数机制:

  • CountDownLatch:递减计数器
  • 信号量:管理许可证(令牌)

可复位性:

  • CountDownLatch:不可重置(一次性同步)
  • 信号量:可重置(可以多次释放和获取许可证)

动态许可计数:

  • CountDownLatch:不
  • 信号量:是(可以在运行时调整许可)


CountDownLatch实现
首先,我们创建一个CountDownLatch,其初始计数等于要完成的任务数。每个工作线程模拟一个任务,并在任务完成时使用countDown()方法减少锁存器计数。主线程使用await()方法等待所有任务完成:

int numberOfTasks = 3;
CountDownLatch latch = new CountDownLatch(numberOfTasks);
for (int i = 1; i <= numberOfTasks; i++) {
    new Thread(() -> {
        System.out.println("Task completed by Thread " + Thread.currentThread().getId());
        latch.countDown();
    }).start();
}
latch.await();
System.out.println(
"All tasks completed. Main thread proceeds.");

所有任务完成并且锁存器计数达到零后,尝试调用countDown()将无效。此外,由于锁存器计数已经为零,因此对await()的任何后续调用都会立即返回,而不会阻塞线程:

latch.countDown();
latch.await(); // This line won't block
System.out.println(
"Latch is already at zero and cannot be reset.");

现在让我们观察程序的执行并检查输出:

Task completed by Thread 11
Task completed by Thread 12
Task completed by Thread 13
All tasks completed. Main thread proceeds.
Latch is already at zero and cannot be reset.


信号量实现
在此示例中,我们创建一个具有固定数量的许可NUM_PERMITS的信号量。每个工作线程在访问资源之前通过使用acquire()方法获取许可来模拟资源访问。需要注意的一点是,当线程调用acquire()  方法获取许可时,它可能会在等待许可时被中断。因此,必须在try – catch块中捕获InterruptedException,以优雅地处理此中断。

完成资源访问后,线程使用release()方法释放许可:

int NUM_PERMITS = 3;
Semaphore semaphore = new Semaphore(NUM_PERMITS);
for (int i = 1; i <= 5; i++) {
    new Thread(() -> {
        try {
            semaphore.acquire();
            System.out.println("Thread " + Thread.currentThread().getId() + " accessing resource.");
            Thread.sleep(2000);
// Simulating resource usage
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release();
        }
    }).start();
}

接下来,我们通过释放额外的许可来模拟重置信号量,以使计数回到初始许可值。这表明信号量允许在运行时动态调整或重置:

try {
    Thread.sleep(5000);
    semaphore.release(NUM_PERMITS); // Resetting the semaphore permits to the initial count
    System.out.println(
"Semaphore permits reset to initial count.");
} catch (InterruptedException e) {
    e.printStackTrace();
}

以下是程序运行后的输出:

Thread 11 accessing resource.
Thread 12 accessing resource.
Thread 13 accessing resource.
Thread 14 accessing resource.
Thread 15 accessing resource.
Semaphore permits reset to initial count.

 结论
在本文中,我们探讨了CountDownLatch和Semaphore的关键特征。CountDownLatch非常适合在允许线程继续之前需要完成一组固定任务的场景,使其适合一次性同步事件。相比之下,信号量用于通过限制可以同时访问共享资源的线程数量来控制对共享资源的访问,从而对并发管理提供更细粒度的控制。