Java Semaphore 实战指南:用许可证控制并发、限流和资源池

简介

Semaphore 是 Java 并发包 java.util.concurrent 里的同步工具。

它的中文名一般叫信号量。

最容易理解的说法是:

text 复制代码
Semaphore 像一组许可证。
线程先拿许可证,拿到后才能继续执行。
执行完再归还许可证。
许可证用完后,后面的线程等待或直接失败。

比如:

java 复制代码
Semaphore semaphore = new Semaphore(3);

表示一共有 3 个许可证。

同一时刻最多 3 个线程能拿到许可证并进入受保护的代码段。

一句话概括:

text 复制代码
Semaphore 适合控制并发数量,比如接口并发限制、资源池、连接数控制、批量任务限流。

Semaphore 解决什么问题

一个接口可能会访问下游资源:

text 复制代码
HTTP 请求
  |
  v
订单接口
  |
  v
数据库 / Redis / 第三方接口

如果瞬间来了很多请求,全部直接打到下游,可能出现:

text 复制代码
数据库连接耗尽
第三方接口超时
线程池队列堆积
CPU 上下文切换变多
服务响应变慢

Semaphore 可以在入口处加一道并发闸门:

text 复制代码
1000 个请求
  |
  v
Semaphore(50)
  |
  +--> 同一时刻最多 50 个请求进入业务逻辑
  +--> 其他请求等待、超时或直接返回繁忙

它控制的是"同时正在执行的数量"。

这点和按时间窗口统计 QPS 的限流器不一样。

核心模型:许可证

Semaphore 内部维护一个许可数量。

java 复制代码
Semaphore semaphore = new Semaphore(2);

初始状态:

text 复制代码
permits = 2

线程 A 调用:

java 复制代码
semaphore.acquire();

拿走 1 个许可:

text 复制代码
permits = 1

线程 B 再拿走 1 个:

text 复制代码
permits = 0

线程 C 再调用 acquire()

text 复制代码
没有可用许可,线程 C 阻塞等待

当线程 A 执行完:

java 复制代码
semaphore.release();

归还 1 个许可:

text 复制代码
permits = 1

等待中的线程就有机会继续执行。

常用 API

方法 说明
acquire() 获取 1 个许可,没有许可就阻塞,可响应中断
acquire(int permits) 一次获取多个许可
acquireUninterruptibly() 获取 1 个许可,阻塞时不响应中断
tryAcquire() 尝试获取 1 个许可,立即返回成功或失败
tryAcquire(timeout, unit) 在指定时间内尝试获取许可
release() 释放 1 个许可
release(int permits) 释放多个许可
availablePermits() 查看当前可用许可数量
getQueueLength() 估算正在等待许可的线程数量
hasQueuedThreads() 判断是否有线程在等待
drainPermits() 一次性拿走当前所有可用许可

最常用的是:

text 复制代码
acquire()
tryAcquire()
tryAcquire(timeout, unit)
release()

第一个 Demo:最多 3 个线程同时执行

下面模拟 10 个任务,但同一时刻最多只允许 3 个任务执行。

java 复制代码
package com.example.semaphore;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SemaphoreBasicDemo {

    private static final Semaphore SEMAPHORE = new Semaphore(3);

    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            int taskId = i;

            Thread thread = new Thread(() -> runTask(taskId), "task-" + taskId);
            thread.start();
        }
    }

    private static void runTask(int taskId) {
        boolean acquired = false;

        try {
            SEMAPHORE.acquire();
            acquired = true;

            System.out.printf(
                    "任务 %d 开始执行,可用许可=%d%n",
                    taskId,
                    SEMAPHORE.availablePermits()
            );

            TimeUnit.SECONDS.sleep(2);

            System.out.printf("任务 %d 执行完成%n", taskId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.printf("任务 %d 被中断%n", taskId);
        } finally {
            if (acquired) {
                SEMAPHORE.release();
            }
        }
    }
}

这里用了一个 acquired 标记。

原因很关键:

text 复制代码
只有真正拿到许可后,finally 里才释放许可。

如果线程在 acquire() 阻塞等待时被中断,实际并没有拿到许可。此时如果无条件 release(),许可数量会被凭空增加,并发控制会失效。

acquire 和 release 要成对

正确写法:

java 复制代码
boolean acquired = false;

try {
    semaphore.acquire();
    acquired = true;

    // 业务逻辑
} finally {
    if (acquired) {
        semaphore.release();
    }
}

如果 acquire() 已经成功,并且业务逻辑抛异常,finally 也能释放许可。

如果 acquire() 没成功,finally 不会释放许可。

这点比直接写下面这种更稳:

java 复制代码
try {
    semaphore.acquire();
    // 业务逻辑
} finally {
    semaphore.release();
}

