Java String 面面观

本文主要介绍Java中与字符串相关的一些内容,主要包括String类的实现及其不变性、String相关类(StringBuilderStringBuffer)的实现 以及 字符串缓存机制的用法与实现。html

String类的设计与实现

String类的核心逻辑是经过对char型数组进行封装来实现字符串对象,但实现细节伴随着Java版本的演进也发生过几回变化。java

Java 6

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** The offset is the first index of the storage that is used. */
    private final int offset;
    /** The count is the number of characters in the String. */
    private final int count;
    /** Cache the hash code for the string */
    private int hash; // Default to 0
}
复制代码

Java 6中,String类有四个成员变量:char型数组value、偏移量 offset、字符数量 count、哈希值 hashvalue数组用来存储字符序列, offsetcount 两个属性用来定位字符串在value数组中的位置,hash属性用来缓存字符串的hashCodegit

使用offsetcount来定位value数组的目的是,能够高效、快速地共享value数组,例如substring()方法返回的子字符串是经过记录offsetcount来实现与原字符串共享value数组的,而不是从新拷贝一份。substring()方法实现以下:github

String(int offset, int count, char value[]) {
	this.value = value;    // 直接复用原数组
	this.offset = offset;
	this.count = count;
}
public String substring(int beginIndex, int endIndex) {
    // ...... 省略一些边界检查的代码 ......
    return ((beginIndex == 0) && (endIndex == count)) ? this :
        new String(offset + beginIndex, endIndex - beginIndex, value);
}
复制代码

可是这种方式却颇有可能会致使内存泄漏。例如在以下代码中:面试

String bigStr = new String(new char[100000]);
String subStr = bigStr.substring(0,2);
bigStr = null;
复制代码

bigStr被设置为null以后,其中的value数组却仍然被subStr所引用,致使垃圾回收器没法将其回收,结果虽然咱们实际上仅仅须要2个字符的空间,可是实际却占用了100000个字符的空间。数组

Java 6中,若是想要避免这种内存泄漏状况的发生,可使用下面的方式:缓存

String subStr = bigStr.substring(0,2) + "";
// 或者
String subStr = new String(bigStr.substring(0,2));
复制代码

在语句执行完以后,substring方法返回的匿名String对象因为没有被别的对象引用,因此可以被垃圾回收器回收,不会继续引用bigStr中的value数组,从而避免了内存泄漏。安全

Java 7 & Java 8

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** Cache the hash code for the string */
    private int hash; // Default to 0
}
复制代码

Java 7-Java 8中,JavaString 类作了一些改变。String 类中再也不有 offsetcount 两个成员变量了。substring()方法也再也不共享 value数组,而是从指定位置从新拷贝一份value数组,从而解决了使用该方法可能致使的内存泄漏问题。substring()方法实现以下:markdown

public String(char value[], int offset, int count) {
    // ...... 省略一些边界检查的代码 ......

    // 从原数组拷贝
    this.value = Arrays.copyOfRange(value, offset, offset+count);   
}
public String substring(int beginIndex, int endIndex) {
    // ...... 省略一些边界检查的代码 ......
    return ((beginIndex == 0) && (endIndex == value.length)) ? this
            : new String(value, beginIndex, subLen);
}
复制代码

Java 9

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final byte[] value;
    /** The identifier of the encoding used to encode the bytes in {@code value}. */
    private final byte coder;
    /** Cache the hash code for the string */
    private int hash; // Default to 0
}
复制代码

为了节省内存空间,Java 9中对String的实现方式作了优化,value成员变量从char[]类型改成了byte[]类型,同时新增了一个coder成员变量。咱们知道Javachar类型占用的是两个字节,对于只占用一个字节的字符(例如,a-zA-Z)就显得有点浪费,因此Java 9中将char[]改成byte[]来存储字符序列,而新属性 coder 的做用就是用来表示value数组中存储的是双字节编码的字符仍是单字节编码的字符。coder 属性能够有 01 两个值,0 表明 Latin-1(单字节编码),1 表明 UTF-16(双字节编码)。在建立字符串的时候若是判断全部字符均可以用单字节来编码,则使用Latin-1来编码以压缩空间,不然使用UTF-16编码。主要的构造函数实现以下:网络

