摘要:比如上次做秒杀功能,为了处理瞬时涌入的请求,你想着多创建点线程扛压力,结果线程刚开到几万,服务器就报 “内存溢出”—— 这时候你是不是特疑惑:为啥操作系统线程这么 “金贵”,多建几个都不行?还有最近常听人说的 “虚拟线程”,号称能轻松支撑百万级数量,它到底凭啥
作为互联网软件开发人员,你肯定在高并发场景里栽过 “线程” 的坑吧?
比如上次做秒杀功能,为了处理瞬时涌入的请求,你想着多创建点线程扛压力,结果线程刚开到几万,服务器就报 “内存溢出”—— 这时候你是不是特疑惑:为啥操作系统线程这么 “金贵”,多建几个都不行?还有最近常听人说的 “虚拟线程”,号称能轻松支撑百万级数量,它到底凭啥这么 “廉价”?跟咱们平时用的操作系统线程(也就是平台线程)到底啥关系?JVM 又是怎么把这么多虚拟线程管得服服帖帖的?
今天咱们就用大白话拆解这些问题,从开发中遇到的实际痛点切入,把虚拟线程的核心原理讲透,最后再给大家提提实操建议,看完你说不定就能在下次项目里用上这门技术了。
咱们先回到最实际的场景:你写了个接口处理用户请求,用线程池管理线程,本来跑着挺稳,结果一到活动日并发上来,线程数往上涨到 1 万多,服务就开始卡顿,再往上甚至直接崩了。你查日志发现是 “OutOfMemoryError”,这时候你可能会想:不就是个线程吗,为啥这么占内存?
其实问题出在操作系统线程的 “重量级” 上。咱们平时说的 “平台线程”,本质上是操作系统内核级的线程,每个线程都要占用两块关键资源:
一是线程栈内存。默认情况下,一个 Java 平台线程的栈内存就得占 1MB(你没看错,是 1 兆),这还只是初始大小,要是线程里调用的方法层级深,栈还会扩容。你算笔账:1000 个平台线程就是 1000MB,也就是 1GB 内存;要是想建 10 万个,那光栈内存就需要 100GB—— 这对大部分服务器来说,根本扛不住。
二是内核资源开销。每个平台线程都要对应操作系统内核里的一个 “任务控制块(TCB)”,内核要负责线程的创建、调度、上下文切换。而操作系统的内核调度能力是有限的,比如 Linux 系统默认的进程数上限也就几万,线程数再多,内核调度不过来,就会出现 “线程饥饿”,导致服务响应变慢。
还有个更隐蔽的问题:上下文切换成本。当多个平台线程竞争 CPU 时,操作系统需要频繁切换线程状态 —— 保存当前线程的寄存器值、程序计数器,再加载下一个线程的信息。这个过程看着快,但次数多了就会占用大量 CPU 资源。比如你有 1 万个平台线程,CPU 核心只有 8 个,那上下文切换的开销会远大于线程实际执行任务的开销,最后服务就卡在 “切换” 上了。
这就是为啥咱们平时用平台线程时,线程池核心数一般设成 “CPU 核心数 + 1” 或者 “2*CPU 核心数”,根本不敢往高了设 —— 不是不想,是操作系统线程 “太金贵”,实在建不起、用不起。
既然平台线程有这么多限制,那虚拟线程是为了 “替代” 它吗?其实不是 —— 咱们得先搞清楚虚拟线程的定位,不然原理越学越乱。
首先说背景:虚拟线程是 Java 19 引入的预览特性,Java 21 正式转正,它的设计目标很明确 ——解决 “高并发场景下线程数量不足” 的问题,但它并没有打算把平台线程干掉,而是和平台线程形成 “协作关系”。
你可以把它们的关系理解成 “员工和小组长”:
平台线程(操作系统线程) 是 “小组长”,它有自己的 “办公位”(内核资源、栈内存),一个小组长能管好多个 “员工”;虚拟线程 是 “员工”,它没有自己的 “办公位”,而是共享小组长的 “办公位”,但每个员工有自己的 “工作笔记”(轻量级栈);当员工需要 “出差”(比如调用 IO 操作,像数据库查询、网络请求)时,会暂时把 “工作笔记” 交给小组长保管,自己先 “休息”,这时候小组长可以带其他员工干活;等 “出差” 回来,员工再从小组长那拿回笔记,继续干活。为啥要这么设计?因为咱们开发中大部分线程其实都在 “等”—— 比如线程发起数据库查询后,要等数据库返回结果;发起 HTTP 请求后,要等对方服务响应。这段 “等待时间” 里,平台线程其实是空闲的,但它还占着内存和内核资源,特别浪费。
虚拟线程就是把这段 “等待时间” 利用起来了:当虚拟线程执行 IO 操作时,JVM 会把它 “挂起”,然后把对应的平台线程腾出来,去运行其他虚拟线程。等 IO 操作完成,被挂起的虚拟线程再 “恢复”,找个空闲的平台线程继续执行。
这样一来,一个平台线程就能 “复用” 给多个虚拟线程,相当于用少量 “小组长” 管了大量 “员工”—— 这就是虚拟线程能支撑百万级数量的核心逻辑,也是它 “廉价” 的关键:它不占用独立的内核资源,栈内存也比平台线程小得多(默认几十 KB,最大也就几 MB)。
咱们接下来拆解最关键的两个问题:虚拟线程到底 “廉价” 在哪?JVM 又是怎么调度百万个虚拟线程的?这部分是原理核心,咱们尽量讲得通俗,不绕太多专业术语。
第一个密码:轻量级栈(用户态栈)。
平台线程用的是 “内核态栈”,由操作系统分配和管理,大小固定且不能太小(否则容易栈溢出);而虚拟线程用的是 “用户态栈”,由 JVM 管理,大小是动态的 —— 初始只有几十 KB,当线程执行方法需要更多栈空间时,JVM 再动态扩容;当方法执行完,栈空间又能回收。
比如一个虚拟线程执行 “查询数据库” 的逻辑,调用了 3 个方法,栈空间只需要存这 3 个方法的局部变量和返回地址,也就几十 KB;而同样的逻辑,平台线程得占用 1MB 的初始栈内存 —— 这么一对比,虚拟线程的内存占用直接降了一个数量级。
第二个密码:无内核资源独占。
每个平台线程都要占用操作系统的 TCB、文件描述符等内核资源,而虚拟线程不直接对应内核线程,它的所有操作都通过 JVM 间接映射到平台线程上。这就好比:平台线程是 “直接跟政府打交道的个体户”,要办各种证件(内核资源);虚拟线程是 “个体户手下的临时工”,不用单独办证,跟着个体户干活就行。
没有了内核资源的束缚,虚拟线程的创建和销毁速度也快得多 —— 创建一个虚拟线程只需要几微秒,而创建一个平台线程需要几毫秒,速度差了上千倍。
第三个密码:IO 等待时自动挂起,不占用平台线程。
这一点咱们前面提过,这里再展开说细节:当虚拟线程执行到 IO 操作(比如 Socket.read 、ResultSet.next 这些)时,JVM 会检测到这个 “阻塞操作”,然后做两件事:
一是把虚拟线程的 “当前执行状态”(比如程序计数器、栈帧)保存到内存里,让它 “挂起”;二是把它占用的平台线程 “释放”,让这个平台线程去运行其他就绪的虚拟线程。
等 IO 操作完成(比如数据库返回结果了),JVM 会把挂起的虚拟线程状态恢复,然后找一个空闲的平台线程,让它继续执行 —— 这个过程叫 “非阻塞 IO 映射”,完全在 JVM 层面完成,操作系统根本不知道有虚拟线程的存在。
正是因为这个设计,虚拟线程的 “等待时间” 被充分利用,一个平台线程能支撑几十个甚至上百个虚拟线程,资源利用率直接拉满。
你可能会问:既然一个平台线程能管多个虚拟线程,那 JVM 是怎么安排哪个虚拟线程该运行、哪个该挂起的?这里要讲 JVM 的 “调度器” 设计,核心是 “ForkJoinPool 调度器”(Java 里的一个线程池实现)。
咱们可以把 JVM 的调度过程分成 3 步,用 “员工干活” 的例子再讲一遍:
第一步:给 “小组长” 分配任务池。JVM 会初始化一个 ForkJoinPool,这个池里的 “工作线程” 就是咱们说的 “平台线程”(小组长)。默认情况下,ForkJoinPool 的线程数等于 CPU 核心数,比如 8 核 CPU 就有 8 个工作线程 —— 这就够了,因为 CPU 同一时间只能跑 8 个任务,多了也是切换。
第二步:给 “员工” 分配任务,让小组长带活。当你创建一个虚拟线程(员工)并启动时,JVM 会把它放到 ForkJoinPool 的 “任务队列” 里。每个工作线程(小组长)会从队列里拿虚拟线程,然后执行它的代码。
第三步:员工 “出差” 时,小组长换个人带。当虚拟线程执行到 IO 操作(出差),JVM 会把它从工作线程上 “摘下来”,保存它的执行状态,然后把它放到 “等待队列” 里。这时候工作线程(小组长)就空了,会去任务队列里再拿一个虚拟线程(另一个员工)继续干活。
等 IO 操作完成(出差回来),JVM 会把 “等待队列” 里的虚拟线程移回 “任务队列”,让它重新排队,等有空闲的工作线程了再继续执行。
这里有个关键:JVM 的调度是 “用户态调度”,不需要操作系统参与。比如虚拟线程的挂起、恢复、队列切换,都是 JVM 自己做的,速度比操作系统的 “内核态调度” 快得多 —— 这也是百万级虚拟线程能顺畅运行的关键:调度成本低,不会给 CPU 带来额外负担。
举个实际的例子:如果你的服务有 100 万个虚拟线程,其中 80 万个都在等 IO(比如查数据库、调接口),那只需要 8 个工作线程(对应 8 核 CPU),就能把剩下 20 万个就绪的虚拟线程管好 —— 因为这 8 个工作线程会不断从任务队列里拿线程执行,遇到 IO 阻塞就换一个,根本不会闲着。
讲完原理,咱们再落地到实际开发 —— 虚拟线程这么好用,咱们该怎么用?有啥坑要避开?
首先说用法:Java 21 之后,创建虚拟线程特别简单,不用改太多代码。比如原来你用 Thread 创建线程:
Thread thread = new Thread( -> { // 业务逻辑});thread.start;Thread thread = Thread.ofVirtual.start( -> { // 业务逻辑});或者用 ExecutorService 线程池,Java 21 提供了专门的虚拟线程池:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor) { // 提交100万个任务,每个任务对应一个虚拟线程 for (int i = 0; i { // 业务逻辑,比如查询数据库、调用接口 }); }}是不是特别简单?不用改业务逻辑,只需要换个创建线程的方式,就能享受到百万级并发的能力。
然后说注意事项,这 3 个坑千万别踩:
第一个坑:把虚拟线程当 “无限创建” 的线程用。虽然虚拟线程廉价,但 100 万个线程同时就绪,还是会占用内存 —— 比如每个虚拟线程占 100KB,100 万个就是 10GB 内存,服务器还是会扛不住。所以还是要根据服务器内存大小,合理控制虚拟线程数量。
第二个坑:在虚拟线程里执行 CPU 密集型任务。虚拟线程的优势是 “IO 密集型任务”(比如查数据库、调接口),因为这类任务有大量等待时间,能让 JVM 复用平台线程。如果是 CPU 密集型任务(比如大量计算),虚拟线程会一直占用平台线程,没法被挂起,这时候虚拟线程和平台线程没啥区别,甚至因为调度开销,性能还会下降。
第三个坑:依赖 ThreadLocal 的代码要小心。ThreadLocal 是绑定到 “线程” 的变量,原来用平台线程时,每个线程的 ThreadLocal 是独立的;但虚拟线程是复用平台线程的,如果你在虚拟线程里用 ThreadLocal,可能会出现 “线程安全问题”—— 比如前一个虚拟线程的 ThreadLocal 值,被后一个虚拟线程读到了。解决办法是用 Java 21 新出的ThreadLocal.withInitial,或者避免在虚拟线程里用 ThreadLocal。
最后给大家提个小建议:如果你做的是网关、接口服务、消息消费这类 IO 密集型业务,现在就可以试试用虚拟线程替换原来的线程池 —— 比如把消息消费者的线程池换成newVirtualThreadPerTaskExecutor,你会发现同样的服务器配置,能处理的消息量直接翻好几倍。
当然,技术好不好用,还是要自己试过才知道。你平时在项目里有没有遇到过线程不够用的问题?如果用虚拟线程,你觉得能解决哪些痛点?欢迎在评论区分享你的经历,咱们一起讨论实操技巧,把这门技术用得更溜~
来源:从程序员到架构师