
如果你曾经在编译一个稍微大点的项目时突然发现风扇狂转、鼠标移动都开始卡顿打开任务管理器一看内存占用飙到 90% 以上而你的编译进程已经长时间“看起来还活着但实际毫无反应”那你大概率遇到了经典的“编译期内存不足”。我自己在开发机和云服务器上都踩过这种坑而且踩过不止一次。先说结论这种情况不用急着加物理内存配置好虚拟内存也就是交换空间/页面文件往往就能让编译任务从“卡死”变成“慢速但能完成”。这篇文章不打算绕弯子直接从编译为什么会吃满内存讲起把 Linux、Windows 和 macOS 三个平台上的虚拟内存配置步骤都过一遍再附带一些编译参数调整和排查技巧。无论你是用 8GB 内存的笔记本还是在低配云主机上做构建这套折腾路径基本都能覆盖。1. 编译内存吃紧的根源以及“卡死”是怎么发生的1.1 编译过程真正的内存大户很多人以为编译就是“把源代码翻译成机器码”应该不费什么内存。但实际走一遍编译流程就知道现代编译器的中间表示、运行时优化、模板展开和代码生成阶段内存消耗比很多人想象得大得多。以 C 项目为例前端的语法解析阶段会为每个翻译单元构建一棵完整的抽象语法树AST。当你 include 了一堆头文件尤其是标准库、模板库这类“头部重灾区”AST 节点数会迅速膨胀。一个稍微复杂一点的 .cpp 文件前端峰值轻松吃掉 500MB 到 1GB 内存如果是重度模板代码单文件峰值 2GB 并不罕见。到了优化阶段编译器会构建各种中间表示IR并进行多轮 pass 处理。每轮 pass 都可能产生新的数据副本内存占用会再次爬升。一个几百行的函数可能没什么感觉但一个由代码生成器产出的大型源文件IR 规模可以非常夸张。链接阶段同样不轻松。传统链接器需要读取所有目标文件进行符号解析、重定位等操作整个过程会维护大量的符号表和重定位信息。如果开启了 LTO链接时代码优化链接器相当于把各个编译单元的核心中间表示全载入内存重新分析内存峰值可以直接翻几倍。我用过一个带了 LTO 的中大型项目链接阶段一度吃掉 6GB 以上。1.2 并行编译把内存需求放大成倍数关系内存不足还有一个高效的“放大器”并行编译。make -j$(nproc)这类命令很常见大家的本能是“有多少核就开多少编译任务”。想法没错但这里的算账逻辑要重新审视编译峰值内存 ≈ 单个编译进程的峰值内存 × 并行编译任务数。假设一台 8 核机器每个编译进程在优化阶段峰值内存 1.5GB开 8 个并行任务就是 12GB。如果物理内存只有 8GB系统早就撑不住了。就算你把并发数降到 44 × 1.5GB 6GB加上操作系统、桌面环境、浏览器等基础开销内存依旧告急。所以很多“编译卡死”并不是某一步要把内存涨到天上去而是并行度没有和物理内存做出匹配。虚拟内存在这里不是用来“无中生有”的而是给“并发数偏高、内存加载偏满”的场景准备一个缓冲垫。1.3 所谓“卡死”其实是内存管理系统在极限挣扎没有虚拟内存兜底、物理内存又耗尽时系统会发生什么Linux 上有多种机制在极限状态下轮番上阵内核会先回收 page cache尝试把干净的页释放出来如果还不够就可能开始内存压缩和频繁换页。但一旦内存短缺到连核心进程都无法分配内核会调用 OOM Killer 来终止部分进程。麻烦的是这个过程中磁盘 I/O 往往已经被打到很高。你看到的现象就是系统像是“死”了鼠标还能动窗口切换非常慢编译进程既不结束也不输出磁盘灯常亮。Windows 上如果没有足够的页面文件也会出现类似的内存耗尽崩溃或者直接弹“内存不足”提示。可以说编译卡死的关键原因之一就是虚拟内存空间太小甚至没有系统连“挣扎”的能力都很差。给它一个足够大的虚拟内存至少可以让任务继续跑下去。2. 虚拟内存为什么能救编译场景以及它的天然边界2.1 核心原理让不常用的数据暂时搬到磁盘虚拟内存本质上是一个思路把物理内存里暂时用不上的页面换出去放到磁盘的一个专门区域Linux 的 swap、Windows 的页面文件腾出物理内存给正在活跃使用的页面。编译进程要继续访问“换出去”的页面时系统再把数据换回来与此同时别的页面又被换出去。听起来很繁琐但它确实能解决内存物理容量不足的问题。关键在于编译过程中有大量数据是“一次性”的。举个例子编译器前端解析完成后AST 基本就不再被核心后续阶段访问了。如果这些数据暂时被换到磁盘逻辑上完全没问题因为它们已经完成了历史使命。类似的数据还有各轮优化 pass 产生的临时中间结果。虚拟内存非常懂得如何存放大批“历史遗留数据”这正是编译场景的典型特征。2.2 为什么“慢一点”总比“卡死”好虚拟内存要付出的代价是速度。内存带宽目前是几十 GB/s 甚至更高而磁盘的速度要差几个数量级。不过这里有个容易被忽略的事实物理内存耗尽时系统的表现是“完全不可用”而虚拟内存充足时的表现最多是“编译速度严重下降”。在开发场景里慢速完成任务要远远好于无限期卡死之后被迫抓包、重来。我在某台 8GB 的旧笔记本上编译过一个模拟图像处理项目不开 swap 时中途必然卡死配置完 16GB swap 之后虽然整个编译过程慢了不少但能稳定跑完。从那之后配置虚拟内存成了我上任何低配开发机后的第一件事。2.3 虚拟内存的边界不是所有问题都能硬扛不过也别把虚拟内存神话。遇到下面这些情况虚拟内存帮不上什么大忙内存确实不足以支撑进程的基本“活跃工作集”整个编译流程都在反复访问大量数据这种情况会导致抖动即频繁换入换出速度可能慢到你怀疑人生。进程地址空间已经不足比如 32 位程序在 Linux 用户态只有 3GB 地址空间可用给它多少 swap 都白搭。容器被 cgroup 限制死了内存配额宿主机再大的 swap 也不好穿透限制。所以虚拟内存适合的场景是“峰值内存需求偶发且大部分内存页面可以牺牲速度换来空间”。编译恰好符合这个特征。3. 实操三个主流平台的虚拟内存配置3.1 Linux用 swap 文件临时救急的最快命令Linux 配置虚拟内存推荐用 swap 文件理由很简单不依赖预留分区不用为了扩扩容去破坏现有磁盘布局删除也方便。不管你是 Ubuntu、Debian、CentOS 还是其它发行版思路基本一致。先检查一下当前系统有没有启用 swapfree -h swapon --show如果free -h里 Swap 显示全 0或者你发现空间太小那就开始创建 swap 文件。下面以 16GB 为例# 创建一个 16GB 的空文件单位也可以写成 16G sudo fallocate -l 16G /swapfile # 权限必须收紧否则存在安全风险且部分新系统会拒用 sudo chmod 600 /swapfile # 格式化为 swap 文件系统 sudo mkswap /swapfile # 启用 sudo swapon /swapfile再执行free -h你应该能看到 Swap 这一项从 0 变成了 16GB。这里提一个实际经验某些文件系统或内核版本下fallocate创建的文件虽然大小正确但访问时可能出问题。如果你在swapon时见到 “swapon: /swapfile: swapon failed: Invalid argument” 一类的报错别纠结直接用 dd 方式来创建稳妥得多sudo dd if/dev/zero of/swapfile bs1M count16384 statusprogress # 后续的 chmod、mkswap、swapon 步骤与上面一致重启之后想要让 swap 文件仍然生效需要把它写进/etc/fstab。这里只说最简配置/swapfile none swap sw 0 0注意不要在这种自定义文件系统上瞎试 Btrfs、ZFS 等高级特性。如果你用的是普通 ext4/xfs 文件系统上面这套流程没问题如果是 Btrfsswap 文件受限较多最好用 swap 分区或者另辟一块 ext4 分区。ZFS 目前不支持 swap 文件。3.2 调整 swappiness让系统在编译时更“舍得用 swap”Linux 默认的 swap 行为是由vm.swappiness参数控制的范围 0 到 100值越高表示内核越“乐意”把内存页面换到 swap。很多发行版默认是 60也就是物理内存占用到一定程度就开始倾向于换页但如果你希望优先保留文件缓存、尽量少换页编译场景可以把它调低一些。执行sudo sysctl vm.swappiness20这是临时生效重启后恢复。要永久生效写入配置目录echo vm.swappiness20 | sudo tee /etc/sysctl.d/99-swappiness.conf有人会问能不能直接把 swappiness 设成 0这需要谨慎。编译场景下设成 0 意味着内核更倾向于保留物理内存但不积极回收匿名页面一旦内存被占满反而更容易触发 OOM。我的经验是 10 到 20 比较合适让内核在紧急情况下愿意拿 swap 去换但平时又不要没事就换页搞出一堆无谓的磁盘 I/O。3.3 容器环境里配置虚拟内存的特殊姿势如果你是在容器里编译比如 Docker 开发容器直接在里面swapon往往行不通因为容器通常没有权限修改内核的 swap 设置。正确的做法是在宿主机配置好 swap然后通过容器运行参数让容器能用到宿主机内存配额。一个比较常见的编译容器启动方式docker run --memory4g --memory-swap8g --name build-env -it your-dev-image这里的--memory4g限制容器最多使用 4GB 物理内存--memory-swap8g表示容器最多可以使用 4GB 物理内存外加 4GB 的宿主机 swap。所以逻辑上你得先在宿主机把 swap 扩容做到位再给容器较大的--memory-swap限制。如果第一次在容器里编译莫名其妙被杀死先看是不是容器自身的内存配额不够而不要把锅全甩给宿主机。3.4 Windows 和 macOS 用户的简版配置方案Windows 上的虚拟内存对应就是“页面文件”。配置路径大致是打开“设置” - “系统” - “关于”点“高级系统设置”。在“性能”区域点“设置”。切到“高级”选项卡在“虚拟内存”区域点“更改”。取消勾选“自动管理所有驱动器的页面文件大小”。选择磁盘勾选“自定义大小”填写初始大小和最大值然后点“设置”并确定重启后生效。对于编译场景我建议直接把初始大小和最大值设成同一个值避免系统反复扩展页面文件时产生额外卡顿。比如内存是 16GB想留点余量可以设成 24GB 或 32GB。如果你有另一块 NVMe 固态也可以把页面文件转移到那块盘上但系统盘最好保留一个相对小的页面文件用于内核崩溃转储以及关键系统操作。macOS 上通常不需要手动配置 swap。系统会根据内存压力自动把内存页面压缩或换入/private/var/vm下的 swap 文件。如果编译时内存吃紧通用做法是关掉浏览器和无用 App少开几个大内存消耗程序。活动监视器里的“内存压力”图表如果长期处于黄色甚至红色区域那说明内存是真的不够了靠 swap 硬顶只会换来慢速编译。4. 配合虚拟内存编译参数必须一起松绑4.1 安全并发数的粗算方式配置好虚拟内存之后千万别立刻重新用make -j64那种极端并发去挑战它。虚拟内存能兜底但兜不住过度并发导致的换页风暴。你可以用一个很粗糙的公式去估算安全并发数安全并发数 ≈ 可用内存 / 单个编译进程峰值内存实际项目里编译器前端的峰值内存不好精确预估。经验值是C 编译进程按 1GB 到 2GB 估算具体取决于项目是否重度用到模板、是否启用强优化。保守起见小内存机器先按 1.5GB 估算。假设你的机器可用内存是 8GB那并发数取 4 到 5 就比较安全。注意我说的“可用内存”要减去系统日常占用比如桌面环境开着浏览器那可用内存就更低了。4.2 make、ninja 与 CMake 的具体限制方法不同构建系统控制并发数的方式基本都围绕-j参数展开# make / GNU Make make -j4 # ninja ninja -j4 # CMake 生成的构建系统 cmake --build build -j4如果你的构建脚本写在别处也可以临时用环境变量覆盖。比如 CMake 项目cmake --build build -- -j2这里我个人的建议是内存吃紧时不一定要把并发数压到 2那会浪费 CPU。宁可先设-j4观察一会儿任务管理器或free -h如果 swap 用量在持续增长且系统响应明显变慢再降到-j2。动态调整几次就能找到这台机器的“甜点并发数”。4.3 链接器和 LTO 的内存控制对大型 C 项目来说链接阶段的内存峰值往往是隐藏炸弹。如果你没有使用 LTO链接器内存压力相对可控一旦开启-flto编译阶段的内存峰值会明显上升链接阶段更是可能成倍增长。所以在内存紧张时果断关掉 LTO收益非常直接。如果你不在 C 项目但在 Rust 项目里遇到 LTO 吃爆内存也一样跳过 LTO 构建、使用更细粒度的 codegen units 数量往往能压下来不少内存。另一个思路是换链接器。很多发行版默认的 GNU ld 内存占用偏高换成 LLVM 的 lld 通常更快、内存更低。如果你的编译器是 Clang使用-fuse-ldlld即可GCC 也能通过-fuse-ldlld配合指定路径来用 lld不过版本比较老时可能步骤多一些。实测下来大型 C 工程从 GNU ld 换到 lld链接内存占用一般能降两到三成链接速度还能提升不少。4.4 省内存的其他旁门左道除了限制并行度和调整 LTO还有几个细节组合起来效果也不错关闭调试信息。-g0或者构建时设置CMAKE_BUILD_TYPERelease不仅能减小目标文件体积同时也能降低编译中内存中的符号表压力。使用-gsplit-dwarf。如果你确实需要调试信息GCC/Clang 支持把调试信息拆成分离 DWARF 文件这样链接器的内存占用会降低不少。用 CCache 或 sccache。增量编译时命中缓存后编译进程直接跳过完整处理阶段内存峰值断崖式下降。首次全量编译也许还是吃内存但你有了一次成功构建后之后每次编译的负载都会小得多。减少并行链接。有一些构建系统默认会并行跑多个链接任务。内存紧张时最好让链接串行make -j能限制就限制或者强制工作在“构建和链接不并行”的模式。拿我某台 8GB 内存的机器来举例一个中大型模拟系统项目的编译最终落地配置是先创建 16GB swapswappiness20只开make -j2加-fuse-ldlld暂时关掉 LTO 和调试信息。编译虽然不像高配机器那样快但整个流程是稳定的再也不会出现中间卡死的鬼样子。5. 效果验证与常见问题速查5.1 怎么确认你的虚拟内存真的在起作用配置完成并启动编译后别只盯着“文件能编过”这一件事。多用系统工具观察内存曲线能帮你快速判断 swap 是否发挥作用。Linux 上核心命令就几个# 看当前内存和 swap 总量与剩余 free -h # 看 swap 使用量 swapon --show # 每 1 秒刷新一次看 si/so 换入换出列 vmstat 1 10vmstat输出里的siswap in和soswap out如果经常出现非零数值说明系统确实在用 swap。如果so数字高得离谱且持续很久大概率是并发开太猛了可以适当降低-j。想更精确拿到编译进程的峰值内存可以用 GNU time 来跑编译命令/usr/bin/time -v make -j2 21 | grep -E Maximum resident|Elapsed输出里的Maximum resident set size (kbytes)就是进程峰值的物理内存占用单位是 KB。以后调并行度时你不再猜直接看数值定参数。Windows 上比较简单任务管理器切到“性能”-“内存”能看到页面文件使用情况如果编译过程中“分页的已提交内存”曲线不断上升说明系统正在积极使用页面文件。性能监视器里也可以加计数器来看页面文件的活动与 % Usage不过日常观察任务管理器就够用。5.2 编译内存问题的典型排查与避坑整理了一张速查表几乎覆盖了我遇到过的所有“编译时内存不足”相关情况。现象可能原因处理方式编译进程被直接杀掉日志尾部出现 “Killed”Linux OOM Killer 触发加大 swap、降低并行度、减少单进程峰值内存系统键盘鼠标都卡成幻灯片物理内存耗尽系统在疯狂回收与换页检查 free -h / 任务管理器调大 swap降低-jswap 文件创建后无法启用报 Invalid argument文件系统不支持或 fallocate 留下空洞文件改用dd创建或换到 ext4/xfs 分区swap 明明很大编译还是被杀容器配额/cgroup 内存限制检查docker inspect或 systemd 对进程的 MemoryLimitswap 空间被瞬间占满并行度过高发生换页风暴立即降低-j等待系统换页平缓编译速度下降到一个无法接受的程度swap 放在机械硬盘上或者并发太高用 SSD/NVMe 作为 swap 介质降低并发数某一步提示无法分配内存进程地址空间耗尽32位场景改用 64 位构建环境删除超大调试信息段开了-flto后内存爆表LTO 链接器加载大量 IR临时关闭 LTO或先做普通链接这块还有一个容易被忽视的坑swap 文件所在磁盘的剩余空间。很多人的/分区并不大而编译中间产物也占地方。你得同时确认/tmp有足够空间和 swap 文件所在分区有足够空间两回事缺一个都会导致诡异失败。老规矩再提醒一句。swap 救急是有效的但它在机械硬盘上的体验真的不好。如果你有 NVMe 固态或者至少是 SSD把 swap 文件放到这块盘上编译过程的卡顿感会明显减轻。要是你还在用旧式机械硬盘当系统盘“编译慢”这件事就别幻想靠 swap 解决了物理内存升级或者多买内存条才是正路。我个人在实际操作中的体会是虚拟内存就像开发机上的保险丝平时你几乎感觉不到它的存在但一旦编译的峰值内存顶到极限它就是那个让你免于全盘崩溃的关键。与其等卡死之后再抢任务管理器不如在编译任何大项目之前先把 swap 或页面文件配好再顺手把并行度压到合理范围。这套操作加起来也就十分钟换来的是整个编译流程的稳定可靠。以后你还会遇到更奇怪的内存问题但至少“编译到一半系统变砖”这种状况应该能从你的开发日常里彻底消失了。