后者在 acquire() 被中断时可能多释放一次。

tryAcquire:拿不到就直接拒绝

接口限流里,经常不希望请求一直排队。

如果许可已经用完,可以直接返回繁忙。

java 复制代码
package com.example.semaphore;

import java.util.concurrent.Semaphore;

public class FastRejectLimiter {

    private final Semaphore semaphore;

    public FastRejectLimiter(int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
    }

    public String handle(int requestId) {
        boolean acquired = semaphore.tryAcquire();

        if (!acquired) {
            return "请求 " + requestId + " 被限流";
        }

        try {
            return doBusiness(requestId);
        } finally {
            semaphore.release();
        }
    }

    private String doBusiness(int requestId) {
        return "请求 " + requestId + " 处理成功";
    }
}

tryAcquire() 不会阻塞。

适合:

text 复制代码
没有容量就快速失败
避免请求长期排队
保护后端资源

tryAcquire 超时等待

有些场景可以等一小会儿。

比如最多等 200 毫秒,拿到许可就处理,拿不到就返回繁忙。

java 复制代码
package com.example.semaphore;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class TimeoutLimiter {

    private final Semaphore semaphore = new Semaphore(5);

    public String handle(int requestId) {
        boolean acquired = false;

        try {
            acquired = semaphore.tryAcquire(200, TimeUnit.MILLISECONDS);

            if (!acquired) {
                return "系统繁忙,请稍后再试";
            }

            return doBusiness(requestId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "请求被中断";
        } finally {
            if (acquired) {
                semaphore.release();
            }
        }
    }

    private String doBusiness(int requestId) {
        return "request-" + requestId;
    }
}

相比 acquire() 无限等待,带超时的 tryAcquire 更适合 Web 接口。

Spring Boot 接口并发限制

下面用 Semaphore 给某个接口做并发限制。

java 复制代码
package com.example.semaphore.web;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/reports")
public class ReportController {

    private final Semaphore reportSemaphore = new Semaphore(10);

    @GetMapping("/export")
    public ResponseEntity<String> exportReport() {
        boolean acquired = false;

        try {
            acquired = reportSemaphore.tryAcquire(1, TimeUnit.SECONDS);

            if (!acquired) {
                return ResponseEntity
                        .status(HttpStatus.TOO_MANY_REQUESTS)
                        .body("报表导出请求过多,请稍后重试");
            }

            String fileUrl = exportLargeReport();
            return ResponseEntity.ok(fileUrl);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("报表导出被中断");
        } finally {
            if (acquired) {
                reportSemaphore.release();
            }
        }
    }

    private String exportLargeReport() {
        return "https://cdn.example.com/report/report-10001.xlsx";
    }
}

这个接口同一时刻最多允许 10 个导出任务执行。

其他请求最多等 1 秒,仍然拿不到许可就返回 429 Too Many Requests

这种做法适合保护 CPU、数据库、文件导出、第三方接口等有限资源。

连接池 Demo

Semaphore 很适合控制有限资源。

下面写一个简化版连接池:

java 复制代码
package com.example.semaphore.pool;

import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SimpleConnectionPool {

    private final Semaphore semaphore;

    private final Queue<MockConnection> idleConnections = new ArrayDeque<>();

    public SimpleConnectionPool(int size) {
        this.semaphore = new Semaphore(size, true);
        for (int i = 1; i <= size; i++) {
            idleConnections.add(new MockConnection("conn-" + i));
        }
    }

    public MockConnection borrow(long timeout, TimeUnit unit) throws InterruptedException {
        boolean acquired = semaphore.tryAcquire(timeout, unit);
        if (!acquired) {
            return null;
        }

        synchronized (idleConnections) {
            MockConnection connection = idleConnections.poll();
            if (connection == null) {
                semaphore.release();
                throw new IllegalStateException("许可和连接池状态不一致");
            }
            return connection;
        }
    }

    public void release(MockConnection connection) {
        if (connection == null) {
            return;
        }

        synchronized (idleConnections) {
            idleConnections.offer(connection);
        }
        semaphore.release();
    }

    public static class MockConnection {

        private final String name;

        public MockConnection(String name) {
            this.name = name;
        }

        public void execute(String sql) {
            System.out.println(name + " 执行 SQL:" + sql);
        }
    }
}

测试:

java 复制代码
package com.example.semaphore.pool;

import java.util.concurrent.TimeUnit;

public class ConnectionPoolDemo {

    public static void main(String[] args) {
        SimpleConnectionPool pool = new SimpleConnectionPool(3);

        for (int i = 1; i <= 8; i++) {
            int taskId = i;
            new Thread(() -> useConnection(pool, taskId), "worker-" + taskId).start();
        }
    }

    private static void useConnection(SimpleConnectionPool pool, int taskId) {
        SimpleConnectionPool.MockConnection connection = null;

        try {
            connection = pool.borrow(1, TimeUnit.SECONDS);

            if (connection == null) {
                System.out.println("任务 " + taskId + " 获取连接超时");
                return;
            }

            connection.execute("select * from user where id = " + taskId);
            TimeUnit.MILLISECONDS.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            pool.release(connection);
        }
    }
}

这个例子里:

text 复制代码
Semaphore 控制最多同时借出几个连接
Queue 保存真正的连接对象
synchronized 保护 Queue 的线程安全

Semaphore 只负责控制数量,不负责保存资源对象。

资源对象需要另外的数据结构管理。

公平模式和非公平模式

Semaphore 构造方法有一个 fair 参数。

非公平模式:

java 复制代码
Semaphore semaphore = new Semaphore(5);

公平模式:

java 复制代码
Semaphore semaphore = new Semaphore(5, true);

区别:

模式 说明 特点
非公平 释放许可后,后来的线程也可能先抢到 吞吐通常更高
公平 等待较久的线程优先获取许可 排队更有秩序,但开销更高

接口限流、高吞吐场景一般用默认非公平模式。

资源池、等待时间比较敏感的场景可以考虑公平模式。

一次获取多个许可

有些任务占用的资源不是 1 份。

比如批量导出任务按数据量占用不同许可。

java 复制代码
package com.example.semaphore;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class MultiPermitDemo {

    private static final Semaphore SEMAPHORE = new Semaphore(10);

    public static void main(String[] args) {
        runJob("small-job", 2);
        runJob("big-job", 7);
        runJob("medium-job", 4);
    }

    private static void runJob(String jobName, int permits) {
        Thread thread = new Thread(() -> {
            boolean acquired = false;

            try {
                SEMAPHORE.acquire(permits);
                acquired = true;

                System.out.printf("%s 获取 %d 个许可%n", jobName, permits);
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                if (acquired) {
                    SEMAPHORE.release(permits);
                    System.out.printf("%s 释放 %d 个许可%n", jobName, permits);
                }
            }
        });

        thread.start();
    }
}

使用多个许可时,获取和释放数量要一致。

text 复制代码
acquire(permits)
release(permits)

drainPermits:临时抽干许可

drainPermits() 会拿走当前所有可用许可,并返回拿走的数量。

java 复制代码
int drained = semaphore.drainPermits();

它适合临时暂停新任务进入。

java 复制代码
package com.example.semaphore;

import java.util.concurrent.Semaphore;

public class DrainPermitsDemo {

    private final Semaphore semaphore = new Semaphore(5);

    public int pauseNewRequests() {
        return semaphore.drainPermits();
    }

    public void resumeNewRequests(int permits) {
        semaphore.release(permits);
    }
}

注意,drainPermits() 只会拿走当前还没被线程占用的许可。

已经拿到许可并正在执行的线程不会被强制停止。

Semaphore 和虚拟线程

虚拟线程可以创建很多并发任务,但下游资源并不会因为虚拟线程变多而自动变强。

比如同时发起 10000 个 HTTP 调用,如果第三方接口只能承受 100 个并发,仍然需要限制并发。

Semaphore 可以和虚拟线程一起使用。

java 复制代码
package com.example.semaphore.virtualthread;

import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class VirtualThreadSemaphoreDemo {

    private static final Semaphore SEMAPHORE = new Semaphore(100);

    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 1000; i++) {
                int taskId = i;
                executor.submit(() -> callRemoteService(taskId));
            }
        }
    }

    private static void callRemoteService(int taskId) {
        boolean acquired = false;

        try {
            acquired = SEMAPHORE.tryAcquire(2, TimeUnit.SECONDS);

            if (!acquired) {
                System.out.println("任务 " + taskId + " 获取许可超时");
                return;
            }

            TimeUnit.MILLISECONDS.sleep(200);
            System.out.println("任务 " + taskId + " 调用完成");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (acquired) {
                SEMAPHORE.release();
            }
        }
    }
}