String(char[] value, int off, int len, Void sig) {
    if (len == 0) {
        this.value = "".value;
        this.coder = "".coder;
        return;
    }
    if (COMPACT_STRINGS) {
        byte[] val = StringUTF16.compress(value, off, len);  // 尝试压缩字符串,使用单字节编码存储
        if (val != null) {   // 压缩成功,可使用单字节编码存储
            this.value = val;
            this.coder = LATIN1;
            return;
        }
    }
    // 不然,使用双字节编码存储
    this.coder = UTF16;
    this.value = StringUTF16.toBytes(value, off, len);
}
复制代码

String类的不变性

咱们注意到String类是用final修饰的;全部的属性都是声明为private的;而且除了hash属性以外的其余属性也都是用final修饰。这保证了:

  1. String类由final修饰,因此没法经过继承String类改变其语义;
  2. 全部的属性都是声明为private的, 因此没法在String外部直接访问或修改其属性;
  3. 除了hash属性以外的其余属性都是用final修饰,表示这些属性在初始化赋值后不能够再修改。

上述的定义共同实现了String类一个重要的特性 —— 不变性,即 String 对象一旦建立成功,就不能再对它进行任何修改。String提供的方法substring()concat()replace()等方法返回值都是新建立的String对象,而不是原来的String对象。

hash属性不是final的缘由是:StringhashCode并不须要在建立字符串时当即计算并赋值,而是在hashCode()方法被调用时才须要进行计算。

为何String类要设计为不可变的?

  1. 保证 String 对象的安全性。String被普遍用做JDK中做为参数、返回值,例如网络链接,打开文件,类加载,等等。若是 String 对象是可变的,那么 String 对象将可能被恶意修改,引起安全问题。
  2. 线程安全。String类的不可变性自然地保证了其线程安全的特性。
  3. 保证了String对象的hashCode的不变性。String类的不可变性,保证了其hashCode值可以在第一次计算后进行缓存,以后无需重复计算。这使得String对象很适合用做HashMap等容器的Key,而且相比其余对象效率更高。
  4. 实现字符串常量池Java为字符串对象设计了字符串常量池来共享字符串,节省内存空间。若是字符串是可变的,那么字符串对象便没法共享。由于若是改变了其中一个对象的值,那么其余对象的值也会相应发生变化。

与String类相关的类

除了String类以外,还有两个与String类相关的的类:StringBufferStringBuilder,这两个类能够看做是String类的可变版本,提供了对字符串修改的各类方法。二者的区别在于StringBuffer是线程安全的而StringBuilder不是线程安全的。

StringBuffer / StringBuilder的实现

StringBufferStringBuilder都是继承自AbstractStringBuilderAbstractStringBuilder利用可变的char数组(Java 9以后改成为byte数组)来实现对字符串的各类修改操做。StringBufferStringBuilder都是调用AbstractStringBuilder中的方法来操做字符串, 二者区别在于StringBuffer类中对字符串修改的方法都加了synchronized修饰,而StringBuilder没有,因此StringBuffer是线程安全的,而StringBuilder并不是线程安全的。

咱们以Java 8为例,看一下AbstractStringBuilder类的实现:

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    /** The value is used for character storage. */
    char[] value;
    /** The count is the number of characters used. */
    int count;
}
复制代码

value数组用来存储字符序列,count则用来存储value数组中已经使用的字符数量,字符串真实的内容是value数组中[0,count)之间的字符序列,而[count,length)之间是未使用的空间。须要count属性记录已使用空间的缘由是,AbstractStringBuilder中的value数组并非每次修改都会从新申请,而是会提早预分配必定的多余空间,以此来减小从新分配数组空间的次数。(这种作法相似于ArrayList的实现)。

