前情提要
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位虚拟机的情况:
事实上, 对于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