Java基础、中级、高级、架构面试资料

@ConcurrencyLimit滥用引发死锁,Spring7+虚拟线程不是银弹

JAVA herman 24浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:xttblog2,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog2,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领
腾讯云】1核2G5M轻量应用服务器50元首年,高性价比,助您轻松上云

一个多月前,我写过一篇文章《https://mp.weixin.qq.com/s/6YWJ_-1c4L323wjHs6JohQ》,讲到的是 Spring 7 引入的@ConcurrencyLimit。最近呢,这个注解被一些老外程序员玩坏了,玩出了死锁

更早之前,也有不少老外网友使用@ConcurrencyLimit + @Async + @Transactional,结果发现事务失效了。当然现在这个问题已经被修复了,后面就不细说了,我后面单独来说说这 3 个注解组合起来带来的问题。

今天,我们重点来聊聊@ConcurrencyLimit的可重入性、死锁、虚拟线程与最佳实践。

@ConcurrencyLimit

@ConcurrencyLimit 是 Spring 7 提供的“流量闸门”,防止我们的应用产生并发爆炸,尤其是在虚拟线程时代。

这个注解,被官方称为虚拟线程时代的安全阀。在 Java 21 以及之后的版本中,可轻松创建百万级虚拟线程的场景下,这个看似简单的注解背后,藏着一整套精密的并发控制哲学。

但老外开发者们很快发现,这个“闸门”使用不到,会出现死锁,尤其是递归调用这种重入性场景。下面我们就一一慢慢展开吧。

ConcurrencyThrottleInterceptor

@ConcurrencyLimit 并非全新发明,而是对 Spring 1.0 时代 ConcurrencyThrottleInterceptor 的现代化封装。而ConcurrencyThrottleInterceptor正是@ConcurrencyLimit的核心机制。

它的底层实现逻辑,精简后的源码如下所示。

// 来自 spring-context 模块的 ConcurrencyThrottleInterceptor
public class ConcurrencyThrottleInterceptor implements MethodInterceptor {
    private final Object monitor = new Object();
    private int concurrencyCount = 0;
    private int concurrencyLimit = 1; // 默认限制为 1

    protected void beforeAccess() {
        if (this.concurrencyLimit > 0) {
            synchronized (this.monitor) {
                // 关键:自旋等待,直到获取许可
                while (this.concurrencyCount >= this.concurrencyLimit) {
                    try {
                        this.monitor.wait(); // 阻塞等待
                    } catch (InterruptedException ex) {
                        Thread.currentThread().interrupt();
                        throw new IllegalStateException("Interrupted");
                    }
                }
                this.concurrencyCount++; // 占用一个槽位
            }
        }
    }

    protected void afterAccess() {
        if (this.concurrencyLimit >= 0) {
            synchronized (this.monitor) {
                this.concurrencyCount--; // 释放槽位
                this.monitor.notify();   // 唤醒一个等待线程
            }
        }
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        beforeAccess();
        try {
            return invocation.proceed(); // 执行原方法
        } finally {
            afterAccess(); // 确保无论如何都会释放
        }
    }
}

看起来非常简单,核心设计也非常简洁。

  • 计数器 + 对象监视器(Monitor):经典的生产者-消费者模式
  • 阻塞式等待:wait()/notify() 实现线程间协调
  • finally 释放:确保异常场景下也能正确归还许可

相比大家实现的单进程限流方法,上面的实现是不是简单了很多。当然,这段代码是简化后的,实际代码比这个复杂一丢丢。

可重入性与递归调用

看懂上面的代码之后,你就会发现。@ConcurrencyLimit与可重入性是一点关系也没有,因为它压根与线程就没关系。

别看有 synchronized,它虽然支持可重入。但有“双检锁”把关,它是无法重入的,因此它默认是不可重入的!所以,在串行化以及递归场景下,就需要特别注意了,预防跳进坑里。

递归场景模拟

假设有下面这样一个递归场景,当然串行不串行的不重要,串行化更容易复现。

@Service
public class RiskyService {
    @ConcurrencyLimit(1)  // 限制为1(串行)
    public void process(int depth) {
        System.out.println("Depth: " + depth);
        if (depth > 0) {
            // 同一线程内递归调用自己
            process(depth - 1); // 这里会死锁!
        }
    }
}

如果是串行化,那么执行结果很可能如下所示。

Thread-1: process(3) -> 获得许可,concurrencyCount=1
Thread-1: process(2) -> 尝试获取许可,但 concurrencyCount=1,进入 while 循环等待
Thread-1: wait() -> 自己把自己阻塞了!永久死锁!

如果不是串行化,那假设并发是 10,现在如果你的递归超过 10,那么对不起,你很可能是死锁的结果。

即使你的递归深度不超过 10,那么在并发场景下,稍微互相循环个几次,并发次数不够用了,那也会死锁。

因此,切记不要出现重复调用自己的情况

