欢迎访问我的GitHub

这里分类和汇总了欣宸的全部原创(含配套源码):https://github.com/zq2599/blog_demos

场景

  • 小宝是个Java程序员,负责公司后端开发,下面是小宝公司系统的简化版架构,用户请求A服务的时候,A会去调用B服务
    在这里插入图片描述
  • 如果B服务响应太慢,会导致A服务有大量线程在等待响应结果,所以大多数时候A的线程池会调大一些,也就是下图黄色箭头位置的配置
    在这里插入图片描述
  • 上周六大促压测,小宝干了件"疯狂"的事
  • 测试A服务的同学把并发数从10逐步加码到10000,小宝盯着监控屏手心冒汗——按以往经验,服务早该线程池爆满、响应时间爆表、然后开始疯狂报RejectedExecutionException了。
  • 但这次,CPU只用到35%,内存稳如老狗,平均响应时间居然从原来的3秒降到了180ms。
  • 测试同学以为小宝偷偷换了台128核的服务器,小宝指着配置中心的spring.threads.virtual.enabled=true说:“就改了一行配置,连代码都没重新编译。”
  • 这就是JDK 21虚拟线程(Virtual Threads)给Spring Boot带来的降维打击,如果你还在用传统线程池硬扛高并发,看完这篇能帮你省不少服务器预算。

传统线程模型:我们到底在忍受什么痛苦?

  • 先算笔账,Spring Boot应用默认Tomcat线程池是200,这意味着第201个请求进来时,要么排队,要么被拒
    在这里插入图片描述

  • 为了支撑"万一来的"远高于200的并发,有这些选择:

  1. 加机器:横向扩容、上K8S、上负载均衡,经费在燃烧
  2. 调线程池:server.tomcat.threads.max=1000,然后看着内存占用从2GB飙升到4GB(每个线程默认1MB栈空间)
  3. 改代码:学习WebFlux,把JDBC换成R2DBC,把@RestTemplate换成WebClient,掉进Mono.flatMap()的回调地狱
  • 最憋屈的是,小宝的代码其实不吃CPU——它只是在等服务B的响应(也可以衍生到等数据库、等redis这些服务的响应),传统线程就像花大价钱请了个高级工程师,结果他90%的时间都在工位上干坐着等快递(IO阻塞),还不能去干别的活
  • 资源长时间低负载率,导致运维同事会时不时把小宝作为反面教材调侃

虚拟线程的四大爽点(Spring Boot 3.2+)

  • JDK 21的虚拟线程不是优化,是编程模型的革命,这里把最直接的好处提前小结一下:
  1. 吞吐量暴力拉升:从"挤独木桥"到"走高速公路"
    这是转载的测试数据,仅供参考(同样的4核8G服务器,简单CRUD接口):
指标 传统线程池(200线程) 虚拟线程(无限制) 提升幅度
最大并发支撑 400(开始超时) 10000+(稳定) 25倍
平均响应时间(RT) 3200ms 150ms 95%↓
内存占用(10000并发) 爆内存OOM 只增加300MB 节省90%+
  • 原理简单:虚拟线程遇到IO阻塞(查数据库、调外部API)时,会自动让出底层的OS线程(载体线程),去处理其他请求。等IO结果回来了,再挂载回来继续执行。
  • 于是OS线程只有4个(等于CPU核数),但能"分时复用"处理成千上万个虚拟线程
  • 就像4个收银员同时服务10000个顾客——顾客填表的时候(IO阻塞),收银员就去服务下一个人,而不是傻等着。
  1. 代码零改造:写"阻塞代码"也能有高并发,这是最爽的
  • 以前想支持超高并发,你得学WebFlux把通同步执行改造成异步响应,虽说可以强行让自己适应,但还是有些别扭:
// WebFlux的"地狱回调"
public Mono<Order> getOrder() {
    return webClient.get()
        .uri("/api/order")
        .retrieve()
        .bodyToMono(Order.class)
        .flatMap(order -> userService.findById(order.getUserId()))  // 地狱开始
        .flatMap(user -> addressService.getDefaultAddress(user.getId()));  // 嵌套深渊
}
  • 现在用虚拟线程,咱就维持传统写法吧,阻塞就阻塞
@GetMapping("/order")
public Order getOrder() {
    // 这是阻塞调用!但在虚拟线程里,它不会阻塞OS线程
    Order order = restTemplate.getForObject("/api/order", Order.class);
    User user = userService.findById(order.getUserId());  // 阻塞JDBC查询
    Address addr = addressService.getDefaultAddress(user.getId());  // 再阻塞
    return order;
}
  • 代码可读性回来了! 不用再纠结map和flatMap的区别,不用再处理Mono和Flux的类型转换,就写你最熟悉的那种"一行一行往下执行"的代码。
  1. 内存占用降低
  • 传统Java线程 = 1个OS线程,成本是1MB内存 + 内核调度开销
  • 虚拟线程 = JVM管理的对象,成本几百字节。
  • 这意味着:以前开1000个线程要1GB内存,现在开100万个虚拟线程也用不了几百MB
  • Go程序员笑而不语,这是他们的日常操作
  • 以前线程上下文切换是性能杀手,现在虚拟线程的切换完全在用户态完成,比传统线程快数倍
  1. 故障隔离性:一个请求崩了不影响全局
  • 在传统线程池里,如果某个请求导致线程死锁或长时间阻塞,线程池会被慢慢耗尽,最后整个服务不可用。
  • 虚拟线程有超时和取消机制。即使某个虚拟线程死循环了,它也只是占用一个载体线程的一小段切片时间,不会拖垮整个服务

