Java漫談-String下

  • 2020 年 2 月 10 日
  • 筆記

上篇介紹了一些String基礎與簡單的創建方式,本篇引入它的intern()方法。

關於intern()方法

當一個String實例str調用intern()方法時,如果常量池中已經有了這個字符串,那麼直接返回常量池中它的引用,如果沒有,那就將它的引用保存一份到字符串常量池,然後直接返回這個引用。可參考JDK中的解釋或The Java Virtual Machine Specification, Java SE 8 Edition (§5.1),簡單來說就是一個可以手動將未存在常量池的字符串存入常量池並返回其引用的方法。

示例3

現在再來看另一種方式創建String的例子:

public class StringDemo3 {      public static void main(String[] args) {          String s1 = new String("1") + new String("a"); // 1          s1.intern();          // 2          String s2 = "1a";    // 3          System.out.println(s1 == s2);     // 4          System.out.println(s1.intern() == s2); // 5          String s3 = "1"+"a";          System.out.println(s3 == s2); // 6      }  }

運行結果

true  true  true

解析

語句6肯定是true,因為編譯器會對"1"+"a"進行優化,使其在編譯完成後成為"1a",即String s3 = "1a";,從而導致s3和s2均為字符串常量池中的字符串的引用,通過位元組碼也能看到類似情形:

// ... 省略    // s2  40: ldc           #11                 // String 1a  42: astore_2    // ...省略    // s3  78: ldc           #11                 // String 1a  80: astore_3    // ...省略

現在我們看下String s1 = new String("1") + new String("a");的位元組碼:

0: new           #2                  // class java/lang/StringBuilder  3: dup  4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V  7: new           #4                  // class java/lang/String  10: dup  11: ldc           #5                  // String 1  13: invokespecial #6                  // Method java/lang/String."<init>":(Ljava/lang/String;)V  16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;  19: new           #4                  // class java/lang/String  22: dup  23: ldc           #8                  // String a  25: invokespecial #6                  // Method java/lang/String."<init>":(Ljava/lang/String;)V  28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;  31: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;  34: astore_1

1.通過位元組碼可知,字符串常量池中存在"1","a"兩個常量,字符串s1的內部實現是通過StringBuilder執行append方法拼接後執行toString()獲得的。這一段代碼不算常量池中的一共創建了3個對象,一個StringBuilder,一個String 1,一個String a,最後在僅堆中生成引用s1所指向的字符串「1a」,此時常量池中並無「1a」。

2.當執行s1.intern();時,發現常量池中不存在「1a」,故在常量池中複製一份與s1相同的引用,即直接將s1鎖指向的字符串「1a」的地址複製一份到常量池中。

3.此時再執行String s2 = "1a";,拿到的就和s1相同了,從而有了語句3和4的true。

如果將語句1和2對調,則會出現結果:

false  true  true

問題

按照上文所言,在類加載階段「1a」應該已經與"1","a"兩個常量一樣,被加載了,為何上面的解說1中最後說此時常量池中並無「1a」呢?

解惑

其實這涉及到類加載階段中的resolve階段,這個階段會解析Class文件中常量並在字符串常量池中駐留其引用。但是,該過程是lazy resolve的,而觸發執行加載的命令就是ldc

ldc指令是否需要創建新的String實例,全看在第一次執行該指令時,字符串常量池中是否已經記錄了一個對應內容的String的引用。

在StringDemo3中,執行s1.intern();時,第一次執行了ldc,此時查找字符串常量池,發現沒有對應內容的String的引用,故直接使用了s1的引用。

若是將語句2和3互換,此時屬於第一次執行針對1aldc指令,此時查找字符串常量池,發現沒有對應內容的String的引用,故創建新的String實例,將引用存入字符串常量池中一份並返回給s2,如此s1 == s2的結果將為false

實例4

現在我們看下《深入理解Java虛擬機》中的一個例子:

public class StringDemo4 {      public static void main(String[] args) {          String str1 = new StringBuilder("計算機").append("軟件").toString();          System.out.println(str1.intern() == str1);            String str2 = new StringBuilder("ja").append("va").toString();          System.out.println(str2.intern() == str2);      }  }

結果

true  false

原文解析 原文中如是說:

這段代碼在JDK1.6中運行,會得到兩個false,而在JDK1.7中運行,會得到一個true和一個false。產生差異的原因是: 在JDK1.6中,intern()方法會把首次遇到的字符串實例複製到永久代中,返回的也是永久代中這個字符串實例的引用,而由StringBuilder創建的字符串實例在Java堆上,所以必然是兩個不同的引用,將返回false。 而JDK1.7(以及其他部分虛擬機,例如JRockit)的intern()實現不會再複製實例,只是在常量池中記錄首次出現的實例引用。因此intern()返回的引用和由StringBuilder創建的那個字符串實例是同一個。 對於str2比較返回false是因為「java」字符串在執行StringBuilder.toString()之前已經出現過,字符串常量池中已經有它的引用了,不符合「首次出現」的原則,而「計算機軟件」這個字符串則是首次出現的,因此返回true.

解析 原文說「java」這個字符串之前已經出現過,卻沒說在哪出現過。

RednaxelaFX在基於OpenJDK 7u45的實驗中發現其來自sun.misc.Version類,在Oracle JDK7u / OpenJDK7u里的HotSpot VM會通過該類獲取JDk的名稱和具體版本信息。

擴展

1. 常量池

嚴格來說,Java中存在着3中常量池:

