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 文件,事后想查都没得查。所以第一件事是确认并打开。
$ulimit-c# 查看当前 core 大小限制0# 0 = 关闭,崩了不生成 core临时打开(只对当前 shell 及其子进程生效,重连就没了):
$ulimit-cunlimited# 不限制 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:ulimit-S-cunlimited>/dev/null2>&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 里配:
[Service] LimitCORE=infinity改完systemctl daemon-reload && systemctl restart your-service,再用cat /proc/<pid>/limits | grep core确认那个具体进程的限制真的变了:
$cat/proc/12345/limits|grep-icore Max corefilesize unlimited unlimited bytes排查技巧 #1:别信"我配过了",信
/proc/<pid>/limits。core 开没开、堆栈大小、文件句柄数,都以目标进程自己的/proc/<pid>/limits为准,而不是你当前 shell 的ulimit -a。游戏服基本都是 systemd/脚本托管,这一步最容易翻车。
三、core 生成在哪、叫什么名:core_pattern
开了之后,进程崩了 core 落在哪、叫什么?由/proc/sys/kernel/core_pattern决定:
$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 失败):
$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默认是个竖线开头的管道:
$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来查,比翻文件方便得多:
$ coredumpctl list# 列出所有已收集的崩溃TIME PIDUIDGID SIG COREFILE EXE Wed2024-06-0317:01:22 CST123451000100011present /usr/bin/java $ coredumpctl gdb12345# 直接用 gdb 打开这个 core$ coredumpctl dump12345--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,版本必须完全一致,否则符号对不上)。
$ gdb$JAVA_HOME/bin/java /data/coredump/core-java-12345-... GNU gdb(GDB)... Core was generated by `java-jarbattle-server.jar'. Program terminated with signal SIGSEGV, Segmentation fault.#0 0x00007f8b2c1d3e55 in luaV_execute () from /home/game/server/lib/libbattlecheck.so(gdb)进去之后几条最常用的命令:
(gdb) bt # backtrace:崩溃线程的调用栈(最关键的一步) (gdb) info threads # 列出所有线程,* 号是当前崩溃线程 (gdb) thread apply all bt # 打印所有线程的栈(多线程问题时必看) (gdb) frame 3 # 切到第 3 号栈帧,看那一层的上下文 (gdb) info registers # 看寄存器(如崩溃地址) (gdb) info sharedlibrary # 看加载了哪些 .so,崩在哪个库里 (gdb) quitbt的输出,从#0(栈顶,崩溃点)往下读:
(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 工具链失效之后的最后一道排查防线。而这一切的前提,是你在还没崩的时候,就确认过它开着、知道它会落在哪。