虚拟线程解决的是线程成本问题。

Semaphore 解决的是下游资源并发上限问题。

两者并不冲突。

和线程池的区别

线程池也能限制并发。

比如:

java 复制代码
Executors.newFixedThreadPool(10);

它限制的是同时执行任务的线程数量。

Semaphore 限制的是进入某段代码或访问某个资源的并发数量。

对比:

对比项 线程池 Semaphore
控制对象 任务执行线程数 某段代码或资源的并发数
是否负责执行任务 负责 不负责
是否保存任务队列 通常有 没有
是否能包住局部逻辑 不方便 很方便
常见场景 执行异步任务 限制资源访问

两者可以一起使用:

text 复制代码
线程池负责执行任务
Semaphore 负责限制访问数据库、接口、文件等资源

和其他同步工具对比

工具 作用 特点
synchronized 互斥访问 同一时刻只允许一个线程进入
ReentrantLock 互斥锁 synchronized 更灵活
Semaphore 控制并发数量 同一时刻允许 N 个线程进入
CountDownLatch 等待一组任务完成 一次性倒计时
CyclicBarrier 多个线程互相等待 可以循环使用

如果资源只能一个线程访问,用锁。

如果资源允许 N 个线程同时访问,用 Semaphore。

常见问题

release 次数超过 acquire 会怎样