  • Class常量池(Class文件中的The Constant Pool)
  • 運行時常量池(Run-Time Constant Pool)
  • 字符串常量池(String Pool/string literal pool,有時也稱String池/全局字符串池)

1.1 Class常量池

Class文件中的除了有類的版本、字段、方法、接口等描述信息,還有一項時常量池(Constant Pool Table),這裏面主要存放兩大類常量:字面量(Literal)符號引用(Symbolic References)

字面量比較接近於Java語言層面的常量概念,如:

  • 文本字符串
  • 聲明為final的常量值等。

符號引用屬於編譯原理方面的概念,包含下面三類常量:

  • 類和接口的全限定名(Fully Qualified Name)
  • 字段的名稱和描述(Descriptor)
  • 方法的名稱和描述

可以通過上一節中的javap命令查看,以將上面的StringDemo3反編譯為例,格式類似如下:

Constant pool:     #1 = Methodref          #15.#37        // java/lang/Object."<init>":()V     #2 = Class              #38            // java/lang/StringBuilder     #3 = Methodref          #2.#37         // java/lang/StringBuilder."<init>":()V     #4 = Class              #39            // java/lang/String     #5 = String             #40            // 1     #6 = Methodref          #4.#41         // java/lang/String."<init>":(Ljava/lang/String;)V     #7 = Methodref          #2.#42         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;     #8 = String             #43            // a     #9 = Methodref          #2.#44         // java/lang/StringBuilder.toString:()Ljava/lang/String;    #10 = Methodref          #4.#45         // java/lang/String.intern:()Ljava/lang/String;    #11 = String             #46            // 1a    #12 = Fieldref           #47.#48        // java/lang/System.out:Ljava/io/PrintStream;    #13 = Methodref          #49.#50        // java/io/PrintStream.println:(Z)V    #14 = Class              #51            // Others/base/StringDemo4    #15 = Class              #52            // java/lang/Object    #16 = Utf8               <init>    #17 = Utf8               ()V    #18 = Utf8               Code    #19 = Utf8               LineNumberTable    #20 = Utf8               LocalVariableTable    #21 = Utf8               this    #22 = Utf8               LOthers/base/StringDemo4;    #23 = Utf8               main    #24 = Utf8               ([Ljava/lang/String;)V    #25 = Utf8               args    #26 = Utf8               [Ljava/lang/String;    #27 = Utf8               s1    #28 = Utf8               Ljava/lang/String;    #29 = Utf8               s2    #30 = Utf8               s3    #31 = Utf8               StackMapTable    #32 = Class              #26            // "[Ljava/lang/String;"    #33 = Class              #39            // java/lang/String    #34 = Class              #53            // java/io/PrintStream    #35 = Utf8               SourceFile    #36 = Utf8               StringDemo4.java    #37 = NameAndType        #16:#17        // "<init>":()V    #38 = Utf8               java/lang/StringBuilder    #39 = Utf8               java/lang/String    #40 = Utf8               1    #41 = NameAndType        #16:#54        // "<init>":(Ljava/lang/String;)V    #42 = NameAndType        #55:#56        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;    #43 = Utf8               a    #44 = NameAndType        #57:#58        // toString:()Ljava/lang/String;    #45 = NameAndType        #59:#58        // intern:()Ljava/lang/String;    #46 = Utf8               1a    #47 = Class              #60            // java/lang/System    #48 = NameAndType        #61:#62        // out:Ljava/io/PrintStream;    #49 = Class              #53            // java/io/PrintStream    #50 = NameAndType        #63:#64        // println:(Z)V    #51 = Utf8               Others/base/StringDemo4    #52 = Utf8               java/lang/Object    #53 = Utf8               java/io/PrintStream    #54 = Utf8               (Ljava/lang/String;)V    #55 = Utf8               append    #56 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;    #57 = Utf8               toString    #58 = Utf8               ()Ljava/lang/String;    #59 = Utf8               intern    #60 = Utf8               java/lang/System    #61 = Utf8               out    #62 = Utf8               Ljava/io/PrintStream;    #63 = Utf8               println    #64 = Utf8               (Z)V

由上可以看到涉及到String的有兩個類型,CONSTANT_Utf8CONSTANT_String

CONSTANT_Utf8,即 CONSTANT_Utf8_info Structure,是一個用於表示常量字符串值的,這裡真正持有字符串內容。(§4.4.7)

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

CONSTANT_String, 即CONSTANT_String_info Structure,該類型用於表示該類型的常量對象String。其不直接持有字符串內容,而是持有一個string_indexstring_index該必須是constant_pool表中的有效索引,該constant_pool索引處的條目必須是一個CONSTANT_Utf8_info結構,即為一個CONSTANT_Utf8類型的常量,從而持有字符串內容。(§4.4.3)

CONSTANT_String_info {      u1 tag;      u2 string_index;  }

1.2 運行時常量池

方法區的一部分。Class文件的常量池(上面的1.1)中的內容將在類加載後進入方法區的運行時常量池中存放。

每個運行時常量池都是從Java虛擬機的方法區域(§2.5.4)中分配的。當Java虛擬機創建類或接口(§5.3)時,將構造類或接口的運行時常量池(§2.5.5)

A run-time constant pool is a per-class or per-interface run-time representation of the constant_pool table in a class file (§4.4). It contains several kinds of constants, ranging from numeric literals known at compile-time to method and field references that must be resolved at run-time. The run-time constant pool serves a function similar to that of a symbol table for a conventional programming language, although it contains a wider range of data than a typical symbol table. Each run-time constant pool is allocated from the Java Virtual Machine's method area (§2.5.4). The run-time constant pool for a class or interface is constructed when the class or interface is created (§5.3) by the Java Virtual Machine.

這意味着每個類/接口都會有一個運行時常量池。

1.3 字符串常量池

HotSpot VM里,記錄interned string的一個全局表叫做StringTable,它本質上就是個HashSet。這是個純運行時的結構,而且是惰性(lazy)維護的。注意它只存儲對java.lang.String實例的引用,而不存儲String對象的內容。

一般我們說一個字符串進入了全局的字符串常量池其實是說在這個StringTable中保存了對它的引用,反之,如果說沒有在其中就是說StringTable中沒有對它的引用。

字面量進入字符串常量池的時機

通過上篇文章,我們可以得到如下兩個結論:

1.StringDemo3.class 的 class文件常量池 中 是含有 "1" ,"a","1a"的。 2.在類加載階段, JVM會在堆中創建 對應這些 class文件常量池中的 字符串對象實例 並在字符串常量池中駐留其引用。具體在resolve階段執行。這些常量全局共享。

但resolve階段實際上並不是立即就創建對象並且在字符串常量池中駐留了引用。 JVM規範里明確指定resolve階段可以是lazy的[(§5.4)]:(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4)

… Resolution of symbolic references in the class or interface is an optional part of linking. … a Java Virtual Machine implementation may choose to resolve each symbolic reference in a class or interface individually when it is used ("lazy" or "late" resolution), or to resolve them all at once when the class is being verified ("eager" or "static" resolution). This means that the resolution process may continue, in some implementations, after a class or interface has been initialized. Whichever strategy is followed, any error detected during resolution must be thrown at a point in the program that (directly or indirectly) uses a symbolic reference to the class or interface.

在HotSpot VM中,運行時常量池裡:

