Java-Unsafe

前言

Unsafe类位于sun.misc包下,它提供了一些相对底层方法,能够让开发者接触到一些更接近操作系统底层的资源,如系统的内存资源、cpu指令等。而通过这些方法,能够完成一些普通方法无法实现的功能,例如直接使用偏移地址操作对象、数组等。

但是在使用这些方法提供的便利的同时,也存在一些潜在的安全因素,例如对内存的错误操作可能会引起内存泄漏,严重时甚至可能引起JVM崩溃。因此在使用Unsafe前,必须要了解它的工作原理与各方法的应用场景,并且在此基础上仍需要非常谨慎的操作。

网上对Unsafe的看法是:

强烈不建议程序中自己调用Unsafe类方法;
使用Unsafe会使代码出错几率变大;
官方建议不是开发标准库不要使用Unsafe;
会导致JVM崩溃级别的异常;


Unsafe基础

Unsafe实例获取

首先直接通过new试试看

Unsafe unsafe = new Unsafe();

编译失败:

Unsafe() 在 Unsafe 中是 private 访问控制

走读sun.misc.Unsafe源代码:

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

可以看到:

  1. Unsafefinal关键字修饰,无法继承;
  2. 无参构造方法是private的,无法直接通过new Unsafe()生成实例;
  3. Unsafe static静态代码块中初始化了局部变量thUnsafeUnsafe 类另提供了静态方法public static Unsafe getUnsafe() {......},但是观察源码发现,也不能直接调用获取实例:
public static void main(String[] args) {
    Unsafe.getUnsafe();
}

输出:
Exception in thread "main" java.lang.SecurityException: Unsafe
    at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
    at com.wkw.study.jdk.UnsafeTest.main(UnsafeTest.java:14)

tips:getUnsafe()方法上的@CallerSensitive注解有什么用?

JVM中存在部分方法比较危险,比如上面的Reflection.getCallerClass()方法;

因此Reflection.getCallerClass()方法规定,调用它的方法必须用@CallerSensitive注解声明,否则就会报"java.lang.InternalError: CallerSensitive annotation expected at frame 1"异常;

而且@CallerSensitive注解有个特殊之处,它声明方法所在的类必须由启动类ClassLoader加载,@CallerSensitive注解才可以被识别到;

可以用JVM参数 -Xbootclasspath/a:path 指定path路径下的类都由启动类ClassLoader加载;


-Xbootclasspath:bootclasspath : 让JVM从指定的路径中加载bootclass,用来替换JDK的rt.jar,一般不会用到;
-Xbootclasspath/a : path : 被指定的文件追加到默认的bootstrap路径中;
-Xbootclasspath/p : path : 让JVM优先于默认的bootstrap去加载path中指定的class;

可以看到调用静态方法getUnsafe()报异常java.lang.SecurityException: Unsafe,走读源代码发现,只有被启动类加载器Bootstrap ClassLoader加载的类(Class类的类加载器为null)才能够调用静态方法getUnsafe(),来防止这些方法在不可信的代码中被调用。


Unsafe如何正确地获取实例

利用反射获得Unsafe类中已经实例化完成的单例对象theUnsafe

public static void main(String[] args) throws Exception {
    Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
    boolean accessible = theUnsafe.isAccessible();
    theUnsafe.setAccessible(true);
    Unsafe unsafe = (Unsafe)theUnsafe.get(null);
    theUnsafe.setAccessible(accessible);
    System.out.println(unsafe);
}

输出:
sun.misc.Unsafe@3e3abc88

成功拿到Unsafe实例。


Unsafe功能概览

可以看到JVM底层对Unsafe使用非常谨慎,也从侧面说明了Unsafe的功能非常底层;

截图


Unsafe应用

内存操作

