gc触发crash,根因却是unsafe

📅 2026/6/26 1:29:59
gc触发crash,根因却是unsafe
背景用户 jvm 进程偶发 crash报错信息如下G1ParScanThreadState::copy_to_survivor_space(InCSetState, oopDesc*, markOopDesc*) ()根据堆栈来看G1 gc 在 ygc过程中内存访问错误这个是进程挂掉的直接原因。从错误信息看好像是 jvm gc 的 bug遇到这种情况建议换一个 gc 类型再跑程序如果在 gc 阶段依旧 crash说明问题不是在 gc 上而是 jvm 对象模型被破坏了gc 根据对象模型扫描对象访问到错误的内存地址触发 crash。相似的场景社区 bug上也有记录https://bugs.openjdk.org/browse/JDK-8317577下面我们详细讲述一下jvm 对象模型破坏的形式和分析这类问题的方法。jvm 对象模型破坏的形式jvm 对象模型可以简单用如下表格展示结构组成64 位操作系统大小MarkWord8 字节对象头指针在开启指针压缩的状况下占 4 字节未开启状况下占 8 字节。数组长度只有数组有4 字节实例数据对齐填充8 字节对齐从这个表格我们可以看到我们构建一个 java 对象除了数据之外会多对象头对齐填充的部分。对象头大小也不是固定的。类型不同组成也不同例如数组会多一个数组长度。指针压缩会影响指针长度。开启压缩是 4不开启是 8。如果是通过 java语法创建对象jvm 虚拟机会自动按照上述的规则排放。jdk 也暴露了一个 unsafe接口可以绕开上面的规则直接修改。例如下面的方法填入一个对象一个偏移量一个double就可以把double写入对象对应的偏移量中。public void putDouble(Object o, long offset, double x) { beforeMemoryAccess(); theInternalUnsafe.putDouble(o, offset, x); }这里容易出现 2 个错误。错误 1偏移量计算错误对象头大小至少考虑 2 种情况常见的就是指针问题压缩和不压缩的长度不同jdk 默认heap 32g以下自动开启压缩heap 超过 32g 自动关闭压缩。本地编写代码一般是不会超过 32g就会出现 32g以下程序正常运行超过 32g 就 crash 的情况。错误 2对象类型错误例如声明是 int 类型调用了putDouble。虽然知道了错误的原因但是现象是无法和原因对齐的。unsafe 调用不会立刻报错下次按照正常的对象规则读取才触发这就导致了直接原因和根因现场差距很大。解决方案直接原因和根因差距比较大的情况我们可以不断的缩小范围并且记录小范围内的堆栈记录来进行排查。缩小范围的方式很简单可以通过 gc 去校验。如果 gc 不频繁的情况可以使用主动的方式例如 system.gc 和 jcmd GC.run。只要 gc 成功说明之前的所有操作都是正常的。范围缩小之后unsafe的操作堆栈就会变的比较少人可以根据堆栈和代码结合分析。很多时候 unsafe并不是我们的代码直接操作的而是通过 maven 引入的第三方包间接调用的。想在自己的代码埋点是无法分析的。想从底层埋点不同版本的 jdk 的方法是不一样的我们从高到低分为 23118 三个版本方案。jdk23unsafe api 过于依赖编写代码的人稍有不慎就会破坏模型。社区已经要删除 unsafe 用更安全的 api 替换jdk23 是一个重要版本提供了记录 unsafe堆栈的能力帮助用户发现自己 unsafe 代码的调用从而让用户迁移 api。我们可以通过参数启动记录 unsafe 堆栈。--sun-misc-unsafe-memory-accessdebug开启之后我们就会看到如下的输出。WARNING: sun.misc.Unsafe::putInt called by UnsafeCrash (file:xx) at UnsafeCrash.main(UnsafeCrash.java:58)可以看到我在UnsafeCrash中调用了Unsafe的putInt。jdk11jdk 自带的记录是 23 才能有从 11 到 23就需要另外一种方式。这里只标注 11因为目前不会有人使用 jdk9和 jdk10。jdk 模块化之后把 unsafe的实现都迁移到jdk.internal.misc.Unsafe。对外使用的还是sun.misc.Unsafe但是把所有方法做了一个代理。ForceInline public int getIntVolatile(Object o, long offset) { return theInternalUnsafe.getIntVolatile(o, offset); }这个代理把所有的实现都换成了 java。我们可以利用 bci 的能力来记录。如果是分布式软件我们可以写一个 javaagent下面展示ByteBuddy的字节码修改非常简单。new AgentBuilder.Default() .ignore(none()) // 不要忽略 JDK 核心类 .with(AgentBuilder.TypeStrategy.Default.REDEFINE) .type(named(sun.misc.Unsafe)) .transform((builder, typeDescription, classLoader, module) - builder.method(any()) // 拦截所有方法 .intercept(MethodDelegation.to(UnsafeInterceptor.class)) ).installOn(instrumentation);只要写一个 javaagent 就行。如果是单个的 java 进程我们还可以用 arthas。options unsafe true stack sun.misc.Unsafe * -n 100000jdk8jdk8 unsafe 的实现还是以 native 方法为主。无法延用 bci 的方式。public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);jdk 并没有把这些方法保留成 uprobe所以系统软件的方式也不适合我们可以写一个 nativeagent 来拦截函数替换这里用到了 jvmti 的能力。jvmtiEventCallbacks callbacks; memset(callbacks, 0, sizeof(callbacks)); callbacks.NativeMethodBind cb_NativeMethodBind;注册一个NativeMethodBind的 callback。void JNICALL NativeMethodBind( jvmtiEnv *jvmti_env, JNIEnv* jni_env, jthread thread, jmethodID method, void* address, // 原始 C 函数的地址 void** new_address_ptr // 允许你写入新的函数地址替换掉原始地址 )我们可以拦截 jni 的绑定把自己写的代理方法替换掉原来的 jni。static void JNICALL wrap_putInt_obj(JNIEnv *env, jobject self, jobject obj, jlong offset, jint val) { char tname[128]; get_thread_name(env, tname, sizeof(tname)); LOG([%s] putInt(obj%p, offset%ld, value0x%08x), tname, (void*)obj, (long)offset, (unsigned int)val); print_java_stack(env); //原来的函数指针 orig_putInt_obj(env, self, obj, offset, val); }写一个 nativeagent 也是一种负担虽然可以借助 ai稍微压力小一点。如果我们能明确 unsafe 的调用方法我们还可以依赖 async目前只支持关注一个方法。因为都是 jni我们现得查看unsafe jni 的符号。0000000000afe390 t Unsafe_SetLong 0000000000affd40 t Unsafe_SetLong140 0000000000af8270 t Unsafe_SetLongVolatile 0000000000aff680 t Unsafe_SetMemory 0000000000b000a0 t Unsafe_SetMemory2 0000000000afa4e0 t Unsafe_SetNativeAddress 0000000000afcbf0 t Unsafe_SetNativeByte 0000000000afc2d0 t Unsafe_SetNativeChar 0000000000afcdc0 t Unsafe_SetNativeDouble 0000000000afcf90 t Unsafe_SetNativeFloat 0000000000afc100 t Unsafe_SetNativeInt 0000000000af8cd0 t Unsafe_SetNativeLong 0000000000afbf30 t Unsafe_SetNativeShort 0000000000af5bb0 t Unsafe_SetObject 0000000000b00790 t Unsafe_SetObject140 0000000000af6720 t Unsafe_SetObjectVolati不同版本的 jdk 的符号会有出入要根据使用中的libjvm.so来查看。获得符号也可以直接调用asprof不过asprof是采集一段时间的结合需要配合缩小时间来操作否则还没拿到收集的结果就触发 crash 了。asprof -e Unsafe_SetNativeInt总结遇到 crash 的堆栈在 gc的情况应该现换个 gc 来看看是否是 gc 的 bug。确认是对象模型被破坏的场景我们可以通过缩小范围记录 unsafe 堆栈的方式追踪根因栈。追踪堆栈方案按照方便程度程度排序 jdk23jdk11jdk8社区已经有替换 unsafe api 的方案替换方案可以绕开unsafe 引发的 crash。相关链接