value数组扩容的策略是:当对字符串进行修改时,若是当前的value数组不知足空间需求时,则会从新分配更大的value数组,分配的数组大小为min( 原数组大小×2 + 2 , 所需的数组大小 ),更加细节的逻辑能够参考以下代码:

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private int newCapacity(int minCapacity) {
    // overflow-conscious code
    int newCapacity = (value.length << 1) + 2;    //原数组大小×2 + 2 
    if (newCapacity - minCapacity < 0) {     // 若是小于所需空间大小,扩展至所需空间大小
        newCapacity = minCapacity;
    }
    return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
        ? hugeCapacity(minCapacity)
        : newCapacity;
}

private int hugeCapacity(int minCapacity) {
    if (Integer.MAX_VALUE - minCapacity < 0) { // overflow
        throw new OutOfMemoryError();
    }
    return (minCapacity > MAX_ARRAY_SIZE)
        ? minCapacity : MAX_ARRAY_SIZE;
}
复制代码

固然AbstractStringBuilder也提供了trimToSize方法去释放多余的空间:

public void trimToSize() {
    if (count < value.length) {
        value = Arrays.copyOf(value, count);
    }
}
复制代码

String对象的缓存机制

由于String对象的使用普遍,JavaString对象设计了缓存机制,以提高时间和空间上的效率。在JVM的运行时数据区中存在一个字符串常量池String Pool),在这个常量池中维护了全部已经缓存的String对象,当咱们说一个String对象被缓存(interned)了,就是指它进入了字符串常量池

咱们经过解答下面三个问题来理解String对象的缓存机制:

  1. 哪些String对象会被缓存进字符串常量池
  2. String对象被缓存在哪里,如何组织起来的?
  3. String对象是何时进入字符串常量池的?

说明: 如未特殊指明,本文中说起的JVM实现均指的是OracleHotSpot VM,而且不考虑 逃逸分析(escape analysis)、标量替换(scalar replacement)、无用代码消除(dead-code elimination)等优化手段,测试代码基于不添加任何额外JVM参数的状况下运行。

预备知识

为了更好的阅读体验,在解答上面三个问题前,但愿读者对如下知识点有简单了解:

  • JVM运行时数据区
  • class文件的结构
  • JVM基于栈的字节码解释执行引擎
  • 类加载的过程
  • Java中的几种常量池

为了内容的完整性,咱们对下文涉及较多的其中两点作简要介绍。

类加载的过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期依次为:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为链接(Linking)。 加载、验证、准备、初始化和卸载这5个阶段的顺序是肯定的,类的加载过程必须按照这种顺序循序渐进地开始,而解析阶段则不必定:它在某些状况下能够在初始化阶段以后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

Java中的几种常量池

1. class文件中的常量池 咱们知道java后缀的源代码文件会被javac编译为class后缀的class文件(字节码文件)。在class文件中有一部份内容是 常量池(Constant Pool) ,这个常量池中主要存储两大类常量:

  • 代码中的字面量或者常量表达式的值;
  • 符号引用,包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。

2. 运行时常量池JVM运行时数据区(Run-Time Data Areas)中,有一部分是运行时常量池(Run-Time Constant Pool),属于方法区的一部分。运行时常量池class文件中每一个类或者接口的常量池(Constant Pool )的运行时表示形式,class文件的常量池中的内容会在类加载后进入方法区的运行时常量池

3. 字符串常量池 字符串常量池String Pool)也就是咱们上文提到的用来缓存String对象的常量池。 这个常量池是全局共享的,属于运行时数据区的一部分。

哪些String对象会被缓存进字符串常量池?

Java中,有两种字符串会被缓存到字符串常量池中,一种是在代码中定义的字符串字面量或者字符串常量表达式,另外一种是程序中主动调用String.intern()方法将当前String对象缓存到字符串常量池中。下面分别对两种方式作简要介绍。

