
在Java开发者的常规认知里安全和封装是刻在语言基因里的东西private字段不能碰对象必须走构造器数组越界一定抛异常。但在JDK内部其实一直藏着一个专门破坏这些规则的类——sun.misc.Unsafe。搞并发框架、高性能中间件的人对它又爱又怕圈子里习惯直接叫它“魔法类”。这篇文章我打算从一个实际使用者的角度把Unsafe拆开揉碎讲清楚它到底是怎么绕过语言约束的、为什么一个看起来这么危险的后门会被无数底层组件依赖以及如果你想在自己的基础设施里用它哪些地方绝对不能碰。如果你是写业务代码的看完至少能明白网上那些底层框架源码里的奇怪操作在干什么。如果你要维护中间件、基础组件那这篇文章应该能帮你省掉不少试错时间。Unsafe的内容很多我按“是什么、怎么拿、核心能力、并发玩法、高级魔法、踩坑实录”这条线来讲代码尽量短核心思路重点说透。1. Unsafe是什么为什么搞并发的都绕不开它1.1 一个打破规则的内部工具箱Unsafe位于sun.misc包下是JDK的内部类从名字就能感受到它官方有多不待见。它不是给普通业务代码准备的而是给JDK自身的基础库比如并发包、NIO和一些有极高性能要求的组件使用的。它可以做很多常规Java代码“原则上不被允许”的事情直接分配、读取、修改堆外内存native memory不经过Java堆自然也不受GC管理。绕开访问修饰符直接通过“对象基址字段偏移量”读写任意对象的字段包括private字段。执行CAS原子操作也就是compareAndSwap系列这是无锁并发的基石。不调用构造函数就创建对象也就是allocateInstance。直接控制线程的挂起和唤醒对应park/unpark。操作内存屏障loadFence/storeFence/fullFence强制多核CPU下的可见性和顺序性。简单打个比方普通Java代码像小区业主进出只能走大门访客登记、快递放前台Unsafe相当于物业自己保留的施工专用通道。业主不应该随便走但修水管、铺线路的工程队没有这条通道就干不了活。1.2 官方不建议直接用但框架又离不开很多官方文档和JCP讨论都明确警告过Unsafe不是公共API不稳定未来JDK版本可能调整甚至移除。这一警告并不是“私藏后门”的意思而是因为Unsafe的能力实在太底层滥用会带来两个致命问题。第一个问题是进程级崩溃。正常的Java异常可以被try/catch捕获但如果通过Unsafe写内存时偏移量算错写到了随机的内存地址上HotSpot直接抛SIGSEGV整个JVM进程当场退出。这种崩溃连日志都来不及写完整反复出现几次你就知道它有多难受了。第二个问题是内存生命周期管理。Unsafe分配的堆外内存不受GC管理分配了就必须手动释放。很多人以为对象用完就没事了结果RSS内存一路涨到容器被杀。后面我会专门聊怎么排查这个。但风险不等于不能用。实际上并发包里的AbstractQueuedSynchronizerAQS、早期的ConcurrentHashMap、Netty的DirectBuffer、Kafka的零拷贝块等都直接或间接用到Unsafe。原因很简单某些性能诉求用标准API做不到或者会导致成倍的开销。比如无锁并发里最关键的CASJUC里AtomicInteger其实也是包了一层Unsafe没有Unsafe的话实现无锁同步就只能靠synchronized放弃那种小而美的高并发路径。我个人的态度是业务代码最好永远别碰它。但是如果你在做底层框架、性能敏感的基础设施了解它是必修课。2. 把Unsafe“请”出来实例获取的完整兼容方案2.1 反射获取theUnsafe的通用写法Unsafe的构造器是私有的所以不能直接new。不过在HotSpot内部Unsafe类会维护一个静态字段theUnsafe它就是那个唯一的实例。很多框架在启动时通过反射把它读出来。我长期使用的一段兼容代码长这样import sun.misc.Unsafe; import java.lang.reflect.Field; public final class UnsafeHolder { private static final Unsafe UNSAFE; static { try { Field field Unsafe.class.getDeclaredField(theUnsafe); field.setAccessible(true); UNSAFE (Unsafe) field.get(null); } catch (Exception e) { throw new ExceptionInInitializerError(Unsafe init failed, e); } } public static Unsafe get() { return UNSAFE; } }关键步骤只有三个getDeclaredField(theUnsafe)、setAccessible(true)、field.get(null)。注意这里取静态字段传的是null不是某个对象实例。只要没有模块访问限制这段代码从JDK 8到JDK 21都可以用。2.2 JDK 9模块系统限制怎么处理JDK 9引入模块系统之后情况有变化。虽然sun.misc.Unsafe这个类还对外开放着但它属于java.base模块而Unsafe类内部的theUnsafe字段是私有的。默认情况下外部模块无法通过反射访问这个私有字段。如果你直接跑上面的代码大概率会遇到java.lang.reflect.InaccessibleObjectException: Unable to make field private static final sun.misc.Unsafe accessible解决办法是给JVM加上启动参数--add-opens java.base/sun.miscALL-UNNAMED在IDE里运行测试、打java -jar启动服务、Gradle/Maven插件内嵌JVM都要把这个参数加进去。否则反射setAccessible(true)会被拒绝。如果你的项目模块化声明了自己的module-info.java不是处在UNNAMED模块里那还要把sun.misc包也opens给对应模块名比如--add-opens java.base/sun.misccom.example.mymodule这里一个很容易踩的坑是团队里有人没加参数或者服务器部署脚本改了JVM参数后忘记同步导致服务启动失败。所以在底层的封装类里最好加一个明确的启动检查发现拿不到Unsafe就输出友好的错误信息而不是等到调用时莫名其妙抛个ExceptionInInitializerError。2.3 获取不到实例时的备选路线除了模块系统限制还有一些环境根本提供不了标准的sun.misc.Unsafe。最常见的两类是Android的运行时虽然有类似的libcore.util.Unsafe但包名、方法签名都和HotSpot完全不同写在Java后端里的Unsafe代码基本不能照搬到Android。GraalVM原生镜像Native Image在静态分析阶段默认不支持Unsafe的部分底层操作除非你已经做了显式相关配置。如果你遇到的是这两种情况就别纠结Unsafe了赶紧上官方替代API。JDK 9之后Java其实提供了更安全、更面向用户的底层操作方式VarHandle和java.lang.invoke.MethodHandles。VarHandle可以做各种原子操作、字段访问、数组元素CAS基本具备Unsafe在并发场景下的大部分能力而且类型安全、不需要依赖具体偏移量。我把不同JDK版本的实践策略整理成一张表环境Unsafe可用性推荐做法JDK 8直接反射即可沿用上面的UnsafeHolderJDK 9-21需要--add-opens放开模块限制反射获取同时准备VarHandle迁移方案Android不是HotSpot实现API不同直接用VarHandle或标准同步组件GraalVM原生镜像默认受限尽量迁移到VarHandle/MemorySegment这里想强调一条经验如果你在一个新项目里引入Unsafe最好先确认团队是否愿意接受“只能在标准HotSpot上运行”这个约束。有些组织会明确禁止使用Unsafe这种情况下即便你能通过反射拿到实例代码评审也过不了。3. 堆外内存与字段级操作“上帝视角”的读写能力3.1 堆外内存分配与释放allocateMemory/freeMemory堆外内存是Unsafe最出名的能力之一。它直接绕开Java堆向操作系统申请原生内存返回给你一个long类型的地址。整个过程没有任何边界检查也没有GC参与。一段最简单的堆外内存读写流程长这样Unsafe unsafe UnsafeHolder.get(); long address unsafe.allocateMemory(8); try { unsafe.putLong(address, 1024L); long value unsafe.getLong(address); System.out.println(value); } finally { unsafe.freeMemory(address); }注意几个关键点。allocateMemory(long size)的参数是字节数不是元素个数。写入8字节的long就分配8字节写入16字节的两个long就分配16字节。如果分配的字节数不够写入时就会踩到相邻内存这是极其隐蔽的崩溃源头。freeMemory必须和allocateMemory一一对应。分配了不释放就会发生堆外内存泄漏。这块内存不属于Java堆GC感知不到等JVM的RSS涨上来往往为时已晚。我见过一个具体的坑某个基础组件在构造函数里allocateMemory但对象被废弃时忘了在清理流程里freeMemory结果每个对象泄漏几百字节到几KB的堆外内存。跑了一段时间后容器内存告警用NMT才查到问题。后面我会详细讲排查方法。那为什么还要用堆外内存最直接的原因是减少GC压力。如果一个超大的缓冲区是Java堆里的byte[]GC每次扫描年轻代、老年代都会遍历它尤其是大对象晋升到老年代后full GC成本很高。把缓冲区挪到堆外JVM不再跟踪它GC只管对象里的那个long address指针性能自然好很多。NIO的DirectByteBuffer、RocketMQ的MappedFile、Netty的DirectBuffer底层都是这个思路。另一个原因是数据不经过堆可以更高效地和操作系统IO交互避免一次拷贝。这一点在零拷贝相关场景里优势极其明显。3.2 字段偏移量的威力objectFieldOffset putInt/getInt除了直接操作堆外内存Unsafe还可以直接修改Java堆内对象的字段而完全不经过访问控制。原理是HotSpot的对象在内存中有固定的布局实例字段相对对象起始地址的偏移量offset是确定的。Unsafe先通过objectFieldOffset(Field)拿到这个偏移量然后利用对象引用加偏移量直接定位字段内存位置。举个例子。有一个很普通的类class User { private int age; }正常情况下你只能用getAge/setAge或者反射改字段但Unsafe可以这样Field ageField User.class.getDeclaredField(age); long ageOffset unsafe.objectFieldOffset(ageField); User user new User(); unsafe.putInt(user, ageOffset, 25); int age unsafe.getInt(user, ageOffset); System.out.println(age);这里并没有调用任何setter也没有调用Field.set但user的age字段已经被改成了25。因为对象引用本身已经包含了堆中对象的位置unsafe.putInt(user, ageOffset, 25)相当于拿着对象地址往里偏移、直接写入。你可能想问这不就是反射吗区别在于反射要经过一大堆安全检查、方法调用而Unsafe直接是一个CPU级别的内存读写没有隐藏开销。在极端场景下比如要循环更新几十万次对象字段Unsafe的性能优势非常明显。更关键的是Unsafe可以通过偏移量完成“原子”读写这是反射做不到的。后面讲CAS时你会看到偏移量的真正威力。这块有个使用细节拿到一个类的字段偏移量后同一个类的所有实例都能复用这个偏移量没必要每个对象都重新计算一次。所以在框架源码里偏移量总是放在static final常量里初始化一次整个生命周期用到底。3.3 数组元素寻址是怎么做到的数组在Java里也是一个对象它的元素在内存中连续排列。Unsafe提供两个方法来确定元素位置arrayBaseOffset(Class arrayClass)返回数组中第一个元素的偏移量。arrayIndexScale(Class arrayClass)返回数组元素的大小字节数。确定这两个值后第index个元素的地址就是baseOffset index * indexScale。看一个简单的演示int[] arr new int[3]; long base unsafe.arrayBaseOffset(int[].class); long scale unsafe.arrayIndexScale(int[].class); unsafe.putInt(arr, base 1L * scale, 42); int value unsafe.getInt(arr, base 1L * scale); System.out.println(value); // 42这段代码直接跳过了arr[1] 42这个语法从内存层面把第二个元素改成了42。为什么要这么绕因为无锁并发结构往往需要直接对数组里的某个元素做CAS操作比如并发队列里的环形数组。框架需要先算出某个下标对应的内存偏移量再调用compareAndSwapObject或者compareAndSwapInt完成更新。如果只靠标准数组操作和锁性能会差一个量级。这里要提醒一个容易犯的错误arrayIndexScale对于非基本类型的对象数组不是对象大小而是引用指针大小。例如Object[]在64位JVM上通常是8字节如果开启压缩指针则可能是4字节。计算偏移量一定要以实际返回值为准不要自己硬编码。3.4 内存屏障到底在解决什么问题内存屏障这个话题很多人一听就晕我用大白话解释一下。CPU为了性能会乱序执行指令编译器也会做指令重排。在多核环境下一个线程写入某个字段另一个线程不一定能立刻看到。volatile关键字之所以能保证可见性本质就是在读写前后加了内存屏障。Unsafe暴露的三个方法loadFence()保证此屏障之前的读操作完成后其后的读操作才能开始。可以把数据从CPU缓存强制刷新到可见状态。storeFence()保证此屏障之前的写操作对其它处理器可见。fullFence()同时提供读写屏障类似volatile读写一起用。一个典型场景是发布一个共享对象先写入对象内部所有字段执行storeFence然后把“对象已可用”的标志字段设为true。消费者看到标志为true时它能保证观察到完整的发布状态不会读到半初始化的对象。在JDK源码的ConcurrentHashMap、ForkJoinPool、StampedLock里都可以看到这些屏障的身影。它们让框架可以做到“在需要精确顺序控制的地方精确控制”而不是为了安全无脑synchronized。不过在业务代码里我建议你尽量远离这三个方法连volatile都嫌多的时候再考虑。手写内存屏障极其容易出错一旦序错了数据竞争的情况是随机出现、极难复现的。4. CAS与线程控制用Unsafe写无锁并发组件4.1 CAS原语解析从cmpxchg到compareAndSwapIntCAS全称是Compare And Swap也就是比较并交换。用Unsafe最经典的方法是boolean compareAndSwapInt(Object target, long offset, int expectedValue, int newValue);执行逻辑是读取target对象在offset位置的字段值如果和expectedValue相等就把该字段更新为newValue返回true如果不相等什么都不做返回false。这个操作在硬件层面是CPU的cmpxchg指令它保证整个过程是原子的不会被其他线程打断。换句话说一个原本需要“读、比较、写”三步的操作被压缩成了一条原子指令。为什么不直接加锁锁会让线程进入阻塞、唤醒的代价里上下文切换开销很大。CAS则让线程在失败后立刻重试不进入内核态短临界区下吞吐量非常高。但这也引出它的阿喀琉斯之踵如果竞争太激烈线程会在循环里疯狂自旋CPU被打满。还有一种需要警惕的经典问题叫ABA问题线程把值从A改成B又改回A另一个线程在CAS时看到A就认为是“没有变过”从而错误地通过。解决方式一般是加版本号AtomicStampedReference就是这么设计的。4.2 实战一个简化版原子计数器的实现理解了CAS后我们自己动手写一个简化版原子计数器。这样你对JUC的AtomicLong就不会再觉得神秘了。import sun.misc.Unsafe; public class SimpleAtomicLong { private static final Unsafe unsafe UnsafeHolder.get(); private static final long valueOffset; static { try { valueOffset unsafe.objectFieldOffset( SimpleAtomicLong.class.getDeclaredField(value)); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } private volatile long value; public SimpleAtomicLong(long initValue) { this.value initValue; } public long incrementAndGet() { while (true) { long current unsafe.getLongVolatile(this, valueOffset); long next current 1; if (unsafe.compareAndSwapLong(this, valueOffset, current, next)) { return next; } } } public long get() { return unsafe.getLongVolatile(this, valueOffset); } }代码核心在于while(true)循环先读到当前值尝试CAS如果失败说明其他线程抢先更新了于是用最新值再试。这个模式也叫自旋锁或乐观锁。这里有一个很关键的细节读当前值必须用getLongVolatile而不是getLong。普通读不保证可见性可能一直读到本地缓存里过期的值导致高并发下CAS疯狂失败甚至死循环。getLongVolatile则带了volatile语义能强制看到多线程间的最新值。我在实际测试中对比过这个简易版和JUC的AtomicLong两者的实现路径高度相似。用Unsafe自己实现最大的价值不是替代AtomicLong——毕竟标准库已经存在——而是让你理解无锁组件内部到底是怎么运行的排查问题时思路完全不一样。4.3 park/unpark线程挂起与唤醒的原生能力park和unpark是Unsafe的线程控制方法它们也是整个JUC同步体系的地基。大名鼎鼎的LockSupport底层就是对这两个方法的封装。park(boolean isAbsolute, long time)挂起当前线程。第二个参数是等待时间传0表示永久等待直到被唤醒。unpark(Thread thread)唤醒指定线程。一个最简单示例Unsafe unsafe UnsafeHolder.get(); Thread t new Thread(() - { System.out.println(线程开始挂起); unsafe.park(false, 0L); System.out.println(线程被唤醒); }); t.start(); Thread.sleep(2000); unsafe.unpark(t);运行后辅助线程会挂起2秒然后被主线程唤醒。相比Object.wait/notifypark/unpark的一个大优势是不必持有对象锁。wait必须放进synchronized块里而park没有这个限制。另外unpark可以先于park执行相当于提前给线程发了一个“许可”线程稍后park时会立刻返回。这点让同步组件实现条件队列时灵活得多。实战中要特别注意park返回后并不代表一定是因为unpark唤醒了还有可能是虚假唤醒。所以所有基于park的循环结构都必须重新检查“是否真的应该继续等待”把条件判断放在while里而不是if里。这是无数并发Bug的经典来源。5. 更高级的魔法绕过构造器与动态类定义5.1 allocateInstance不执行构造器就创建对象allocateInstance(Class? cls)是另一个让人眼前一亮的操作它可以直接创建对象而完全不调用构造函数。构造函数里的初始化逻辑、参数校验、资源加载全部被跳过。看个例子class Sample { private int count; public Sample() { System.out.println(构造器执行了); this.count 100; } }如果走正常new构造器会打印日志count会变成100。但用allocateInstanceSample s (Sample) unsafe.allocateInstance(Sample.class); System.out.println(s.count); // 0控制台没有任何打印count保持默认值0。对象就这么“凭空”诞生了。这个能力最常用的地方是反序列化框架。比如某些高性能序列化工具在恢复对象时不想执行原始构造函数因为构造函数可能依赖外部环境、会触发业务逻辑甚至导致循环依赖。这时直接分配实例再通过字段反射把反序列化出的值填进去效率和安全都更高。对对象池来说也一样如果对象创建本身不开销但构造器里的校验逻辑很昂贵用allocateInstance可以省掉这部分成本。风险同样明显字段没有初始化很多后续代码拿到半成品状态的对象就可能空指针。尤其是final字段不一定被赋值直接破坏了一个类的“必然约束”。所以一般只在你自己完全掌控类结构时才建议这么玩。5.2 defineClass的变迁和新替代方案Unsafe里还有一个老玩家很熟悉的方法defineClass。在JDK早期通过它可以从字节码数组直接定义出一个类不需要经过标准类加载器的任何保护逻辑。这在动态语言支持、字节码生成、热部署等场景里非常有用。但后来JDK的安全策略收紧了。Unsafe.defineClass从某个版本开始对非引导类加载器下的调用直接抛UnsupportedOperationException。也就是说你现在想在自己的应用里用Unsafe偷偷塞一个类进去基本是不可能了HotSpot不允许。如果确实有动态定义类的需求标准路线是MethodHandles.Lookup.defineClass或者自己实现一个ClassLoader并在findClass里调用父类的defineClass保护方法。这些方式虽然没有Unsafe那么“野”但安全性、链路清晰度都更符合生产要求。这块的变化也印证了一个规律Unsafe这类内部API的进化方向就是“收权”。凡是能做危险操作的口子HotSpot都在逐步封堵或转移。5.3 Unsafe里那些容易被忽略的其他方法除了上面说的主力方法Unsafe还有很多容易被忽略但同样有实战价值的API。列一些比较常用的copyMemory在内存间拷贝数据相当于高效的本机memcpy。setMemory把一块内存填充为指定值常用于初始化。getAddress/putAddress读写本机地址底层指针操作的窗口。getAndAddInt原子地“取旧值并加上增量”比自旋CAS封装得更方便。compareAndExchangeObjectCAS与读取结合返回旧值而不是布尔值某些场景能少一次循环。这些方法在JDK内部的ForkJoinPool、ConcurrentSkipListMap、DirectByteBuffer里都有各自的位置。我的建议是不要把Unsafe当一个万能的“野生API”去把所有方法都用一遍而要有明确目的。真正到了生产组件里能用其中一两个能力解决特定问题就够了。6. 实战踩坑与排查记录Unsafe使用中的高频问题6.1 堆外内存持续增长先查NMT再查分配点现象很好认Java堆老年代正常GC都很健康但是容器RSS一直缓慢往上涨直到触发OOM Kill。这种问题十有八九出在堆外内存。排查时我一般分三步第一步打开JVM的Native Memory TrackingNMT看大概方向-XX:NativeMemoryTrackingsummary启动后在命令行用jcmd抓内存明细jcmd pid VM.native_memory summary如果看到Internal或Other区域明显增长基本可以确认是堆外内存分配。第二步检查谁在调用allocateMemory。这一步比较笨但有效在你封装的Unsafe工具类里临时加日志或计数器把每次分配的大小和调用栈打出来。生产环境配合动态开关使用线上也能用。第三步审查分配和释放的配对。我见过的泄漏大多是有的分配点在初始化时释放点却写在某个异常分支里异常路径一触发就漏掉。写Unsafe封装时建议分配完立刻用try/finally把释放带上别依赖“总会有人记得释放”。6.2 进程直接崩溃的SIGSEGV怎么定位这个比较吓人没有Java堆栈没有异常堆栈直接hs_err_pid*.log最前面写着SIGSEGV。我遇到过几次原因基本都一样偏移量算错或者写穿了内存边界。Unsafe不会像数组那样让你抛ArrayIndexOutOfBoundsException写错了就是直接动到别人的内存进程当场没。关键排查思路是打开hs_err日志拉到崩溃时的当前线程栈和指令地址再对照你最近改动的Unsafe操作。如果崩溃发生在unsafe_PutInt这类native方法附近目标基本锁定。还有一个常见触发场景是把堆外内存地址当成长期ID存在某个对象里等对象经过多次GC后地址虽然没有变化但内存已被释放再按旧地址写就崩了。这种情况我强烈建议操作堆外地址一定要在持有内存的生命周期内进行绝不要把裸地址持久化或跨线程随意传递。6.3 CAS自旋导致的CPU打满无锁并不等于免费。高竞争场景下线程一直在while循环里执行CAS终态上CPU会被拉满。从jstack看大量线程卡在compareAndSwapLong或incrementAndGet上。解决方向有两个一是退避。CAS失败后不要立刻傻乎乎重试可以Thread.onSpinWait()或者短暂parkNanos(1)让出CPU。这样竞争激烈时反而总吞吐更高。二是分段。把单一热点计数器拆成多个计数器每个线程只更新自己的那一段最后汇总。LongAdder就是干这个的。所以如果你已经在用Unsafe实现计数器发现竞争把CPU打满不妨先换回LongAdder试试能不打则不打。6.4 关于升级到新JDK和标准替代方案的选择JDK 17之后Unsafe仍然存在但不排除未来某个长期支持版本里会进一步限制或移出。长期维护一个依赖Unsafe的组件必须考虑后续升级路径。官方推荐的方向是java.lang.invoke.VarHandle处理字段、数组元素的原子操作和内存屏障语义是并发场景下替代Unsafe的首选。java.lang.foreign.MemorySegment来自Project Panama替代堆外内存分配、memcpy等操作API更安全带边界检查。MethodHandles替代部分反射和动态类定义需求。我在做一些新的底层组件时已经默认先写VarHandle版本。只有在确定某能力对应API没覆盖才会退回Unsafe。这样后面升级JDK时迁移成本会小很多。下面是一个简单的排查速查表送给后来者现象重点排查方向常见解决办法RSS持续上涨但heap正常堆外内存泄漏NMT定位工具类日志生命周期配对JVM直接SIGSEGV崩溃偏移量计算错误、地址失效查hs_err日志、禁止保存裸地址跨生命周期CAS高竞争CPU打满线程自旋退避策略、LongAdder分段模块系统下反射报错缺少--add-opens启动参数放开sun.miscUnsafe这种工具我的体会是你越了解它就越应该克制它。它背后是整个JVM对底层能力的一种让渡信任成本极高。我自己的习惯是用Unsafe的地方必须集中在同一个隔离包封装成内部专用API禁止散落到业务代码里每次改动先过一遍越界风险和内存释放逻辑上线前用并发压测和长时间稳定性测试多蹂躏几轮。如果你能做到这几点Unsafe可以成为你工具箱里最锋利的刀而不是随时会反噬的残次品。