在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);
public static void main(String[] args) {
    Unsafe unsafe = getUnsafe();
    int size = 4;
    //申请4字节内存
    long allocateMemoryAddress = unsafe.allocateMemory(size);
    //重新调整内存为8字节
    long reallocateMemoryAddress = unsafe.reallocateMemory(allocateMemoryAddress, size * 2);
    System.out.println("allocateMemoryAddress=" + allocateMemoryAddress);
    System.out.println("reallocateMemoryAddress=" + reallocateMemoryAddress);
    try {
        //初始化4个字节内存,每个字节用 1 byte初始化
        unsafe.setMemory(null, allocateMemoryAddress, size, (byte) 1);
        for (int i = 0; i < 2; i++) {
            //将初始化过的4字节内存内容拷贝到下一4字节内存上
            unsafe.copyMemory(null, allocateMemoryAddress, null, reallocateMemoryAddress + size * i, 4);
        }
        //获取4字节整型
        System.out.println(unsafe.getInt(allocateMemoryAddress));
        //获取8字节长整型
        System.out.println(unsafe.getLong(reallocateMemoryAddress));
    } finally {
        //堆外内存无法垃圾回收,只能手动释放
        unsafe.freeMemory(allocateMemoryAddress);
        unsafe.freeMemory(reallocateMemoryAddress);
    }
}