为什么会死锁?

为什么会死锁,上面我其实说的已经很明白了,下面我在细列一下。

  1. 许可计数不识别线程身份:concurrencyCount 是全局计数器,不区分线程
  2. 同一线程无法重复获取:即使是你自己占用的许可,再次请求也会触发 wait()
  3. 没有重入计数机制:不像 ReentrantLockholdCount 记录持有次数

重入性死锁解决方案

下面我们简单说说,假设你有递归,会有可重入的用法,那该怎么办?我这里简单推荐两个方案,供参考!

方案 1 重构代码,避免在 @ConcurrencyLimit 方法内递归。

@Service
public class SafeService {
    @ConcurrencyLimit(1)
    public void process(int depth) {
        doProcess(depth); // 将逻辑抽离到内部方法
    }

    private void doProcess(int depth) { // 无注解,可自由递归
        System.out.println("Depth: " + depth);
        if (depth > 0) {
            doProcess(depth - 1); // 安全
        }
    }
}

方案 2 使用 ThreadLocal 实现手动重入,这个就比较高级了,我不推荐大家这样做,搞不好会出事故的。

@Service
public class AdvancedService {
    private final ThreadLocal<Integer> reentrantCount = ThreadLocal.withInitial(() -> 0);

    @ConcurrencyLimit(1)
    public void process(int depth) {
        int currentCount = reentrantCount.get();
        if (currentCount == 0) {
            // 首次进入,正常获取许可
            reentrantCount.set(1);
            try {
                doProcess(depth);
            } finally {
                reentrantCount.set(0);
            }
        } else {
            // 重入调用,跳过许可获取
            reentrantCount.set(currentCount + 1);
            try {
                doProcess(depth);
            } finally {
                reentrantCount.set(currentCount);
            }
        }
    }

    private void doProcess(int depth) { /* 递归逻辑 */ }
}

最重要的是,官方也不推荐这种做法,更好的方式是将并发控制与业务逻辑解耦

虚拟线程与 wait

这里我简单说一下,明天或后面我写一篇文章来细说一下。

Spring 7 和虚拟线程对上面精简代码的关键优化如下。

  • 虚拟线程被 wait() 时,底层载体线程(Carrier Thread)会被释放
  • 实现近乎零成本的阻塞等待
  • 可以设置更高的并发限制(如 1000+)而不担心资源耗尽

wait 它变了,变得更好用了。在之前,wait 可是不会释放线程本身的。具体的细节,后面我单独写文章来说。

同时,这里也说一下,更高级的场景。比如@ConcurrencyLimit@Async@Retryable@Transactional 组合的协同,后面我单独来说。之前的候选预览版本就发生过@Async + @ConcurrencyLimit + @Transactional导致事务失效的 bug,这里我们暂不细表。

最佳实践

下面,我根据社区网友的讨论,总结了 Spring 7 并发控制的 10 条军规,供大家参考!

限制值设定

要坚信科学而不是猜测或随机或看个人喜好设置。

// CPU 密集型任务(计算、加密)
@ConcurrencyLimit(Runtime.getRuntime().availableProcessors())

// IO 密集型任务(HTTP、DB)
@ConcurrencyLimit(50) // 根据连接池大小调整

// 外部 API 调用(已知限流)
@ConcurrencyLimit(10) // 匹配下游服务的 QPS 限制

将并发控制提到服务编排层

避免自己调自己,避免在 @ConcurrencyLimit 方法内同步调用其他 @ConcurrencyLimit

// 嵌套调用可能导致死锁
@ConcurrencyLimit(5)
public void methodA() {
    methodB(); // 如果 methodB 也需要许可,可能死锁
}

@ConcurrencyLimit(5)
public void methodB() {
    // ...
}

// 将并发控制上提到服务编排层
@ConcurrencyLimit(5)
public void orchestrate() {
    internalMethodA(); // 无注解
    internalMethodB(); // 无注解
}

虚拟线程增大限制

如果是虚拟线程环境,则推荐设置更大的限制。

// 传统平台线程
@ConcurrencyLimit(10)

// 虚拟线程(轻量,可设置更大)
@ConcurrencyLimit(200) 

监控与可观测性

为了更好的提升服务稳定性,可以建立或自行提供监控与可观测性。

@ConcurrencyLimit(10)
public void monitoredMethod() {
    Metrics.gauge("concurrency.current", 
        getCurrentConcurrency());
    Metrics.gauge("concurrency.waiting", 
        getWaitingThreads());

    // 业务逻辑
}

// 获取当前并发数(需要获取拦截器内部状态)
private int getCurrentConcurrency() {
    // 建议自定义拦截器暴露指标
    return MyConcurrencyMetrics.getActiveCount();
}

超时控制

一定要注意设置超时时间,预防超时问题,否则不管多大的并发控制,都会被耗干。

