
有个做设备上位机维护的朋友前阵子被一台机器折腾了半个月。程序跑十几个小时后界面卡顿、日志写入明显变慢重启就恢复。他在任务管理器里看内存那一列只有 190MB 上下物理内存 16GB 还空着一多半于是判断问题一定在数据库或者磁盘上来回换了三块硬盘也没解决。后来我让他切到详细信息页右键列标题把提交大小勾出来那一列显示 7.6GB而且每十分钟往上跳几十兆。同一个进程一列说 190MB一列说 7.6GB差四十倍他当场就懵了。这不是个例。任务管理器里跟内存沾边的列有四五列名字像绕口令工作集、内存专用工作集、提交大小、页面缓冲池、分页池。名字越像越没人愿意细看结果排查从第一步就跑偏——要么把正常的缓存当成泄漏要么把真正的泄漏放过去。这篇就把工作设置内存工作集内存专用工作集提交大小这三兄弟拆开讲包括它们各自怎么算、为什么对不上、怎么组合起来读、怎么把数据抓下来做长期观察以及我自己踩过的那几个坑。1. 三个数字其实是三本完全不同的账1.1 内存有三层记账方式任务管理器只给你看一层理解这三个指标最省事的办法是先接受一个事实一个进程的内存在不同维度上有三套独立的账本。第一套是虚拟地址空间也就是进程以为自己能用的那片地。32 位程序默认只有 2GB 可用地址空间64 位程序在 64 位系统上有 128TB 的理论空间。地址空间只是预留不代表真的占了任何东西。第二套是提交Commit是系统给出的承诺这片地址无论你现在有没有往里写数据只要你需要系统保证能在物理内存或者页面文件里给你腾出位置。提交一增加系统的提交限制就被消耗一份。第三套是工作集Working Set是此时此刻真的被压在物理内存条上的那部分页面是实打实占用资源的量。任务管理器进程标签页上那一列内存从 Win10 1703 之后语义趋于稳定它显示的是专用工作集Private Working Set。注意它就是第三套账里的一小块切片——只统计这个进程独占、其他进程用不了的那些物理页共享的 DLL、共享内存段、内存映射文件全部不算。为什么默认选这一列因为把共享部分算进去同一份代码页会在几十个进程里被重复计数加起来会超过物理内存总量用户看了更容易晕。用专用工作集至少能回答一个最朴素的问题把这个进程关掉能省多少内存。1.2 从那次内存没涨、提交爆了的排查说起回到开头那台设备。它的症状很有代表性专用工作集稳定在 190MB 左右不动提交大小却像爬楼梯一样匀速上涨。这个组合说明什么说明程序在不停地向系统下单但很少真的去用这些内存。用 VMMap 抓两次快照做 diff能看到涨的是 Private Data 区域而且是十几块固定大小的块在稳定增加。顺着这个线索翻代码最后定位到一个上报队列每次网络重连都会 new 一个缓冲区塞进队列消费端出错就直接 return忘了出队。队列里的对象从来没被真正写过数据所以物理内存里不体现专用工作集自然不动但每个缓冲区的地址空间都是实实在在提交了的所以提交大小一路涨。这个案例最值得记住的一点是只看内存列这类泄漏永远抓不到。而这类泄漏的危害一点都不小——它不会立刻拖慢系统但会持续消耗提交限制直到提交逼近上限程序在某次分配时直接抛内存不足而且报错的位置通常离真正的病灶十万八千里。1.3 列名对照表先存下来省得每次都查各个工具的命名各不相同下面这张表建议直接收藏。同一行是同一个东西只是叫法不同。任务管理器列名性能计数器名Process Explorer 叫法一句话含义内存专用工作集Working Set - PrivatePrivate Working Set此刻独占驻留物理内存的量工作集内存Working SetWorking Set驻留物理内存总量含共享部分提交大小Private BytesPrivate Bytes已向系统下单的私有内存RAM 加页面文件分页池Pool Paged BytesPaged Pool内核可分页池页面缓冲池Pool Nonpaged BytesNonPaged Pool内核不可分页池常被误读峰值工作集Peak Working SetPeak Working Set进程生命周期内工作集最高水位虚拟化Virtual BytesVirtual Size虚拟地址空间占用表格里最容易搞错的是页面缓冲池这个中文译名。它对应的英文是 Nonpaged Pool也就是不可分页的内核池恰恰是绝对不能换出到磁盘的那部分。名字里的缓冲两个字来自早年的翻译习惯跟缓存没有任何关系。我见过有人把这一列当成系统缓存来解读方向全错。2. 提交大小进程向系统下的那张内存订单2.1 提交承诺了什么以及承诺的代价提交的本质是操作系统对虚拟内存子系统的一种后备承诺。进程调用 VirtualAlloc 提交一段内存系统会记账这段地址空间已经在 RAM 或者页面文件里预留了落位。哪怕进程一个字节都不写这笔账也已经挂上了。这带来一个直接后果系统级的提交限制会被消耗。提交限制的计算方式是提交限制 物理内存总量 页面文件当前已分配大小注意这里说的是当前已分配大小不是初始大小也不是最大值。Windows 会随着压力按需把页面文件长上去一直长到配置的最大值。所以你在任务管理器性能页看到的提交 12.4/25.6 GB分母是会变的——今天看到的是 25.6GB明天压力大了可能变成 40GB。很多人以为分母固定看到分子接近分母就慌其实系统可能刚刚把页面文件扩容了。分子逼近分母时会发生什么新的内存分配会失败报出来的错误就是经典的内存不足哪怕物理内存还空着 8GB。这个现象有个专门的名字叫提交耗尽出错码常见的是 0x8007000E。我见过不止一个程序在物理内存富余的服务器上因为这个原因启动失败运维查了半天物理内存才发现根本不该往那个方向查。2.2 提交和工作集之间的差额到底去了哪三个地方一个进程提交了 2GB专用工作集只有 300MB中间那 1.7GB 不是凭空消失的它通常落在三个去处。第一个去处是已提交但从未写入的页。最典型的就是预分配大缓冲区代码里 Reserve 加 Commit 了一大块等着后续慢慢填结果填的速度赶不上分配的速度中间这段就是挂着账、没落位。第二个去处是被换出到页面文件或者被丢进待机列表的页。进程曾经用过这些内存但系统发现它们最近没人访问就挪走了。挪到页面文件叫换出挪到待机列表叫软换出——后者其实还在内存里只是被标记为随时可以让位。第三个去处是被工作集修剪掉的页。工作集修剪是系统在内存压力下的常规动作进程的某些页面被移出工作集需要时再按需取回。所以说提交大本身不是问题持续增长才是问题。合理的预分配设计会让提交在高位保持一条水平线有泄漏的进程则是一条稳定上扬的斜线。这两者的区别靠一次采样看不出来必须拉长时间轴。2.3 提交的斜率比绝对值有用得多我自己的习惯是只要怀疑某个进程有问题先做一次至少 30 分钟的采样每 5 秒一个点然后看提交的斜率。有几个经验阈值可以参考。稳态运行的服务提交大小的日间波动通常不超过峰值的 15%夜间的低谷和白天的高峰之间是一条平滑曲线。如果观察到提交在没有任何业务量变化的情况下每小时稳定上涨超过初始值的 5%基本可以判定有东西在累积。另一个更有价值的信号是锯齿的形态。健康的进程提交是缓慢上去、缓慢下来的锯齿有泄漏的进程锯齿的上沿被不断抬高下沿也在抬也就是每次回收都不彻底。这个形态变化比看绝对值直观得多。3. 工作集与专用工作集压在内存条上的那些页3.1 工作集是一张快照不是总量工作集最容易让人误解的地方是它看起来像个累计值实际上它是一张瞬时快照——这一刻这个进程有多少页面正驻留在物理内存里。这个特性解释了几个常见的困惑。为什么同一个程序刚启动时工作集 400MB跑一会儿降到 80MB因为启动阶段大量代码页被读进来之后就没人访问了系统顺手把它们移出工作集顺便说一句这些页大多进了待机列表还在内存里躺着并不是被释放了。为什么点了某些内存优化软件的释放内存按钮之后数字变小了因为那个按钮调的是 EmptyWorkingSet把页面全推到页面文件或待机列表里数字好看了下次访问全得读回来硬错误不降反升。这种操作纯粹是给自己看个心理安慰。所以用工作集判断问题一定要配合硬错误一起看。硬错误Hard Fault指的是必须从磁盘读回页面的次数性能计数器是 Memory\Pages Input/sec。工作集降了但硬错误飙升说明系统正在反复倒腾性能只会更差。3.2 专用与共享的切分DLL、映像和映射文件工作集减去专用工作集剩下的就是共享工作集。这一块的构成主要有三类。第一类是可执行映像的代码页也就是 exe 和 dll。同一份 kernel32.dll 被 200 个进程加载它只占一份物理内存但会出现在 200 个进程的工作集里。这就是为什么把所有进程的工作集加起来会严重超标。第二类是共享内存段包括命名共享内存、内存映射文件。数据库、浏览器这类程序用得很多。第三类是文件映射。一个程序用内存映射方式打开一个 4GB 的日志文件做随机读它可以把工作集撑到几 GB专用工作集却只有几十 MB。这种情况下任务管理器的内存列完全反映不了它的真实内存压力。反过来说这也提供了一个快速判断的小技巧如果工作集远大于专用工作集基本可以断定这个进程在大量访问映射文件或者共享库而不是自己吃掉了多少内存。想验证的话用 VMMap 看一眼 Image 和 Mapped File 两个区域的大小就够了。3.3 工作集会被系统裁剪也会自己缩回去工作集的大小不是进程自己决定的很大程度上受系统调度。内存不紧张的时候系统懒得多管进程工作集想多大就多大内存一紧张系统开始按优先级修剪工作集先修最低优先级的进程把它们的页推出去。这里有个实际影响很大的细节进程可以通过 SetProcessWorkingSetSize 设置自己的工作集上下限。设了最小值的进程系统在修剪时会尽量保它没设的进程在压力大时会被修得很狠表现为性能抖动。SQL Server 这类程序就是靠着锁定页机制尽量把内存留住所以它在任务管理器里的数字往往偏小需要看它自己的内存 DMV 才准。这一点很多人不知道看见 SQL Server 的内存列只有 2GB 就以为它没在用内存实际上它占了 20GB。4. 组合起来读五种典型数字形态和对应结论单个指标意义有限三个指标放在一起看才会说话。下面这五种形态是我这些年碰得最多的基本可以当查表用。形态提交大小专用工作集工作集最可能的原因一持续上涨基本不动基本不动虚拟地址空间泄漏分配了不用二台阶式上涨同步台阶式上涨同步上涨真实的物理内存泄漏三小且平稳小且平稳很大映射文件或共享库占主导四平稳平稳波动大系统修剪不是进程的问题五不动不动不动内核池或句柄在涨看别的列4.1 形态一提交一路涨专用工作集躺平这是最容易被漏掉的一类因为大部分人只盯内存列。它的成因在 1.2 节已经讲过一个案例除此之外还有几个常见来源。一是容器或队列只进不出。任务对象、日志缓冲、事件对象被不断创建消费失败时忘了释放就没有出队时间长了堆积成山。二是句柄关联的内核对象。每个句柄背后都有内核结构占用提交句柄泄漏通常表现为提交缓慢上涨同时句柄列同步上升。任务管理器详细信息页可以加句柄列把两列并排看如果同步在涨方向就明确了。三是线程栈。每个线程默认保留 1MB 地址空间虽然实际提交很懒但线程泄漏创建了不退出会让提交缓慢上扬同时线程列同步增长。排查这一形态的通用套路是先把句柄线程提交大小三列并排放在一起观察十分钟看是哪一列在领头涨方向立刻清晰。4.2 形态二两个指标一起台阶式上涨两个一起涨说明程序真的在吃内存而且吃进去就吐不出来。台阶形态特别值得注意因为台阶意味着分配是批量的、集中的不是零散泄漏。这类问题最常见的载体是缓存。缓存有上限的进程涨到上限就平稳缓存没有上限的进程就是一路台阶上去直到崩。判断方法很直接看提交涨到某个值之后会不会回落。会回落的叫缓存不会回落的叫泄漏。如果程序里有淘汰策略但没生效比如 key 一直不命中导致旧数据永远不被清理台阶就会一直往上走。4.3 形态三工作集很大、专用工作集很小前面讲过成因。这里补充一个排查要点这种情况下任务管理器的内存列会严重低估真实压力因为映射文件占用的物理页也是真金白银。要评估这类进程的压力得看系统级的可用内存和待机列表而不是单看进程列表。一个实用判断如果任务管理器性能页的可用在缓慢下降同时某个进程的工作集在涨而专用工作集不动那这个进程就是主要嫌疑人。4.4 形态四内存压缩和被压缩的页Win10 之后多了一个很多人没留意的机制内存压缩。当待机列表里的页面被修改过Modified Page系统不急着写回页面文件而是把它们压缩后留在内存里由内存压缩这个特殊进程持有。任务管理器性能页显示使用中已压缩括号里就是被压缩的量。这个数字大到 1 到 3GB 都很正常它代表的是系统已经在内存压力下开始倒腾了而不是有进程在泄漏。看到它的时候正确动作是找谁把内存用满了而不是去怪内存压缩这个进程本身——它只是个搬运工。顺带说一个跨平台的坑WSL2 跑起来之后任务管理器里会出现 vmmem 或 vmmemWSL它吃内存的方式很霸道用完不主动吐。可以在用户目录下建一个 .wslconfig 文件限制它[wsl2] memory8GB processors4 swap2GB改完执行一次 wsl --shutdown 重启子系统才生效。这也解释了为什么很多人觉得开了个 Docker Desktop 之后内存就再也降不下来了——不是 Windows 的问题是那台小虚拟机不肯还。4.5 形态五三个内存列都不动但机器在变慢这种情况说明内存问题出在进程之外重点看两处。一是内核池。把分页池和页面缓冲池两列打开如果页面缓冲池持续增长通常是某个驱动在泄漏。要用 poolmon 或者 WPA 才看得清具体是哪个 tag普通排查到这一步一般就得找驱动厂商了。二是句柄和 GDI/USER 对象。任务管理器里可以加GDI 对象USER 对象两列。这两个数字破万就是明确信号典型症状是界面卡顿、控件不刷新而不是内存不足。很多老程序在长时间运行后出现这个重启就好本质是资源句柄没释放。5. 把数据抓下来从手动加列到脚本采样5.1 任务管理器和资源监视器的正确打开方式先说最基本的操作。任务管理器切到详细信息页右键任意列标题选选择列才能把提交大小工作集专用工作集句柄线程页面缓冲池分页池这些勾出来。默认只给内存一列这是很多人第一步就卡住的地方。想看得更细用资源监视器在任务管理器性能页底部有入口或者直接搜 resmon。它的内存标签页有个很好的设计它会把每个进程的内存拆成提交工作集可共享专用四列并排列出还带一个硬错误/秒的实时曲线。判断是软换页还是真读盘看这个曲线最直观。提示资源监视器的数据粒度比任务管理器细但代价是开销也更大别在长期采集的场景里一直开着它。5.2 一行命令取到当前值临时确认一个进程的三个数字用 PowerShell 最快$p Get-Process -Name myapp [PSCustomObject]{ Name $p.Name PrivateWS {0:N1} MB -f ($p.WorkingSet64/1MB) # 工作集含共享 Commit {0:N1} MB -f ($p.PrivateMemorySize64/1MB) # 提交大小 Virtual {0:N1} MB -f ($p.VirtualMemorySize64/1MB) # 虚拟地址空间 Paged {0:N1} MB -f ($p.PagedMemorySize64/1MB) # 分页内存 }这里有个坑要提醒Get-Process 里没有专用工作集这个直接属性。WorkingSet64 是完整工作集PrivateMemorySize64 是提交大小。想拿专用工作集只能走性能计数器(Get-Counter \Process(myapp)\Working Set - Private).CounterSamples[0].CookedValue / 1MB如果同一个程序开了多个实例计数器会返回带 #1、#2 后缀的多个实例取的时候要注意筛选否则脚本会静默取错。5.3 长时间采样和斜率判断一次采样看不出泄漏必须拉长时间。下面这个脚本每 5 秒采一个点把三个指标和时间戳一起落盘跑一晚上第二天用表格算斜率$name myapp $out C:\temp\mem_$(Get-Date -Format yyyyMMdd_HHmm).csv Time,PrivateWS_MB,Commit_MB,WorkingSet_MB,Handles,Threads | Out-File $out while ($true) { $p Get-Process -Name $name -ErrorAction SilentlyContinue if ($p) { $pws (Get-Counter \Process($name)\Working Set - Private -ErrorAction SilentlyContinue).CounterSamples[0].CookedValue {0},{1:N1},{2:N1},{3:N1},{4},{5} -f (Get-Date -Format HH:mm:ss), ($pws/1MB), ($p.PrivateMemorySize64/1MB), ($p.WorkingSet64/1MB), $p.HandleCount, $p.Threads.Count | Out-File $out -Append } Start-Sleep -Seconds 5 }如果不想用脚本cmd 下的 typeperf 也能干同样的事而且开销更低适合长时间挂在生产机上typeperf \Process(myapp)\Working Set - Private \Process(myapp)\Private Bytes ^ \Process(myapp)\Handle Count -si 5 -sc 1440 -o C:\temp\mem.csv采集完之后我一般直接把 CSV 丢进 Excel 画折线同时算一个简单的线性拟合。判断标准是斜率显著为正、且相关系数很高说明是匀速上涨而不是随机波动基本可以定案。5.4 VMMap 做二次定位确定是泄漏之后下一步是搞清漏在哪一类。这时候 VMMap 是无可替代的工具。它的价值在于把进程内存按类型拆开Image、Mapped File、Shareable、Private Data、Heap、Stack、Managed Heap。操作方式很简单程序跑起来之后抓第一次快照跑一小时后抓第二次用它的对比功能看 diff。如果涨的是 Private Data多半是原生堆或缓冲区涨的是 Managed Heap那就是托管堆里对象没释放涨的是 Mapped File说明是在映射文件上出了问题。有了这个结论排查范围能从整个代码库缩到几个模块。6. 容量估算到底给进程留多少内存才够6.1 拿峰值提交做上限而不是峰值工作集这是最反直觉、也最重要的一条经验。做容量规划时要用峰值提交大小不是峰值工作集。原因很直接提交耗尽是导致程序崩溃的直接原因而提交耗尽跟工作集没关系。一个进程可能工作集只有 200MB提交却接近 2GB32 位进程这时候物理内存还剩一堆程序照样崩。具体做法是让程序跑完一轮完整的业务周期包含所有峰值场景记录提交的最高水位然后按 1.5 倍留余量。生产环境的经验值是单机的总提交使用率长期不要超过 70%峰值不要超过 85%。超过这个线某些瞬时的大块分配就可能失败而且失败位置随机很难复现排查成本极高。6.2 多进程和 WSL 场景的算法多进程场景下直接相加各进程的专用工作集是高估相加提交是接近准确的。因为共享代码页在提交里基本不重复计入而专用工作集本来就不含共享。实际估算可以这样做预留总量 ≈ Σ(各进程峰值提交) 内核与系统占用(约 1.5~2GB) 页面文件余量如果机器上还跑 WSL2 或者 Docker Desktop得额外把 vmmem 算进去按 .wslconfig 里配置的上限加 20% 来估。很多人算容量时忘了这一块最后发现什么都没跑内存就没了。6.3 把页面文件全关掉是个经典的自伤操作网上一直有关掉页面文件能提速的说法这个说法在物理内存远超实际需求的老机器上有那么一点道理但在现代系统上几乎全是坏处。第一提交限制会塌到接近物理内存大小。所有依赖提交的程序都会提前失败尤其是那些习惯预分配大块地址的程序和大型 IDE。第二崩溃转储无法生成。程序崩了之后你想抓 dump 分析系统告诉你转储空间不够这时候再回头开页面文件也来不及了。第三系统的内存回收手段少了一个。工作集修剪出去的那些页无处可去只能留在待机列表里内存压力会更快传导到压缩机制上。我的一般建议是把页面文件交给系统托管或者手动设一个固定大小物理内存的 1 到 1.5 倍放在 SSD 上这样至少能保证提交限制稳定可预测。7. 几个我踩过的坑和反复验证过的经验7.1 只盯内存列是最高频的误判来源我自己早期也干过这事客户说卡我看内存列没涨就直接把内存那条线划掉了最后折腾了两天才发现是提交在涨。从那以后我养成了一个习惯——排查性能问题第一动作就是把提交大小句柄线程三列加出来哪怕这次真的不是内存问题花十秒钟确认一下也值。顺带说内存列在任务管理器里还会随版本变化语义跨版本对比数据时要留意。用性能计数器采集的数据反而更稳定因为它有明确的定义文档。7.2 把待机内存当成被占用会得出完全错误的结论任务管理器性能页里可用是包含待机内存的。也就是说一台机器显示可用 2GB不等于只剩 2GB 能用那 2GB 里很大一部分是随时可以让出来的缓存。判断是不是真的紧张要三个指标一起看可用的绝对值、硬错误/秒、已压缩的大小。可用低但硬错误接近 0、已压缩很小说明缓存用得很充分机器状态良好可用低、硬错误飙升、已压缩涨到几 GB才是真紧张。这个区别直接影响扩容决策。我见过有人因为可用一直只有几百兆就申请加内存结果加完发现性能毫无变化——因为原来那台机器的瓶颈根本不在内存。7.3 32 位程序的地址空间地板比你想的低32 位进程在 64 位系统上默认只有 2GB 用户态地址空间就算做了 Large Address Aware 也只有 4GB。实际经验是提交到 1.6GB 到 1.8GB 之间就很容易开始随机失败因为地址空间碎片化之后找不到连续的大块。如果程序报内存不足但任务管理器看着还有余量先确认它是 32 位还是 64 位。这个信息在任务管理器详细信息页加平台列就能看到。很多老工业软件至今还是 32 位的这种情况加物理内存完全无效只能从减少预分配、拆分进程或者直接换 64 位版本入手。7.4 关于无法结束进程这类现象有时候想结束一个占内存的进程任务管理器提示无法完成操作、拒绝访问。这通常不是内存指标的问题而是权限或进程性质的问题可能是受保护进程可能是 SYSTEM 权限下运行的也可能是被其他进程持有句柄。处理方式是先用管理员身份运行任务管理器仍不行再用命令行taskkill /PID 12345 /T /F/T 是连同子进程一起结束。如果还失败多半是驱动级或者受保护进程硬杀有风险得先弄清它是谁的依赖。我现在的习惯是只要一台机器出现跑一段时间就变慢的投诉第一件事不是看 CPU而是把提交大小和句柄两列开出来挂半小时。这两个数字的组合形态能解释掉我遇到过的八成以上的长时间运行类故障。真正让人头疼的从来不是内存不够而是账本看错了方向。