资讯详情

Java虚拟机崩溃排查:读懂hs_err_pid与SIGSEGV现场

📅 2026/11/2 5:04:41 | 华诺云谱 👁 阅读
Java虚拟机崩溃排查:读懂hs_err_pid与SIGSEGV现场
凌晨两点被电话叫起来说整个服务集群大面积不可用登上去看到的日志第一行就是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 pc0x00007f3a2c1d4b21, pid21876, tid21902 # # JRE version: OpenJDK Runtime Environment (17.0.99) (build 17.0.99) # Java VM: OpenJDK 64-Bit Server VM (17.0.99, mixed mode, sharing, tiered, g1 gc, linux-amd64) # Problematic frame: # C [libnative_core.so0x1c4b21] do_transform0x31 # # Core dump will be written. Default location: /data/app/core.21876逐行看。第一行是标题没有信息量。第二行信息量极大信号类型、错误地址pc、进程号pid、线程号tid。SIGSEGV是段错误说明进程访问了一块它没权限访问或者压根没映射的内存。pc0x...是出错的指令地址这个地址配合后面的库列表可以算出具体在哪个动态库的哪个偏移。第三、四行告诉你虚拟机的版本和运行模式。注意括号里那一串mixed mode, sharing, tiered, g1 gc之类的标记它们反映的是当前生效的配置解释器和即时编译混合执行、开启了类数据共享、开启分层编译、垃圾收集器是 G1。如果这段里出现了和你预期不符的收集器或者模式说明启动参数没生效本身就是个线索。第五、六行是“问题帧”也就是虚拟机认为最可能出错的位置。这里的格式是类型 [库名偏移] 函数名函数内偏移。类型标识有三种常见取值C表示本地代码j表示 Java 代码V表示虚拟机内部代码。上面这个例子里是C指向一个自定义本地库libnative_core.so里的do_transform函数偏移 0x31。到这一步排查方向基本就明确了——问题出在本地库不是 Java 层的逻辑。如果问题帧显示的是V [libjvm.so0x...]那说明虚拟机自己的代码崩了量级更严重通常意味着要么虚拟机版本有缺陷要么它依赖的系统库有问题要么内存被外力破坏。如果显示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:MaxDirectMemorySize512m \ -XX:NativeMemoryTrackingsummary \ -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:CompileCommandexclude,com/example/SomeClass::suspectMethod -jar app.jar这一类问题的最终解决往往是升级虚拟机版本因为编译器缺陷基本都要靠版本更新修。但排查阶段用上面这些开关能帮你快速定性避免在大方向上走偏。3.5 系统资源线程、句柄与内核参数还有一类崩溃的诱因在操作系统层面虚拟机只是受害者。线程创建失败是最常见的一种。每个进程能创建的线程数受用户级限制和容器级限制双重约束达到上限时新线程创建会失败。表现可能是抛出无法创建本地线程的错误也可能直接崩溃。排查方式是看当前进程的线程数、看用户的进程数限制、看容器的进程数限制。线程数暴涨的常见原因有线程池没有上限、网络连接数突增导致每连接一线程、定时任务重叠执行。文件句柄耗尽通常表现为输入输出异常而不是崩溃但如果某个本地库在句柄耗尽的情况下继续尝试打开文件并解引用返回的空指针就会走到段错误。排查方式是看进程打开的文件数、看系统级和进程级的上限设置。内存映射数量也有上限。每个映射的库、每个共享内存段、每个大文件的内存映射都占一个槽位达到上限后mmap会失败本地库如果不检查返回值就会踩空。内存过量使用策略在有些环境里被关掉了意味着进程申请多少内存系统就实际预留多少。这种情况下虚拟机按虚拟内存计算的参数比如堆预留可能直接失败启动阶段就崩。反过来过量使用开启得过猛系统在真正内存不足时会强制终止某个进程此时进程往往没有机会写现场文件日志里只有Killed。注意进程被系统强制终止时不会有hs_err_pid文件看到“进程凭空消失且没有现场文件”先查系统内核日志里是否有终止记录不要浪费时间在虚拟机日志里翻。4. 实操复现与验证把偶然变成必然4.1 启动参数模板先把现场留住故障处理的第一原则是保现场。很多团队吃亏就吃在现场没留住日志目录满了被轮转掉、核心转储没开、错误文件路径不可写问题发生了等于白发生。下面是我在线上服务里用的参数基线直接抄改就能用#!/bin/bash # 基础内存参数容器限额 4G 的场景 export MALLOC_ARENA_MAX2 export LC_ALLen_US.UTF-8 exec java \ -Xms2560m -Xmx2560m \ -XX:MaxMetaspaceSize384m \ -XX:ReservedCodeCacheSize256m \ -XX:MaxDirectMemorySize384m \ -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:filecount10,filesize50m \ -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 scaleMB前提是启动时加了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% 作为缓冲。这条约束写进文档新人配参数时照着算能挡掉一大半配置类事故。二是关键指标监控。除了常规的堆用量和垃圾收集次数我会额外盯四个数进程总内存与容器限额的比值、线程总数、元空间用量、直接内存用量。前两个是容量红线后两个是缓慢泄漏的早期信号。这四个数都设告警阈值在崩溃之前先收到预警。三是升级策略定量化。虚拟机版本的小版本更新经常包含崩溃相关的修复但盲升有风险。我的做法是维护一个版本对照表记录每个版本修复了哪些已知问题结合自己的本地库依赖做评估先在预发跑一周长稳压测再灰度到小流量观察四个指标有没有异常波动再全量。最后再分享一个我在处理这类问题时养成的小习惯每次事故处理完把现场文件、当时的参数、系统环境信息、最终的根因和修复动作一起归档到一个固定的目录里。半年后再遇到相似的现象翻一翻历史归档经常能直接找到可对比的样本比重新从零推理快得多。这套积累做上一年你手里就有一份属于自己的案例库那才是真正难被替代的东西。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