public static Unsafe getUnsafe() {
    Unsafe unsafe = null;
    try {
        Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        boolean accessible = theUnsafeField.isAccessible();
        theUnsafeField.setAccessible(true);
        unsafe = (Unsafe)theUnsafeField.get(null);
        theUnsafeField.setAccessible(accessible);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return unsafe;
}

结果输出:

allocateMemoryAddress=140539857000048
reallocateMemoryAddress=140539857000048
16843009
72340172838076673

分析一下运行结果,首先调用allocateMemory方法申请4字节长度的内存空间,在循环中调用setMemory方法向每个字节写入内容为byte类型的1,当使用Unsafe调用getInt方法时,因为一个int型变量占4个字节,会一次性读取4个字节,组成一个int的值,对应的十进制结果为16843009,可以通过图示理解这个过程:

截图

在代码中调用reallocateMemory方法重新分配了一块8字节长度的内存空间,通过比较allocateMemoryAddress和reallocateMemoryAddress可以看到和之前申请的内存地址是相同的。在代码中的第二个for循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址allocateMemoryAddress开始的4个字节,分别拷贝到以reallocateMemoryAddress和reallocateMemoryAddress+4开始的内存空间上(即以reallocateMemoryAddress开始的8个字节大小的内存空间上):

截图

拷贝完成后,调用getLong方法一次性读取8个字节,得到long类型的值为72340172838076673。

需要注意,通过这种方式分配的内存属于堆外内存,是无法进行垃圾回收的,需要把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。

内存屏障

编译器和CPU会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致CPU的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过组织屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。

在硬件层面上,内存屏障是CPU为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在Java8中,引入了3个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由JVM来生成内存屏障指令,来实现内存屏障的功能。Unsafe中提供了下面三个内存屏障相关方法:

//禁止读操作重排序
public native void loadFence();

//禁止写操作重排序
public native void storeFence();

//禁止读、写操作重排序
public native void fullFence();

内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载

看到这估计很多小伙伴们会想到volatile关键字了,如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:

public class FlagSwitchThread extends Thread {
    @Getter
    /*volatile */public boolean flag = false;

    @Override
    public void run() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag switch to true");
    }

    public static void main(String[] args) {
        FlagSwitchThread flagSwitchThread = new FlagSwitchThread();
        flagSwitchThread.start();
        System.out.println("flag switch before : " + flagSwitchThread.isFlag());
        while (true) {
            boolean flag = flagSwitchThread.isFlag();
            getUnsafe().loadFence();
            if (flag) {
                System.out.println("flag changed");
                break;
            }
        }
    }

    public static Unsafe getUnsafe() {
        Unsafe unsafe = null;
        try {
            Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
            boolean accessible = theUnsafeField.isAccessible();
            theUnsafeField.setAccessible(true);
            unsafe = (Unsafe)theUnsafeField.get(null);
            theUnsafeField.setAccessible(accessible);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return unsafe;
    }
}

运行结果:

flag switch before : false
flag switch to true
flag changed

而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。可以用图来表示上面的过程:

截图

根据Java内存模型(JMM),运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。

对象操作

类成员属性普通读写

Unsafe提供了全部8种基础数据类型以及Objectputget方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。阅读openJDK源码中的注释发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。

//在对象的指定偏移地址获取一个对象引用
public native Object getObject(Object o, long offset);

//在对象指定偏移地址写入一个对象引用
public native void putObject(Object o, long offset, Object x);

//在对象的指定偏移地址获取一个整型数据
public native int getInt(Object var1, long var2);

//在对象指定偏移地址写入一个整型数据
public native void putInt(Object var1, long var2, int var4);

//在对象的指定偏移地址获取一个布尔型数据
public native boolean getBoolean(Object var1, long var2);

//在对象指定偏移地址写入一个布尔型数据
public native void putBoolean(Object var1, long var2, boolean var4);

//在对象的指定偏移地址获取一个字节数据
public native byte getByte(Object var1, long var2);

//在对象指定偏移地址写入一个字节数据
public native void putByte(Object var1, long var2, byte var4);

//在对象的指定偏移地址获取一个短整型数据
public native short getShort(Object var1, long var2);

//在对象指定偏移地址写入一个短整型数据
public native void putShort(Object var1, long var2, short var4);

//在对象的指定偏移地址获取一个字符数据
public native char getChar(Object var1, long var2);

//在对象指定偏移地址写入一个字符数据
public native void putChar(Object var1, long var2, char var4);

//在对象的指定偏移地址获取一个长整型数据
public native long getLong(Object var1, long var2);

//在对象指定偏移地址写入一个长整型数据
public native void putLong(Object var1, long var2, long var4);

//在对象的指定偏移地址获取一个单精度浮点数据
public native float getFloat(Object var1, long var2);

//在对象指定偏移地址写入一个单精度浮点数据
public native void putFloat(Object var1, long var2, float var4);

//在对象的指定偏移地址获取一个双精度浮点
public native double getDouble(Object var1, long var2);

//在对象指定偏移地址写入一个双精度浮点
public native void putDouble(Object var1, long var2, double var4);

示例:

public static void main(String[] args) throws Exception {
    Unsafe unsafe = getUnsafe();

    TestA testA = new TestA();
    long ageOffset = unsafe.objectFieldOffset(TestA.class.getDeclaredField("age"));
    System.out.println(testA.getAge());
    unsafe.putInt(testA, ageOffset, 5);
    System.out.println(testA.getAge());
    System.out.println(unsafe.getInt(testA, ageOffset));

    long userNameOffset = unsafe.objectFieldOffset(TestA.class.getDeclaredField("userName"));
    System.out.println(testA.getUserName());
    unsafe.putObject(testA, userNameOffset, new String("张三"));
    System.out.println(testA.getUserName());
    System.out.println(((String) unsafe.getObject(testA, userNameOffset)));
}

public class TestA {

    @Getter
    private String userName;

    @Getter
    private int age;
    
}

输出:

0
5
5
null
张三
张三

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较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:

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

顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为Store Store类型,而在volatile写入时加入的内存屏障是Store Load类型,如下图所示:

截图

在有序写入方法中,使用的是Store Store屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是Store Load屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,Store Load屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。

综上所述,在上面的三类写入方法中,在写入效率方面,按照putputOrderputVolatile的顺序效率逐渐降低。

类实例化

UnsafeallocateInstance方法允许使用非常规的方式进行对象的实例化

public native Object allocateInstance(Class<?> var1) throws InstantiationException;
public static void main(String[] args) throws Exception {
    Unsafe unsafe = getUnsafe();

    new TestA();
    System.out.println("--------");
    TestA.class.newInstance();
    System.out.println("--------");
    unsafe.allocateInstance(TestA.class);
}

public class TestA {

    @Getter
    private int age;

    public TestA() {
        System.out.println("无参构造方法执行");
    }
}

输出:

无参构造方法执行
--------
无参构造方法执行
--------

可以看到,通过allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将TestA类的构造函数改为private类型,将无法通过构造函数和反射创建对象,但allocateInstance方法仍然有效

数组操作

//获取数组第一个元素的偏移地址
public native int arrayBaseOffset(Class<?> var1);

//获取数组元素间偏移地址的增量
public native int arrayIndexScale(Class<?> var1);
public static void main(String[] args) throws Exception {
    Unsafe unsafe = getUnsafe();
    String[] test = {"张三", "李四", "王五", "钱六"};
    int arrayBaseOffset = unsafe.arrayBaseOffset(String[].class);
    System.out.println(((String) unsafe.getObject(test, (long) arrayBaseOffset)));
    int arrayIndexScale = unsafe.arrayIndexScale(String[].class);
    System.out.println(((String) unsafe.getObject(test, (long) arrayBaseOffset + arrayIndexScale * 2)));
}

输出:

张三
王五

通过配合使用数组偏移首地址各元素间偏移地址的增量,可以方便的定位到数组中的任何元素在内存中的位置,进而通过getObject方法直接获取任意位置的数组元素。

注意:arrayIndexScale获取的并不是数组中元素占用的大小,而是地址的增量(上面例子中,String数组中元素可以相当长,但是相邻元素偏移地址增量是固定的),按照openJDK中的注释,可以将它翻译为元素寻址的转换因子(scale factor for addressing elements),其实可以这么理解:

对于基本数据类型的数组,数组中直接存的是基本数据类型的数据,因此元素间偏移地址增量就是基本数据类型的大小,如int[],arrayIndexScale=4;long[],arrayIndexScale=8;

对于引用数据类型,数组中存的是引用地址,这个引用地址是int类型的,固定大小是4字节,因此,对于引用数据类型数组,arrayIndexScale固定为4。

Unsafe如何根据arrayBaseOffset及arrayIndexScale访问数组元素

截图

String数组对象中,对象头包含3部分,mark word标记字占用8字节,Class point类型指针占用4字节,数组对象特有的数组长度部分占用4字节,总共占用了16字节。第一个String的引用类型相对于对象的首地址的偏移量是就16,之后每个元素在这个基础上加4,正好对应了上面代码中的寻址过程,之后再使用前面说过的getObject方法,通过数组对象可以获得对象在堆中的首地址,再配合对象中变量的偏移量,就能获得每一个变量的引用。

CAS操作

Unsafe提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的CAS操作

public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);

