Java学习之核心类Unsafe详解
概述
-
背景
最近在适配JDK7时,发现LongAdder是从JDK8开始提供,于是研究juc源码的时候,发现在很多工具类中都调用了一个Unsafe类中的方法(如:Striped64)。
-
概念
Unsafe类是一个位于sun.misc包下的类,它提供了一些相对底层方法,能够让我们接触到一些更接近操作系统底层的资源,如系统的内存资源、cpu指令等。
通过这些方法,我们能够完成一些普通方法无法实现的功能,例如直接使用偏移地址操作对象、数组等等。但是在使用这些方法提供的便利的同时,也存在一些潜在的安全因素,例如对内存的错误操作可能会引起内存泄漏,严重时甚至可能引起jvm崩溃。
基础介绍
获取实例
错误示例1:new方式创建
-
异常信息
Unsafe() has private access in 'sun.misc.Unsafe'
-
原因分析
查看Unsafe类的源码,发现它被final修饰不允许被继承,并且构造函数为private类型,即不允许我们手动调用构造方法进行实例化,只有在static静态代码块中,以单例的方式初始化了一个Unsafe对象。
public final class Unsafe { private static final Unsafe theUnsafe; ... private Unsafe() { } ... static { theUnsafe = new Unsafe(); } }
错误示例2:静态方法getUnsafe获取
-
异常信息
Exception in thread "main" java.lang.SecurityException: Unsafe at sun.misc.Unsafe.getUnsafe(Unsafe.java:90) at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12)
-
原因分析
在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用Unsafe类中的方法,来防止这些方法在不可信的代码中被调用。
@CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); if (!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException("Unsafe"); } else { return theUnsafe; } }
正确示例:反射获取
-
源码示例
/** * @description: 获取Unsafe类 * @param * @return: sun.misc.Unsafe */ public static Unsafe getUnsafeByReflect() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe) field.get(null); } catch (NoSuchFieldException | IllegalAccessException e) { e.printStackTrace(); } return null; }
属性读写
-
源码示例
public void fieldTest(Unsafe unsafe) throws NoSuchFieldException { User user=new User(); long fieldOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("age")); System.out.println("offset:"+fieldOffset); unsafe.putInt(user,fieldOffset,20); System.out.println("age:"+unsafe.getInt(user,fieldOffset)); System.out.println("age:"+user.getAge()); }
-
结果说明
Unsafe类的objectFieldOffset方法获取了对象中字段的偏移地址,这个偏移地址不是内存中的绝对地址而是一个相对地址,之后再通过这个偏移地址对int类型字段的属性值进行了读写操作
offset:12 age:20 age:20
-
方法源码
getInt用于从对象的指定偏移地址处读取一个int,putInt用于在对象指定偏移地址处写入一个int,并且即使类中的这个属性是private私有类型的,也可以对它进行读写。
public native int getInt(Object o, long offset); public native void putInt(Object o, long offset, int x);
应用举例
内存操作
-
源码介绍
在java中是不允许直接对内存进行操作的,对象内存的分配和回收都是由jvm自己实现的。但是在Unsafe中,提供的下列接口可以直接进行内存操作。
//分配新的本地空间 public native long allocateMemory(long bytes); //重新调整内存空间的大小 public native long reallocateMemory(long address, long bytes); //将内存设置为指定值 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); //清除内存 public native void freeMemory(long address);
-
测试样例
private void memoryTest() { int size = 4; long addr = unsafe.allocateMemory(size); long addr3 = unsafe.reallocateMemory(addr, size * 2); System.out.println("addr: "+addr); System.out.println("addr3: "+addr3); try { unsafe.setMemory(null,addr ,size,(byte)1); for (int i = 0; i < 2; i++) { unsafe.copyMemory(null,addr,null,addr3+size*i,4); } System.out.println(unsafe.getInt(addr)); System.out.println(unsafe.getLong(addr3)); }finally { unsafe.freeMemory(addr); unsafe.freeMemory(addr3); } }
-
结果说明
addr: 2433733895744 addr3: 2433733894944 16843009 72340172838076673
分析一下运行结果,首先使用allocateMemory方法申请4字节长度的内存空间,在循环中调用setMemory方法向每个字节写入内容为byte类型的1,当使用Unsafe调用getInt方法时,因为一个int型变量占4个字节,会一次性读取4个字节,组成一个int的值,对应的十进制结果为16843009
在代码中调用reallocateMemory方法重新分配了一块8字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。在代码中的第二个for循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的4个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:
拷贝完成后,使用getLong方法一次性读取8个字节,得到long类型的值为72340172838076673。
需要注意,通过这种方式分配的内存属于堆外内存,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。
内存屏障
-
背景知识
编译器和CPU会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致CPU的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
在硬件层面上,内存屏障是CPU为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在java8中,引入了3个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由jvm来生成内存屏障指令,来实现内存屏障的功能。
-
源码介绍
//禁止读操作重排序 public native void loadFence(); //禁止写操作重排序 public native void storeFence(); //禁止读、写操作重排序 public native void fullFence();
内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
-
测试样例
如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:
@Getter class ChangeThread implements Runnable{ /**volatile**/ boolean flag=false; @Override public void run() { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("subThread change flag to:" + flag); flag = true; } }
在主线程的while循环中,加入内存屏障,测试是否能够感知到flag的修改变化:
public static void main(String[] args){ ChangeThread changeThread = new ChangeThread(); new Thread(changeThread).start(); while (true) { boolean flag = changeThread.isFlag(); unsafe.loadFence(); //加入读内存屏障 if (flag){ System.out.println("detected flag changed"); break; } } System.out.println("main thread end"); }
-
结果说明
subThread change flag to:false detected flag changed main thread end
而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。
运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的代码的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。
对象操作
属性值修改
-
基本介绍
对象成员属性的内存偏移量获取,以及字段属性值的修改。Unsafe提供了全部8种基础数据类型以及Object的put和get方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。
阅读openJDK源码中的注释发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。
-
源码介绍
Object的读写方法
//在对象的指定偏移地址获取一个对象引用 public native Object getObject(Object o, long offset); //在对象指定偏移地址写入一个对象引用 public native void putObject(Object o, long offset, Object x);
volatile读写方法
volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,以int类型为例:
//在对象的指定偏移地址处读取一个int值,支持volatile load语义 public native int getIntVolatile(Object o, long offset); //在对象指定偏移地址处写入一个int,支持volatile store语义 public native void putIntVolatile(Object o, long offset, int x);
相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。
有序写入的方法
public native void putOrderedObject(Object o, long offset, Object x);
public native void putOrderedInt(Object o, long offset, int x);
public native void putOrderedLong(Object o, long offset, long x);
有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:
1. Load:将主内存中的数据拷贝到处理器的缓存中
2. Store:将处理器缓存的数据刷新到主内存中
顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型。
在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。
-
结果说明
在上面的三类写入方法中,在写入效率方面,按照put、putOrder、putVolatile的顺序效率逐渐降低。
对象实例化
-
源码介绍
Unsafe的allocateInstance方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:
@Data public class A { private int b; public A(){ this.b =1; } }
分别基于构造函数、反射以及Unsafe方法的不同方式创建对象进行比较:
public void objTest() throws Exception{ A a1=new A(); System.out.println(a1.getB()); A a2 = A.class.newInstance(); System.out.println(a2.getB()); A a3= (A) unsafe.allocateInstance(A.class); System.out.println(a3.getB()); }
-
结果分析
打印结果分别为1、1、0。
说明通过allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。
在上面的例子中,如果将A类的构造函数改为private类型,将无法通过构造函数和反射创建对象,但allocateInstance方法仍然有效。
数组操作
-
源码分析
在Unsafe中,可以使用arrayBaseOffset方法获取数组中第一个元素的偏移地址,使用arrayIndexScale方法可以获取数组中元素间的偏移地址增量。
private void arrayTest() { String[] array=new String[]{"str1str1str","str2","str3"}; int baseOffset = unsafe.arrayBaseOffset(String[].class); System.out.println(baseOffset); int scale = unsafe.arrayIndexScale(String[].class); System.out.println(scale); for (int i = 0; i < array.length; i++) { int offset=baseOffset+scale*i; System.out.println(offset+" : "+unsafe.getObject(array,offset)); } }
-
结果分析
16 4 16 : str1str1str 20 : str2 24 : str3
通过配合使用数组偏移首地址和各元素间偏移地址的增量,可以方便的定位到数组中的元素在内存中的位置,进而通过getObject方法直接获取任意位置的数组元素。需要说明的是,arrayIndexScale获取的并不是数组中元素占用的大小,而是地址的增量,按照openJDK中的注释,可以将它翻译为元素寻址的转换因子(scale factor for addressing elements)。
在上面的例子中,第一个字符串长度为11字节,但其地址增量仍然为4字节。
-
背景知识
在String数组对象中,对象头包含3部分:mark word标记字占用8字节,klass point类型指针占用4字节,数组对象特有的数组长度部分占用4字节,总共占用了16字节。
第一个String的引用类型相对于对象的首地址的偏移量就是16,之后每个元素在这个基础上加4,正好对应了我们上面代码中的寻址过程,之后再使用前面说过的getObject方法,通过数组对象可以获得对象在堆中的首地址,再配合对象中变量的偏移量,就能获得每一个变量的引用。
CAS操作
-
背景知识
在Unsafe类中,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的CAS操作。
-
源码分析
参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
-
测试样例
private volatile int a; public static void main(String[] args){ CasTest casTest=new CasTest(); new Thread(()->{ for (int i = 1; i < 5; i++) { casTest.increment(i); System.out.print(casTest.a+" "); } }).start(); new Thread(()->{ for (int i = 5 ; i <10 ; i++) { casTest.increment(i); System.out.print(casTest.a+" "); } }).start(); } private void increment(int x){ while (true){ try { long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a")); if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x)) break; } catch (NoSuchFieldException e) { e.printStackTrace(); } } }
-
结果说明
1 2 3 4 5 6 7 8 9
在上面的例子中,使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作。
需要注意的是,在调用compareAndSwapInt方法后,会直接返回true或false的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。
线程调度
-
源码分析
Unsafe类中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法进行线程调度,在前面介绍AQS的文章中我们提到过使用LockSupport挂起或唤醒指定线程,看一下LockSupport的源码,可以看到它也是调用的Unsafe类中的方法:
public static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null); } public static void unpark(Thread thread) { if (thread != null) UNSAFE.unpark(thread); }
LockSupport的park方法调用了Unsafe的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。
-
测试样例
public static void main(String[] args) { Thread mainThread = Thread.currentThread(); new Thread(()->{ try { TimeUnit.SECONDS.sleep(5); System.out.println("subThread try to unpark mainThread"); unsafe.unpark(mainThread); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); System.out.println("park main mainThread"); unsafe.park(false,0L); System.out.println("unpark mainThread success"); }
-
结果分析
子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠5秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。
park main mainThread subThread try to unpark mainThread unpark mainThread success
-
其他
Unsafe源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:
//获得对象锁 @Deprecated public native void monitorEnter(Object var1); //释放对象锁 @Deprecated public native void monitorExit(Object var1); //尝试获得对象锁 @Deprecated public native boolean tryMonitorEnter(Object var1);
monitorEnter方法用于获得对象锁,
monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。
tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false。
Class操作
-
内容介绍
Unsafe对Class的相关操作主要包括类加载和静态变量的操作方法。
静态变量操作
-
源码分析
//获取静态属性的偏移量 public native long staticFieldOffset(Field f); //获取静态属性的对象指针 public native Object staticFieldBase(Field f); //判断类是否需要实例化(用于获取类的静态属性前进行检测) public native boolean shouldBeInitialized(Class<?> c);
-
测试样例
@Data public class User { public static String name="Hydra"; int age; } private void staticTest() throws Exception { User user=new User(); System.out.println(unsafe.shouldBeInitialized(User.class)); Field sexField = User.class.getDeclaredField("name"); long fieldOffset = unsafe.staticFieldOffset(sexField); Object fieldBase = unsafe.staticFieldBase(sexField); Object object = unsafe.getObject(fieldBase, fieldOffset); System.out.println(object); }
-
结果分析
false Hydra
在Unsafe的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。
在上面的代码中首先创建一个User对象,这是因为如果一个类没有被实例化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建User对象的语句,运行结果会变为:
true null
类加载
-
源码分析
public native Class<?> defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain);
在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。
-
测试举例
反编译生成后的class文件的功能:
private static void defineTest() { String fileName="F:\\workspace\\unsafe-test\\target\\classes\\com\\cn\\model\\User.class"; File file = new File(fileName); try(FileInputStream fis = new FileInputStream(file)) { byte[] content=new byte[(int)file.length()]; fis.read(content); Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null); Object o = clazz.newInstance(); Object age = clazz.getMethod("getAge").invoke(o, null); System.out.println(age); } catch (Exception e) { e.printStackTrace(); } }
在上面的代码中,首先读取了一个class文件并通过文件流将它转化为字节数组,之后使用defineClass方法动态的创建了一个类,并在后续完成了它的实例化工作,并且通过这种方式创建的类,会跳过JVM的所有安全检查。
-
其他
Unsafe还提供了一个defineAnonymousClass方法:
public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);
使用该方法可以用来动态的创建一个匿名类,在Lambda表达式中就是使用ASM动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在jdk15发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用Unsafe的defineAnonymousClass方法。
系统信息
-
基本介绍
Unsafe中提供的addressSize和pageSize方法用于获取系统信息,调用addressSize方法会返回系统指针的大小,如果在64位系统下默认会返回8,而32位系统则会返回4。调用pageSize方法会返回内存页的大小,值为2的整数幂。
-
测试样例
private void systemTest() { System.out.println(unsafe.addressSize()); System.out.println(unsafe.pageSize()); }
-
结果分析
8 4096
这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测32位系统的情况。
总结
-
小结
之前有传言说会在java9中移除Unsafe类,不过它还是照样已经存活到了jdk16,按照存在即合理的逻辑,只要使用得当,它还是能给我们带来不少的帮助,因此最后还是建议大家,在使用Unsafe的过程中一定要做到使用谨慎使用、避免滥用。