凌晨两点被电话叫起来,说整个服务集群大面积不可用,登上去看到的日志第一行就是A fatal error has been detected by the Java Runtime Environment:,后面跟着SIGSEGV、Problematic frame、Core dump will be written这些字眼。这种场面我经历过不止一次,第一次的时候完全懵,因为平时处理的都是NullPointerException、OutOfMemoryError这类有明确堆栈的异常,而这行字背后往往什么都没有——业务日志干干净净,只剩一个hs_err_pid文件躺在工作目录里。这篇文章就是围绕这条报错展开的:它到底在说什么、日志该怎么读、常见根因怎么分、复现和验证怎么做、参数基线怎么定。适合已经能写业务代码但没怎么碰过虚拟机底层的开发者,也适合需要值班处理线上事故的运维同学,全文尽量用大白话加实操命令,能直接抄着用。
1. 先把这条报错读明白:它不是异常,是事故现场
1.1 这条报错和业务异常是两本完全不同的账
很多人第一反应是去业务日志里找堆栈,这是个方向性错误。A fatal error has been detected by the Java Runtime Environment:这行字出现的前提,是虚拟机自身已经无法继续执行下去了,它不是在 Java 代码层面抛了个对象,而是虚拟机在运行过程中踩到了自己处理不了的硬件级或操作系统级错误。打个比方:普通异常像是店里某个员工操作失误,店长写个报告记一笔,生意照做;而这条 fatal error 像是店面本身塌了,所有人只能往外跑,跑之前有人匆忙写了一张纸条说明塌的时候发生了什么。
这两本账的区别体现在几个地方。第一,普通异常有完整的 Java 堆栈,从at com.xxx一路指到出问题的那一行;fatal error 没有这玩意,它给出的是一串十六进制的程序计数器地址、寄存器快照、以及一个被称作“问题帧”的位置,通常是某个本地库的某个函数。第二,普通异常可以被try-catch捕获、被全局异常处理器兜住;fatal error 一旦发生,虚拟机会调用自己的错误报告模块,把现场信息写进文件,然后直接abort进程,finally块不保证执行,shutdown hook不保证执行,连接池不会被优雅关闭。第三,普通异常通常只影响单个请求;fatal error 意味着整个进程没了,如果是单机部署,服务直接中断。
理解这一点的实际意义在于:排查方向完全不同。业务异常的排查靠读代码逻辑,fatal error 的排查靠读现场信息、靠对比环境差异、靠二分法缩小范围。你拿读业务异常的思路去猜“是不是哪个 if 写错了”,基本不可能有结果。我见过有同学在这种报错面前反复 review 业务代码三天,最后发现是容器内存限额和堆参数不匹配,这类问题在代码层面永远看不出来。
还有一点容易误解:这条报错并不代表内存一定有问题。它是个“总入口”,内存、本地库、指令集、文件映射、线程资源,任何一环出问题都可能走到这个打印语句。所以看到它第一件事不是猜,而是去捞那个自动生成的现场文件。
1.2 谁在什么节点打出这行字
这行输出不是你的代码打的,是虚拟机的错误处理器打的。主流虚拟机在启动时会为一批致命信号注册处理函数,包括SIGSEGV(段错误)、SIGBUS(总线错误)、SIGILL(非法指令)、SIGFPE(算术错误)等。当进程收到这些信号,且虚拟机判定自己没法安全恢复时,就会接管控制权,执行下面这套动作。
第一步,尝试停止所有应用线程,这个动作叫“安全点停顿”,但在已经崩溃的情况下经常只能尽力而为。第二步,收集现场信息:操作系统类型和版本、CPU 架构和指令集特性、内存总量和可用量、虚拟机的完整启动参数、环境变量、加载的所有动态库列表、所有线程的栈、堆的各个分区用量、代码缓存的占用情况、崩溃前的一批内部事件记录。第三步,把这些内容写进一个文件,命名规则是hs_err_pid<进程号>.log,默认落在进程的当前工作目录下;如果目录不可写,会退到/tmp之类的备用位置。第四步,如果系统开启了核心转储,还会触发一次 core dump。第五步,进程退出。
这里有个特别值得记住的细节:工作目录可写性直接决定你还剩多少线索。很多容器镜像的启动脚本把工作目录设成只读挂载,或者干脆是/,结果崩溃后你以为“什么都没留下”,其实是文件写到了别处或者根本没写成功。所以部署时一定要显式指定错误文件路径,这个后面给参数模板。
另外,有些进程根本不会走到这行打印。比如被操作系统的内存回收机制直接干掉的进程,通常连这行字都没有,日志里只有一个Killed,这种情况下现场文件不存在是正常的,得去查系统内核日志。这个区分非常关键,后面单独说。
1.3 先做一次五分钟的粗筛
崩溃发生后,我习惯先花五分钟做一轮快速分类,避免一头扎进细节。这五分钟问四个问题。
第一个问题:是启动就崩,还是跑一段时间才崩?启动阶段就崩,嫌疑范围立刻收窄到环境层:CPU 指令集不支持、依赖的本地库版本不对、文件系统权限、内存限额太小导致虚拟机初始化失败。运行一段时间才崩,嫌疑范围就转向:内存增长、本地库在特定输入下出错、并发竞争、资源泄漏。
第二个问题:有没有生成现场文件?有文件,说明是虚拟机主动接管的崩溃,信息量大。没文件但有Killed字样,说明是系统层面的强制终止。没文件也没Killed,可能是进程被信号直接杀掉且文件写入失败,得看系统日志。
第三个问题:崩溃前业务在做什么?是不是有大批量导入、是不是刚上线新版本、是不是流量高峰、是不是刚好在做定时任务。这些信息决定了复现路径怎么设计。
第四个问题:是单机现象还是全集群现象?单机通常是环境差异(这台机器的库版本不同、内存规格不同)。全集群同时崩,基本可以指向统一变更(新版本、新参数、新镜像)或者上游流量突变。
这四问不需要工具,纯靠信息和记忆,但能省掉大量瞎猜。我遇到过最典型的一种情况是:开发同学在本地完全复现不出来,因为本地是普通物理机,而线上是容器环境,内存限额被设成堆大小的 1.1 倍,虚拟机光是初始化各种结构就把限额吃满了,一分配对象就踩到上限。这种“环境差异类”问题,只有先把分类做对才有机会发现。
2. hs_err_pid 日志精读:二十分钟锁定方向
2.1 头部信息:信号、地址、问题帧
打开现场文件,前二十行含金量最高。以一个典型片段为例:
# A fatal error has been detected by the Java Runtime Environment: # # SIGSEGV (0xb) at pc=0x00007f3a2c1d4b21, pid=21876, tid=21902 # # JRE version: OpenJDK Runtime Environment (17.0.9+9) (build 17.0.9+9) # Java VM: OpenJDK 64-Bit Server VM (17.0.9+9, mixed mode, sharing, tiered, g1 gc, linux-amd64) # Problematic frame: # C [libnative_core.so+0x1c4b21] do_transform+0x31 # # Core dump will be written. Default location: /data/app/core.21876逐行看。第一行是标题,没有信息量。第二行信息量极大:信号类型、错误地址pc、进程号pid、线程号tid。SIGSEGV是段错误,说明进程访问了一块它没权限访问或者压根没映射的内存。pc=0x...是出错的指令地址,这个地址配合后面的库列表可以算出具体在哪个动态库的哪个偏移。
第三、四行告诉你虚拟机的版本和运行模式。注意括号里那一串mixed mode, sharing, tiered, g1 gc之类的标记,它们反映的是当前生效的配置:解释器和即时编译混合执行、开启了类数据共享、开启分层编译、垃圾收集器是 G1。如果这段里出现了和你预期不符的收集器或者模式,说明启动参数没生效,本身就是个线索。
第五、六行是“问题帧”,也就是虚拟机认为最可能出错的位置。这里的格式是类型 [库名+偏移] 函数名+函数内偏移。类型标识有三种常见取值:C表示本地代码,j表示 Java 代码,V表示虚拟机内部代码。上面这个例子里是C,指向一个自定义本地库libnative_core.so里的do_transform函数,偏移 0x31。到这一步,排查方向基本就明确了——问题出在本地库,不是 Java 层的逻辑。
如果问题帧显示的是V [libjvm.so+0x...],那说明虚拟机自己的代码崩了,量级更严重,通常意味着要么虚拟机版本有缺陷,要么它依赖的系统库有问题,要么内存被外力破坏。如果显示j com.xxx.Foo.bar,那说明崩溃发生在执行 Java 方法的过程中,但注意,这不代表 Java 代码本身有问题,更可能是这个方法刚刚调用了一个本地方法,或者刚刚访问了一个被释放的对象引用。
紧接着错误信号还有一个关键的细节在siginfo段:
siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0000000000000000si_code是判断性质的关键。SEGV_MAPERR意思是访问了一个未映射的地址,si_addr为 0 就是典型的空指针解引用;SEGV_ACCERR意思是地址映射了但权限不对,常见于栈溢出踩到保护页、或者往只读段写数据;SIGBUS里常见的是BUS_ADRERR,往往是映射的文件被截断或磁盘满导致写入失败。这几种在原生日志里长得极像,但对排查方向的指向完全不同,看错一次就得白忙两天。
2.2 线程栈:倒下的是哪个线程
现场文件里有一大段是各线程的栈。找到出问题的那个线程(对应头部的tid),看它的完整调用链。这段信息的价值在于上下文:它告诉你这个线程在崩溃的瞬间正在执行什么。
栈的呈现方式分两层:Java frames列 Java 方法调用链,Native frames列本地调用链。如果一个线程的本地栈里出现了libnative_core.so,然后往上几层出现了Java_com_xxx_NativeBridge_transform,那就能把问题精确定位到“某个 Java 方法通过本地接口调进了某个 C 函数”。如果本地栈里出现的是libc.so的malloc、memcpy、free,那多半是内存管理出了问题,可能是堆外内存被写坏、可能是双重释放。
我特别关注栈里的两类特殊信号。一类是栈深度。如果某个线程的栈帧多到溢出显示,同时又伴随SEGV_ACCERR,基本可以判定是本地栈溢出,常见于深度递归、超大局部变量数组、以及线程栈设置过小(-Xss参数)。另一类是看起来正常的栈。有些崩溃的线程栈非常浅,就一两层,这种往往意味着栈本身已经被破坏,返回地址被写坏了,实际执行位置和栈上显示的完全不是一回事。
除了崩溃线程,还要看其他线程在干什么。如果发现大量线程卡在同一把锁的获取上,而崩溃线程正好持有这把锁,那崩溃的根因可能是一次本地方法的死循环或者长时间阻塞,最后被外部信号打断。这部分不需要全看,扫一眼线程数量和几个重复度最高的栈就够了。
2.3 内存账本与容器限额
现场文件里有一段Memory或者Heap,会列出堆的每个分区容量和已用量、元空间用量、代码缓存用量、压缩类空间用量,还有一行是“虚拟地址空间”的整体占用。这一段是判断内存类问题的核心依据。
看的时候要注意三个层次的账。第一个层次是堆内:年轻代、老年代各用了多少,剩余多少。如果老年代已经顶到上限,并且崩溃前正在做 Full GC,那很可能是垃圾收集压力引发的连锁反应。第二个层次是堆外:元空间、代码缓存、直接内存、线程栈,这些都不在你设的-Xmx里,但都吃物理内存和容器的内存额度。第三个层次是进程整体:虚拟机自身占用、加载的本地库、系统分配的结构。三层加起来才是进程真实占用。
容器环境下这里最容易出问题。很多人只算了“堆大小等于容器限额的 80%”,剩下的 20% 听起来很宽裕,实际上元空间、代码缓存、每线程栈、直接内存、垃圾收集器自身的元数据、本地库映射加在一起经常超过 20%,尤其是线程数多、本地库大、类加载量大的应用。我在一个本地库体积接近 300MB 的服务上见过这种情况:容器限额 4G,堆设了 3.2G,结果元空间加代码缓存加本地库映射就吃掉 600MB,几十个线程栈又是两三百兆,进程总占用在流量上来之后直接越过限额,被系统干掉或者分配失败崩溃。
判断方法很直接:拿现场文件里的各分区用量加起来,和你设的容器限额对比。如果总占用的峰值已经接近限额的 95% 以上,那不管这次的直接原因是什么,参数配置都是有问题的,得先把这个坑填上。
2.4 动态库、代码缓存与环境变量
现场文件后半部分有几段经常被跳过,但价值很高。
Dynamic libraries段列出进程加载的全部动态库及其版本、路径、基地址。这一段的用途是查版本冲突:同一个库出现两个不同路径、某个库指向了系统目录而不是应用目录、某个库的不带版本号的软链指向了错误的目标。我遇到过本地库依赖的系统基础库版本比编译时低,导致某个函数符号解析到了旧实现,运行到特定分支就崩。
CodeCache段列出即时编译产生的代码缓存使用情况。如果代码缓存接近满,虚拟机在做编译优化时会变得保守,甚至出现编译线程和清理线程的竞争。更麻烦的一种情况是编译出来的代码本身有问题——这类崩溃的“问题帧”往往显示在libjvm.so内部,函数名带Compile、ciMethod、Phase之类的字样。
Environment Variables段看起来最无害,实际上经常藏着关键线索。常见的有:MALLOC_ARENA_MAX影响内存分配器的行为,某些设置下虚拟内存会膨胀得很快;LANG、LC_ALL影响字符编码,某些本地库对非 UTF-8 环境处理不当会越界;LD_LIBRARY_PATH决定了动态库的搜索顺序,一旦被覆盖,应用自己带的高版本库可能被系统旧库顶替。
VM Arguments段是排查中的“诚信记录”。出过太多次这种情况:同学信誓旦旦说“我配了堆转储参数”,一看这段发现压根没生效,原因是参数写在了启动脚本的错误位置,或者被后面的覆盖了。所以任何关于“我配了某参数”的判断,都以这段为准。
3. 分类排查:从内存到本地库的完整打法
3.1 内存四本账:堆内、堆外、栈、元空间
内存类的崩溃在实践里占比最高。它的一个共同特征是:崩溃发生前往往有征兆,只是被忽略了。常见的征兆是垃圾收集越来越频繁、单次停顿越来越长、日志里出现分配失败的告警。
第一本账是堆内。堆本身是有上限的,超了会抛内存溢出错误而不是直接崩溃,但有一类例外:虚拟机在尝试扩展堆或者做垃圾收集时,如果底层系统拒绝提供内存(比如物理内存不足、容器限额已满、交换空间不可用),就会走到致命错误路径。这种情况在现场文件里的表现是头部出现了 “Native memory allocation (mmap) failed” 之类的字样,而不是单纯的段错误。
第二本账是直接内存。通过ByteBuffer.allocateDirect或者本地接口申请的堆外内存,不受-Xmx约束,默认上限等于堆最大值。大量做网络传输、序列化、图像处理的应用很容易把它用满。满了之后的报错可能是内存溢出,也可能是更底层的分配失败。诊断方式是显式设置上限并开启本地内存追踪:
java -XX:MaxDirectMemorySize=512m \ -XX:NativeMemoryTracking=summary \ -jar app.jar然后在运行期用jcmd <pid> VM.native_memory summary看各分类的占用。这个追踪本身有 5% 到 10% 的性能开销,所以只在排查阶段开,问题定位后关掉。
第三本账是线程栈。每个线程默认占用 1MB 左右的虚拟地址空间(不同平台不同),这个空间是惰性提交的,但虚拟地址是实打实预留的。线程数上千的服务,光栈就预留了上百 GB 虚拟内存,如果系统对虚拟内存有限制或者地址空间碎片严重,就会分配失败。更直接的风险是单个线程栈溢出:深递归、超大的局部数组、本地库里的定长缓冲区,都可能踩穿栈底。
第四本账是元空间和代码缓存。元空间存放类元数据,默认不设上限(受物理内存约束),类加载器泄漏的应用会看到它一路增长。代码缓存存放即时编译生成的机器码,默认上限在几百兆,本地库多、类多、方法多的应用容易打满。这两个区域满了通常不直接崩溃,但会让虚拟机进入各种降级路径,间接引发其他问题。
实操上的做法是给四本账都设上限,加起来留足余量。粗略的公式是:容器限额 = 堆 + 元空间上限 + 代码缓存上限 + 直接内存上限 + 线程数 × 单线程栈 + 本地库与系统开销(经验值 300MB 到 800MB),最后再留 15% 到 20% 的缓冲。
3.2 本地库与 JNI:最难缠的一类
问题帧指向某个lib*.so的时候,基本可以确认是本地代码的问题。这类问题的难点在于:Java 层的日志、堆栈、参数全都正常,出问题的逻辑在一个你没法单步调试的黑盒里。
常见的几个来源。一是自己编译的本地库,尤其是用 C 或 C++ 写的、做了数组操作和字符串处理的部分。空指针、越界写、缓冲区溢出、忘记做长度校验,这几个错误在 C 里不会报错,会直接把相邻内存写坏,然后等到下一次分配才崩,崩溃现场和真正的错误点隔了几十条指令。二是第三方本地库,图像处理、压缩解压、加解密、数据库驱动、硬件加速这几类用得最多。这类库的问题通常是版本与 CPU 指令集不匹配,或者同一个库被两个不同版本的依赖同时加载。三是接口调用本身的错误,比如传了一个已经被垃圾收集回收的数组引用给本地方法、本地代码持有全局引用却没释放、从多个线程同时进入同一个本地方法。
排查手段按成本从低到高排。最省事的是换版本:把可疑的本地库升到最新小版本,很多崩溃是已知缺陷,升级就没了。第二个是隔离验证:写一个最小复现程序,只调用那个本地方法,输入用线上崩溃时的真实数据,看能不能稳定复现。能复现就说明问题在库本身;不能复现就说明是并发或者内存状态相关。第三个是加日志:如果是自己写的本地库,在关键入口和出口打日志,崩溃前最后一条日志就是嫌疑范围。第四个是用调试器看现场:有核心转储文件的话,用gdb加载程序和转储文件,查看崩溃线程的完整栈、寄存器、以及反汇编。这一步门槛稍高,但信息量最大,尤其是栈被破坏的情况下,寄存器里的值经常能直接指出是哪个指针出了问题。
提示:本地库排查有一条铁律——不要在没有核心转储的情况下凭猜。很多时间浪费在“我觉得是这个函数”上,而一行反汇编就能给出准确答案。
3.3 容器化环境的经典错位
容器给这类崩溃贡献了一类特有原因:虚拟机对资源上限的认知和实际限额不一致。
早些年虚拟机不识别容器的资源限制,看到的是宿主机的大内存,于是按宿主机的规格去设置默认堆大小、默认线程数、默认垃圾收集器参数,结果在容器限额下瞬间超限。近年的版本已经支持容器感知,但依然有不少坑。
第一个坑是限额的传递方式。有的编排平台把限制写在容器配置里,有的写在进程的启动参数里,有的两者都写且不一致。虚拟机读哪个值取决于具体版本和是否开启了对应支持开关。排查方式是到容器里直接看控制组文件里的实际数值,再和虚拟机启动日志里打印的“检测到的内存”对比,两者不一致就是这里的问题。
第二个坑是堆大小的计算基准。默认堆是按可用内存的固定比例算的,如果按容器限额的 1/4 算,那剩下的 3/4 看似宽裕,实际上线程多、本地库大的应用依然会超。所以生产上不要依赖默认值,显式写死。
第三个坑是线程数的隐性开销。每个线程不只是栈,还有内核侧的调度结构、内存分配器为每个线程准备的独立缓存。分配器默认会为每个线程创建本地缓存池,线程数一多,这部分内存能到几百兆甚至上 GB,而且不计入任何虚拟机自己的账本。控制手段是设置MALLOC_ARENA_MAX,一般设成 2 到 4 就够,代价是高并发下分配竞争略增,但内存占用会明显收敛。
第四个坑是临时目录和共享内存。容器的/dev/shm默认往往只有 64MB,某些本地库或者网络框架会用它做映射,空间不足时会出现映射失败,表现为总线错误或者段错误。还有临时目录被挂成只读或者容量极小,导致现场文件写不出来、类数据共享的映射文件不存在,后者会直接引发启动阶段的崩溃。
3.4 指令集、代码缓存与即时编译
有一类崩溃只在特定机器上出现,换台机器就好,这类问题通常和 CPU 指令集或者即时编译有关。
先说指令集。本地库在编译时可以选择针对特定指令集优化,比如某些向量指令集在高版本 CPU 上才有。如果编译产物在一个不支持这些指令的机器上运行,执行到对应代码段时就会收到非法指令信号,现场文件里的信号是SIGILL而不是SIGSEGV,这个特征很好认。解决办法是重新编译本地库并指定兼容的指令集基线,或者在部署时按机器能力分池。虚拟机自身在启动时会探测 CPU 特性,正常不会出这种问题,但如果发生了跨代迁移或者虚拟化层屏蔽了部分特性,也可能触发。
再说即时编译。虚拟机在运行期会把热点方法编译成机器码,这个过程本身是复杂的优化流程,偶发缺陷是客观存在的。判断特征是:问题帧显示在虚拟机自身的库文件里,函数名带编译器相关字样;或者加上“只用解释器执行”的开关后问题消失:
java -Xint -jar app.jar-Xint会禁用即时编译,性能会下降一个数量级,但能快速判定问题是否出在编译器。如果加上这个参数后压测不复现,去掉又复现,那基本锁定编译器。接下来可以进一步用-XX:-TieredCompilation只关闭分层编译,看看是否是某一层的问题;或者用编译排除参数把可疑方法排除掉:
java -XX:CompileCommand=exclude,com/example/SomeClass::suspectMethod -jar app.jar这一类问题的最终解决往往是升级虚拟机版本,因为编译器缺陷基本都要靠版本更新修。但排查阶段用上面这些开关能帮你快速定性,避免在大方向上走偏。
3.5 系统资源:线程、句柄与内核参数
还有一类崩溃的诱因在操作系统层面,虚拟机只是受害者。
线程创建失败是最常见的一种。每个进程能创建的线程数受用户级限制和容器级限制双重约束,达到上限时新线程创建会失败。表现可能是抛出无法创建本地线程的错误,也可能直接崩溃。排查方式是看当前进程的线程数、看用户的进程数限制、看容器的进程数限制。线程数暴涨的常见原因有:线程池没有上限、网络连接数突增导致每连接一线程、定时任务重叠执行。
文件句柄耗尽通常表现为输入输出异常而不是崩溃,但如果某个本地库在句柄耗尽的情况下继续尝试打开文件并解引用返回的空指针,就会走到段错误。排查方式是看进程打开的文件数、看系统级和进程级的上限设置。
内存映射数量也有上限。每个映射的库、每个共享内存段、每个大文件的内存映射都占一个槽位,达到上限后mmap会失败,本地库如果不检查返回值就会踩空。
内存过量使用策略在有些环境里被关掉了,意味着进程申请多少内存系统就实际预留多少。这种情况下,虚拟机按虚拟内存计算的参数(比如堆预留)可能直接失败,启动阶段就崩。反过来,过量使用开启得过猛,系统在真正内存不足时会强制终止某个进程,此时进程往往没有机会写现场文件,日志里只有Killed。
注意:进程被系统强制终止时不会有
hs_err_pid文件,看到“进程凭空消失且没有现场文件”,先查系统内核日志里是否有终止记录,不要浪费时间在虚拟机日志里翻。
4. 实操复现与验证:把偶然变成必然
4.1 启动参数模板:先把现场留住
故障处理的第一原则是保现场。很多团队吃亏就吃在现场没留住,日志目录满了被轮转掉、核心转储没开、错误文件路径不可写,问题发生了等于白发生。
下面是我在线上服务里用的参数基线,直接抄改就能用:
#!/bin/bash # 基础内存参数(容器限额 4G 的场景) export MALLOC_ARENA_MAX=2 export LC_ALL=en_US.UTF-8 exec java \ -Xms2560m -Xmx2560m \ -XX:MaxMetaspaceSize=384m \ -XX:ReservedCodeCacheSize=256m \ -XX:MaxDirectMemorySize=384m \ -Xss1m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dump/heap_%p.hprof \ -XX:ErrorFile=/data/dump/hs_err_pid%p.log \ -XX:+ExitOnOutOfMemoryError \ -Xlog:gc*:file=/data/logs/gc_%p.log:time,uptime,level,tags:filecount=10,filesize=50m \ -jar app.jar几个参数的意图说清楚。-Xms和-Xmx设成一样,避免运行期扩缩堆带来的抖动和额外的地址空间管理,这在容器里尤其重要。MaxMetaspaceSize显式设上限,防止类加载器泄漏把整个限额吃光;代价是类加载过多时会抛元空间溢出,但这是可控的失败,比进程被系统干掉好处理。ReservedCodeCacheSize同理。MaxDirectMemorySize是给堆外内存划边界,尤其是用了大量网络传输和序列化框架的服务。-Xss1m是线程栈大小,如果你的应用有深递归或者本地库用栈较多,可以提到 2m,但要注意线程数乘出来的总量。
ErrorFile指向一个独立目录,这个目录必须可写,并且要有独立的空间配额,别和业务日志混在一个会被轮转清理的目录里。HeapDumpPath里的%p是进程号占位,避免多实例互相覆盖。
再配合系统侧开启核心转储:
ulimit -c unlimited # 或者显式指定转储路径和命名,避免落到工作目录 sysctl -w kernel.core_pattern=/data/dump/core.%e.%p.%t即使不打算立刻用调试器分析,也建议开着。核心转储的价值在于它保存的是崩溃瞬间的完整内存镜像,事后任何时候都可以拿出来用调试器看,而现场文件只保存了结构化的摘要信息。
4.2 命令行工具交叉验证
有了现场文件之后,如果进程还在(有时候是部分功能异常而非整体崩溃),或者能稳定复现,可以用命令行工具做交叉验证。
看线程全景:
jcmd <pid> Thread.print > /tmp/threads.txt重点看线程总数、有没有大量线程堵在同一处、有没有线程处于等待本地方法返回的状态。线程总数上千而业务线程池配置的是几十,说明有地方在无限制创建线程。
看本地内存分类:
jcmd <pid> VM.native_memory summary scale=MB前提是启动时加了NativeMemoryTracking参数。输出会按“堆、类、线程、代码、垃圾收集、编译器、内部、符号、本地库、未知”分类列出占用。其中“未知”这一项如果很大,往往指向本地库自己申请的内存,虚拟机管不到也统计不到,这是排查本地库内存泄漏的重要线索。
看堆的构成:
jcmd <pid> GC.heap_info jmap -histo:live <pid> | head -30前者给出各分区用量,后者给出存活对象数量排名。如果排名前列的是一堆本该被回收的缓存对象,说明有引用没释放。如果对象数量正常但内存占用巨大,可能是有超大数组。
看虚拟机关键配置是否生效:
jcmd <pid> VM.flags jcmd <pid> VM.system_properties | grep -i "java.version\|os.arch"前者列出所有最终生效的参数,后者给出运行环境属性。任何“我明明配了”的争论,都以这两条命令的输出为准。
如果在容器里,还要补一条环境侧的验证:
cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes cat /sys/fs/cgroup/pids.max 2>/dev/null cat /proc/<pid>/status | grep -i "threads\|vmrss"把虚拟机认为的内存上限和这里读到的实际限额对一遍,不一致就是配置错位。
4.3 压力复现与二分法定位
线上偶发、本地不复现,这是最难的一类。复现的思路是把变量一个个钉死。
第一轮,固定输入。把线上崩溃前处理的请求或者数据抽样落盘,用这些真实数据在本地回放。很多崩溃只在特定数据形态下触发,比如超长字符串、超深嵌套结构、特殊字符组合。
第二轮,固定并发。用压测工具按线上的并发规模打,逐步加量,记录崩溃出现的阈值。如果加到某个并发数就崩,那大概率是并发相关的竞争问题,重点查本地方法的多线程安全和共享状态。
第三轮,固定时长。有些问题是内存缓慢增长或者资源缓慢泄漏导致的,短时间压测看不出来,得跑够时间。我一般按线上崩溃时间的 1.5 倍做持续压测。
第四轮,二分环境。如果本地怎么都复现不了,就开始换变量:换虚拟机版本、换基础镜像、换本地库版本、换机器规格。每次只改一个,改完重跑同一套压测。这个过程很枯燥,但收敛速度其实不慢,通常五六轮就能把范围缩到一两个变量上。
还有一个更狠但很有效的手段:开关降级验证。启动时加上-Xint判断是不是编译器问题;加-XX:-UseCompressedOops判断是不是压缩指针相关问题;把垃圾收集器换一种判断是不是收集器相关;把可疑的本地库功能整个关掉,看崩溃是否消失。这类开关能快速把“整机问题”切成“模块问题”,切完再深挖。
提示:二分的过程中一定要记录下来每一次的变量和结果。这类排查轮次多,凭记忆很容易搞混“上次改的是哪个参数”,白跑好几轮的情况我见过太多次。
4.4 修复后的验证矩阵
问题定位到之后,改完不等于结束,得有验证。我习惯用一个简单的矩阵来确认。
| 验证维度 | 具体做法 | 通过标准 |
|---|---|---|
| 功能正确性 | 全量回归加真实数据回放 | 无功能差异,无新的异常日志 |
| 长稳运行 | 按线上 1.5 倍时长持续压测 | 无崩溃、无现场文件生成 |
| 资源曲线 | 监控堆、元空间、线程数、进程总内存 | 各曲线平稳,无单向增长 |
| 边界场景 | 大数据量、高并发、异常输入 | 优雅报错而非进程崩溃 |
| 环境一致性 | 容器限额与参数对照复核 | 峰值占用低于限额 80% |
| 现场保留能力 | 人为触发一次内存溢出 | 堆转储与错误文件按预期生成 |
最后一项经常被忽略但很重要:你得确保下次真出问题时能拿到现场。我会在预发环境故意制造一次内存溢出,确认堆转储文件正常落盘、路径可写、容量够、有轮转清理策略不会被填满。这个动作花不了十分钟,但能保证下次事故时手里有牌。
5. 常见问题速查与避坑清单
5.1 高频问题对照表
把我在实践中反复见到的情形整理成一张表,遇到崩溃先对着扫一遍,能覆盖大部分场景。
| 现场特征 | 最可能的原因 | 首要动作 |
|---|---|---|
| 问题帧指向自定义本地库,信号为段错误,地址接近 0 | 本地代码空指针或未检查返回值 | 检查该函数的入参校验与错误分支 |
| 问题帧指向系统内存分配函数,堆用量不高 | 进程整体内存超容器限额 | 核对各分区总和与限额,压缩堆或加限额 |
信号为SIGILL,只在部分机器出现 | 本地库指令集与 CPU 不匹配 | 按兼容基线重新编译本地库 |
信号为SIGBUS,伴随文件映射相关栈 | 映射文件被截断、临时空间满 | 检查临时目录与共享内存容量 |
| 头部出现本地内存分配失败 | 堆外内存、线程栈或元空间耗尽 | 开启本地内存追踪,逐项核对上限 |
启动阶段立刻崩溃,java -version也崩 | 环境层问题(库版本、指令集、权限) | 换基础镜像,最小化环境复现 |
| 线程栈极浅或显示异常,返回地址可疑 | 栈被写坏或栈溢出 | 检查深递归、大局部变量、-Xss设置 |
| 问题帧指向虚拟机内部编译相关函数 | 即时编译缺陷 | 用-Xint或编译排除参数验证 |
进程消失但无现场文件,日志只有Killed | 被系统强制终止 | 查系统内核日志的终止记录 |
| 崩溃集中在流量高峰,平时正常 | 容量不足或并发竞争 | 按峰值 1.5 倍压测复现 |
这张表不是穷举,但它能把“完全没方向”变成“有几个候选方向”,实际处理中省下的时间非常可观。
5.2 几条踩过坑之后才明白的经验
第一条,先看现场文件的后半段。很多人打开文件从头读到尾,被前面几百行寄存器快照劝退。实际上最有用的几段——动态库列表、生效参数、环境变量、内存账本——都在中后部。我现在的习惯是先跳到参数段确认配置和预期一致,再看内存账本算总账,最后才回头看问题帧和线程栈。
第二条,不要相信“上次也是这样,改那个就好了”。同一个报错前缀对应的根因可能完全不同,凭经验直接套方案,运气好省十分钟,运气不好把现场破坏了,后面查三天。我坚持每次至少花十分钟做分类,哪怕看起来很像上次那个问题。
第三条,堆外内存的问题不要盯着堆看。堆用量正常、垃圾收集正常、但进程总内存一路涨,这类问题在堆里永远找不到答案。直接上本地内存追踪,看“未知”分类的增长,那基本就是本地库在申请内存。
第四条,参数一致比参数正确更重要。开发、预发、生产三套环境的参数不一样,是复现困难的常见原因。参数不一致的时候,本地压测结果没有参考价值。我倾向于把参数抽成一个共享配置文件,各环境只覆盖差异项。
第五条,给崩溃留一条独立的日志通道。业务日志可能因为级别设置、轮转策略、磁盘满而丢失,但现场文件必须能落盘。独立目录、独立配额、独立清理策略,这三件事在部署时就该配好,别等出事了再补。
5.3 长期防护:参数基线与监控
单次事故处理完,真正有价值的是把它变成一条长期有效的防线。我通常会落三件事。
一是参数基线化。把内存、线程栈、各区域上限、现场文件路径、核心转储开关写成一份带注释的基线文件,纳入版本管理,任何改动都要走评审。基线的核心不是数值本身,而是那条总量约束:所有分区上限加上本地库与系统开销,必须低于容器限额的 80%,剩下 20% 作为缓冲。这条约束写进文档,新人配参数时照着算,能挡掉一大半配置类事故。
二是关键指标监控。除了常规的堆用量和垃圾收集次数,我会额外盯四个数:进程总内存与容器限额的比值、线程总数、元空间用量、直接内存用量。前两个是容量红线,后两个是缓慢泄漏的早期信号。这四个数都设告警阈值,在崩溃之前先收到预警。
三是升级策略定量化。虚拟机版本的小版本更新经常包含崩溃相关的修复,但盲升有风险。我的做法是维护一个版本对照表,记录每个版本修复了哪些已知问题,结合自己的本地库依赖做评估,先在预发跑一周长稳压测,再灰度到小流量,观察四个指标有没有异常波动再全量。
最后再分享一个我在处理这类问题时养成的小习惯:每次事故处理完,把现场文件、当时的参数、系统环境信息、最终的根因和修复动作,一起归档到一个固定的目录里。半年后再遇到相似的现象,翻一翻历史归档,经常能直接找到可对比的样本,比重新从零推理快得多。这套积累做上一年,你手里就有一份属于自己的案例库,那才是真正难被替代的东西。