Java魔法类之Unsafe源码分析

一、简介

Unsafe类在JDK源码中被广泛使用,在Spark使用off-heap memory时也会使用到,该类功能很强大,涉及到类加载机制(深入理解ClassLoader工作机制),其实例一般情况是获取不到的,源码中的设计是采用单例模式,不是系统加载初始化就会抛出SecurityException异常。

Java不能直接访问操作系统底层,这个类的提供了一些绕开JVM的更底层功能,基于它的实现可以提高效率。但是,它是一把双刃剑:正如它的名字所预示的那样,它是Unsafe的,它所分配的内存需要手动free(不被GC回收)。如果对Unsafe类理解的不够透彻,就进行使用的话,就等于给自己挖了无形之坑,最为致命。

Unsafe类,来源于sun.misc包。该类封装了许多类似指针操作,可以直接进行内存管理、操纵对象、阻塞/唤醒线程等操作。Java本身不直接支持指针的操作,所以这也是该类命名为Unsafe的原因之一。

二、CAS

CAS(compare-and-swap):直译即比较并交换,提供原子化的读改写能力,是Java并发中所谓lock-free(无锁)机制的基础。JAVA 1.5开始引入了CAS,主要代码都放在JUCatomic包下。

JUC中的许多CAS方法,内部其实都是Unsafe类在操作。比如AtomicBooleancompareAndSet方法:

public final boolean compareAndSet(boolean expect, boolean update) {
    int e = expect ? 1 : 0;
    int u = update ? 1 : 0;
    return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}

unsafe.compareAndSwapInt方法是个native方法。(如果对象中的字段值与期望值相等,则将字段值修改为x,然后返回true;否则返回false):

/**
 * 比较o的offset处内存位置中的值和期望的值,如果相同则更新。此更新是不可中断的。
 * 
 * @param o 需要更新的对象
 * @param offset obj中整型field的偏移量
 * @param expect 希望field中存在的值
 * @param update 如果期望值expect与field的当前值相同,设置filed的值为这个新值
 * @return 如果field的值被更改返回true
 */
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

入参的含义如下:

参数名称 含义
o 需要修改的对象
offset 需要修改的字段到对象头的偏移量(通过偏移量,可以快速定位修改的是哪个字段)
expected 期望值
x 要设置的值

2.1 工作流程

