做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、LockSupport,NIO里的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.misc=ALL-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,其实也在间接用。JMM(Java内存模型)里有happens-before规则,落地到CPU层面就是各种屏障指令。普通Java代码里,volatile关键字、synchronized都会自动插入屏障,你感知不到。但如果你想写无锁数据结构,又需要精细控制内存可见性,就需要手动用:
- loadFence():保证此屏障后的读操作不会被重排到屏障前
- storeFence():保证此屏障后的写操作不会被重排到屏障前
- fullFence():兼具读写屏障,最重型的操作
举个实际例子,我在写一个无锁环形队列时,生产者和消费者通过volatile变量同步状态。由于队列的索引更新不是简单的原子操作,我需要用fullFence确保写索引之前,队列里的所有数据都已经对外可见。加了内存屏障之后,消费者端读到的状态才是完全一致的状态。注意,这里不能简单用synchronized替代,因为synchronized太重量级,会牵连线程上下文切换,而无锁加屏障能做到真正的“零阻塞”。
2.2 线程挂起与恢复,park/unpark
Unsafe里有park(boolean isAbsolute, long time)和unpark(Thread t)方法。这两个方法在并发工具里极其重要。LockSupport就是基于它们实现的中断式线程等待原语。
简单来说,unpark相当于给线程发了一张“许可证”,调用park会等待许可证,如果许可证已经存在则立即返回。这跟Object.wait/notify最大的区别在于:
- unpark可以先于park执行,许可证会被保留下来
- park不需要提前获取监视器锁,不会抛出IllegalMonitorStateException
- park支持超时控制,可以精确到纳秒级
我在写限流组件时,用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压力过大甚至STW(Stop The World)停顿。而堆外内存不受GC管,生命周期由开发者自己把握。Netty就是基于这个原理,用Unsafe直接分配并管理DirectBuffer,减少数据在堆内和堆外之间的拷贝次数,实现了极致的网络IO性能。
但代价也很直接:堆外内存如果忘记释放,等价于C语言的内存泄漏,而且因为堆外内存不占用堆空间,你甚至很难从-Xmx配置里感知到问题。我在生产环境就排查过一次内存持续增长的问题,最后发现是框架里的ByteBuf没有正确release,导致堆外内存越积越多,GC明明是健康的,操作系统却显示进程内存不断攀升。这种问题用jstat看不出来,得用Native Memory Tracking盯。
2.4 操作对象字段,直接访问内存布局
Unsafe提供了一组基于偏移量的对象操作API:
- objectFieldOffset(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 无锁并发编程的基础:CAS
CAS(CompareAndSwap,比较并交换)是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> Node<K,V> tabAt(Node<K,V>[] tab, int i) { return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE); } static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,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和LockSupport,Java锁的基石
Java并发包的很多同步器(ReentrantLock、CountDownLatch、Semaphore等)都继承自AbstractQueuedSynchronizer(AQS)。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("Can't 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.misc=ALL-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全核跑满,业务无响应。
解决办法有两个方向:一是降低自旋次数,超过阈值后主动让出CPU,Thread.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%的开发者都深入一层。