过去这两年我在公司负责底层基础设施的维护和性能调优,被问得最多的几个问题很有意思:为什么一个 Java 服务的内存占用比 JVM 堆还高?为什么线上线程池明明设置了最大线程数,请求还是变慢了?为什么用协程重写之后,同一个线程池能抗住的并发量翻了几倍?
这些问题表面上看起来是配置问题,但追到根上,都绕不开操作系统四个核心抽象——虚拟内存、进程、线程、协程——之间的关系。这几个概念单拎出来,每个都有大量资料可以看,难点在于把它们串成一条线:从虚拟内存到进程,从进程到线程,从线程到协程,每一层到底解决了什么问题,为什么不能直接省略掉其中一层。
这篇文章我想用偏实操的视角把这些关系讲透,结合排查线上问题时的经验和那些容易被忽略的细节。不管你是做后端、客户端还是搞运维,只要程序跑在操作系统上,这些东西迟早会来找你。
1. 虚拟内存与进程:先搞懂“资源”和“容器”的关系
1.1 虚拟内存到底“虚拟”在哪里
很多同学对虚拟内存的理解还停留在 Windows 的页面文件设置上。其实虚拟内存是操作系统最基础、也最容易被忽视的内存抽象手段。它解决的核心矛盾是:多个程序同时运行,但物理内存只有一份,怎么保证它们不互相踩踏?
答案就是给每个进程发一张“假的地图”。每个进程都以为自己拥有一整块连续、私有的地址空间——在 64 位系统上这个空间大到几乎用不完——但实际这些地址只有在真正访问的时候才会被映射到物理内存的某个页框上。负责维护这张映射关系的是页表,每个进程一份,CPU 在访问内存时会通过 MMU(内存管理单元)自动完成地址翻译。
这个过程我经常用“酒店房间号”来类比:客人(进程)手里拿着自己的房卡,房卡上印的是房号(虚拟地址),但酒店前台那里的登记表(页表)才知道这个房号对应的是哪个房间(物理页框)。客人打开房门走进去,根本不需要知道自己在酒店的哪一层、哪一栋楼。
这带来两个直接好处:第一,进程之间天然隔离,A 进程的地址空间在 B 进程看来是不可访问的,一个进程崩溃不会把别的进程内存搞坏;第二,物理内存可用得“很抠”,程序申请了内存但一直不碰,那这块虚拟内存就不会真的占用物理页,只有第一次写入时才发生缺页中断,系统才去分配物理页,这个机制叫做按需分页。
1.2 进程的地址空间里装了什么
一个新进程被创建以后(Linux 下通常由 fork + exec 完成),它的虚拟地址空间从低地址到高地址大致是这么排的。
代码段(text)存放编译后的机器指令,只读可执行;数据段(data 和 bss)存放全局变量和静态变量;往上是堆(heap),通过 malloc/new 动态分配的内存都从堆里拿,堆从低地址向高地址增长;再往上是一大块未映射区域,通常叫 mmap 区域,动态库、mmap 文件映射都在这里;最高地址段是栈(stack),存放函数调用相关的局部变量、参数、返回地址,栈从高地址向低地址往下长。
这里有一个特别容易踩的坑,就是“进程内存占用为什么比 JVM 堆大”这类问题。JVM 的 -Xmx 只约束了 Java 堆,但进程的实际 RSS 常驻内存还包括:线程栈(每个线程默认 1MB 左右)、元空间、JIT 编译产物、直接内存(DirectByteBuffer)、GC 相关的 mark 位图,以及 glibc 分配器自己持有的 arena。一个 8G 堆的服务,进程 RSS 轻松到 10G 甚至 12G 以上,这是正常现象,不是内存泄漏。
1.3 虚拟内存设置的实操经验
操作系统层面理解了虚拟内存之后,再看 Windows 虚拟内存(页面文件)的设置问题就特别清楚了。页面文件本质上是虚拟内存的“换页区”,物理内存不够用的时候,把一些暂时不用的页写回磁盘,腾出物理页给当前活跃的数据用。
热门搜索词里那些问题,我集中回答一下,都是被问过无数遍的。
32G 物理内存该设置多大。物理内存足够大的时候,页面文件的主要作用已经退化成“兜底”和“存储 crash dump”。我个人建议初始值和最大值都可以设为 4G 到 8G,或者干脆让 Windows 自动管理。强行禁用页面文件不是一个好主意,因为某些老软件、游戏、以及系统自身在内存分配时会把页面文件当作一种“可承诺的容量”,禁掉之后反而会出现奇怪的内存分配失败。
32G 和 48G 内存设置多少。32G 内存的机器,页面文件设 4G 起步、最大 8G 基本就够用了;48G 内存的机器可以设 8G 起步、最大 16G,不过这些都是经验值,实际影响并不大。真正重要的是物理内存余量,只要日常压力下物理内存占用没到 80% 以上,页面文件设多大都不会被频繁用到。
虚拟内存改到其他盘有没有必要。如果 C 盘空间紧张,把页面文件挪到其他盘完全可行,Windows 并不要求页面文件必须在系统盘。但从性能角度说,页面文件是随机读写,挪到机械盘反而更慢,挪到 SSD 会好很多。而且页面文件是一个经常被读写的文件,放 SSD 上会消耗 SSD 的写入寿命,不过现代 SSD 的寿命已经不需要太在意这一点。
虚拟内存用固态还是机械盘。前面已经说了,优先放固态盘。页面文件本质上是内存的“溢出区”,它需要的是低延迟随机读写,机械硬盘的寻道时间在这个场景下是灾难。如果你有 NVMe 固态,放它上面对。
虚拟内存设置小些有什么影响。最典型的影响是,当物理内存真的吃紧且页面文件又过小,进程申请内存时可能直接返回失败,表现就是系统弹出“内存不足”,或者某个大程序运行到一半崩溃退出。普通开发机、办公机上,把页面文件设小甚至关掉,日常不太容易感觉出区别,因为物理内存通常够用。但一旦遇到内存压力瞬间冲高的场景,没有页面文件缓冲,系统可能直接触发 OOM(内存耗尽)机制,开始杀进程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程:进程内部的“轻量级分身”
2.1 为什么有了进程还要线程
进程是操作系统资源分配的基本单位,拥有独立的地址空间、文件描述符表、信号处理器等。但进程的粒度太“重”了,创建和切换的成本都很高,而且进程之间地址空间互相隔离,数据共享很麻烦,想共享一份数据就得用进程间通信(IPC)那套机制——管道、消息队列、共享内存、信号量、Socket——每一套都有学习成本和性能损耗。
线程的诞生就是为了解决“在同一个进程里跑多个执行流”的问题。线程是 CPU 调度的基本单位,同一个进程里的所有线程共享进程的地址空间和大部分资源,但每个线程有自己的线程栈和寄存器上下文。线程创建和切换的开销比进程小得多,因为线程切换不涉及地址空间切换,页表不用换,TLB 也不用刷,这在频繁上下文切换的场景下是巨大的性能优势。
用我实际维护过的 Java 服务来说,一个 4C8G 的实例上跑几百个线程是很常见的事情。如果这些执行流都被拆成独立进程,光进程切换就能把 CPU 吃干净,更不用说内存里每份代码和数据都要重复驻留。
2.2 线程安全问题的根源和解决思路
线程安全问题的根源在于“共享可变状态”。进程里所有线程共享同一块内存,所以线程 A 改一个变量,线程 B 立刻能看到——前提是没有做同步控制。一旦多个线程同时读写同一个数据,就会出现数据竞争:常见的现象是计数变少、List 变空、map 里丢了 key,或者直接抛 ConcurrentModificationException。
处理线程安全的手段,从底层往上数大概是这几类:
- 锁:synchronized、ReentrantLock、ReentrantReadWriteLock 等。通过互斥保证同一时刻只有一个线程进入临界区。
- 原子类:AtomicInteger、LongAdder 等,基于 CPU 的 CAS 指令实现无锁更新。
- 不可变对象:数据不提供修改方法,线程安全天然成立,典型的比如 String、LocalDateTime。
- ThreadLocal:每个线程一份独立副本,从根本上避免了共享。
- 线程封闭:把数据限制在某个线程的作用域内,不跨线程传递。
实际写代码的时候,我最常提醒后辈的一件事:不是“加了锁就安全”,而是要搞清楚锁的对象是谁。很多人用 synchronized 修饰方法,结果实例方法锁的是 this,静态方法锁的是 Class 对象,如果多线程走的是不同实例,那锁形同虚设。排查线程安全问题时,别急着改代码,先用 jstack 抓线程快照,看清楚竞争到底发生在哪个类、哪个对象上。
2.3 线程池的正确打开方式
线程池本质上是一个生产者消费者模型:任务提交方是生产者,工作线程是消费者,中间用一个阻塞队列做缓冲。Java 的 ThreadPoolExecutor 有七个构造参数,但核心就四个:corePoolSize、maximumPoolSize、workQueue、RejectedExecutionHandler。
这个执行逻辑值得反复背:新任务进来,先看当前线程数是不是小于核心线程数,是就新建线程执行;否则尝试塞进工作队列;队列满了再看线程数是不是小于最大线程数,是就新建线程直到达到上限;最后还处理不了,就触发拒绝策略。注意这个顺序,和很多人的直觉“先满核心线程再满队列”不完全一样——从流程上看,是核心线程满 → 队列满 → 最大线程数 → 拒绝策略。
关于线程池,热词里有两个被反复问到的问题。
submit 和 execute 的区别。submit 方法底层其实调用 execute,但 submit 会返回一个 Future 对象,可以获取执行结果、取消任务,同时它对异常的处理也更“温和”——任务内部抛异常不会直接打日志,而是被封装到 Future 里,等你调用 future.get() 时才会抛出来。execute 方法不关心返回值,任务在执行过程中抛出的异常会直接由线程池内的工作线程处理,默认打到 stdout 或日志里。如果你的任务是纯执行、不需要结果,用 execute 更省心;需要拿返回值或者传递异常,就必须用 submit。
线程池的阻塞队列怎么选。这是很多面试喜欢问、实际开发里也真会踩的问题。LinkedBlockingQueue 默认是无界队列,任务会无限排队,导致 maximumPoolSize 和拒绝策略形同虚设,极端情况下队列积压出几十万个任务,内存直接爆掉;ArrayBlockingQueue 是有界队列,可以设置容量,这是生产上最推荐的选择;SynchronousQueue 不存任务,来一个直接尝试交给线程,线程池里没有空闲线程就新建线程执行,配合 maximumPoolSize 可以实现“线程满后再让新任务阻塞”的语义;PriorityBlockingQueue 是优先级队列,适合按任务紧急程度排队的场景,但注意它不保证相同优先级任务的 FIFO 顺序。
再补充一个经常被忽略的点:线程池里的异常处理。如果用 execute 提交任务,任务内部对异常处理不当,可能会导致线程池里的线程死掉——不过 ThreadPoolExecutor 在 Worker 线程跑完 run() 后会再去队列里取下一个任务,线程不会因为一次异常就永久退出。真正危险的是你用的是 setUncaughtExceptionHandler 或者自定义线程工厂,异常处理要设计清楚,否则日志里什么都找不到。
2.4 守护线程、进程通信和“杀不死”的进程
热词列表里还有几个挺典型的问题,也一并整理了。
Java 守护线程。线程分用户线程和守护线程两类,JVM 在所有用户线程结束后就会退出,而守护线程会跟着 JVM 一起结束,GC 线程就是典型的守护线程。代码里通过 thread.setDaemon(true) 设置,但要注意必须在 start() 之前调用,否则会抛 IllegalThreadStateException。守护线程适合做一些后台任务,比如定期清理缓存,但千万不要在守护线程里做关键的数据持久化操作,因为 JVM 退出时它可能只执行到一半就被强杀了。
Linux 进程间通信(IPC)使用套接字。跨主机通信只能用网络 socket,但同一台机器上的进程间通信,其实有很多更高效的选择:Unix domain socket 走的是内核内部的 socket 机制,不经过网络协议栈,性能比 TCP loopback 好;共享内存是速度最快的 IPC 方式,但要配合信号量来做同步;管道适合父子进程之间单向通信;消息队列适合一次性传递结构化消息。选型时核心看三点:是否跨主机、数据量大不大、同步还是异步。
kill -9 杀不死进程。这个现象大概率跟进程状态有关。用 ps 看状态列,如果进程处于 D 状态(不可中断睡眠),通常是被内核驱动、IO 操作等卡住了,kill -9 也无效,因为信号处理需要进程被调度到,而 D 状态的进程根本不会检查信号队列。这种情况只能等它自己恢复,或者重启机器。另一种常见情况是僵尸进程(Z 状态),进程已经退出但还没有被父进程回收,kill 是杀不掉的,正确的做法是杀掉它的父进程,或者让父进程调用 wait() 去收尸。
3. 协程:用户态的“伪线程”
3.1 协程的本质是一个可以暂停和恢复的函数
一个线程能做到的事情,很多从系统调用层面看起来都已经很高效了,但线程的切换仍然要经过内核,每次切换都要陷入内核态,做一次完整的上下文保存和恢复,代价在微秒级。对高并发场景来说,几万并发就意味着频繁切换,CPU 相当一部分时间被消耗在切换而不是干活上。
协程的出现就是为了解决这个问题。协程是运行在线程内部的、由用户态运行时调度的并发单元。它不依赖内核调度,可以在函数级别暂停(挂起)和恢复,暂停时可以保存当前的执行上下文(寄存器、栈指针、局部变量),等条件满足时再恢复执行。因为整个过程不经过内核,协程切换的代价比线程小一到两个数量级,一个线程上可以跑成千上万个协程。
注意协程的调度方式:传统协程是协作式调度,协程自己让出 CPU,其他协程才能跑,如果某个协程写了一个死循环,整个线程上的协程都会被卡死;而 Go 的 goroutine 和 Kotlin 的协程在运行时层面实现了用户态抢占,可以主动检测长任务并切换,所以即使协程不主动让出也能调度。不过实际编码时,协程内部不要跑 CPU 密集的死循环,这仍然是一条铁律。
3.2 Kotlin 协程和 C++ 协程的落地形态
Kotlin 协程是 JVM 生态里非常成熟的一套实现。它的核心是 suspend 关键字:被 suspend 修饰的函数可以被暂停,但 suspend 函数只能在协程作用域或另一个 suspend 函数里调用。协程的“暂停”并不是把线程停掉,而是把当前状态保存在堆上,然后线程可以去执行其他协程,等网络返回、IO 完成、或者延迟到期后,再由调度器把协程恢复到某个线程上继续执行。
Kotlin 里 Dispatchers 是线程池抽象,Dispatchers.Main 跑在主线程(Android 上),Dispatchers.IO 用于 IO 密集型任务,Dispatchers.Default 用于 CPU 密集型任务。协程之间可以自由切换线程,比如子协程可以丢到 IO 线程池里跑网络请求,回来时再切回 Main 更新 UI,这套机制让异步代码写起来就像同步代码一样直白。
C++20 的协程则更偏系统级,核心是三个关键字:co_await、co_yield、co_return。co_await 用于挂起当前协程并等待某个异步操作完成,co_yield 用于产生一个值并挂起(生成器模式),co_return 用于返回并结束协程。C++ 协程最底层的特征是“无栈协程”,协程的状态被编译器拆成一个状态机,每个挂起点对应一个状态编号,本质上是一套编译期的语法糖。这也是为什么 C++ 协程上手难度明显高于 Kotlin——你需要自己实现 promise_type、awaitable、coroutine_handle 等一堆东西,才能写出一个能跑的协程。
3.3 协程和线程怎么分工
很多刚接触协程的同学会问:那是不是有了协程,线程就不需要了?当然不是。协程必须跑在线程上,线程才是真正被操作系统调度执行的单位。协程解决的是“同一个线程内多个并发任务的切换代价”问题,线程解决的是“并行执行”问题。如果一个程序跑在单核 CPU 上,无论多少协程都不会真正并行,只有多核才能并行。
实际工程里的分工大概是这样的:线程决定程序的并发容量上限,协程决定线程内部的任务切换效率。比如一个 Kotlin 服务端应用,连接数可能到上万,但线程池只配了 64 个线程,剩下的都是被挂在 IO 上的协程。线程池里的线程不会因为某个协程在等网络响应就被白白占住,而是转去执行另一批协程,这样同样的线程数能承载的并发任务量就直接翻了几倍。
4. 四者关系全景:从一次请求看虚拟内存、进程、线程、协程如何协作
4.1 从一次 HTTP 请求看完整链路
把四个概念串起来的场景,最典型的是一次 HTTP 请求从进入到返回的全过程。
假设一个 Java web 服务部署在 Linux 上,客户端发来一个请求。操作系统网络栈收到数据包后,会把它放到 socket 的接收缓冲区里。这时候 JVM 进程早在启动时就创建好了监听线程,这个监听线程阻塞在 accept() 上,本质上是在等待内核从就绪队列里把它唤醒。监听到新连接,线程池就会从池子里取一个工作线程来处理请求。注意这个工作线程是进程内存里早就创建好的,它有自己的线程栈,栈空间来自进程的虚拟地址空间,而线程栈对应的物理页,很可能是在创建时懒分配的——这就是虚拟内存参与的环节。
处理请求的过程中,业务代码可能会读数据库、调下游接口、做计算。如果使用了协程(比如 Kotlin),遇到 IO 等待时会挂起协程,并把线程“让”给其他协程使用。如果没有用协程,那么传统写法是线程阻塞在 IO 上,线程池里的其他线程继续处理别的请求,只是这个线程本身被占住了,并发上限就受限于线程数。
请求处理完之后,响应数据写入 socket 缓冲区,然后线程被归还给线程池,等待下一个请求。整个流程里,进程提供了地址空间和资源隔离,线程提供了并行处理请求的能力,协程提供了线程内部的高效切换,虚拟内存在背后保证所有内存访问都井然有序。四个概念每一层都不多余。
4.2 真实场景中的典型问题与排查实录
长期维护线上服务,踩过的坑不少,挑几个高频的整理成速查表。
任务管理器进程空白。Windows 任务管理器偶尔会出现进程列表空白,这个大概率不是系统坏了,而是权限问题——以高权限运行的进程,在低权限模式下打开任务管理器会看不到。用管理员身份重新打开任务管理器就能解决。
kswapd 进程是什么。这个进程几乎在所有 Linux 系统上都能看到,它是内核的内存回收守护线程。物理内存压力大时,kswapd 会开始换页、回收页面缓存。如果 kswapd 的 CPU 占用非常高,说明系统内存严重不足,系统的处理路径是:先回收 page cache,再换出匿名页,最后触发 OOM killer 杀进程。排查时用 free -h 看 available 列,如果一直低于 10% 总内存,就得扩容或者排查内存泄漏了。
jps 增量注解进程已禁用。IDEA 或 Maven/Gradle 编译器报的提示,通常是因为项目用了增量编译,但 JDK 版本和构建工具的增量注解处理不兼容。解决办法是关闭增量编译(IDEA 里 Build Compiler 的 Build project automatically 取消勾选),或者升级构建工具版本。它不是一个致命错误,但会导致部分增量编译后的产物不准确。
线程死锁怎么查。JVM 下用 jstack 抓线程快照,搜索 “Found one Java-level deadlock” 就能看到死锁的线程和锁对象。排查思路是看两个线程各自持有哪些锁、又在等待哪些锁,一旦形成环,就是死锁。预防手段包括:锁的获取顺序全局一致、使用 tryLock 带超时、减少锁的持有时间。
退出代码为 -1。终端里看到“进程已终止,退出代码: -1”,通常意味着程序是被信号杀掉的,比如 Linux 下 SIGTERM 或 SIGKILL。最典型的是进程被 OOM killer 杀掉,或者是系统 shutdown 流程里被终止。先看系统日志(journalctl、dmesg)确认有没有 Out of memory 关键字,再排查是不是内存配额太小。
4.3 一套顺手的排查工具和命令
工具不在多,够用就行。我日常排查这套问题最常用的就这么几个。
- Linux 查进程:ps -ef、top、htop。看状态用什么?ps aux 的 STAT 列,R 代表运行中,S 代表可中断睡眠,D 代表不可中断睡眠,Z 代表僵尸。
- Java 服务必装:jps(查 JVM 进程)、jstack(抓线程快照)、jmap(看堆)、jstat(看 GC)。Thread 死锁排查 90% 靠 jstack。
- Windows 杀进程:tasklist 查 PID,taskkill /F /PID 强杀。如果任务管理器里进程列表空白,用管理员身份运行 PowerShell,命令同样好使。
- 看线程核心数:Linux 下在进程目录里 grep 一下 /proc/
/status 里的 Threads,或者用 top -H -p 查看进程内每个线程的 CPU。 - 监控前台进程:Android 平台可以用前台服务 + 前台进程类型(并设置 android:foregroundServiceType),或者通过 ActivityManager 的 RunningAppProcessInfo 判断进程是否位于前台。
5. 这些概念背后的设计哲学,对日常编码的启示
把虚拟内存、进程、线程、协程的关系捋完,你会发现操作系统设计里有一条清晰的思路:越接近硬件的层,越追求隔离和安全;越接近应用的层,越追求效率和灵活。虚拟内存和进程提供了稳固的隔离边界,让程序崩溃不会带崩整台机器;线程和协程则是在这个边界内部,不断降低并发编程的切换成本和代码复杂度。
我自己这两年的体会是,很多线上故障的根本原因,并不是某一个单独概念没弄懂,而是这些概念之间的边界被跨了。比如线程池配置过大,导致线程过多、内存占用高、上下文切换频繁,最终触发了整个进程被 OOM 杀掉;再比如协程里写了阻塞操作,把一个线程池的线程全占住了,反过来影响了其他协程的执行。这些都是典型的“跨层”事故。
如果你现在正在做并发编程相关的优化,我的建议是先把进程和线程之间的关系当成基础知识过关,再动手调线程池参数;调协程之前,先确认线程池的模型已经清晰,否则协程只会让你更困惑。最后再分享一个小技巧:排查性能问题的时候不要只盯着 CPU 占用率,多看看上下文切换次数(Linux 下可以用 vmstat 或 pidstat -w 看),切换次数一旦到了每秒几十万次,很多时候比 CPU 高更值得警惕。
