分层理解Java字符串常量池

Java是一门计算机编程语言,但我们脑海中所理解的Java不仅仅是一门语言。它还包括Java虚拟机(JVM)的一系列规定,及具体Java产品(如Hotspot)的实现原理。

不管我们日常在Java中用到的任何一种语法,都会由语言规范对其进行语义和用法上的规定,再由虚拟机规范进行实现方案上的约束和建议,最后由具体的产品进行编码实现。其中,语言规范和虚拟机规范是 Oracle 制定好的(https://docs.oracle.com/javase/specs/index.html),不同的Java产品(如Hotspot、JRockit、J9)等,对虚拟机规范的实现方式不尽相同。

image

我们将在此思想基础上,探究Java中字符串常量池的概念,及字符串的对象创建、引用、“驻留”(intern)等一系列操作,及由此引申的Java加载、链接、初始化步骤。

Java语言层面

在Java语言标准中,没有提及字符串常量池,但是有对于“字符串字面常量”的定义:

3.10.5. 字符串字面量
字符串字面常量是用由双引号括起来的0个或多个字符构成的,字符串字面常量的类型总是String,是对String类的实例的引用。
...
一个字符串字面常量总是引用String类的同一个实例。这是因为其被通过使用String.intern() 方法而“驻留”了(直译应为“限定”,但是“驻留”更能体现字符串常量池的存在),这样做是为了让它们可以共享唯一的实例。

从这段表述中可以得出几点结论:

  • 字符串字面量的指向不会发生变化,被指向的实体是“先到先得”的;
  • 不同的 String 对象可以通过 String.intern() 方法得到同一个字符串字面量,即同一个 String 对象的引用;

尽管标准中没有提到字符串常量池,但姑且根据常识和上面的描述推测出一个简单模型:

image

按照这个模型,可以推出几个简单的判断

String a = "xyz";
String b = new String("xyz");
System.out.println(a==b); //false
System.out.println(a==a.intern()); //true
System.out.println(a==b.intern()); //true
System.out.println(b==b.intern()); //false

为什么a = "xyz"b = new String("xyz")对应的是两个对象呢,Java语言标准中有对创建类实例的标准描述 :

12.5. 创建新的类实例
新的类实例在类实例创建表达式的计算导致类被实例化时显式地创建。
新的类实例可以在下列情况下隐式地创建:

  • 加载包含String字面常量的类或接口时,会创建新的String对象,用来表示该字面常量。(如果同一个String对象之前已经被驻留了,那么这里就不会再创建新的String对象了);
  • 执行不是常量表达式的字符串连接操作符时,有时会创建新的String对象以表示执行结果。

从这段表述中可以得出几点结论:

  • String a = new String()这种显式创建的写法,必定会在堆中创建一个新的对象
  • String a = "xyz"这种写法一般情况下会在类加载过程中隐式在堆中创建一个新的对象并将字面量“xyz”驻留,但是若"xyz"字面量已经被驻留的话,则不创建新对象
  • String a = new String("xy") + new String("z"),这种通过"+"连接的写法,运行时会创建一个新的String对象表示结果,但是并不会将"xyz"驻留(这里要注意了)
  • 但如果是一个常量表达式 (§15.29) String a = "xy" + "z"则不同,它与String a = "xyz"一样,对应的"xyz"字面量已在类加载过程中被驻留(interned)了(可以理解为编译期间已经被优化为了"xyz"),且运行时不会创建新对象

这里再重点强调一遍,对于String a = "xyz",在类加载过程中,就已经生成了一个代表"xyz"的对象,在运行这行代码时仅仅是从字符串常量池中获取了该对象的引用并返回,但是对于String a = new String("xyz"),虽然在类加载过程中就已经生成了一个代表"xyz"的对象,但是由于是显式的使用了new关键字,所以仍会创建一个新的对象

那么根据这几点表述,继续完善模型:

image

按照这个模型,可以推出几个简单的判断:

String a = "xyz";
String b = new String("xyz");
String c = "xy"+"z";
String d = "xyz";
String e = new String("xy")+"z";
System.out.println(a==b); //false
System.out.println(a==c); //true
System.out.println(a==d); //true
System.out.println(a==e); //false

整理已经介绍过的标准,可以给出一个推论,当且仅当出现以下三种情况下,字面量才有可能会驻留(interned):

  • 代码中出现被引号包含的字面量,如:String a = "xyz",字面量"xyz"在类加载过程被驻留
  • 代码中出现String类型的常量表达式,如:String a = "xy" + "z",字面量"xyz"在类加载过程被驻留
  • 调用了intern()方法,如 a.intern(),若a对应的字面量没有被驻留过,则驻留该字面量,否则返回之前驻留的字面量(即对象引用)

可以结合以下例子理解:

// 字面量abc既未被引号包围,也不是一个常量表达式,仅创建对象
String f = new String("ab") + new String("c"); 
// 将字面量abc驻留(即f对象引用)
f.intern();
// 字面量abc被引号包围,类加载过程中,发现字面量abc已被驻留,则直接返回f对象引用
String g = "abc";
// true
System.out.println(f == g); 

这里要注意一点:f.intern()f = f.intern()是不一样的,f.intern()并不更改f本身的值。

到这里,可以看出,java语言层面中尽管没有提到字符串常量池这个字眼,但是对驻留(interned)这个概念的解释已经非常通透了,根据以上根据标准的推论,所有字符串的 == 问题都能够得到答案,接下来介绍语言层面之下的细节。

JVM层面

在JAVA语言标准中,定义字面量是被引号包围的东西,实际上是一个对象引用,那么JVM标准层面是如何描述字面量这个概念的呢:

5.1. 运行时常量池

字符串常量是指向String类实例的引用,它来自于类或接口二进制表示中的 CONSTANT_String_info 结构。其给出了由Unicode码点序列所组成的字符串常量。
Java语言规定,相同的字符串常量必须指向同一个String类实例。此外,如果在任一字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。

为了得到字符串常量,Java虚拟机需要检查 CONSTANT_String_info 结构中的码点序列:

  • 如果某String实例所包含的Unicode码点序列与 CONSTANT_String_info 结构所给出的序列相同,而之前又曾在该实例上面调用过 String.intern 方法,那么此次字符串常量获取的结果将是一个指向相同String实例的引用;
  • 否则,会创建一个新的String实例,其中包含由 CONSTANT_String_info 结构所给出的Unicode码点序列;字符串常量获取的结果是指向那个新String实例的引用,最后,新String实例的intern方法被Java虚拟机自动调用。

4.4.3. CONSTANT_String_info 结构

CONSTANT_String_info structure 用于表示String类型的常量对象,其结构如下:

CONSTANT_String_info {
u1 tag;
u2 string_index;
}

CONSTANT_String_info 结构各项说明如下:

  • tag:值为 CONSTANT_String (8);
  • string_index:必须是对常量池标的有效索引,常量池表在该索引出的成员必须是 CONSTANT_Utf8_info 结构,此结构表示Unicode码点序列,此序列最终会被初始化为一个String对象。

4.4.7. CONSTANT_Utf8_info 结构

CONSTANT_Utf8_info 结构用来表示字符串常量的值:

CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}