1. 隐式缓存 - 字符串字面量 或者 字符串常量表达式

之因此称之为隐式缓存是由于咱们并不须要主动去编写缓存相关代码,编译器和JVM会帮咱们完成这部分工做。

字符串字面量 第一种会被隐式缓存的字符串是 字符串字面量字面量 是类型为原始类型、String类型、null类型的值在源代码中的表示形式。例如:

int i = 100;   // int 类型字面量
double f = 10.2;  // double 类型字面量
boolean b = true;   // boolean 类型字面量
String s = "hello"; // String类型字面量
Object o = null;  // null类型字面量
复制代码

字符串字面量是由双引号括起来的0个或者多个字符构成的。 Java会在执行过程当中为字符串字面量建立String对象并加入字符串常量池中。例如上面代码中的"hello"就是一个字符串字面量,在执行过程当中会先 建立一个内容为"hello"String对象,并缓存到字符串常量池中,再将s引用指向这个String对象。

关于字符串字面量更加详细的内容请参阅Java语言规范JLS - 3.10.5. String Literals)。

字符串常量表达式 另一种会被隐式缓存的字符串是 字符串常量表达式常量表达式指的是表示简单类型值或String对象的表达式,能够简单理解为常量表达式就是在编译期间就能肯定值的表达式。字符串常量表达式就是表示String对象的常量表达式。例如:

int a = 1 + 2;
double d = 10 + 2.01;
boolean b = true & false;
String str1 =  "abc" + 123;

final int num = 456;
String  str2 = "abc" +456;
复制代码

Java会在执行过程当中为字符串常量表达式建立String对象并加入字符串常量池中。例如,上面的代码中,会分别建立"abc123""abc456"两个String对象,这两个String对象会被缓存到字符串常量池中,str1会指向常量池中值为"abc123"String对象,str2会指向常量池中值为"abc456"String对象。

关于常量表达式更加详细的内容请参阅Java语言规范JLS - 15.28 Constant Expressions)。

2. 主动缓存 - String.intern()方法

除了声明为字符串字面量/字符串常量表达式以外,经过其余方式获得的String对象也能够主动加入字符串常量池中。例如:

String str = new String("123") + new String("456");
str.intern();
复制代码

在上面的代码中,在执行完第一句后,常量池中存在内容为"123""456"的两个String对象,可是不存在"123456"String对象,但在执行完str.intern();以后,内容为"123456"String对象也加入到了字符串常量池中。

咱们经过String.intern()方法的注释来看下其具体的缓存机制:

When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned. It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.

简单翻译一下:

当调用 intern 方法时,若是常量池中已经包含相同内容的字符串(字符串内容相同由 equals (Object) 方法肯定,对于 String 对象来讲,也就是字符序列相同),则返回常量池中的字符串对象。不然,将此 String 对象将添加到常量池中,并返回此 String 对象的引用。 所以,对于任意两个字符串 st,当且仅当 s.equals(t)的结果为true时,s.intern() == t.intern()的结果为true

String对象被缓存在哪里,如何组织起来的?

HotSpot VM中,有一个用来记录缓存的String对象的全局表,叫作StringTable,结构及实现方式都相似于Java中的HashMap或者HashSet,是一个使用拉链法解决哈希冲突的哈希表,能够简单理解为HashSet<String>,注意它只存储对String对象的引用,而不存储String对象实例。 通常咱们说一个字符串进入了字符串常量池实际上是说在这个StringTable中保存了对它的引用,反之,若是说没有在其中就是说StringTable中没有对它的引用。

而真正的字符串对象实际上是保存在另外的区域中的,在Java 6字符串常量池中的String对象是存储在永久代Java 8以前HotSpot VM方法区的实现)中的,而在Java 6以后,字符串常量池中的String对象是存储在中的。

