
做Java几年以后只要你稍微往并发编程、高并发中间件、或者JVM调优的方向看一眼几乎都会撞见一个名字——sun.misc.Unsafe。这东西在JDK源码里出现频率极高ConcurrentHashMap用它做无锁并发Netty用它分配堆外内存AQS的锁操作底层也离不开它。很多面试题里问CAS、问原子类、问LockSupport追到源头都会落到这个类上。我第一次真正被Unsafe震撼到是当时去翻ConcurrentHashMap的源码发现里面没有用synchronized而是直接用Unsafe的compareAndSwapXXX方法去控制节点的插入和扩容。那时我脑子里的Java还停留在“对象创建走构造器、字段访问走getter/setter、内存分配交给JVM”的阶段结果这玩意直接把内存地址摆在你面前自己动手读写字节、分配内存、甚至绕过构造器创建对象。说实话第一反应是有点颠覆认知的Java不是号称有沙箱机制吗怎么还留了这么个后门后来研究得越深越明白Unsafe就是JVM给JDK内部以及高性能框架开发者留的一扇门。它绕过了绝大多数Java层的安全检查把内存布局、原子指令、线程阻塞这些都直接暴露出来。代价就是你要是用不好JVM直接崩给你看不是抛异常那种崩是进程退出那种崩。这篇文章我想好好聊聊Unsafe这个“魔法类”。内容会包括它的核心能力、底层原理、实际应用场景以及我在实战中踩过的坑。适合正在啃并发源码的Java开发者也适合对JVM底层机制感兴趣、想突破“业务开发天花板”的朋友。读完你至少能明白两件事Unsafe到底在底层做了什么以及为什么那么多高并发框架离不开它。1. 先搞清楚Unsafe到底是什么凭什么“不安全”1.1 一个绕过JVM约束的底层工具类Unsafe位于sun.misc包下从名字就能看出它不是Java标准规范里的东西而是JDK内部实现的一部分。它的设计初衷是给JDK类库自身用的。比如java.util.concurrent包里的AtomicInteger、LockSupportNIO里的DirectByteBuffer底层都是靠它实现的。为什么说它“魔法”你要知道正常Java代码是活在JVM安全管理之下的对象创建要经过构造器或者至少经过类加载流程数组越界会抛ArrayIndexOutOfBoundsException内存由GC统一管理开发者不需要也不能手动释放字段访问有public/private/protected的可见性约束而Unsafe把这些规则全部按在地上摩擦。它允许你直接分配和释放堆外内存像一个C语言程序员那样手动管理内存通过偏移量读写某个对象的任意字段不管它是不是private绕过构造器和初始化逻辑创建对象调用底层CPU的CAS指令实现无锁并发挂起线程、恢复线程绕过synchronized这哪还是Java这分明是在Java的躯壳里露出了C/C的灵魂。1.2 获取Unsafe实例的几种方式正因为Unsafe太危险JDK在设计时就加了访问限制。最正规的方式是Unsafe unsafe Unsafe.getUnsafe();但这个方法有个前提调用它的类必须由启动类加载器Bootstrap ClassLoader加载。你自己写的代码是AppClassLoader加载的直接调用就会抛SecurityException提示“Unsafe access denied”。所以普通业务代码基本走不通这条路。实际开发中大家用得最多的套路是用反射强行拿到那个单例。Unsafe类内部有个私有的静态字段theUnsafe只要反射出来就行Field f Unsafe.class.getDeclaredField(theUnsafe); f.setAccessible(true); Unsafe unsafe (Unsafe) f.get(null);这段代码在JDK 8常见但到了JDK 9之后模块化系统加入sun.misc.Unsafe所在的jdk.unsupported模块默认没有被导出反射会报IllegalAccessError。这时候需要在启动参数里加--add-exports java.base/jdk.internal.miscALL-UNNAMED不过在JDK 9之后其实有更友好的选择。JVM在jdk.internal.misc包下新增了Unsafe的替代品同时官方逐步把原来sun.misc.Unsafe的很多能力迁过去了。如果你只是想研究JDK源码或者写工具类直接用jdk.internal.misc.Unsafe也完全可以但它同样面临模块导出问题。我在JDK 17下做实验时用的是反射加模块导出参数结合的方式简单粗暴。说句题外话Hutool这种工具库里甚至封装了一个UnsafeUtil可以直接拿来用底层就是这么反射拿的。不过作为开发者我建议你至少自己写过一次反射获取的代码不然你对Unsafe的理解永远是停留在“会用”而不是“懂它”。2. Unsafe的核心能力拆解它到底能做哪些底层操作拿到Unsafe实例之后你会发现它的方法体系相当庞大。我根据自己的使用经验把常用能力分为六块。2.1 内存屏障保证可见性内存屏障这块即使你不直接用Unsafe其实也在间接用。JMMJava内存模型里有happens-before规则落地到CPU层面就是各种屏障指令。普通Java代码里volatile关键字、synchronized都会自动插入屏障你感知不到。但如果你想写无锁数据结构又需要精细控制内存可见性就需要手动用loadFence()保证此屏障后的读操作不会被重排到屏障前storeFence()保证此屏障后的写操作不会被重排到屏障前fullFence()兼具读写屏障最重型的操作举个实际例子我在写一个无锁环形队列时生产者和消费者通过volatile变量同步状态。由于队列的索引更新不是简单的原子操作我需要用fullFence确保写索引之前队列里的所有数据都已经对外可见。加了内存屏障之后消费者端读到的状态才是完全一致的状态。注意这里不能简单用synchronized替代因为synchronized太重量级会牵连线程上下文切换而无锁加屏障能做到真正的“零阻塞”。2.2 线程挂起与恢复park/unparkUnsafe里有park(boolean isAbsolute, long time)和unpark(Thread t)方法。这两个方法在并发工具里极其重要。LockSupport就是基于它们实现的中断式线程等待原语。简单来说unpark相当于给线程发了一张“许可证”调用park会等待许可证如果许可证已经存在则立即返回。这跟Object.wait/notify最大的区别在于unpark可以先于park执行许可证会被保留下来park不需要提前获取监视器锁不会抛出IllegalMonitorStateExceptionpark支持超时控制可以精确到纳秒级我在写限流组件时用park/unpark实现过一个轻量级的阻塞队列。对比用synchronized加wait/notify的版本park/unpark在“先通知后等待”这种时序下表现更好代码上也少了很多模板化的锁获取和异常处理逻辑。不过要注意park返回时可能有两种情况许可证已经消费掉、或者超时了。实际编码时不能假设“既然从park返回了说明一定有数据可写”该做的状态检查一次都不能少。2.3 直接分配和操作堆外内存这块是我认为Unsafe最“C语言味道”的能力。allocateMemory(long size)在堆外申请一段内存返回内存地址freeMemory(long address)释放内存putInt、getInt、copyMemory、setMemory等方法可以像C语言的指针一样直接在这块内存上读写数据。更底层一点还有reallocateMemory来调整内存大小addressSize来获取当前JVM是32位还是64位指针。为什么Java世界需要堆外内存最直接的原因是堆内内存受GC管理频繁创建销毁大对象会导致GC压力过大甚至STWStop The World停顿。而堆外内存不受GC管生命周期由开发者自己把握。Netty就是基于这个原理用Unsafe直接分配并管理DirectBuffer减少数据在堆内和堆外之间的拷贝次数实现了极致的网络IO性能。但代价也很直接堆外内存如果忘记释放等价于C语言的内存泄漏而且因为堆外内存不占用堆空间你甚至很难从-Xmx配置里感知到问题。我在生产环境就排查过一次内存持续增长的问题最后发现是框架里的ByteBuf没有正确release导致堆外内存越积越多GC明明是健康的操作系统却显示进程内存不断攀升。这种问题用jstat看不出来得用Native Memory Tracking盯。2.4 操作对象字段直接访问内存布局Unsafe提供了一组基于偏移量的对象操作APIobjectFieldOffset(Field f)获取某个字段在对象内部布局中的偏移量getInt(Object o, long offset)读取对象o在offset处的整型值putInt(Object o, long offset, int value)往对象o的offset处写入整型值getObject(Object o, long offset) / putObject(Object o, long offset, Object value)读写引用类型字段初看可能觉得这不如直接“对象.字段”方便但在写通用框架时这就成了杀手锏。你可以在运行时才知道要操作哪个字段而不是在编译期把字段写死。反射虽然也能做类似的事但Unsafe的方式性能高得多因为反射最终可能走到MethodHandle或Native方法而Unsafe直接算好偏移量后就是一次普通的内存访问。我用它写过一个小工具可以绕过getter直接读取任意Java对象的任何字段值哪怕是private final修饰的。同一套逻辑用反射也能实现但Unsafe版本在大批量读取时性能优势明显基准测试下来有数十倍的差距。当然这种能力相当危险因为它直接破环了Java的封装性生产环境慎用。2.5 无锁并发编程的基础CASCASCompareAndSwap比较并交换是Unsafe中最被广泛使用的能力之一。它对应CPU提供的一条原子指令可以做到比较目标内存位置的值与预期值如果相等则更新为新值整个操作在硬件层面保证原子性。Unsafe提供了三个版本compareAndSwapInt(Object o, long offset, int expected, int x)compareAndSwapLong(Object o, long offset, long expected, long x)compareAndSwapObject(Object o, long offset, Object expected, Object x)以compareAndSwapInt为例如果对象o在offset位置的值等于expected就把它替换成x返回true否则不修改返回false。在并发编程里CAS配合循环就是经典的乐观锁不断尝试CAS操作直到成功为止。这里有一个常见的误区CAS只保证单个内存地址的原子性不能保证多个地址的组合原子性。所以JDK里做复合操作时都会把多个字段合成一个long型字段再用CAS去改或者配合版本号解决ABA问题。AtomicStampedReference就是引用了版本号思路。2.6 绕过构造器创建对象Unsafe的allocateInstance(Class? cls)可以直接在堆上创建对象但是跳过了构造方法、跳过实例初始化、甚至跳过类构造器。也就是说用allocateInstance创建出来的对象字段全是默认值构造器没有执行。这个能力在序列化框架和对象池里非常有用。比如Kryo这类高性能序列化框架反序列化时如果用反射先new一个对象再填充字段构造器可能产生副作用或者被要求参数非空而用allocateInstance直接分配一个空壳对象再手动填充字段就干净利落得多。也有的测试Mock框架用allocateInstance来创建无法被实例化的类型比如私有构造器的工具类。不过我要提醒一句这个API非常危险。如果一个类的构造器里做了关键的状态初始化你用allocateInstance绕过它那对象就会处于一个不完全初始化的状态后续调用方法时可能出现不可预料的NullPointerException或者更诡异的逻辑错误。用的时候一定要确认这个类不依赖构造器的副作用。3. 那些量产级别的框架到底是怎么用Unsafe的Unsafe不是给人练手的玩具真正让它扬名立万的是那些工业级并发框架。3.1 ConcurrentHashMap的无锁并发设计JDK 8的ConcurrentHashMap是最能展示Unsafe威力的案例。统计了一下这类代码里大量出现static final K,V NodeK,V tabAt(NodeK,V[] tab, int i) { return (NodeK,V)U.getObjectAcquire(tab, ((long)i ASHIFT) ABASE); } static final K,V boolean casTabAt(NodeK,V[] tab, int i, NodeK,V c, NodeK,V v) { return U.compareAndSetObject(tab, ((long)i ASHIFT) ABASE, c, v); }这里ABASE是Node数组对象在内存中的起始偏移量ASHIFT是每个数组元素占用的空间指数。tabAt和casTabAt本质上就是直接定位到数组第i个槽位的内存地址然后做读或者CAS写操作。因为这些都是Unsafe做的内存级操作所以没有了读写数组的边界检查性能非常高。我自己测试过JDK 8的ConcurrentHashMap在并发put场景下比JDK 7用分段锁的版本吞吐量高出不少。这是分代锁淘汰和CAS优化一起作用的结果而Unsafe是这套优化中不可缺少的下层支撑。3.2 AQS和LockSupportJava锁的基石Java并发包的很多同步器ReentrantLock、CountDownLatch、Semaphore等都继承自AbstractQueuedSynchronizerAQS。AQS内部用volatile int state保存同步状态但这个状态不能被多个线程同时改所以它的修改走的是Unsafe的compareAndSetInt。protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }当线程获取锁失败时AQS会把它包装成Node丢进同步队列然后调用LockSupport.park挂起线程。LockSupport的park方法最终就是走到Unsafe.park。所以你看到的ReentrantLock、ReentrantReadWriteLock的这些高级锁机制最后的原子判断和线程阻塞全部由Unsafe承担。3.3 Netty的堆外内存管理与分配Netty是一款处理超高并发网络请求的框架它的ByteBuf对性能要求极高。Netty内部利用Unsafe分配堆外内存、读取/写入地址上的数据并且通过引用计数器管理内存释放。如果用上一节提到的大对象分配方式每次都要走系统调用性能肯定不行所以Netty还实现了内存池——提前用allocateMemory申请一大块内存然后用类似自定义malloc/free的方式切片分配。我看到过一个有趣的案例Netty中还有通过Unsafe直接把非堆内存地址包装为Java对象的能力配合sun.misc.Unsafe的getLong/putLong等方法可以做到“零拷贝”地读写网络缓冲区。这也是Netty能在高并发网络场景下把性能压榨到极致的原因之一。3.4 Java 8 Stream和Lambda的编译产物你可能想不到Stream和Lambda的加速也离不开Unsafe。Lambda表达式编译成invokedynamic指令后实际调用的函数式接口实例在生成时有一个LambdaMetafactory的实现路径最终会用到Unsafe.defineAnonymousClass去动态生成隐藏类。这个技术使得JDK可以在运行时动态生成各种辅助类而且不需要ClassLoader参与速度极快还可以让这些类被卸载掉降低内存占用。对普通业务开发来说感知不到这层存在但实际上你写下的“list.stream().filter(...)”.forEach(...)这行代码里就已经有Unsafe在背后默默发力了。4. 手写一个小实验用Unsafe实现一个简单的堆外内存存储理论讲再多不如手写一段代码来得直观。我设计了一个极简的堆外内存存储类核心功能是把一个int数组存进堆外内存再读回来。代码逻辑很简单但能完整展示allocateMemory、putInt、getInt、freeMemory的全流程。import sun.misc.Unsafe; import java.lang.reflect.Field; public class OffHeapIntStore { private static final Unsafe UNSAFE; static { try { Field f Unsafe.class.getDeclaredField(theUnsafe); f.setAccessible(true); UNSAFE (Unsafe) f.get(null); } catch (Exception e) { throw new RuntimeException(Cant get Unsafe instance, e); } } private final int capacity; private final long address; public OffHeapIntStore(int capacity) { this.capacity capacity; // 每个int占4字节这里直接申请capacity * 4Byte this.address UNSAFE.allocateMemory((long) capacity * 4); // 顺手把内存清零 UNSAFE.setMemory(address, (long) capacity * 4, (byte) 0); } public void put(int index, int value) { // 越界检查不过Unsafe本身不会帮你检查 if (index 0 || index capacity) { throw new IndexOutOfBoundsException(index: index); } // 第index个int的起始地址 基地址 index * 4 UNSAFE.putInt(address (long) index * 4, value); } public int get(int index) { if (index 0 || index capacity) { throw new IndexOutOfBoundsException(index: index); } return UNSAFE.getInt(address (long) index * 4); } public void destroy() { UNSAFE.freeMemory(address); } public static void main(String[] args) { OffHeapIntStore store new OffHeapIntStore(4); store.put(0, 100); store.put(1, 200); store.put(2, 300); store.put(3, 400); for (int i 0; i 4; i) { System.out.println(index i - store.get(i)); } store.destroy(); } }跑完这段代码你会看到四个值都正常读出来。但注意这里没有任何GC参与所有数据都在堆外。如果你把destroy()注释掉程序结束后那块堆外内存不会自动回收就会泄漏到操作系统层面。我还做过一个更进阶的实验把堆外内存包装成byte[]通过Unsafe.copyMemory直接跟堆内数组交互模拟DirectBuffer的行为。这个过程让我对“零拷贝”有了直观认识——数据从Socket读入时先落到堆外缓冲区再零拷贝写入文件或者发送出去不需要在堆内和堆外之间来回搬移省掉了很多隐性的复制开销。不过实验归实验生产代码里我强烈建议直接用现成的类普通场景用ByteBuffer.allocateDirect需要更精细控制时用Netty的PooledByteBuf或Apache Arrow的常量池没事不要自己裸奔Unsafe。手写堆外存出错时轻则内存泄漏重则把JVM搞崩定位成本非常高。5. 实战中的坑与排查经验5.1 获取Unsafe实例在不同JDK版本的差异我在JDK 8时代写好的Unsafe工具类升级到JDK 11之后直接跑不动了。原因就是模块系统把sun.misc.Unsafe默认隔离了。如果你只是在自己内部项目里用最稳妥的办法还是启动时加--add-exports java.base/sun.miscALL-UNNAMED但这只是妥协方案。更好的选择是提前抽象一层接口通过反射兼容不同版本的Unsafe获取方式。如果你不是非要访问sun.misc.Unsafe特有的方法完全可以考虑jdk.internal.misc.Unsafe。我实测过新版本里它性能与老版本相当而且JDK官方明确会维护它。换过去最麻烦的是方法名变了比如compareAndSwapInt变成了compareAndSetInt用的时候要留意。5.2 堆外内存释放不及时很多人以为堆外内存也会被GC回收。但实际上对于允许Unsafe直接分配的堆外内存JVM并不知情。如果你用ByteBuffer.allocateDirect分配的内存它的Cleaner会在GC时被调用从而触发释放。你直接用Unsafe.allocateMemory分配的内存没有任何自动回收机制必须你手动freeMemory。我在一个数据处理服务里见过一个例子代码里用了Unsafe分配了临时缓冲区但异常分支里漏掉了finally释放导致每次处理大数据时都泄漏一小块堆外内存。进程跑了三周之后RSS内存从3GB涨到了45GB监控告警时才发现。排查思路是用jcmd的VM.native_memory查看Native内存分布重点看malloc区域是否持续上涨。所以最佳实践是所有Unsafe分配的堆外内存必须在finally块释放或者用try-with-resources结构封装好保证一旦用完就能回收。5.3 CAS自旋导致的CPU飙升CAS本身失败时没阻塞而是立即返回false。你在循环里写自旋时如果竞争很激烈CPU会被白白烧掉。经典的做法是int current; do { current unsafe.getIntVolatile(obj, offset); } while (!unsafe.compareAndSwapInt(obj, offset, current, current 1));但如果多个线程拼命争抢这个循环可能要退让很多次才能成功一次CPU占用率就会飙升到接近100%。我处理过一次自旋锁导致的线上问题核心线程数很多大家同时去竞争同一个状态字段线程都卡在自旋上CPU全核跑满业务无响应。解决办法有两个方向一是降低自旋次数超过阈值后主动让出CPUThread.yield或LockSupport.parkNanos(1)二是改用JUC里已经封装好的工具比如LongAdder这种分片计数结构它把竞争分散到多个槽位上而不是所有人挤一根独木桥。5.4 内存对齐与位数问题底层操作时很容易忽略数据对齐的问题。比如你分配了一块内存然后往上面直接写long类型如果地址没有做8字节对齐某些架构下性能会大幅下降甚至抛异常。Unsafe提供pageSize()、addressSize()这些方法能帮你算对齐信息。实测经验是尽量用2的幂次作为分配粒度比如Sqlite存储时常见的4K对齐大概率不会踩到对齐问题的坑。另外字段偏移量objectFieldOffset在不同JVM上结果可能不同。HotSpot对对象布局的优化不是固定的开了压缩指针-XX:UseCompressedOops之后引用类型字段占4字节而不是8字节偏移量差异很大。所以千万不能在代码里硬编码偏移量必须动态通过objectFieldOffset计算。5.5 并发读写的可见性Unsafe的普通getInt/putInt没有volatile语义不会自动插入内存屏障。如果你把它们当普通字段用在多线程环境下可能读到过期数据。有并发需求时应该用getIntVolatile/putIntVolatile。我在写无锁队列时就犯过这个错误生产者和消费者都用普通putInt写状态结果消费者一直看不到生产者的更新排查了很久才发现是缺了屏障。换成putIntVolatile之后立刻就好了。这条规则也适用于堆外内存的读写操作不要以为Unsafe做了底层操作就一定“最线程安全”它只是把指令暴露出来该加屏障还是得自己加。6. 关于Unsafe未来的趋势以及我的使用建议JDK团队这两年一直在推进一个叫“Panama”的项目目的就是提供更安全、更正规的底层API替代Unsafe中很多危险操作。java.lang.foreign API已经能实现堆外内存分配和访问并且能在释放内存后自动清理还提供了更严格的下界检查。Arena、MemorySegment这些概念未来会逐步成为主流。但至少目前Unsafe还没有被废弃JDK内部的很多核心组件依然依赖它。对于应用开发者来说我的建议是能做“消费者”就别做“发明者”。你完全可以去读ConcurrentHashMap的源码理解它为什么用Unsafe、怎么用Unsafe但自己的业务代码里尽量别直接调Unsafe。因为你控制不了它带来的风险和版本兼容性问题。如果确实有堆外内存需求优先用ByteBuffer.allocateDirect其次用第三方库封装好的内存管理。如果确实需要无锁并发优先用JUC包下的AtomicXXX和ConcurrentLinkedQueue。不过话又说回来如果你想深入理解Java底层花时间研究Unsafe是非常值的。它是理解内存布局、CPU指令、并发模型的绝佳窗口。你看完这些底层实现再回头用ConcurrentHashMap、ReentrantLock这些高级并发工具时会多一层“我知道它在背后怎么干活”的掌控感。这层掌控感在很多关键问题排查时能救你一命。我最后再分享一个小技巧线上排查中如果怀疑类库内部用了Unsafe导致堆外内存泄漏可以试着用-XX:MaxDirectMemorySize限制DirectBuffer的容量再用jcmd VM.native_memory summary去分区域查看内存占用。Unsafe的堆外分配往往会体现在malloc那一行的持续增长上。这个特征能帮你快速区分“堆内GC问题”和“堆外内存泄漏”节省好几个小时的排查时间。Unsafe这条路走进去就像打开了Java世界的暗门。它不适合所有人但一旦你掌握了它你对“Java到底是怎么跑起来的”这个问题理解会比99%的开发者都深入一层。