这段描述中,string constant就是java语言标准中的字面量(string literals),对其特性又描述了一遍,基本与java标准中描述的一致,但是提供了更多的细节:

  • 字面量是通过运行时常量池中的CONSTANT_String_info结构来获取的
  • CONSTANT_String_info结构只持有了一个CONSTANT_Utf8_info结构在运行时常量池中的index
  • CONSTANT_Utf8_info持有了一个Unicode字节序列,JVM可以通过这个序列来做唯一性检测

通过上面的几点描述,字符串常量池的轮廓已经差不多了,规范中说要通过CONSTANT_String_info来获取字面量,那怎么获取呢,很容易推断出JVM中应该存在一个类似HashMap的结构,key可能是根据CONSTANT_String_info获取到的CONSTANT_Utf8_info中的Unicode字节序列(bytes[length])(这里只是一个猜测,实际如何得看HotSpot层面的实现了),value就是字面量(对象引用)

至于CONSTANT_Utf8_info究竟是个什么东西,就得看具体JVM是如何实现的了,对于Hotspot而言,所谓的Unicode字节序列可能就是个C++的bytes数组。

Java产品实现层面

Java的实现,我们日常接触最多的就是Hotspot。我们以Hotspot为例了解。

回顾一下JAVA语言层面与JVM标准层面的描述:JVM可以通过类运行时常量池中的CONSTANT_String_info来找到对应的字面量(即对象引用),于是我们假设存在一个类似HashMap结构的字符串常量池来辅助完成这件事情,那么真的有这么个结构吗,可以从HotSpot代码中找到答案:

HotSpot VM里实现字符串常量池功能的是StringTable类,在hotspot/src/share/vm/classfile/symbolTable.[hpp|cpp]中,看它的定义:

class StringTable : public Hashtable

在C++层面的确是个Hashtable类型,key是oop类型(oop类型就是Java层面的对象引用),value是mySymbol类型,乍眼一看不知道是什么东西,那么可以从向这个Stringtable里插入的代码入手:

oop StringTable::basic_add(int index_arg, Handle string, jchar* name,
                           int len, unsigned int hashValue_arg, TRAPS) {
  // ...
  // 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* entry = new_entry(hashValue, string());
  add_entry(index, entry);
  return string();
}

首先根据jchar* name计算出hash值,转化赋值给index,然后调用了lookup方法去判断StringTable中是否已经存在对应的字面量,结合JVM规范来看,jchar* name这个入参应该就是运行时常量池中CONSTANT_Utf8_info类型变量持有的Unicode字节序列(bytes[length])

再看lookup函数:

oop StringTable::lookup(int index, jchar* name,
                        int len, unsigned int hash) {
  int count = 0;
  for (HashtableEntry* l = bucket(index); l != NULL; l = l->next()) {
    count++;
    if (l->hash() == hash) {
      if (java_lang_String::equals(l->literal(), name, len)) {
        return l->literal();
      }
    }
  }
  // If the bucket size is too deep check if this hash code is insufficient.
  if (count >= BasicHashtable::rehash_count && !needs_rehashing()) {
    _needs_rehashing = check_rehash_table(count);
  }
  return NULL;
}

逻辑非常简单,根据index找到了hashtable对应的拉链节点的位置,然后逐个对节点的key进行判断,对比jchar* name是否与对象表示的字符串相同(java_lang_String::equals),若一致,则直接把key(对象引用)返回。

所以,StringTable的结构就可以当成是个简单的hashtable(拉链式),hashcode是根据对应的Unicode字节序列计算而来,节点中存放的就是对象引用(字面量)。

综上,我们从不同层次介绍了字符串常量池的概念和实现。实际上我们主要需要聚焦Java语言层面和JVM层面,实现层面可以给我们提供更多的实践思路。关于引出的Java加载、链接、初始化的过程,以后再继续探究。

你可能感兴趣的:(分层理解Java字符串常量池)