CAS是一种无锁算法,CAS3个操作数,内存值V,旧的预期值E,要修改的新值N

  • E == V时,修改V值为N,返回true`。
  • E != V时,修改失败,返回false

cas.png

2.2 底层实现

JAVA中的CAS操作都是通过sun包下Unsafe类实现,而Unsafe类中的方法都是native方法,由JVM本地实现,是JNI链接通过C++代码实现。CASCPU指令级的操作,只有一步原子操作,所以非常快。

例如对int变量的CAS操作,就是通过调用Unsafe#compareAndSwapInt()来完成。Hotsport虚拟机对compareAndSwapInt()的实现是通过调用Atomic::cmpxchg方法完成,这个cmpxchg在不同的操作系统和CPU架构模式下都不一样,Linux_X86架构下,CAS最底层就是通过cmpxchgl汇编指令来完成,但是因为CAS保证了原子性没有保证可见性,所以Hotspotcmpxchgl前加入了LOCK_IF_MP判断是否为多核处理架构,如果是多核则在汇编指令前加入CPULock前缀指令来保证可见性问题。所以JavaCAS机制既能保证原子性也能保证可见性。

2.3 缺陷

2.3.1 Unsafe中的CAS自旋方式缺陷

cmpxchgl汇编指令本身没有自旋的功能,JDK中原子类和unsafe类提供的CAS是有while自旋操作的,但是如果在高并发场景下对共享变量修改时,会让大量的线程修改失败转而进行自旋,此时CPU会因此大量的自旋从而CPU开销变大,CPU利用率降低。并且,JVM在多核架构下还会添加Lock前缀指令造成总线事务的攀升,总线事务嗅探也变得极为繁忙,总线带宽打满,进而造成总线风暴。

如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用:

  1. 它可以延迟流水线执行指令(de-p ip eline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

2.3.2 只能保证一个共享变量的原子操作缺陷

当对一个共享变量执行操作时,可以使用循环CAS的方式来保证原子操作,JDK也提供了AtomicReference类来优化这个问题,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。

2.3.3 ABA问题

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A就会变成1A-2B-3A

aba.png

JDK提供了版本号机制AtomicStampedReference<V>类来解决ABA问题。

三、使用

3.1 Unsafe.allocateInstance

public class Test {

    public static void main(String[] args) throws Exception {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);

        A o1 = new A(); // constructor
        o1.a(); // prints 1
        A o2 = A.class.newInstance(); // reflection
        o2.a(); // prints 1
        A o3 = (A) unsafe.allocateInstance(A.class); // unsafe
        o3.a(); // prints 0
    }

    static class A {
        private long a; // not initialized value, default 0
        public A() {
            this.a = 1; // initialization
        }
        public long a() {
            return this.a;
        }
    }
}

allocateInstance()根本没有进入构造方法,对于单例模式,简直是噩梦。

3.2 内存修改,绕过安全检查器(Unsafe.objectFieldOffset)

public class Test {

    public static void main(String[] args) throws Exception {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);

        Guard guard = new Guard();
        guard.giveAccess();   // false, no access

        // bypass
        Field field = guard.getClass().getDeclaredField("ACCESS_ALLOWED");
        unsafe.putInt(guard, unsafe.objectFieldOffset(field), 42); // memory corruption
        guard.giveAccess(); // true, access granted
    }

    static class Guard {
        private int ACCESS_ALLOWED = 1;
        public boolean giveAccess() {
            return 42 == ACCESS_ALLOWED;
        }
    }
}

通过获取目标对象的字段在内存中的offset,并使用putInt()方法,类的ACCESS_ALLOWED被修改。在已知类结构的时候,数据的偏移总是可以获得的(与c++中的类中数据的偏移计算是一致的)。

3.3 sizeOf计算内存大小(Unsafe.getDeclaredFields和Unsafe.objectFieldOffset)

public class Test {

    public static void main(String[] args) throws Exception {
        Guard guard = new Guard();
        sizeOf(guard); // 16, the size of guard
    }

    static class Guard {
        private int ACCESS_ALLOWED = 1;
        public boolean giveAccess() {
            return 42 == ACCESS_ALLOWED;
        }
    }

    public static long sizeOf(Object o) throws Exception {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);

        HashSet<Field> fields = new HashSet();
        Class c = o.getClass();
        while (c != Object.class) {
            for (Field field : c.getDeclaredFields()) {
                if ((field.getModifiers() & Modifier.STATIC) == 0) {
                    fields.add(field);
                }
            }
            c = c.getSuperclass();
        }

        // get offset
        long maxSize = 0;
        for (Field field : fields) {
            long offset = unsafe.objectFieldOffset(field);
            if (offset > maxSize) {
                maxSize = offset;
            }
        }
        return ((maxSize / 8) + 1) * 8;   // padding
    }
}

算法的思路非常清晰:从底层子类开始,依次取出它自己和它的所有超类的非静态域,放置到一个HashSet中(重复的只计算一次,Java是单继承),然后使用objectFieldOffset()获得一个最大偏移,最后还考虑了对齐。

3.4 实现Java浅复制

public class Test {

    public static void main(String[] args) throws Exception {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        unsafe = (Unsafe) f.get(null);
        Guard guard = new Guard();
        shallowCopy(guard);
    }

    private static Unsafe getUnsafe() {
        return unsafe;
    }

    static Object shallowCopy(Object obj) throws Exception {
        long size = sizeOf(obj);
        long start = toAddress(obj);
        long address = getUnsafe().allocateMemory(size);
        getUnsafe().copyMemory(start, address, size);
        return fromAddress(address);
    }

    static long toAddress(Object obj) {
        Object[] array = new Object[]{obj};
        long baseOffset = getUnsafe().arrayBaseOffset(Object[].class);
        return normalize(getUnsafe().getLong(array, baseOffset));
    }

    static Object fromAddress(long address) {
        Object[] array = new Object[] {null};
        long baseOffset = getUnsafe().arrayBaseOffset(Object[].class);
        getUnsafe().putLong(array, baseOffset, address);
        return array[0];
    }

    static class Guard {
        private int ACCESS_ALLOWED = 1;
        public boolean giveAccess() {
            return 42 == ACCESS_ALLOWED;
        }
    }

    public static long sizeOf(Object o) throws Exception {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);

        HashSet<Field> fields = new HashSet();
        Class c = o.getClass();
        while (c != Object.class) {
            for (Field field : c.getDeclaredFields()) {
                if ((field.getModifiers() & Modifier.STATIC) == 0) {
                    fields.add(field);
                }
            }
            c = c.getSuperclass();
        }

        // get offset
        long maxSize = 0;
        for (Field field : fields) {
            long offset = unsafe.objectFieldOffset(field);
            if (offset > maxSize) {
                maxSize = offset;
            }
        }
        return ((maxSize / 8) + 1) * 8;   // padding
    }
}

思路很简单,利用Unsafe.copyMemory(),将老地址及其指向的对象的size,拷贝到新的内存地址上。并且浅复制函数可以应用于任意java对象,它的尺寸是动态计算的。(在实际测试的时候,执行unsafe.copyMemory时,JVM会输出hs_err_pid.log日志然后挂掉,该问题还有待排查)

3.5 隐藏密码

一般密码都要存成byte[]或者char[]数组,为什么呢?

因为我们使用完了,可以直接将他们设为null。但如果密码存在String中,将其设为null,密码实际任然存在在内存中,等到GC后,才能被释放,就很不安全。

所以当我们把密码字段存储在String中时,在密码字段使用完之后,最安全的做法是:将它的值覆盖。

很多不再需要的,但是又是比较机密的对象,想快点消灭证据,都可以通过这种方法来消除。

Field stringValue = String.class.getDeclaredField("value");
stringValue.setAccessible(true);
char[] mem = (char[]) stringValue.get(password);
for (int i = 0; i < mem.length; i++) {
    mem[i] = '?';
}

3.6 多继承

java中没有多继承,除非我们能在这些不同的类互相强制转换。

long intClassAddress = normalize(getUnsafe().getInt(new Integer(0), 4L));
long strClassAddress = normalize(getUnsafe().getInt("", 4L));
getUnsafe().putAddress(intClassAddress + 36, strClassAddress);

上面这段代码将String添加为int的父类,所以我们转换的时候就不会报错了。

(String) (Object) (new Integer(666))

3.7 动态加载类

标准的动态加载类的方法是Class.forName()(在编写jdbc程序时,记忆深刻),使用Unsafe也可以动态加载javaclass文件。

我们可以在程序运行是动态加载编译好的.class文件,不明白ClassLoader的可以参考<深入理解ClassLoader工作机制>具体是怎么实现的呢?

// 读取一个class文件到byte数组中
byte[] classContents = getClassContent();
// 通过Unsafe.defineClass()来加载对应的Class
Class c = getUnsafe().defineClass(null, classContents, 0, classContents.length);
// 调用
c.getMethod("a").invoke(c.newInstance(), null); // 1
    
private static byte[] getClassContent() throws Exception {
    File f = new File("/home/mishadoff/tmp/A.class");
    FileInputStream input = new FileInputStream(f);
    byte[] content = new byte[(int)f.length()];
    input.read(content);
    input.close();
    return content;
}

动态加载、代理、切片等功能中可以应用。

3.8 快速序列化

我们都知道标准的Java Serializable速度很慢,它还限制类必须有public无参构造函数。Externalizable相对好些,但它需要为序列化的类指定schema。更流行的如kyro,在小内存的情况下不适用。

序列化过程:

  1. 用反射构建对象的schema
  2. Unsafe中的getLonggetIntgetObject等方法来检索对象中字段的值。
  3. 增加对象对应的类的标示符,来标记序列化结果。
  4. 将结果写入文件或者输出流。(可以增加压缩来减小序列化结果)。

反序列化过程:

  1. 使用Unsafe.allocateInstance()来实例化一个被序列化的对象。(不需要执行构造函数)
  2. 构建schema,同序列化过程中的第一步。
  3. 从文件或者输入流中读取所有的字段。
  4. Unsafe中的putLongputIntputObject等方法来填充该对象。

kyro序列化中,也有一些使用Unsafe的尝试:https://code.google.com/archive/p/kryo/issues/75

3.9 在非Java堆中分配内存

使用javanew会在堆中为对象分配内存,并且对象的生命周期内,会被JVM GC管理。Unsafe分配的内存,不受Integer.MAX_VALUE的限制,并且分配在非堆内存,使用它时,需要非常谨慎:忘记手动回收时,会产生内存泄露;非法的地址访问时,会导致JVM崩溃。在需要分配大的连续区域、实时编程(不能容忍JVM延迟)时,可以使用它。java.nio使用这一技术。Spark中的Netty也使用了这个技术。在Spark UnsafeMemoryAllocator源码中我们可以看到其使用了Unsafe.allocateMemory()并会抛出OOM异常:

@Override
public MemoryBlock allocate(long size) throws OutOfMemoryError {
    long address = Platform.allocateMemory(size);
    MemoryBlock memory = new MemoryBlock(null, address, size);
    if (MemoryAllocator.MEMORY_DEBUG_FILL_ENABLED) {
        memory.fill(MemoryAllocator.MEMORY_DEBUG_FILL_CLEAN_VALUE);
    }
    return memory;	
}

3.10 大数组

Java的数组最大容量受常量Integer.MAX_VALUE的限制,如果我们用直接申请内存的方式去创建数组,那么数组大小只会收到堆的大小的限制。

private static Unsafe unsafe;

public static void main(String[] args) throws Exception {
    Field f = Unsafe.class.getDeclaredField("theUnsafe");
    f.setAccessible(true);
    unsafe = (Unsafe) f.get(null);
    // 设置数组大小为Integer.MAX_VALUE的2倍
    long SUPER_SIZE = (long) Integer.MAX_VALUE * 2;
    SuperArray array = new SuperArray(SUPER_SIZE);
    System.out.println("Array size:" + array.size()); // 4294967294
    int sum = 0;
    for (int i = 0; i < 100; i++) {
        array.set((long) Integer.MAX_VALUE + i, (byte) 3);
        sum += array.get((long) Integer.MAX_VALUE + i);
    }
    System.out.println("Sum of 100 elements:" + sum);  // 300
}

private static Unsafe getUnsafe() {
    return unsafe;
}

static class SuperArray {
    private final static int BYTE = 1;
    private long size;
    private long address;

    public SuperArray(long size) {
        this.size = size;
        address = getUnsafe().allocateMemory(size * BYTE);
    }
    public void set(long i, byte value) {
        getUnsafe().putByte(address + i * BYTE, value);
    }
    public int get(long idx) {
        return getUnsafe().getByte(address + idx * BYTE);
    }
    public long size() {
        return size;
    }
}

输出结果:

Array size:4294967294

Sum of 100 elements:300

四、对象的创建

Unsafe是一个final类,不能被继承,也没有公共的构造器,只能通过工厂方法getUnsafe获得Unsafe的单例。以下是部分源码:

public final class Unsafe {

    private static final Unsafe theUnsafe;

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

    static {
        registerNatives();
        Reflection.registerMethodsToFilter(Unsafe.class, new String[]{"getUnsafe"});
        theUnsafe = new Unsafe();
    }
    // ...
}

但是getUnsafe方法限制了调用该方法的类的类加载器必须为Bootstrap ClassLoader

Java中的类加载器可以大致划分为以下三类:

类加载器名称 作用
Bootstrap ClassLoader
(Bootstrap类加载器)
主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是JVM自身的一部分,
它负责将【JDK的安装目录】/lib路径下的核心类库,如rt.jar
Extension ClassLoader
(扩展类加载器)
该加载器负责加载【JDK的安装目录】jre/lib/ext目录中的类库,开发者可以直接使用该加载器
Application ClassLoader
(系统类加载器)
负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,也是默认的类加载器

所以在用户代码中直接调用getUnsafe方法,会抛出异常。因为用户自定义的类一般都是由系统类加载器加载的。

但是,是否就真的没有办法获取到Unsafe实例了呢?当然不是,要获取Unsafe对象的方法很多,这里给出一种通过反射的方法:

// 通过反射实例化Unsafe
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);

但是,除非对Unsafe的实现非常清楚,否则应尽量避免直接使用Unsafe来进行操作。

五、核心方法

Unsafe类大部分都是native方法,具体实现由JVM完成:unsafe.cpp

63412848.png

概括的来说,Unsafe类实现功能可以被分为下面8类:

  1. 内存操作
  2. 内存屏障
  3. 对象操作
  4. 数据操作
  5. CAS操作
  6. 线程调度
  7. Class操作
  8. 系统信息

5.1 内存操作

如果你是一个写过C或者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);
    }
}

先看结果输出:

addr: 2433733895744
addr3: 2433733894944
16843009
72340172838076673

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

image-20220717144344005.png

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

image-20220717144354582.png

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

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

为什么要使用堆外内存?

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

典型应用

DirectByteBufferJava用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在NettyMINANIO框架中应用广泛。DirectByteBuffer对于堆外内存的创建、使用、销毁等逻辑均由Unsafe提供的堆外内存API来实现。

下图为DirectByteBuffer构造函数,创建DirectByteBuffer的时候,通过Unsafe.allocateMemory分配内存、Unsafe.setMemory进行内存初始化,而后构建Cleaner对象用于跟踪DirectByteBuffer对象的垃圾回收,以实现当DirectByteBuffer被垃圾回收时,分配的堆外内存一起被释放。

DirectByteBuffer(int cap) {                   // package-private

    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 分配内存并返回基地址
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // 内存初始化
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    // 跟踪DirectByteBuffer对象的垃圾回收,以实现堆外内存释放
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

5.2 内存屏障

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

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

Unsafe中提供了下面三个内存屏障相关方法:

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

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

// 内存屏障,禁止load、store操作重排序
public native void fullFence();

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

看到这估计很多小伙伴们会想到volatile关键字了,如果在字段上添加了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中循环。可以用图来表示上面的过程:

image-20220717144703446.png

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

典型应用

Java8中引入了一种锁的新机制——StampedLock,它可以看成是读写锁的一个改进版本。StampedLock提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于StampedLock提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存load到线程工作内存时,会存在数据不一致问题。

为了解决这个问题,StampedLockvalidate方法会通过UnsafeloadFence方法加入一个load内存屏障。

public boolean validate(long stamp) {
    U.loadFence();
    return (stamp & SBITS) == (state & SBITS);
}

5.3 对象操作

对象属性对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的putIntgetInt方法外,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类型,如下图所示:

image-20220717144834132.png

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

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

对象实例化

使用UnsafeallocateInstance方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:

@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方法仍然有效。

典型应用

  • 常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过new机制来实现对象的创建。但是,new机制有个特点就是当类只提供有参的构造函数且无显示声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。
  • 非常规的实例化方式:而Unsafe中提供allocateInstance方法,仅通过Class对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM安全检查等。它抑制修饰符检测,也就是即使构造器是private修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstancejava.lang.invokeObjenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。

5.4 数组操作

arrayBaseOffsetarrayIndexScale这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。

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

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

典型应用

这两个与数据操作相关的方法,在java.util.concurrent.atomic包下的AtomicIntegerArray(可以实现对Integer数组中每个元素的原子性操作)中有典型的应用,如下图AtomicIntegerArray源码所示,通过UnsafearrayBaseOffsetarrayIndexScale分别获取数组首元素的偏移地址base及单个元素大小因子scale。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的getAndAdd方法即通过checkedByteOffset方法获取某数组元素的偏移地址,而后通过CAS实现原子性操作。

image-20220717144927257.png

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

什么是CAS?CAS即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS操作包含三个操作数——内存位置、预期原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS是一条CPU的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg

典型应用

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这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt的例子:

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的加一的操作。流程如下所示:

image-20220717144939826.png

需要注意的是,在调用compareAndSwapInt方法后,会直接返回truefalse的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。

5.6 线程调度

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

// 取消阻塞线程
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);

方法parkunpark即可实现线程的挂起与恢复,将一个线程进行挂起是通过park方法实现的,调用park方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark可以终止一个挂起的线程,使其恢复正常。此外,Unsafe源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用。

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

典型应用

Java锁和同步器框架的核心类AbstractQueuedSynchronizer(AQS),就是通过调用LockSupport.park()LockSupport.unpark()实现线程的阻塞和唤醒的,而LockSupportparkunpark方法实际是调用Unsafeparkunpark方式实现的。

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

LockSupportpark方法调用了Unsafepark方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对Unsafe的这两个方法进行测试:

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 main mainThread
subThread try to unpark mainThread
unpark mainThread success

程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠5秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:

image-20220717144950116.png

5.7 Class操作

UnsafeClass的相关操作主要包括类加载和静态变量的操作方法。

静态属性读取相关的方法

//获取静态属性的偏移量
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);
}

运行结果:

falseHydra

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

在上面的代码中首先创建一个User对象,这是因为如果一个类没有被实例化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建User对象的语句,运行结果会变为:

truenull

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

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

image-20220717145000710.png

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

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

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

典型应用

Lambda表达式实现需要依赖UnsafedefineAnonymousClass方法定义实现相应的函数式接口的匿名类。

5.8 系统信息

这部分包含两个获取系统相关信息的方法。

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

典型应用

这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测32位系统的情况。

六、总结

总的来说就是这么几类

  1. Info相关。主要返回某些低级别的内存信息:addressSize(),pageSize()
  2. Objects相关。主要提供Object和它的域操纵方法:allocateInstance(),objectFieldOffset()
  3. Class相关。主要提供Class和它的静态域操纵方法:staticFieldOffset(),defineClass(),defineAnonymousClass(),ensureClassInitialized()
  4. Arrays相关。数组操纵方法:arrayBaseOffset(),arrayIndexScale()
  5. Synchronization相关。主要提供低级别同步原语(如基于CPUCAS(Compare-And-Swap)原语):monitorEnter(),tryMonitorEnter(),monitorExit(),compareAndSwapInt(),putOrderedInt()
  6. Memory相关。直接内存访问方法(绕过JVM堆直接操纵本地内存):allocateMemory(),copyMemory(),freeMemory(),getAddress(),getInt(),putInt()

参考文章

posted @ 2022-06-23 16:34  夏尔_717  阅读(292)  评论(0编辑  收藏  举报