实战:一行配置开启"开挂模式"

  • 不给出真实代码和测试数据,前面写得再多也只是空谈,接下来咱们就来极速编码和压测
  • JDK选择21
  • 要确保springboot使用3.2+的版本,我这里是3.4
    <parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.4.0</version>
		<relativePath/>
	</parent>
  • 然后是关键代码,如下所示,其实毫无技术含量,就是个可以延时的http响应
package com.bolingcavalry.helloworld.controller;

import java.util.Date;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatController {
    
    private static final Logger log = LoggerFactory.getLogger(ChatController.class);
    
    @GetMapping("/delay/{ms}")
    public String delay(@PathVariable int ms) throws InterruptedException {
        // 这里打印的信息可以帮我们确认当前是不是虚拟线程模式
        // log.info("当前线程名: {}", Thread.currentThread().getName());
        Thread.sleep(ms);
        return String.format("Delayed for %dms at %s", ms, new Date());
    }
}

  • 上面有一行日志代码被注释掉了,这是辅助避雷用的,眼下咱们用不上,后面用到时才会打开
  • 最后是配置文件,注意spring.threads.virtual.enabled,我先设置成false表示使用传统线程模式,注意此时虚拟线程未打开
spring.application.name=virtual-thread
server.port=8080

# Tomcat线程池配置,最大线程数
server.tomcat.threads.max=100
# Tomcat线程池配置,最小空闲线程数
server.tomcat.threads.min-spare=2
# Tomcat最大连接数(默认8192)
server.tomcat.max-connections=8192
# Tomcat等待队列大小(增加到5000以支持超高并发压测)
server.tomcat.accept-count=5000

# ========================================
# 虚拟线程配置(关键配置)
# ========================================

# false: 使用传统线程模式
# true: 使用虚拟线程模式
spring.threads.virtual.enabled=false

  • 然后把应用运行起来,先压测传统线程模式时的性能

怎么压测

  • 压测工具有很多种,我这里用的是hey,主要是图它简单好用,您可以自由选择
  • 测试方法如下图,前面写的代码运行在机器192.168.31.210,然后在机器192.168.31.211上用hey压测A机器上的http接口
    在这里插入图片描述

压测,使用传统线程模式

  • 压测命令如下,意思是测试两千并发,总共发送 100,000 个 HTTP 请求
hey -n 100000 -c 2000 http://192.168.31.210:8080/delay/50
  • 压测结果如下,QPS是1877
Summary:
  Total:        53.2705 secs
  Slowest:      1.3969 secs
  Fastest:      0.0532 secs
  Average:      1.0508 secs
  Requests/sec: 1877.2105

压测,使用虚拟线程模式

  • 接下来试试虚拟线程模式吧,停掉服务,按照下图所示修改配置
    在这里插入图片描述
  • 改完把服务启动,使用同样的压测命令
hey -n 100000 -c 2000 http://localhost:8080/delay/50
  • 压测结果如下,QPS是12733,相比传统模式的1877,明显提升了数倍
Summary:
  Total:        7.8289 secs
  Slowest:      0.3913 secs
  Fastest:      0.0538 secs
  Average:      0.1520 secs
  Requests/sec: 12773.1370
  
  Total data:   4800000 bytes
  Size/request: 48 bytes
  • 简单的代码和压测证明了在特定场景下,虚拟线程可以有效提升系统性能

避坑

  1. 再次强调版本:JDK 21+,Spring Boot 3.2+
  2. 注意这个配置的拼写:spring.threads.virtual.enabled,是threads,复数,千万不要写成thread了,小宝就是写错了这个,查了半天
  3. 想确认当前运行进程是传统模式还是虚拟线程模式,可以通过日志查看,就是前面注释的那一行,如下图黄色箭头所示
    在这里插入图片描述
  • 未开启虚拟线程模式的时候,日志内容如下图所示,是nio-8080-exec-1
    在这里插入图片描述
  • 如果开启了虚拟线程模式,日志内容如下图所示,是tomcat-handler-0
    在这里插入图片描述
  • 如此就很好确认了,nio-xxx带表传统模式,tomcat-handler-xxxx代表虚拟线程模式

争议:Java终于"追上"Go了吗?

  • 虚拟线程上线后,小宝的组内炸了锅。
  • 喜欢Go语言的同事说:“Java早该学Go的Goroutine了,现在才出虚拟线程,迟了太多年!”
  • 也有同事觉得这次Java不是跟风,而是弯道超车。
  • Go的Goroutine确实优秀,但Go生态只有Goroutine那一套。而Java的虚拟线程向后兼容了25年的阻塞式代码生态——你的Spring Data JPA、MyBatis、HttpClient、JDBC,全部无痛升级。
  • 这意味着什么?现有Java项目不需要重构就能享受更高的并发能力。
  • 当然,也有工程师担心:“虚拟线程让写阻塞代码变得’无代价’,会不会让程序员更懒了,不再关心IO效率?”
  • 虚拟线程是让Java重获新生,还是会养出一堆不管性能、只会写阻塞代码的"懒虫"?–这个问题在小宝看来,根本就不是重点,因为这些都是明面上能看到的
  • 真正要注意的应该是改用虚拟线程后有没有什么坑,要命的那种
  • 举个例子:现在的业务代码中,100个传统线程有100个ThreadLocal对象,改成虚拟线程后,10000个虚拟线程有10000个ThreadLocal对象吗?内存炸不炸?
  • 评论区说出你的观点,或者等下一篇吧,小宝掉进坑里后,咱们再围观他如何爬出来

你不孤单,欣宸原创一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 数据库+中间件系列
  6. DevOps系列
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