Java 7中将字符串常量池中的对象移动到中的缘由是在 Java 6中,字符串常量池中的对象在永久代建立,而永久代代的大小通常不会设置太大,若是大量使用字符串缓存将可能对致使永久代发生OOM异常。

String对象是何时进入字符串常量池的?

对于经过 在程序中调用String.intern()方法主动缓存进入常量池的String对象,很显然就是在调用intern()方法的时候进入常量池的。

咱们重点来研究一下会被隐式缓存的两种值(字符串字面量字符串常量表达式),主要是两个问题:

  1. 咱们并无主动调用String类的构造方法,那么它们是在什么时候被建立?
  2. 它们是在什么时候进入字符串常量池的?

咱们如下面的代码为例来分析这两个问题:

public class Main {
    public static void main(String[] args) {
        String str1 = "123" + 123;     // 字符串常量表达式
        String str2 = "123456";         // 字面量
        String str3 = "123" + 456;   //字符串常量表达式
    }
}
复制代码

字节码分析

咱们对上述代码编译以后使用javap来观察一下字节码文件,为了节省篇幅,只摘取了相关的部分:常量池表部分以及main方法信息部分:

Constant pool:
  #1 = Methodref          #5.#23         // java/lang/Object."<init>":()V
  #2 = String             #24            // 123123
  #3 = String             #25            // 123456
   // ...... 省略 ......
  #24 = Utf8               123123
  #25 = Utf8               123456
 
 // ...... 省略 ......

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String 123123
         2: astore_1
         3: ldc           #3                  // String 123456
         5: astore_2
         6: ldc           #3                  // String 123456
         8: astore_3
         9: return
      LineNumberTable:
        line 7: 0
        line 8: 3
        line 9: 6
        line 10: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1  str1   Ljava/lang/String;
            6       4     2  str2   Ljava/lang/String;
            9       1     3  str3   Ljava/lang/String;
复制代码

常量池中,有两种与字符串相关的常量类型,CONSTANT_StringCONSTANT_Utf8CONSTANT_String类型的常量用于表示String类型的常量对象,其内容只是一个常量池的索引值indexindex处的成员必须是CONSTANT_Utf8类型。而CONSTANT_Utf8类型的常量用于存储真正的字符串内容。 例如,上面的常量池中的第23项是CONSTANT_String类型,存储的索引分别为2425,常量池中第2425项就是CONSTANT_Utf8,存储的值分别为"123123""123456"

class文件的方法信息中Code属性是class文件中最为重要的部分之一,其中包含了执行语句对应的虚拟机指令,异常表,本地变量信息等,其中LocalVariableTable是本地变量的信息,Slot能够理解为本地变量表中的索引位置。ldc指令的做用是从运行时常量池中提取指定索引位置的数据并压入栈中;astore_<n>指令的做用是将一个引用类型的值从栈中弹出并保存到本地变量表的指定位置,也就是<n>指定的位置。能够看出三条赋值语句所对应的字节码指令其实都是相同的:

ldc           #<index>   // 首先将常量池中指定索引位置的String对象压入栈中
astore_<n>   // 而后从栈中弹出刚刚存入的String对象保存到本地变量的指定位置
复制代码

运行过程分析

仍是围绕上面的代码,咱们结合 从编译到执行的过程 来分析一下字符串字面量字符串常量表达式建立缓存时机。

1. 编译 首先,第一步是javac将源代码编译为class文件。在源代码编译过程当中,咱们上文提到的两种值 字符串字面量"123456") 和 字符串常量表达式"123" + 456)这两类值都会存在编译后的class文件的常量池中,常量类型为CONSTANT_String。值得注意的两点是:

  • 字符串常量表达式会在编译期计算出真实值存在class文件的常量池中。例如上面源代码中的"123" + 123这个表达式在class文件的常量池中的表现形式是123123"123" + 456这个表达式在class文件的常量池中的表现形式是123456
  • 值相同的字符串字面量或者字符串常量表达式class文件的常量池中只会存在一个常量项(CONSTANT_String类型和CONSTANT_Utf8都只有一项)。例如上面源代码中,虽然声明了两个常量值分别为"123456""123" + 456,可是最后class文件的常量池中只有一个值为123456CONSTANT_Utf8常量项以及一个对应的CONSTANT_String常量项。