  • CONSTANT_Utf8 -> Symbol*(一個指針,指向一個Symbol類型的C++對象,內容是跟Class文件同樣格式的UTF-8編碼的字符串)
  • CONSTANT_String -> java.lang.String(一個實際的Java對象的引用,C++類型是oop)

CONSTANT_Utf8會在類加載的過程中就全部創建出來,而CONSTANT_String則是lazy resolve的。例如在第一次引用該項的ldc指令被第一次執行到的時候才會resolve

在尚未resolve的時候,HotSpot VM把它的類型叫做JVM_CONSTANT_UnresolvedString,內容跟Class文件里一樣只是一個index;等到resolve過後這個項的常量類型就會變成最終的JVM_CONSTANT_String,而內容則變成實際的那個oop。

即,就HotSpot VM的實現來說,加載類的時候,那些字符串字面量會進入到當前類的運行時常量池,不會進入全局的字符串常量池(即在StringTable中並沒有相應的引用,在堆中也沒有對應的對象產生)。

再談ldc指令

根據上篇文章我們已知ldc 將int, float或String型常量值從常量池中推送至棧頂。但,根據本文上面說的,在類加載階段,這個 resolve 階段( constant pool resolution )是lazy的。即在resolve階段之前並沒有真正的對象,字符串常量池裡自然也沒有對應的引用。那麼ldc指令還怎麼把人推送至棧頂?或者換一個角度想,既然resolve 階段是lazy的,那總有一個時候它要真正的執行吧,是什麼時候?

執行ldc指令就是觸發這個lazy resolution動作的條件

ldc位元組碼在這裡的執行語義是:

  • 到當前類的運行時常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找該index對應的項。
  • 如果該項尚未resolve,則resolve之,並返回resolve後的內容。
  • 在遇到String類型常量時,resolve的過程如果發現StringTable已經有了內容匹配的java.lang.String的引用,則直接返回這個引用。
  • 反之,如果StringTable里尚未有內容匹配的String實例的引用,則會在Java堆里創建一個對應內容的String對象,然後在StringTable記錄下這個引用,並返回這個引用出去。

可見,ldc指令是否需要創建新的String實例,全看在第一次執行這一條ldc指令時,StringTable是否已經記錄了一個對應內容的String的引用。

參考資料

  1. Java 中new String("字面量") 中 "字面量" 是何時進入字符串常量池的?
  2. The Java® Virtual Machine Specification Java SE 8 Edition
  3. 《深入理解 Java 虛擬機》第二版
  4. 如何理解《深入理解java虛擬機》第二版中對String.intern()方法的講解中所舉的例子?