Unsafe 详解

本文整理于以下文章

介绍

Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe 的使用一定要慎重。

另外,Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 native 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码

尝试获取Unsafe实例

如果直接用new的方式获取,将会报以下错误。
在这里插入图片描述

查看了unsafe的源码后发现,unsafe类被final修饰不允许被子类继承,并且构造方法使用private修饰不允许外部调用,只在static静态代码块里初始化了一个Unsafe对象。

public final class Unsafe {
    private static final Unsafe theUnsafe;
    ...
    private Unsafe() {
    }
    ...
    static {
        ...
        theUnsafe = new Unsafe();
        ...
    }   
}

不过发现还有一个静态方法getUnsafe,我们可以尝试一下用它获取

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

在这里插入图片描述

观察上方代码if判断,在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。

之前从未见过如此难以使用的类,那么为什么对它限制这么严谨呢?

因为它的功能十分底层且危险,例如像c语言一样对内存进行操作,绕过jvm的安全检查创建对象等。这么好玩的功能越想尝试一下了,那么如何创建呢?

创建实例

如何获取Unsafe对象呢,答案是利用反射获得Unsafe类中已经实例化完成的单例对象:

//通过反射获取Unsafe类中的theUnsafe
        Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        unsafeField.setAccessible(true);
        Unsafe unsafe =(Unsafe) unsafeField.get(null);

终于获取到了Unsafe对象,我们先来用它做一个对象属性的改变和获取吧。

		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());

输出结果为

offset:16
age:20
age:20

objectFieldOffset方法就是用来获取对象中字段的便宜地址,该地址不是内存中的绝对地址而实一个相对地址,之后通过这个地址对user的age属性进行赋值。

getInt用于从对象的指定偏移地址处读取一个intputInt用于在对象指定偏移地址处写入一个int

public native int getInt(Object o, long offset);
public native void putInt(Object o, long offset, int x);

这两个方法都被native修饰,这是本地方法,是用java去调用非java代码的接口。

Unsafe 类中的很多基础方法都属于native方法,那么为什么要使用native方法呢?原因可以概括为以下几点:

  • 需要用到 java 中不具备的依赖于操作系统的特性,java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用
  • 对于其他语言已经完成的一些现成功能,可以使用 java 直接调用
  • 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编

Unsafe应用

内存操作

记得大一学c语言的时候接触过内存操作,但开始学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);
    }
}

在这里插入图片描述

首先用allocateMemory分配了4字节的空间,然后又重新分配了8字节空间。

调用setMemory方法向每个字节写入内容为byte类型的 1。

当getInt时,一次读取四个字节,即00000001 00000001 00000001 00000001转为十进制即16843009.

循环中调用copyMemory进行两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3addr3+4开始的内存空间上。

即00000001 00000001 00000001 00000001 00000001 00000001 00000001 00000001

getLong时获取8个字节,即对上面的获取,十进制为72340172838076673。

要注意,通过这种方式分配的内存属于堆外内存,是无法进行垃圾回收的,需要我们手动freeMemory

内存屏障

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

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

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

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

ps:记得学JUC的时候学过volatile,加了volatile关键字的变量有变化所有线程可见,其实我们用loadFence方法也可以实现,只要在别的线程读取数据的后面加上这个方法,它会将缓存设为无效并且重新从主存加载。

在这里插入图片描述

运行中的线程是不能直接读取主内存的变量的,各个线程也无法共享,只能操作自己线程中的副本,然后同步给主内存。

上述loadFence读屏障会从主内存读取新数据。

对象操作

put putOrder putVolatile

Unsafe 提供了全部 8 种基础数据类型以及Objectputget方法,并且所有的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);

除了对象属性的普通读写外,Unsafe 还提供了 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写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型.

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

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

实例化对象

使用 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));
    }
}

通过使用数组偏移首地址和元素之间偏移量,就可以很容易定位到数组中的元素在内存中的位置。

基于这两个值是如何实现的寻址和数组元素的访问呢,这里需要借助一点在前面的文章中讲过的 Java 对象内存布局的知识,先把上面例子中的 String 数组对象的内存布局画出来,就很方便大家理解了:

(图片来自Java 双刃剑之 Unsafe 类详解 - 码农参上 - 2021)

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

CAS操作

juc包的并发工具类中大量地使用了 CAS 操作,像在前面介绍synchronizedAQS的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 Unsafe 类中,提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作。以compareAndSwapInt方法为例:

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

参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。

线程调度

Unsafe 类中提供了parkunparkmonitorEntermonitorExittryMonitorEnter方法进行线程调度,在前面介绍 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方法唤醒当前线程。

总结

了解了Unsafe类后发现确定能给我们提供很多方便,但确实有很多安全问题,所以我们使用的时候还是要小心谨慎一些。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值