Core dump 崩溃排查:JVM 宕机后,那份 core 文件怎么用 gdb 还原现场
凌晨告警:进程没了。翻日志,最后一行戛然而止,没有任何 Java 异常栈------因为它压根不是"抛异常"死的,是崩 死的(SIGSEGV)。arthas attach 不上,jstack 打不出,JVM 层所有工具在这一刻全部失效。这时候能救你的,是那份你**从没确认过"到底开没开"**的 core 文件。这一篇把 core dump 从"怎么开、生成在哪"到"怎么用 gdb 读崩溃栈"一次讲透,顺带说清它和
hs_err_pid.log的分工,以及几个"以为开了其实没开"的生产大坑。
一、core dump 是什么,为什么它是 native 崩溃的最后指望
core dump(核心转储) :进程运行中收到某些致命信号、异常终止时,内核把它当时的内存镜像(栈、堆、寄存器、打开的文件等)写成一个文件,就叫 core 文件。它是一份"崩溃瞬间的现场快照",事后可以用 gdb 加载它,把调用栈、变量、寄存器还原出来。
为什么对 Java 进程尤其重要?因为 JVM 崩溃分两类:
| 类型 | 表现 | 能用什么查 |
|---|---|---|
| Java 层异常 | 抛 Exception/Error,有完整 Java 栈 |
日志、arthas、jstack 都能抓 |
| native / JVM 自身崩溃 | 收到 SIGSEGV 等信号,进程直接死,没有 Java 异常栈 |
只剩 hs_err_pid.log + core 文件 + gdb |
第二类典型来源:JNI 调用的本地库(.so)越界、JIT 编译器 bug、堆外内存被写坏、JVM 自身缺陷。这时栈顶往往停在一个 native 方法上,jstack/arthas 只能看到"进了 native 就断了"------要穿透到 native 栈,core dump + gdb 几乎是唯一手段。
这正是我们那次战斗服宕机排查里用到的关键一招:Java 层看不出所以然,最后靠 gdb 分析 core,栈顶停在 JNI 调用的 xlua 本地库里,才把锅准确甩给了引擎组。那次是"怎么用",这一篇是"怎么开、怎么读"的完整方法论(那次排查的全过程见《记一次战斗服务器 CPU 打满 100% 且无法恢复的排查》)。
二、先确认它开着:ulimit -c
大多数发行版默认是关闭的 (ulimit -c 为 0),也就是说------进程崩了,不会留下 core 文件,事后想查都没得查。所以第一件事是确认并打开。
bash
$ ulimit -c # 查看当前 core 大小限制
0 # 0 = 关闭,崩了不生成 core
临时打开(只对当前 shell 及其子进程生效,重连就没了):
bash
$ ulimit -c unlimited # 不限制 core 大小;也可写具体值如 ulimit -c 1024000(单位 KB)
为什么常用
unlimited?早年写 C 程序内存小,习惯ulimit -c 1024限制成 1MB 免得 core 太大。但一个 JVM 动辄几个 G 的堆,限制太小会导致 core 被截断 (dump 不完整,gdb 读出来是残缺的栈,反而误导)。所以现在一般直接unlimited,代价是 core 文件可能很大------这个磁盘问题第三节讲怎么治。
持久化打开(重启/重连后仍生效),常见三种方式:
-
/etc/profile(或/etc/profile.d/*.sh):找到形如ulimit -S -c 0 > /dev/null 2>&1的行,把0改成unlimited:bashulimit -S -c unlimited > /dev/null 2>&1 -
/etc/security/limits.conf(走 PAM,能按用户/组精细控制):* soft core unlimited * hard core unlimited第一列
*是所有用户,可换成game(指定用户)或@game(指定组)。注意 soft 和 hard 都要给,否则软限制提不上去。 -
两者别打架 :如果
limits.conf里开了,/etc/profile里却还留着-c 0,登录时后者可能又把它压回 0。改了一处,另一处要清掉。
⚠️ 最大的坑:systemd 拉起的服务,上面三种全都不生效。
用 systemctl start 启动的进程不读 /etc/profile,默认也不走 pam_limits,所以你在 shell 里 ulimit -c unlimited 对它毫无影响。必须在 service unit 里配:
ini
[Service]
LimitCORE=infinity
改完 systemctl daemon-reload && systemctl restart your-service,再用 cat /proc/<pid>/limits | grep core 确认那个具体进程的限制真的变了:
bash
$ cat /proc/12345/limits | grep -i core
Max core file size unlimited unlimited bytes
排查技巧 #1:别信"我配过了",信
/proc/<pid>/limits。core 开没开、堆栈大小、文件句柄数,都以目标进程自己 的/proc/<pid>/limits为准,而不是你当前 shell 的ulimit -a。游戏服基本都是 systemd/脚本托管,这一步最容易翻车。
三、core 生成在哪、叫什么名:core_pattern
开了之后,进程崩了 core 落在哪、叫什么?由 /proc/sys/kernel/core_pattern 决定:
bash
$ cat /proc/sys/kernel/core_pattern
core # 最朴素的默认:就叫 core,落在进程的当前工作目录(cwd)
core_pattern 支持一串占位符来命名,常用的:
| 占位符 | 含义 |
|---|---|
%p |
崩溃进程的 PID |
%e |
可执行文件名(如 java,注意可能被截断到 15 字符) |
%t |
崩溃时间(Unix 时间戳) |
%s |
触发崩溃的信号编号(如 11 = SIGSEGV) |
%h |
主机名 |
生产环境的推荐做法:把 core 集中到一个独立目录、文件名带全信息,而不是散落在各进程的 cwd(cwd 有时不可写,会导致 dump 失败):
bash
$ echo '/data/coredump/core-%e-%p-%t-%s' > /proc/sys/kernel/core_pattern
这样崩一个 java 进程会得到类似 /data/coredump/core-java-12345-1717401234-11 的文件,一眼能看出是哪个进程、什么时候、什么信号。要持久化 (重启不丢),写进 /etc/sysctl.conf 再 sysctl -p:
kernel.core_pattern = /data/coredump/core-%e-%p-%t-%s
还有个老参数 core_uses_pid(/proc/sys/kernel/core_uses_pid):为 1 时,若 core_pattern 是纯文件名(不含 %p),内核会自动在后面追加 .pid。现代内核更推荐直接在 core_pattern 里用 %p,这个参数了解即可。
systemd 系统上更常见的是"管道模式"。 很多发行版的 core_pattern 默认是个竖线开头的管道:
bash
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
这表示 core 不落地成裸文件,而是交给 systemd-coredump 统一收集(默认压缩存在 /var/lib/systemd/coredump/)。这时用 coredumpctl 来查,比翻文件方便得多:
bash
$ coredumpctl list # 列出所有已收集的崩溃
TIME PID UID GID SIG COREFILE EXE
Wed 2024-06-03 17:01:22 CST 12345 1000 1000 11 present /usr/bin/java
$ coredumpctl gdb 12345 # 直接用 gdb 打开这个 core
$ coredumpctl dump 12345 --output=/tmp/core-java-12345 # 或导出成文件
几个会让 core "凭空消失"的坑:
- cwd 不可写 / 目录不存在 :
core_pattern指向的目录必须提前建好且进程有写权限,否则内核默默 dump 失败,什么都不留。 - 磁盘被 core 打满 :一个 8G 堆的 JVM,core 可能就好几个 G。集中目录要放在有足够空间的独立分区,并配好定期清理(
systemd-coredump有ExternalSizeMax/KeepFree等配置,裸目录就自己上cron清)。 - 不是所有信号都产生 core :
SIGSEGV(11)、SIGABRT(6)、SIGFPE(8)、SIGBUS(7)、SIGILL(4)、SIGQUIT(3) 这类"崩溃信号"才会 dump;而SIGKILL(9) 和SIGTERM(15) 不会 。所以kill -9、以及 OOM Killer 干掉的进程(发的是 SIGKILL)根本没有 core ------这种要去dmesg//var/log/messages找 OOM 记录,而不是傻等 core 文件(kill -9与kill -15的区别见《kill -15 和 kill -9 之间,差着一份游戏服停机清单》)。
排查技巧 #2:进程"消失得干干净净、连 core 都没有"时,先分清是崩溃 (SIGSEGV → 有 core / hs_err)还是被杀 (SIGKILL → 无 core,查
dmesg里的 OOM Killer、或谁执行了kill -9)。方向完全不同。
四、拿到 core:用 gdb 还原崩溃现场
前提:core 文件 + 产生它的那个可执行文件 (对 JVM 就是 $JAVA_HOME/bin/java,版本必须完全一致,否则符号对不上)。
bash
$ gdb $JAVA_HOME/bin/java /data/coredump/core-java-12345-...
GNU gdb (GDB) ...
Core was generated by `java -jar battle-server.jar'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x00007f8b2c1d3e55 in luaV_execute () from /home/game/server/lib/libbattlecheck.so
(gdb)
进去之后几条最常用的命令:
gdb
(gdb) bt # backtrace:崩溃线程的调用栈(最关键的一步)
(gdb) info threads # 列出所有线程,* 号是当前崩溃线程
(gdb) thread apply all bt # 打印所有线程的栈(多线程问题时必看)
(gdb) frame 3 # 切到第 3 号栈帧,看那一层的上下文
(gdb) info registers # 看寄存器(如崩溃地址)
(gdb) info sharedlibrary # 看加载了哪些 .so,崩在哪个库里
(gdb) quit
bt 的输出,从 #0(栈顶,崩溃点)往下读:
gdb
(gdb) bt
#0 0x00007f8b2c1d3e55 in luaV_execute () from /home/game/server/lib/libbattlecheck.so
#1 0x00007f8b2c1c9a12 in luaD_call () from /home/game/server/lib/libbattlecheck.so
#2 0x00007f8b2c1b7f34 in luaD_pcall () from /home/game/server/lib/libbattlecheck.so
#3 0x00007f8b2c1a5c21 in battle_check_run () from /home/game/server/lib/libbattlecheck.so
#4 0x00007f8b2c194d88 in Java_com_xxx_battle_NativeChecker_check () from /home/game/server/lib/libbattlecheck.so
#5 0x00007f8b0402a318 in ?? ()
怎么读 :#0 是真正触发 SIGSEGV 的指令所在的函数;顺着往下看,找到第一个属于你自己/第三方库 的帧,基本就是责任范围。上例里 #0~#4 全在 libbattlecheck.so(JNI 本地库)内,#4 那个 Java_com_xxx_..._check 更是明晃晃的 JNI 入口------崩在本地库执行逻辑里 ,结论清晰,可以直接拿着这份栈去找库的维护方。栈底的 ?? () 是没有符号信息的帧(JIT 生成的代码或缺符号),通常不影响定位。
排查技巧 #3:符号越全,
bt越可读。生产环境的 native 库如果是-O2且 strip 掉符号,bt会满屏?? ()。给自己维护的.so保留符号(或单独存一份 debug 符号文件)、gdb 里bt才有函数名可看。Java 侧的栈则不靠 core------看下一节的hs_err。
五、别忘了 hs_err_pid.log:JVM 崩溃先读它
对 JVM 崩溃,第一现场往往不是 core,而是 hs_err_pid<pid>.log 。JVM 自己捕获到致命错误时,会在工作目录(可用 -XX:ErrorFile=<path> 指定)生成这份文本报告,比裸 core 好读得多:
# A fatal error has been detected by the Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0x00007f8b2c1d3e55, pid=12345, tid=0x00007f8b1c001700
#
# Problematic frame:
# C [libbattlecheck.so+0x1a3e55] luaV_execute+0x315
#
# Core dump will be written. Default location: /data/coredump/core-java-12345...
往下还有几段极有价值的信息:
- Native frames / Java frames :崩溃时既有 native 栈、也有 Java 栈 ------这是 core + gdb 不方便直接给你的(gdb 看 native 栈在行,看 Java 栈要额外的 SA 工具)。
hs_err里能直接看到崩溃线程当时在跑哪个 Java 方法。 - Registers:崩溃时寄存器、出错地址。
- Dynamic libraries :加载的所有
.so及地址,配合pc=0x...能算出崩在哪个库的哪个偏移。 - VM Arguments / Environment:JVM 启动参数、内存配置------排查"是不是堆外内存/参数问题"时直接可用。
分工记一句话 :JVM 崩溃先读 hs_err (快速定位 Java/native 栈、出问题帧、JVM 参数),需要深挖内存内容、逐帧看变量、或 hs_err 信息不够时,再上 core + gdb 。两者常常是互补的:hs_err 告诉你"崩在 libbattlecheck.so 的 luaV_execute",core + gdb 让你进一步"切到那一帧看它当时在操作什么数据"。
注意
hs_err里那行 "Core dump will be written. Default location: ..." ------它直接告诉你 core 会写在哪。如果这行缺失、或提示不会写 core(不同 JVM 版本措辞不一),那就是第二节的ulimit/LimitCORE没配好,回头补上再复现。
六、复盘
| 步骤 | 命令 / 位置 | 目的 |
|---|---|---|
| 确认开关 | ulimit -c / cat /proc/<pid>/limits |
崩了到底会不会留 core |
| systemd 服务 | unit 里 LimitCORE=infinity |
shell 的 ulimit 对它无效 |
| 落盘位置 | cat /proc/sys/kernel/core_pattern |
core 生成在哪、叫什么 |
| systemd 收集 | coredumpctl list / coredumpctl gdb <pid> |
管道模式下用这个查 |
| 读崩溃栈 | gdb $JAVA_HOME/bin/java <core> → bt |
还原 native 调用栈 |
| 看全线程 | thread apply all bt |
多线程崩溃定位 |
| JVM 首选 | hs_err_pid<pid>.log |
Java+native 栈、出错帧、JVM 参数 |
| 没有 core 时 | dmesg / /var/log/messages |
是被 OOM Killer / kill -9 杀的?(不产生 core) |
几条可复用的经验:
- 平时就把 core dump 开着并验证过 ,别等崩了才发现
ulimit -c是 0------那时现场早没了。关键机器ulimit -c unlimited+core_pattern指向独立目录,是标配。 - systemd 服务要在 unit 里配
LimitCORE,并以/proc/<pid>/limits为准,这是最常见的"以为开了其实没开"。 - JVM 崩溃先
hs_err、深挖再 core+gdb ;SIGSEGV类崩溃才有 core,SIGKILL/OOM 没有,得去dmesg找。 - core 会很大、会打满磁盘,集中目录 + 定期清理 + 保留 native 库符号,才能让这份"最后的现场"真正可用。
七、写在最后
core dump 是那种"平时用不上、用上就是救命"的东西------它把进程死亡瞬间的内存冻成一份文件,让你在事后还能 bt 出崩溃的那一刻。对跑着 JNI/本地库的游戏服来说,它和 hs_err_pid.log 一起,构成了 Java 工具链失效之后的最后一道排查防线。而这一切的前提,是你在还没崩的时候,就确认过它开着、知道它会落在哪。