修改记录:2025年10月27日,新增了回调形式 ListenableFuture 的实现。
前言
在Java并发编程中,有效控制并发度是提高应用性能和资源利用率的关键。本文将通过一个Java示例代码,探讨两种控制并发度的实现方法:竞争队列法和信号量法。我们将详细分析这两种方法的实现原理,并比较它们的优缺点。为降低理解难度,本文不考虑快速失败、任务取消、异常处理、超时控制等问题,这些问题常常基于本文的代码进行拓展。
首先,让我们看看用于生成任务的 getTasks 方法:
private static BlockingQueue<Runnable> getTasks() {
IntFunction<Integer> square = x -> x * x;
BlockingQueue<Runnable> tasks = IntStream.range(0, 10).<Runnable>mapToObj(num -> () -> {
int result = square.apply(num);
System.out.println("The square of " + num + " is " + result);
}).collect(Collectors.toCollection(() -> new ArrayBlockingQueue<>(10)));
return tasks;
}
这个方法创建了一个包含10个任务的 BlockingQueue。每个任务是一个 Runnable 对象,负责计算一个数字的平方并打印结果。这个方法模拟了一个实际场景中的任务队列,每个任务都是独立的且可并行执行的。
现在,让我们讨论两种并发控制方法:
1. 竞争队列法(Race Queue Method)
static class RaceQueueDemo {
public static void main(String[] args) {
ExecutorService e = Executors.newCachedThreadPool();
int parallelism = 3; // 并发度
BlockingQueue<Runnable> tasks = getTasks();
for (int i = 0; i < parallelism; i++) {
e.submit(() -> {
Runnable r;
while ((r = tasks.poll()) != null) {
r.run();
}
});
}
e.shutdown();
}
}
这种方法的核心思想是:
- 创建一个线程池
- 定义并发度(这里是3)
- 提交与并发度相同数量的任务到线程池
- 每个任务负责从任务队列中获取并执行任务
优点:
- 实现简单,代码量少
- 任务执行效率高,同步开销低
2. 信号量法(Semaphore Method)
static class SemaphoreDemo {
public static void main(String[] args) {
ExecutorService e = Executors.newCachedThreadPool();
ExecutorService taskSubmitter = Executors.newSingleThreadExecutor();
int parallelism = 3; // 并发度
Semaphore semaphore = new Semaphore(parallelism);
Future<?> submitted = taskSubmitter.submit(() -> {
BlockingQueue<Runnable> tasks = getTasks();
Runnable r;
while ((r = tasks.poll()) != null) {
try {
semaphore.acquire();
Runnable finalR = r;
e.submit(() -> {
try {
finalR.run();
} finally {
semaphore.release();
}
});
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
});
Futures.getUnchecked(submitted);
taskSubmitter.shutdown();
e.shutdown();
}
}
这种方法的核心思想是:
- 创建一个信号量,初始许可数等于并发度
- 使用单线程执行器来提交任务
- 每次提交任务前先获取信号量许可
- 任务执行完毕后释放信号量许可
优点:
- 并发度控制更加精确
- 可以保证同时执行的任务数不超过设定的并发度
- 资源利用更加均衡
- 可以方便地处理任务执行异常的情况
缺点:
- 实现相对复杂,代码量较多
- 需要额外的线程(taskSubmitter)来管理任务提交
- 由于使用了信号量,可能会有一些同步开销
比较与总结
-
复杂性:竞争队列法实现简单,易于理解和维护;信号量法相对复杂,但提供了更细粒度的控制。
-
资源利用:信号量法能够更均衡地利用资源,特别是在任务执行时间差异较大的情况下。
-
异常处理:信号量法更容易处理任务执行过程中的异常情况,而竞争队列法在这方面略显不足。
-
灵活性:两种方法都允许动态调整并发度,但信号量法在运行时调整并发度更为方便。
-
性能开销:竞争队列法可能在高并发情况下有更好的性能,因为它减少了线程间的协调开销。
选择哪种方法取决于具体的应用场景:
- 如果需要严格控制并发度且任务执行时间差异较大,信号量法可能更合适。
- 如果追求简单实现和较高性能,并且可以容忍短暂的并发度波动,竞争队列法可能是更好的选择。
在实际应用中,可能需要结合这两种方法或使用更高级的并发控制技术来满足特定需求。无论选择哪种方法,都需要根据实际情况进行性能测试和调优,以达到最佳的并发效果。
3. 使用异步编程回调(ListenableFuture、CompletableFuture)实现
使用可回调形式 Future 也可以实现相同目的,这里以 Guava 中的 ListenableFuture 举例:
@Slf4j
public class ParallelismDemo {
private static BlockingQueue<Runnable> getTasks() {
IntFunction<Integer> square = x -> x * x;
return IntStream.range(0, 10).<Runnable>mapToObj(num -> () -> {
log.info("start {} ", num);
Uninterruptibles.sleepUninterruptibly(num, TimeUnit.SECONDS);
int result = square.apply(num);
log.info("The square of {} is {}", num, result);
}).collect(Collectors.toCollection(() -> new ArrayBlockingQueue<>(10)));
}
static class ListenableFutureDemo {
public static void main(String[] args) {
ListeningExecutorService e = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
ListeningScheduledExecutorService scheduler = MoreExecutors.listeningDecorator(new ScheduledThreadPoolExecutor(1));
int parallelism = 3; // 并发度
BlockingQueue<Runnable> tasks = getTasks();
int size = tasks.size();
List<ListenableFuture<Void>> futs = IntStream.range(0, parallelism)
.mapToObj(i -> e.<Void>submit(() -> {
Runnable r;
if ((r = tasks.poll()) != null) {
r.run();
}
return null;
}))
.collect(toList());
ImmutableList<ListenableFuture<Void>> ordered = Futures.inCompletionOrder(futs);
while (futs.size() != size) {
// submit a new task when any task completed
FluentFuture<Void> nextFut = FluentFuture.from(ordered.getFirst())
.transformAsync(__ -> e.submit(() -> {
Runnable r;
if ((r = tasks.poll()) != null) {
r.run();
}
return null;
}), scheduler);
futs.add(nextFut);
ordered = Futures.inCompletionOrder(
ImmutableList.<ListenableFuture<Void>>builder()
.addAll(ordered.subList(1, ordered.size()))
.add(nextFut)
.build()
);
}
// wait for all tasks to complete
ListenableFuture<?> allComplete = Futures.whenAllComplete(futs)
.run(() -> log.info("All tasks completed"), e);
Futures.getUnchecked(allComplete);
e.shutdown();
scheduler.shutdown();
}
}
}
输出结果如下:
12:40.961 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : start 2
12:40.961 INFO --- [pool-1-thread-2] c.e.p.ParallelismDemo : start 1
12:40.961 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : start 0
12:40.964 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : The square of 0 is 0
12:40.968 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : start 3
12:41.967 INFO --- [pool-1-thread-2] c.e.p.ParallelismDemo : The square of 1 is 1
12:41.968 INFO --- [pool-1-thread-2] c.e.p.ParallelismDemo : start 4
12:42.968 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : The square of 2 is 4
12:42.970 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : start 5
12:43.974 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : The square of 3 is 9
12:43.975 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : start 6
12:45.969 INFO --- [pool-1-thread-2] c.e.p.ParallelismDemo : The square of 4 is 16
12:45.969 INFO --- [pool-1-thread-2] c.e.p.ParallelismDemo : start 7
12:47.974 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : The square of 5 is 25
12:47.975 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : start 8
12:49.976 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : The square of 6 is 36
12:49.976 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : start 9
12:52.974 INFO --- [pool-1-thread-2] c.e.p.ParallelismDemo : The square of 7 is 49
12:55.981 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : The square of 8 is 64
12:58.979 INFO --- [pool-1-thread-1] c.e.p.ParallelismDemo : The square of 9 is 81
12:58.982 INFO --- [pool-1-thread-3] c.e.p.ParallelismDemo : All tasks completed
从输出结果可以看出,当任一任务执行完成后,即打印 The square of后,立即开启新任务,保证并发度为3。由于线程池为cached线程池,其默认超时时间为一分钟,一共创建了3个线程,并发度控制和超时时间保证了不会出现创建第4个线程的情形。