神奇的Unsafe类: Java实例内存布局初探

JavaWeb,折腾,技术向 2019-03-14

前情提要

Java相比C/C++最大的一个特点就是没有提供指针, 或者说没有提供对内存的直接访问操作, 导致很多比较底层的操作根本无法进行.
一方面给Java带来了内存上的安全性, 不像C/C++稍微一处理不好就会Segment Fault, 更可怕的是内存访问越界了程序却没有退出, 这会导致程序发生一些莫名其妙的事情, 比如说由于错误修改了循环的迭代变量导致循环无法退出(被坑过的我流下了眼泪)等.
但另一方面降低了Java的灵活性, 也让人无法弄清楚Java的运行时内存布局, 以及进行一些神奇的Hack操作.

但是在Hotspot虚拟机中, 提供了UnSafe类可以给我们进行很多非常底层的虚拟机操作, 包括:

  • 根据对象和偏移读写内存的getInt(Object o, long offset)/putInt(Object o, long offset, int x)
  • 根据地址直接读写内存的getAddress(long address)/putAddress(long address, long x)
  • 在直接内存区(Direct Memory)中分配内存的allocateMemory(long bytes)/freeMemory(long address), 这部分内存主要是被java.nio中的类所使用
  • 内存直接复制copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes) 类似于memcpy
  • 属性偏移计算方法objectFieldOffset(Field f)
  • 一些底层内存信息addressSize()/pageSize()
  • 从字节码直接生成一个类defineClass(String name, byte[] b, int off, int len)
  • 加载实例也不是一个问题allocateInstance(Class cls)
  • 手动模拟Synchronized的加锁解锁操作monitorEnter(Object o)/monitorExit(Object o)
  • 抛出一个异常throwException(Throwable ee)
  • 喜闻乐见的CAS操作compareAndSwapObject(Object o, long offset, Object expected, Object x)
  • 以volatile模式操作某个属性getIntVolatile(Object o, long offset)
  • 读值并直接相加/设置的新原语getAndAddInt(Object var1, long var2, int var4)/getAndSetInt(Object var1, long var2, int var4)
  • 栅栏操作storeFence()/loadFence()

等等

不过, Oracle已经在JDK11里正式移除Unsafe类, 毕竟这些底层操作从某种角度来说破坏了Java构建的安全性, 虽然似乎其实有大量的开源框架都在使用该类, 比如Spring/Netty/Hadoop等等, 在新的JDK11下似乎也有替换的解决方案.
不过这些都不是重点, 本文的重点是如何通过Unsafe类提供的getAddress(long address)观察对象实例的内存布局.

正篇开始

Java的对象实例内存布局其实是一个老生常谈的问题了, 无论是著名的深入理解Java虚拟机还是网上都有很多文章说过这个问题, 但是我几乎没有看到结合实验来说明实例布局的实际情况的文章, 所以本文从实验的角度来说明这个问题.

对象实例内存布局总的来说, 可以分为三块:

  • 对象头
  • 实例数据
  • 对齐填充

下图展示了32位虚拟机的情况:
20190314172833568_30071.png

事实上, 对于64位虚拟机来说对象头其实有128Bit, 也就是16Byte. 在没有开启对象指针压缩的前提下, 对象头的布局才是如下所示:

|------------------------------------------------------------------------------------------------------------|--------------------|
|                                            Object Header (128 bits)                                        |        State       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                                  Mark Word (64 bits)                         |    Klass Word (64 bits)     |                    |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |    OOP to metadata object   |       Normal       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |    OOP to metadata object   |       Biased       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                       ptr_to_lock_record:62                         | lock:2 |    OOP to metadata object   | Lightweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                     ptr_to_heavyweight_monitor:62                   | lock:2 |    OOP to metadata object   | Heavyweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                                                                     | lock:2 |    OOP to metadata object   |    Marked for GC   |
|------------------------------------------------------------------------------|-----------------------------|--------------------|

不过, 从JDK6开始, JVM就会默认开启对象指针压缩, 如果要让内存的结构如上图所示, 必须要手动关掉它-XX:-UseCompressedOops才行.

Talk is cheap. Show me the code.