public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);

参数中o为需要更新的对象,offset是对象o中对象/整型/长整型的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作,方法会直接返回truefalse的修改结果。

线程调度

Unsafe类中提供了parkunparkmonitorEntermonitorExittryMonitorEnter方法进行线程调度

//唤醒当前线程
public native void unpark(Object var1);

//阻塞当前线程
public native void park(boolean var1, long var2);

/** @deprecated */
@Deprecated
public native void monitorEnter(Object var1);

/** @deprecated */
@Deprecated
public native void monitorExit(Object var1);

/** @deprecated */
@Deprecated
public native boolean tryMonitorEnter(Object var1);

monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false

Class操作

Unsafe提供了对类加载及操作静态变量的支持。

读取静态变量

//获取静态属性的偏移量
public native long staticFieldOffset(Field f);

//获取静态属性的对象指针
public native Object staticFieldBase(Field f);

//判断类是否需要实例化(用于获取类的静态属性前进行检测)
public native boolean shouldBeInitialized(Class<?> c);

通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。

如果一个类没有被实例化,那么它的静态属性也不会被初始化。

类定义

UnsafedefineClass方法允许程序在运行时动态地创建一个类

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的所有安全检查。

截图

除了defineClass方法外,Unsafe还提供了一个defineAnonymousClass方法:

public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);

使用该方法可以用来动态的创建一个匿名类,在Lambda表达式中就是使用ASM动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在JDK15发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用UnsafedefineAnonymousClass方法。

系统信息

Unsafe中提供的addressSizepageSize方法用于获取系统信息。

调用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位系统的情况。


参考:
Java双刃剑之Unsafe类详解
@CallerSensitive 注解的作用
Java魔法类:Unsafe应用解析

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值