@ConcurrencyLimit(5)
public void processWithTimeout() throws InterruptedException {
    // 设置最大等待时间,防止无限阻塞
    boolean acquired = tryAcquirePermit(10, TimeUnit.SECONDS);
    if (!acquired) {
        throw new TaskRejectedException("Concurrency limit timeout");
    }
    try {
        // 业务逻辑
    } finally {
        releasePermit();
    }
}

需要特别注意的是@ConcurrencyLimit 本身不提供超时配置,需要配合 SimpleAsyncTaskExecutor.setTaskTerminationTimeout() 或自定义实现。

拒绝策略

推荐设置合适的拒绝策略,比如类似下面的代码。

@Bean
public SimpleAsyncTaskExecutor asyncExecutor() {
    SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
    executor.setConcurrencyLimit(10);
    // 超限直接拒绝
    executor.setRejectTasksWhenLimitReached(true); 
    return executor;
}

类级别 vs 方法级别

@ConcurrencyLimit 也可以作用到类上,它们的区别如下。

// 类级别:所有方法共享同一个限制
@ConcurrencyLimit(20)
@Service
public class SharedResourceService {
    public void method1() {} // 共享 20 个许可
    public void method2() {} // 共享 20 个许可
}

// 方法级别:各自独立限制
@Service
public class IndependentService {
    @ConcurrencyLimit(10)
    public void method1() {} // 独立的 10 个许可

    @ConcurrencyLimit(5)
    public void method2() {} // 独立的 5 个许可
}

测试策略

自己写的代码,一定要严格测试一下,要不发可能会发生意想不到的事故,遇到一个 3.25 的绩效就不好了。

@SpringBootTest
class ConcurrencyLimitTest {

    @Autowired
    private ResilientService service;

    @Test
    void testConcurrencyLimitActuallyWorks() 
            throws InterruptedException {
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch endLatch = new CountDownLatch(10);
        AtomicInteger maxConcurrent = new AtomicInteger(0);
        AtomicInteger currentConcurrent = new AtomicInteger(0);

        // 同时启动 10 个任务,但限制为 3
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    startLatch.await(); // 等待同时启动信号
                    service.limitedMethod(currentConcurrent, maxConcurrent);
                } catch (Exception e) {
                    // 忽略中断
                } finally {
                    endLatch.countDown();
                }
            }).start();
        }

        startLatch.countDown(); // 同时启动
        assertTrue(endLatch.await(30, TimeUnit.SECONDS));

        // 验证最大并发数不超过限制
        assertTrue(maxConcurrent.get() <= 3, 
            "Actual max: " + maxConcurrent.get());
    }
}

与 Resilience4j 的对比选择

@ConcurrencyLimit 好就好在不需要其它依赖,下面是它们之间的简单对比。

特性Spring 7 @ConcurrencyLimitResilience4j RateLimiter
并发控制阻塞等待(Semaphore 模式)令牌桶算法
超时支持需手动实现内置超时
动态配置需重启/刷新 Bean支持动态配置
监控指标需自定义内置 Micrometer
适用场景简单场景、虚拟线程复杂微服务治理

这里我建议:单体应用或简单场景用 @ConcurrencyLimit,微服务网格用 Resilience4j。

虚拟线程专属调优

推荐和虚拟线程一起使用。

@Configuration
@EnableAsync
public class VirtualThreadConfig {

    @Bean
    public SimpleAsyncTaskExecutor virtualThreadExecutor() {
        SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
        executor.setVirtualThreads(true);
        executor.setConcurrencyLimit(500); // 可设置更大
        executor.setThreadNamePrefix("vt-task-");
        return executor;
    }
}

@Service
public class VirtualThreadService {
    @Async("virtualThreadExecutor")
    @ConcurrencyLimit(500) // 匹配执行器限制
    public CompletableFuture<Void> highConcurrencyTask() {
        // 可安全启动大量虚拟线程
        return CompletableFuture.completedFuture(null);
    }
}

限于篇幅和时间,我就写到这里吧,下面我们简单总结一下。

总结

@ConcurrencyLimit 是 Spring 7 在虚拟线程时代的重大创新,但它不是银弹

那么什么时候用它呢?

  • 保护外部 API、DB 连接等有限资源
  • 在虚拟线程环境中防止资源过载
  • 简单场景的快速限流

那么什么时候不要用呢?

  • 需要复杂限流算法(令牌桶、滑动窗口)
  • 需要动态调整限制值
  • 方法内有递归调用(会死锁!)
  • 需要非阻塞响应式编程(用 Mono.flatMap + limitRate 替代)

记住下面这个黄金法则。

如果方法会递归 -> 避免使用 @ConcurrencyLimit
如果使用虚拟线程 -> 可以设置更大的限制
如果组合多个注解 -> 务必充分测试调用后的行为

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号:xttblog2。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » @ConcurrencyLimit滥用引发死锁,Spring7+虚拟线程不是银弹