2. 类加载JVM运行时,加载Main类时,JVM会根据 class文件的常量池 建立 运行时常量池class文件的常量池 中的内容会在类加载时进入方法区的 运行时常量池。对于class文件的常量池中的符号引用,会在类加载的解析(resolve)阶段,会将其转化为真正的值。但在HotSpot中,符号引用的解析并不必定是在类加载时当即执行的,而是推迟到第一次执行相关指令(即引用了符号引用的指令,JLS - 5.4.3. Resolution )时才会去真正进行解析,这就作延迟解析/惰性解析"lazy" or "late" resolution)。

  • 对于一些基本类型的常量项,例如CONSTANT_Integer_infoCONSTANT_Float_infoCONSTANT_Long_infoCONSTANT_Double_info,在类加载阶段会将class文件常量池中的值转化为运行时常量池中的值,分别对应C++中的intfloatlongdouble类型;
  • 对于CONSTANT_Utf8类型的常量项,在类加载的解析阶段被转化为Symbol对象(HotSpot VM层面的一个C++对象)。同时HotSpot使用SymbolTable(结构与StringTable相似)来缓存Symbol对象,因此在类加载完成后,SymbolTable中应该有全部的CONSTANT_Utf8常量对应的Symbol对象;
  • 而对于CONSTANT_String类型的常量项,由于其内容是一个符号引用(指向CONSTANT_Utf8类型常量的索引值),因此须要进行解析,在类加载的解析阶段会将其转化为java.lang.String对象对应的oop(能够理解为Java对象在HotSpot VM层面的表示),并使用StringTable来进行缓存。可是CONSTANT_String类型的常量,属于上文提到的延迟解析的范畴,也就是在类加载时并不会当即执行解析,而是等到第一次执行相关指令时(通常来讲是ldc指令)才会真正解析。

3. 执行指令 上面提到,JVM会在第一次执行相关指令的时候去执行真正的解析,对于上文给出的代码,观察字节码能够发现,ldc指令中使用到了符号引用,因此在执行ldc指令时,须要进行解析操做。那么ldc指令到底作了什么呢?

ldc指令会从运行时常量池中查找指定index对应的常量项,并将其压入栈中。若是该项还未解析,则须要先进行解析,将符号引用转化为具体的值,而后再将其压入栈中。若是这个未解析的项是String类型的常量,则先从字符串常量池中查找是否已经有了相同内容的String对象,若是有则直接将字符串常量池中的该对象压入栈中;若是没有,则会建立一个新的String对象加入字符串常量池中,并将建立的新对象压入栈中。可见,若是代码中声明多个相同内容的字符串字面量或者字符串常量表达式,那么只会在第一次执行ldc指令时建立一个String对象,后续相同的ldc指令执行时相应位置的常量已经解析过了,直接压入栈中便可。

总结一下:

  1. 在编译阶段,源码中字符串字面量或者字符串常量表达式转化为了class文件的常量池中的CONSTANT_String常量项。
  2. 在类加载阶段,class文件常量池中的CONSTANT_String常量项被存入了运行时常量池中,但保存的内容仍然是一个符号引用,未进行解析。
  3. 在指令执行阶段,当第一次执行ldc指令时,运行时常量池中的CONSTANT_String项还未解析,会真正执行解析,解析过程当中会建立String对象并加入字符串常量池。

缓存关键源码分析

能够看到,其实ldc指令在解析String类型常量的时候与String.intern()方法的逻辑很类似:

  1. ldc指令中解析String常量:先从字符串常量池中查找是否有相同内容的String对象,若是有则将其压入栈中,若是没有,则建立新对象加入字符串常量池并压入栈中。
  2. String.intern()方法:先从字符串常量池中查找是否有相同内容的String对象,若是有则返回该对象引用,若是没有,则将自身加入字符串常量池并返回。

