通过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#
全部评论

相关推荐

小红书 后端选手 n*16*1.18+签字费期权
点赞 评论 收藏
分享
蚂蚁 基架java (n+6)*16 签字费若干
点赞 评论 收藏
分享
Noob1024:一笔传三代,人走笔还在
点赞 评论 收藏
分享
不愿透露姓名的神秘牛友
11-27 10:52
点赞 评论 收藏
分享
评论
2
4
分享
牛客网
牛客企业服务