Semaphore 不会检查"最大许可数"。

下面这段代码会让许可数量变大:

java 复制代码
Semaphore semaphore = new Semaphore(1);

semaphore.release();
semaphore.release();

System.out.println(semaphore.availablePermits());

输出可能是:

text 复制代码
3

所以 release() 要和成功的 acquire() 配对。

availablePermits 能不能做业务判断

availablePermits() 适合观察和监控,不适合做并发安全的业务判断。

比如:

java 复制代码
if (semaphore.availablePermits() > 0) {
    semaphore.acquire();
}

这不是原子操作。

判断时有许可,不代表下一行一定还能拿到许可。

需要尝试获取时,使用:

java 复制代码
boolean acquired = semaphore.tryAcquire();

Semaphore 能不能做分布式限流

不能直接做分布式限流。

Semaphore 只在当前 JVM 进程内生效。

如果应用部署了 5 个实例,每个实例都有一个 Semaphore(100),整体并发上限可能变成:

text 复制代码
5 * 100 = 500

集群级限流通常需要 Redis、网关限流、服务治理框架或专门限流组件。

acquire 被中断后怎么处理

acquire() 会响应中断。

捕获 InterruptedException 后,建议恢复中断标记:

java 复制代码
catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

这样上层代码还有机会感知中断状态。

Semaphore 是不是 QPS 限流器

Semaphore 控制的是同一时刻有多少任务正在执行。

QPS 限流控制的是单位时间内允许多少请求进入。

对比:

类型 控制目标
Semaphore 并发数
令牌桶 / 漏桶 单位时间流量

如果目标是"最多 100 个请求同时处理",Semaphore 很合适。

如果目标是"每秒最多 1000 个请求",更适合令牌桶、漏桶或网关限流。

实践建议

场景 建议
接口并发保护 使用 tryAcquire(timeout)
快速失败 使用 tryAcquire()
资源池 Semaphore 控制数量,队列保存资源对象
Web 接口 超时后返回 429 或业务繁忙
释放许可 只在成功获取许可后释放
中断处理 捕获后恢复中断状态
监控 观察 availablePermits()getQueueLength()
高吞吐 默认非公平模式
排队敏感 考虑公平模式
集群限流 使用分布式限流方案

小结

Semaphore 的核心是许可证。

acquire() 获取许可,release() 归还许可。许可数量决定同一时刻最多有多少线程可以进入受保护逻辑。

它和锁不同,锁通常只允许一个线程进入,Semaphore 可以允许 N 个线程同时进入。

日常项目里,Semaphore 最适合做单机并发控制:接口并发限制、连接池、导出任务保护、第三方接口保护、虚拟线程下游限流。

真正使用时,关键点不是 API 名字,而是几个工程细节:拿到许可再释放、超时获取、恢复中断状态、避免超额释放、区分并发限制和 QPS 限流。

相关推荐
geovindu20 小时前
Java: Chain of Responsibility Pattern
java·开发语言·后端·设计模式·责任链模式·行为模式
Zane199420 小时前
ReentrantReadWriteLock 与 Condition:读写锁与精准唤醒机制
java·后端
小罗水20 小时前
第11章 PostgreSQL + pgvector 向量检索
java·数据库·spring cloud·微服务
格兰芬多呼神护卫21 小时前
# Agent 企业评估方案深度分析:从“看答案”到持续质量闭环_码士教育
java·开发语言·jvm
豆角焖肉21 小时前
冒泡、快排、堆排的实现逻辑与选择思路
java·算法·排序算法·快排·冒泡·堆排
番茄炒鸡蛋加糖21 小时前
Spring 事务传播机制 & 事务失效场景
java·后端·spring
这就是佬们吗21 小时前
回溯算法三板斧---掌握「回溯三问」思考模板快速入门回溯
java·数据库·算法
小白说大模型21 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·java·人工智能·windows·python
qq_1715388521 小时前
深入浅出 MyBatis XML:从入门到精通
xml·java·mybatis
qq_1715388521 小时前
Spring TransactionSynchronizationManager:事务同步的幕后指挥官
java·后端·spring