实际在HotSpot内部实现上,ldc指令 与 String.intern()对应的native方法 调用了相同的内部方法。咱们以OpenJDK 8的源代码为例,简单分析一下其过程,代码以下(源码位置:src/share/vm/classfile/SymbolTable.cpp):

// String.intern()方法会调用这个方法
// 参数 "oop string"表明调用intern()方法的String对象
oop StringTable::intern(oop string, TRAPS)
{
  if (string == NULL) return NULL;
  ResourceMark rm(THREAD);
  int length;
  Handle h_string (THREAD, string);
  jchar* chars = java_lang_String::as_unicode_string(string, length, CHECK_NULL);    // 将String对象转化为字符序列
  oop result = intern(h_string, chars, length, CHECK_NULL);
  return result;
}

// ldc指令执行时会调用这个方法
// 参数 "Symbol* symbol" 是 运行时常量池 中 ldc指令的参数(索引位置)对应位置的Symbol对象
oop StringTable::intern(Symbol* symbol, TRAPS) {
  if (symbol == NULL) return NULL;
  ResourceMark rm(THREAD);
  int length;
  jchar* chars = symbol->as_unicode(length);   // 将Symbol对象转化为字符序列
  Handle string;
  oop result = intern(string, chars, length, CHECK_NULL);
  return result;
}

// 上面两个方法都会调用这个方法
oop StringTable::intern(Handle string_or_null, jchar* name, int len, TRAPS) {
  // 尝试从字符串常量池中寻找
  unsigned int hashValue = hash_string(name, len);
  int index = the_table()->hash_to_index(hashValue);
  oop found_string = the_table()->lookup(index, name, len, hashValue);

  // 若是找到了直接返回
  if (found_string != NULL) {
    ensure_string_alive(found_string);
    return found_string;
  }

   // ...... 省略部分代码 ......
   
  Handle string;
  // 尝试复用原字符串,若是没法复用,则会建立新字符串
  // JDK 6中这里的实现有一些不一样,只有string_or_null已经存在于永久代中才会复用
  if (!string_or_null.is_null()) {
    string = string_or_null;
  } else {
    string = java_lang_String::create_from_unicode(name, len, CHECK_NULL);
  }

  //...... 省略部分代码 ......

  oop added_or_found;
  {
    MutexLocker ml(StringTable_lock, THREAD);
    // 添加字符串到 StringTable 中
    added_or_found = the_table()->basic_add(index, string, name, len,
                                  hashValue, CHECK_NULL);
  }
  ensure_string_alive(added_or_found);
  return added_or_found;
}
复制代码

案例分析

说明:由于在Java 6以后字符串常量池永久代移到了中,可能在一些代码上Java 6与以后的版本表现不一致。因此下面的代码都使用Java 6Java 7分别进行测试,若是未特殊说明,表示在两个版本上结果相同,若是不一样,会单独指出。


final int a = 4;
int b = 4;
String s1 = "123" + a + "567";
String s2 = "123" + b + "567";
String s3 = "1234567";
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s2 == s3);
复制代码

结果:

false
true
false
复制代码

解释:

  1. 第三行,由于a被定义为常量,因此"123" + a + "567"是一个常量表达式,在编译期会被编译为"1234567",因此会在字符串常量池中建立"1234567"s1指向字符串常量池中的"1234567"
  2. 第四行,b被定义为变量,"123""567"字符串字面量,因此首先在字符串常量池中建立"123""567",而后经过StringBuilder隐式拼接在堆中建立"1234567"s2指向堆中的"1234567"
  3. 第五行,"1234567"是一个字符串字面量,由于此时字符串常量池中已经存在了"1234567",因此s3指向字符串字符串常量池中的"1234567"

String s1 = new String("123");
String s2 = s1.intern();
String s3 = "123";
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3);
复制代码

