通过Unsafe验证字符串常量池结构
引言
许多Java程序员都知道,在JVM中,字面量表示的字符串是一种特殊的对象,这些字符串被存储在堆中的一个叫做字符串常量池的哈希表结构中,由于字符串常量池是JVM虚拟机的一个组成部分,所以该哈希表是基于C++实现的。
在C++实现的hotspot VM中,实际上字符串常量池(即StringTable)的结构就是一个<char*,java_lang_String>的哈希表结构
当我们说String a="ab"时,实际上是在该哈希表中寻找键值为ab的键值对,当不存在时在该池中创建一个String对象,然后将该对象返回给引用a
当我们说String a=new String("ab")时,"ab"字面量仍然是遵循上述的步骤创建和/或返回了该String对象,但是在new String这一步,实际上new关键字在堆中创建了一个该对象的拷贝,并将拷贝对象返回给了引用a,因此此时引用a所指的String对象和字面量"ab"所指的对象是不等(!=)的。
出于这点考虑,Java在设计时规定了String的不可变性,即:
- String对象的Value是private final类型,不允许更改,且不提供访问Value的setter方法
- String对象本身是final的,防止其他类继承String后覆写String的value字段
通过Unsafe修改String的Value字段
String的内容是存放在内部的一个叫做value的byte数组中的,那么我们有没有办法去修改String的value字段呢?答案是可以借助Unsafe类来强行进行内存操作。
虽然从Java本身的语法上,我们没有任何办法操作String的value字段,但是Java核心库提供了一个Unsafe类,该类可以操作JVM中的内存数据,内存操作相关的方法是被声明为native的,即方法本身是基于其他语言(如C++)实现的。我们可以直接找到String对象的内存地址,找到value数组的偏移值,然后就可以对value数组直接进行内存读写了。
由于Unsafe类是核心库调用的,该类仅能被由BootstrapClassLoader加载的Java核心库实例化,在我们的Java程序中直接实例化会抛出SecurityException 异常。为了在Java中获取Unsafe对象,我们需要借助反射获取Unsafe中提供的theUnsafe单例对象:
import java.lang.reflect.Field;
import sun.misc.Unsafe;
private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
return null;
}
}
在Unsafe类中提供了对象内存操作的相关方法:
/**
* Fetches a reference value from a given Java variable.
* @see #getInt(Object, long)
*/
@ForceInline
public Object getObject(Object o, long offset) {
return theInternalUnsafe.getReference(o, offset);
}
/**
* Stores a reference value into a given Java variable.
* <p>
* Unless the reference {@code x} being stored is either null
* or matches the field type, the results are undefined.
* If the reference {@code o} is non-null, card marks or
* other store barriers for that object (if the VM requires them)
* are updated.
* @see #putInt(Object, long, int)
*/
@ForceInline
public void putObject(Object o, long offset, Object x) {
theInternalUnsafe.putReference(o, offset, x);
}
这里着重考虑putObject(Object o, long offset, Object x)方法,该方法会获取对象o的内存地址起始值,并将地址为起始值+offset的数据(这里约定地址正确,且为引用数据类型)修改为对象x的引用
有了这个方法,我们就可以很轻松地修改一个String的value了
首先,通过反射获取String类中value字段的偏移值:
long offset=unsafe.objectFieldOffset(String.class.getDeclaredField("value"));
对于StringString b= "ab";,我们可以直接通过putObject修改b指向的String实例对象中value字段的内容:
unsafe.putObject(b,offset,new byte[]{'a','b','c'});
此时打印b,有:
System.out.println("b="+b); //b=abc
改变String带来的问题
在上面的基础上,我们设想如果我们修改了String的值有什么问题
修改new出来的String
在String a=new String("ab")时,a指向的对象是在常规堆中的String对象,实际上是字符串常量池中对应"ab"的String对象的拷贝。a指向的对象只对引用a负责,我们可以看如下例子:
Unsafe unsafe=reflectGetUnsafe();
assert unsafe!=null;
long offset=unsafe.objectFieldOffset(String.class.getDeclaredField("value"));
String a=new String("ab");
System.out.println("a="+a); //a=ab
unsafe.putObject(a,offset,new byte[]{'a','b','c'});
System.out.println("a="+a); //a=abc
String b= "ab";
System.out.println("b="+b); //b=ab
这里b的value没有被影响,因为a和b引用指向的实际上是两个不同的String对象,在我们没有通过unsafe修改String之前:
- a引用指向的是一个在常规堆中的String对象,其value为['a','b']
- a引用指向的是一个在常量池中的String对象,其value为['a','b']
修改字面量String
当我们将a修改为字面量赋值时,输出情况发生了变化:
String a="ab";
System.out.println("a="+a); //a=ab
unsafe.putObject(a,offset,new byte[]{'a','b','c'});
System.out.println("a="+a); //a=abc
String b= "ab";
System.out.println("b="+b); //b=abc
这里是因为此时a和b指向的对象是字符串常量池中的同一个String对象,当修改a指向String的内容时,b也随之被影响
String a="ab"从字符串常量池中寻找"ab"键值对应的String对象- 此时常量池中的键值对为<char[]{'a','b'},String(value=byte[]{'a','b'})>
unsafe.putObject(a,offset,new byte[]{'a','b','c'});修改了该对象中value的值为"abc"- 此时常量池中的键值对为<char[]{'a','b'},String(value=byte[]{'a','b','c'})>
String b= "ab";从字符串常量池中寻找"ab"键值对应的String对象- 此时b获取到了对象String(value=byte[]{'a','b','c'})
字符串常量池源码
oop StringTable::basic_add(int index_arg, Handle string, jchar* name,
int len, unsigned int hashValue_arg, TRAPS) {
assert(java_lang_String::equals(string(), name, len),
"string must be properly initialized");
// Cannot hit a safepoint in this function because the "this" pointer can move.
No_Safepoint_Verifier nsv;
// Check if the symbol table has been rehashed, if so, need to recalculate
// the hash value and index before second lookup.
unsigned int hashValue;
int index;
if (use_alternate_hashcode()) {
hashValue = hash_string(name, len);
index = hash_to_index(hashValue);
} else {
hashValue = hashValue_arg;
index = index_arg;
}
// Since look-up was done lock-free, we need to check if another
// thread beat us in the race to insert the symbol.
oop test = lookup(index, name, len, hashValue); // calls lookup(u1*, int)
if (test != NULL) {
// Entry already added
return test;
}
HashtableEntry<oop, mtSymbol>* entry = new_entry(hashValue, string());
add_entry(index, entry);
return string();
}
要注意jchar*是C++基于JNI(Java Native Interface),映射到JVM中char的数据结构,在JNI.h中可以发现JVM中数据类型的定义,其实是一系列不同位长的整数类型(https://github.com/luori366/JNI_doc)
typedef uint8_t jboolean; /* unsigned 8 bits,对应Java中的boolean*/ typedef int8_t jbyte; /* signed 8 bits,对应byte */ typedef uint16_t jchar; /* unsigned 16 bits,对应char */ typedef int16_t jshort; /* signed 16 bits,对应short */ typedef int32_t jint; /* signed 32 bits,对应int */ typedef int64_t jlong; /* signed 64 bits,对应long */ typedef float jfloat; /* 32-bit IEEE 754,对应float */ typedef double jdouble; /* 64-bit IEEE 754,对应double */
观察以上字符串常量池的源码可以发现在StringTable中添加键值对的一些细节
- StringTable接受一个类型为jchar*(可以近似认为为char*)的name参数,并根据该参数计算出hashcode
- 这里的name参数实际上就对应我们在Java中的字面量,即"ab"这部分
- StringTable根据计算出的hashcode在HashTable中找到对应的索引,并在索引处加入一个string对象(string())
在源码的基础上理解,StringTable并不能保证键和值的对应性,在StringTable根据"ab"这个字面量计算出该键值对对应的索引后,就将其放在了相应的位置,在我们修改了String的value字段后,理论上用此时String.value重新计算一次hashcode会得出不一样的结果,并需要更新键值对的位置,但很明显,大家都知道相比随时监听String的对象的数据更改并更新哈希表,Java采用了一个更取巧的办法——将String对象设置为不可变。
总结
要注意的是,字符串常量池中,键的char数组,和值中String.value的byte数组是分离开的两个数组,也就是说,该哈希表中的对应关系是靠约定维持的,即"ab"->代表着"ab"的String对象
当我们修改了该对象的内容为"abc"时,实际上从语义上破坏了这个映射关系,即"ab"->代表着"abc"的String对象,此时再声明String b= "ab";就会从常量池中获取到错误的String对象
这也是为什么String对象不可变的根本原因,我们说为什么String不可变的原因是value字段是private final这一点仅仅是String不可变的直接原因,但是很多人并没有想过这么做的目的是什么。在上述实践后我们就知道,实际上String不可变是为了维持字符串常量池的这一层“脆弱”的语义关系
#Java##JVM##字符串常量池##Unsafe类##String#

查看12道真题和解析