public class MemoryAccess{
    private final static Unsafe unsafe = getUnsafe();
    public int a = 12345678;
    public int b = 87654321;
    public long c = -1;
    public char d = 66;

    public static void main(String[] args){
        MemoryAccess o = new MemoryAccess();
        long base = getBaseAddress(o), size = sizeOf(o);
        System.out.printf("Base: %x, Size: %d\n", base, size);

        // Read Memory First
        readMemory(base, size);

        // Fetch HashCode
        System.out.printf("HashCode: %x\n", o.hashCode());

        // Read a first
        System.out.println("Old a Val: " + o.a);
        // Modify a via memory
        int offset = 24, newAVal = 23333333;
        long newMemoryVal = (unsafe.getAddress(base + offset) & 0xffffffff00000000L) + newAVal;
        unsafe.putAddress(base + offset, newMemoryVal);
        // Read a again
        System.out.println("New a Val: " + o.a);

        // Read Memory Again
        readMemory(base, size);
    }

    static Unsafe getUnsafe(){
        try{
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            return (Unsafe)f.get(null);
        }catch(NoSuchFieldException | IllegalAccessException e){
            e.printStackTrace();
        }
        return null;
    }

    public static long sizeOf(Object object){
        long metaAddr = unsafe.getInt(object, 8L);
        return unsafe.getAddress(metaAddr + 4L) >>> 32;
    }

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

    static void readMemory(long base, long length){
        System.out.printf("| %3s | %79s | %17s | %23s |\n", "Offset", "Binary", "Hex", "Dec");
        for(int i = 0; i < length; i += 8){
            long val = unsafe.getAddress(base + i);
            int leftVal = (int)(val >> 32);
            int rightVal = (int)(val);
            System.out.printf("| %6d | %s | %8x %8x | %11d %11d |\n", i, getLongBinaryStr(val), leftVal, rightVal, leftVal, rightVal);
        }
    }

    static String getLongBinaryStr(long val){
        return getLongBinaryStr(val, true);
    }

    static String getLongBinaryStr(long val, boolean padding){
        StringBuilder builder = new StringBuilder();
        for(int i = 0; i < 64; i++){
            if(padding && i % 4 == 0 && i != 0){
                builder.insert(0, " ");
            }
            builder.insert(0, val & 0x1);
            val = val >>> 1;
        }
        return builder.toString();
    }
}

简单解释一下:

  • getUnsafe用于获取Unsafe类的实例, 因为Unsafe类本来的设计是限定在rt.jar的内部使用, 直接使用getUnsafe()方法的话, 它会在内部验证Caller的类加载器是否为空, 即BootstrapClassLoader.
public static Unsafe getUnsafe() {
    Class cc = sun.reflect.Reflection.getCallerClass(2);
    if (cc.getClassLoader() != null)
        throw new SecurityException("Unsafe");
    return theUnsafe;
}
  • sizeOf用于获取对象实例的大小, 首先是从对象头中取得类在方法区的指针, 然后通过取方法区中的数据获得类的实例大小.
  • getBaseAddress用于获取一个对象实例的基地址, Unsafe类中也没有提供获得对象基地址的方法, 这里用了一个非常巧妙的方法, 即先把对象放到一个数组中, 然后读取数组的值即可知道对象指针的值, 也就是对象实例的基地址了.
  • readMemory用于从基地址开始打印指定长度的内存到控制台, 由于getAddress只能以64bit读取数据, 这里将这64bit分成左右两个部分分别显示十六进制和十进制值
  • getLongBinaryStr用于将long长度的数用带前缀0的二进制打印出来, Long.toBinaryString()是没有前缀0的

输出的结果如下所示:

Base: d4dc74e0, Size: 40
| Offset |                                                                          Binary |               Hex |                     Dec |
|      0 | 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 |        0        1 |           0           1 |
|      8 | 0000 0000 0000 0000 0000 0000 0000 0000 0001 0111 1110 0011 0100 0010 0100 0000 |        0 17e34240 |           0   400769600 |
|     16 | 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 | ffffffff ffffffff |          -1          -1 |
|     24 | 0000 0101 0011 1001 0111 1111 1011 0001 0000 0000 1011 1100 0110 0001 0100 1110 |  5397fb1   bc614e |    87654321    12345678 |
|     32 | 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0100 0010 |        0       42 |           0          66 |
HashCode: 6e0be858
Old a Val: 12345678
New a Val: 23333333
| Offset |                                                                          Binary |               Hex |                     Dec |
|      0 | 0000 0000 0000 0000 0000 0000 0110 1110 0000 1011 1110 1000 0101 1000 0000 0001 |       6e  be85801 |         110   199776257 |
|      8 | 0000 0000 0000 0000 0000 0000 0000 0000 0001 0111 1110 0011 0100 0010 0100 0000 |        0 17e34240 |           0   400769600 |
|     16 | 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 | ffffffff ffffffff |          -1          -1 |
|     24 | 0000 0101 0011 1001 0111 1111 1011 0001 0000 0001 0110 0100 0000 1001 1101 0101 |  5397fb1  16409d5 |    87654321    23333333 |
|     32 | 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0100 0010 |        0       42 |           0          66 |

我们可以看到对象的基地址是0xd4dc74e0, 对象的长度是40Byte.

前128Bit就是之前提到的对象头, 后面的数据分别是64bit长度的属性c=-1, 由于Intel x86是小端序, 所以接下来是32bit长的属性a=12345678和32bit长的属性b=87654321, 最后是8bit长的属性d=66, 以及补齐到64bit对齐的填充0.
和书中说的一样, 实例数据部分会优先分配比较长的属性, 并尽量将相同宽度的属性放在一起.

对于对象头, 参考之前的对象头表, 0x17e34240是指向metadata的指针, 对象长度也是从这个指针所指向的内存中获取的, 而MarkWord部分, 却只知道对象现在是无锁状态(01), 为什么理应放hashCode的部分是0呢?

因为我们还没有计算过hashCode, 只有计算过hashCode, 那么在对象头部分才会将hashCode的值填入.
我们读取过一遍hashCode以后, 再次读取对象头部分的时候, 就会发现hashCode部分存入了之前计算出的hash值, 即0x6e0be858.
从此可见, 对象头中的hashCode相当于是一层简单的缓存, 避免对同一个对象hashCode的重复计算.

后面的代码演示了, 通过直接对内存进行修改来修改对象属性的值. 我们首先读取了属性a的值为12345678, 通过直接对内存进行修改, 我们就其的值改为了23333333.
这里的offset可以通过对对象实例内存进行观察得到, 也可以通过之前提到的objectFieldOffset(Field f)方法获得, 我猜大概也可以直接从方法区中类的metadata中读到.

另外就是这里没有演示出来的一点, 对象的metadata和类的class对象在内存里没有任何关系, 类的class对象也是一个位于堆区的普通对象, 是Class类的一个实例.

总结与展望

通过对Unsafe的操作, 我们能够对对象实例部分的内存进行任意读写, 经过测试, 整个堆区的内存都是可以任意读写的.
这从某种角度来说提供了一个高效的修改对象的方式, 相比反射过程冗长的检查过程, 这种方式简单直接当然也很容易出错, 而且非常依赖于具体的实现.

通过对对象实例内存的读取, 可以对对象实例内存的布局和数据变化有了更加深入的认识和了解, 实例内存布局不再是一个简单的图表或者一个虚拟机实现的头文件, 而是实实在在的数据变化.

考虑到, 我们可以通过对象实例获取到对象方法区的指针, 那么可以通过方法区内存的读取, 从而得到类在方法区内存的布局.
更进一步说, 如果我们能够找到对象的方法表, 而且这块内存可写, 那么我们其实可以做到方法级的Hook操作.
目前已知的一些Hook, 无论是自定义类加载器/动态代理或者是Instrument机制, 我们都只能整体的替换一个类, 而不能做到精准地只替换掉一个方法.
也许, 通过这种操作, 能够实现单独对一个方法的替换.

当然, 这样的hack操作肯定不会在生产环境中使用, 但是从对JVM深入理解以及好玩的角度来说, 我觉得继续探究一下还是挺有意思的.

Ref1: http://mishadoff.com/blog/java-magic-part-4-sun-dot-misc-dot-unsafe/
Ref2: https://github.com/keerath/openjdk-8-source/blob/master/jdk/src/share/classes/sun/misc/Unsafe.java


本文由 SLKun 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论