结果:

false
false
true
复制代码

解释:

  1. 第一行,"123"是一个字符串字面量,因此首先在字符串常量池中建立了一个"123"对象,而后使用String的构造函数在堆中建立了一个"123"对象,s1指向堆中的"123"
  2. 第二行,由于字符串常量池中已经有了"123",因此s2指向字符串常量池中的"123"
  3. 第三行,一样由于字符串常量池中已经有了"123",因此s3指向字符串常量池中的"123"

String s1 = String.valueOf("123");
String s2 = s1.intern();
String s3 = "123";
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3); 
复制代码

结果:

true
true
true
复制代码

解释:与上一种状况的区别在于,String.valueOf()方法在参数为String对象的时候会直接将参数做为返回值,不会在堆上建立新对象,因此s1也指向字符串常量池中的"123",三个变量指向同一个对象。


String s1 = new String("123") + new String("456"); 
String s2 = s1.intern();
String s3 = "123456";
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3);
复制代码

上面的代码在Java 6Java 7中结果是不一样的。 在Java 6中:

false
false
true
复制代码

解释:

  1. 第一行,"123""456"字符串字面量,因此首先在字符串常量池中建立"123""456"+操做符经过StringBuilder隐式拼接在堆中建立"123456"s1指向堆中的"123456"
  2. 第二行,将"123456"缓存到字符串常量池中,由于Java 6字符串常量池中的对象是在永久代建立的,因此会在字符串常量池(永久代)建立一个"123456",此时在堆中和永久代中各有一个"123456"s2指向字符串常量池(永久代)中的"123456"
  3. 第三行,"123456"字符串字面量,由于此时字符串常量池(永久代)中已经存在"123456",因此s3指向字符串常量池(永久代)中的"123456"

Java 7中:

true
true
true
复制代码

解释:与Java 6的区别在于,由于Java 7字符串常量池中的对象是在上建立的,因此当执行第二行String s2 = s1.intern();时不会再建立新的String对象,而是直接将s1的引用添加到StringTable中,因此三个对象都指向常量池中的"123456",也就是第一行中在堆中建立的对象。

Java 7下,s1 == s2结果为true也可以用来佐证咱们上面延迟解析的过程。咱们假设若是"123456"不是延迟解析的,而是类加载的时候解析完成并进入常量池的,s1.intern()的返回值应该是常量池中存在的"123456",而不会将s1指向的堆中的"123456"对象加入常量池,因此结果应该是s2不等于s1而等于s3


String s1 = new String("123") + new String("456");
String s2 = "123456";
String s3 = s1.intern();
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3);
复制代码

结果:

false
false
true
复制代码

解释:

  1. 第一行,"123""456"字符串字面量,因此首先在字符串常量池中建立"123""456"+操做符经过StringBuilder隐式拼接在堆中建立"123456"s1指向堆中的"123456"
  2. 第二行,"123456"是字符串字面量,此时字符串常量池中不存在"123456",因此在字符串常量池中建立"123456"s2指向字符串常量池中的"123456"
  3. 第三行,由于此时字符串常量池中已经存在"123456",因此s3指向字符串常量池中的"123456"

参考

  1. Java substring() method memory leak issue and fix
  2. java - substring method in String class causes memory leak - Stack Overflow
  3. JLS - 3.10.5. String Literals
  4. JLS - 15.28 Constant Expressions
  5. String.intern in Java 6, 7 and 8 – string pooling
  6. (Java 中new String("字面量") 中 "字面量" 是什么时候进入字符串常量池的? - 木女孩的回答 - 知乎
  7. 深刻解析String#intern
  8. JLS - 5.4.3. Resolution
  9. 请别再拿“String s = new String("xyz");建立了多少个String实例”来面试了吧
  10. JVM Internals
  11. 探秘JVM内部结构(翻译)
  12. Java虚拟机原理图解
相关文章
相关标签/搜索