一文深入理解搞懂Java中的魔法类-Unsafe

    技术2022-07-10  126

    一、Unsafe介绍

    Unsafe类,全限定名是sun.misc.Unsafe,从名字中我们可以看出来这个类对普通程序员来说是“危险”的,一般应用开发者不会用到这个类。

    Unsafe类位于JDK的rt.jar包中,它提供了硬件级别的原子性操作,Unsafe类中的方法都是native方法,它们使用JNI的方式访问本地C++实现库。因此Unsafe类主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。

    Unsafe做操作的是直接内存区,所以该类没有办法通过HotSpot的GC进行回收,需要进行手动回收,因此在使用此类时需要注意内存泄漏(Memory Leak)和内存溢出(Out Of Memory)。

    在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

    通过源码我们可以发现Unsafe类是"final"的,不允许继承。且构造函数是private的。

    private Unsafe() { } @CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); if (!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException("Unsafe"); } else { return theUnsafe; } }

    因为该类在sun.misc包下,默认是被BootstrapClassLoader加载的。如果我们在程序中去调用这个类的话,我们使用的类加载器肯定是AppClassLoader。同时我们可以发现在代码里面Unsafe限制了它的 ClassLoader,如果这个方法的调用实例不是由BootClassLoader加载的,则会报SecurityException错误。

    那如若想使用这个类,该如何获取其实例?有如下两个可行方案。

    从getUnsafe方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取Unsafe实例。

    java -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径

    通过反射获取单例对象theUnsafe。

    Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe"); theUnsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) theUnsafeField.get(null); System.out.println(unsafe);

    二、Unsafe类重要方法

    Unsafe类功能介绍:

    1、内存操作

    下面是堆外内存的分配、拷贝、释放、给定地址值操作等方法

    //分配内存, 相当于C++的malloc函数 public native long allocateMemory(long bytes); //扩充内存 public native long reallocateMemory(long address, long bytes); //释放内存 public native void freeMemory(long address); //在给定的内存块中设置值 public native void setMemory(Object o, long offset, long bytes, byte value); //内存拷贝 public native void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes); //获取给定地址值,忽略修饰限定符的访问限制。与此类似操作还有: getInt,getDouble,getLong,getChar等 public native Object getObject(Object o, long offset); //为给定地址设置值,忽略修饰限定符的访问限制,与此类似操作还有: putInt,putDouble,putLong,putChar等 public native void putObject(Object o, long offset, Object x); //获取给定地址的byte类型的值(当且仅当该内存地址为allocateMemory分配时,此方法结果为确定的) public native byte getByte(long address); //为给定地址设置byte类型的值(当且仅当该内存地址为allocateMemory分配时,此方法结果才是确定的) public native void putByte(long address, byte x);

    通常,我们在Java中创建的对象都处于堆内内存(heap)中,堆内内存是由JVM所管控的Java进程内存,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理堆内存。与之相对的是堆外内存,存在于JVM管控之外的内存区域,Java中对堆外内存的操作,依赖于Unsafe提供的操作堆外内存的native方法。

    使用堆外内存的原因
    对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在GC时减少回收停顿对于应用的影响。提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。

    比如,这里我们通过Unsafe类设置基本数据类型变量的值。

    import sun.misc.Unsafe; import java.lang.reflect.Field; public class UnsafeDemo { private int t = 0; public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { //获取Unsafe实例 Field f = Unsafe.class.getDeclaredField("theUnsafe"); // Internal reference f.setAccessible(true); Unsafe unsafe = (Unsafe) f.get(null); //获取字段t在内存中偏移量 long offset = unsafe.objectFieldOffset(UnsafeDemo.class.getDeclaredField("t")); //创建对象实例,设置字段的值 UnsafeDemo unsafeDemo = new UnsafeDemo(); unsafe.putInt(unsafeDemo, offset, 20); //打印结果 System.out.println(unsafeDemo.t); } }

    我们不是直接赋值给变量t,而是通过Unsafe类通过内存给t赋值。可知最终运行结果为20。

    在这个案例中,我们不是直接给int型变量i赋值,而是通过调用以下方法进行赋值:

    unsafe.putInt(unsafeDemo, offset, 20);

    其中offset是表示的是t在内存中的偏移量。那什么是偏移量?

    JVM的实现可以自由选择如何实现Java对象的“布局”,也就是在内存里Java对象的各个部分放在哪里,包括对象的实例字段和一些元数据之类。sun.misc.Unsafe里关于对象字段访问的方法把对象布局抽象出来,它提供了objectFieldOffset()方法用于获取某个字段相对Java对象的“起始地址”的偏移量,也提供了getInt、getLong、getObject之类的方法可以使用前面获取的偏移量来访问某个Java对象的某个字段。

    在上例中,我们通过putInt方法给一个int变量i赋值,类似的,Unsafe也提供了putLong、putFloat、putDouble、putChar、putByte、putShort、putBoolean、以及putObject等方法给对应类型的变量赋值。并提供了相应的get方法。

    2、CAS操作

    下面为CAS相关操作的方法。

    /** * CAS * @param o 包含要修改field的对象 * @param offset 对象中某field的偏移量 * @param expected 期望值 * @param update 更新值 * @return true | false */ public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

    compareAndSwapObject方法:比较对象obj中偏移量为offset的变量的值是否与expected相等,相等则使用update值更新,然后返回true,否则返回false。

    CAS的全称是Compare And Swap 即比较交换,其算法核心思想如下

    执行函数:CAS(V,E,N)

    包含3个参数

    V表示要更新的变量E表示预期值N表示新值 如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。

    CAS在java.util.concurrent.atomic相关类、Java AQS、CurrentHashMap等实现上有非常广泛的应用。后面我会讲解一下JUC相关源码,可以关注一波。

    3、线程调度

    线程调度中提供的方法包括:线程的挂起,恢复和对象锁机制等,其中获取对象的监视器锁方法已经被标记为弃用。主要方法如下:

    //取消阻塞线程 public native void unpark(Object thread); //阻塞线程 public native void park(boolean isAbsolute, long time); //获得对象锁(可重入锁) @Deprecated public native void monitorEnter(Object o); //释放对象锁 @Deprecated public native void monitorExit(Object o); //尝试获取对象锁 @Deprecated public native boolean tryMonitorEnter(Object o);

    如上源码说明中,方法park、unpark即可实现线程的挂起与恢复,将一个线程进行挂起是通过park方法实现的,调用park方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark可以终止一个挂起的线程,使其恢复正常。

    JDK中rt,jar下面的LockSupport工具类就是使用Unsafe类实现的。而整个并发框架中对线程的挂起操作被封装在LockSupport类中,LockSupport 类中有各种版本 pack 方法,但最终都调用了Unsafe.park()方法。

    4、Class相关

    此部分主要提供Class和它的静态字段的操作相关方法,包含静态字段内存定位、定义类、定义匿名类、检验并确保初始化等。

    //获取给定静态字段的内存地址偏移量,这个值对于给定的字段是唯一且固定不变的 public native long staticFieldOffset(Field f); //获取一个静态类中给定字段的对象指针 public native Object staticFieldBase(Field f); //判断是否需要初始化一个类,通常在获取一个类的静态属性的时候(因为一个类如果没初始化,它的静态属性也不会初始化)使用。 当且仅当ensureClassInitialized方法不生效时返回false。 public native boolean shouldBeInitialized(Class<?> c); //检测给定的类是否已经初始化。通常在获取一个类的静态属性的时候(因为一个类如果没初始化,它的静态属性也不会初始化)使用。 public native void ensureClassInitialized(Class<?> c); //定义一个类,此方法会跳过JVM的所有安全检查,默认情况下,ClassLoader(类加载器)和ProtectionDomain(保护域)实例来源于调用者 public native Class<?> defineClass(String name, byte[] b, int off, int len, ClassLoader loader, ProtectionDomain protectionDomain); //定义一个匿名类 public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);

    5、对象操作

    Unsafe类中提供了多个方法来进行对象实例化和获取对象的偏移地址的操作:

    //返回对象成员属性在内存地址相对于此对象的内存地址的偏移量 public native long objectFieldOffset(Field f); //获得给定对象的指定地址偏移量的值,与此类似操作还有:getInt,getDouble,getLong,getChar等 public native Object getObject(Object o, long offset); //给定对象的指定地址偏移量设值,与此类似操作还有:putInt,putDouble,putLong,putChar等 public native void putObject(Object o, long offset, Object x); //从对象的指定偏移量处获取变量的引用,使用volatile的加载语义 public native Object getObjectVolatile(Object o, long offset); //存储变量的引用到对象的指定的偏移量处,使用volatile的存储语义 public native void putObjectVolatile(Object o, long offset, Object x); //有序、延迟版本的putObjectVolatile方法,不保证值的改变被其他线程立即看到。只有在field被volatile修饰符修饰时有效 public native void putOrderedObject(Object o, long offset, Object x); //绕过构造方法、初始化代码来创建对象 public native Object allocateInstance(Class<?> cls) throws InstantiationException;

    常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过new机制来实现对象的创建。但是,new机制有个特点就是当类只提供有参的构造函数且无显示声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。

    非常规的实例化方式:而Unsafe中提供allocateInstance方法,仅通过Class对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM安全检查等。它抑制修饰符检测,也就是即使构造器是private修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance在java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。

    注意: allocateInstance只是给对象分配了内存,它并不会初始化对象中的属性。

    比如,下面的示例:

    import sun.misc.Unsafe; import java.lang.reflect.Field; public class UnsafeDemo { public static void main(String[] args) throws Exception { //获取Unsafe实例 Field f = Unsafe.class.getDeclaredField("theUnsafe"); // Internal reference f.setAccessible(true); Unsafe unsafe = (Unsafe) f.get(null); // 通过allocateInstance实例化对话,变量属性没有初始化,所有age打印为0 Player p = (Player) unsafe.allocateInstance(Player.class); System.out.println(p.getAge()); // Print 0 p.setAge(70); // 重新赋值设置age System.out.println(p.getAge()); // Print 70 Class<? extends Player> pClass = p.getClass(); Field age = pClass.getDeclaredField("age"); Field location = pClass.getDeclaredField("address"); System.out.println(unsafe.objectFieldOffset(age)); unsafe.putInt(p, unsafe.objectFieldOffset(age), 18); System.out.println(p); // 获取定义location字段的类 Object staticFieldBase = unsafe.staticFieldBase(location); System.out.println(staticFieldBase); // 获取static变量address的偏移量 long staticFieldOffset = unsafe.staticFieldOffset(location); // 获取static变量address的值 System.out.println(unsafe.getObject(staticFieldBase, staticFieldOffset)); // 设置static变量address的值 unsafe.putObject(staticFieldBase, staticFieldOffset, "tianjin"); System.out.println(p + " " + p.getAddress()); } } class Player { private int age = 20; private static String address = "Guangzhou"; private Player() { this.age = 60; } public int getAge() { return this.age; } public void setAge(int age) { this.age = age; } public static String getAddress() { return address; } public static void setAddress(String address) { Player.address = address; } @Override public String toString() { return "Player{" + "age=" + age + "address=" + address + '}'; } }

    6、数组相关

    数组操作主要有两个方法:

    //返回数组中第一个元素的偏移地址 public native int arrayBaseOffset(Class<?> arrayClass); //返回数组中一个元素占用的大小 public native int arrayIndexScale(Class<?> arrayClass);

    这两个与数据操作相关的方法,在java.util.concurrent.atomic 包下的AtomicIntegerArray(可以实现对Integer数组中每个元素的原子性操作)中有典型的应用。

    比如获取数组array的起始位置baseOffset,获取每个数组元素大小indexScale,计算索引为 i 的元素即为 baseOffset + i*indexScale。

    nt baseOffset = unsafe.arrayBaseOffset(array.getClass()); int indexScale = unsafe.arrayIndexScale(array.getClass()); baseOffset + i*indexScale

    7、内存屏障

    硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。内存屏障有两个作用:阻止屏障两侧的指令重排序;强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。在Unsafe中提供了三个方法来操作内存屏障:

    //内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前 public native void loadFence(); //内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前 public native void storeFence(); //内存屏障,禁止load、store操作重排序 public native void fullFence();

    先了解两个指令:

    Store:将处理器缓存的数据刷新到内存中。Load:将内存存储的数据拷贝到处理器的缓存中。

    JVM平台提供了一下几种内存屏障:

    屏障类型指令示例说明LoadLoad BarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作StoreStore BarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)该操作先于Store2及其后所有存储指令的操作LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作StoreLoad BarriersStore1;StoreLoad;Load2该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

    StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

    loadFence

    实现了LoadLoad Barriers,该操作禁止了指令的重排序。

    storeFence

    实现了 StoreStore Barriers,确保屏障前的写操作能够立刻刷入到主内存,并且确保屏障前的写操作一定先于屏障后的写操作。即保证了内存可见性和禁止指令重排序。

    fullFence

    实现了 StoreLoad Barriers,强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。

    在 JDK 中调用了内存屏障这几个方法的实现类有 StampedLock。在StampedLock.validate中锁状态校验运算发生重排序导致锁状态校验不准确的问题。关于StampedLock,后期会有相关文章介绍。

    8、系统相关

    获取系统相关信息的方法如下:

    //返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。 public native int addressSize(); //内存页的大小,此值为2的幂次方。 public native int pageSize();

    在 java.nio下的Bits类中调用了pagesize()方法计算系统中页大小:

    private static int pageSize = -1; static int pageSize() { if (pageSize == -1) pageSize = unsafe().pageSize(); return pageSize; }

    参考

    OpenJDK Unsafe sourceJAVA中神奇的双刃剑–UnsafeJava魔法类:Unsafe应用解析Java中的魔法类-Unsafe
    Processed: 0.016, SQL: 9