Bootstrap

Java String 面面观

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

String类的设计与实现

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

Java 6

public final class String implements java.io.Serializable, Comparable, 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
}

在中,类有四个成员变量:型数组、偏移量 、字符数量 、哈希值 。数组用来存储字符序列, 和 两个属性用来定位字符串在数组中的位置,属性用来缓存字符串的。

使用和来定位数组的目的是,可以高效、快速地共享数组,例如方法返回的子字符串是通过记录和来实现与原字符串共享数组的,而不是重新拷贝一份。方法实现如下:

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;

在被设置为之后,其中的数组却仍然被所引用,导致垃圾回收器无法将其回收,结果虽然我们实际上仅仅需要个字符的空间,但是实际却占用了个字符的空间。

在中,如果想要避免这种内存泄漏情况的发生,可以使用下面的方式:

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

在语句执行完之后,方法返回的匿名对象由于没有被别的对象引用,所以能够被垃圾回收器回收,不会继续引用中的数组,从而避免了内存泄漏。

Java 7 & Java 8

public final class String implements java.io.Serializable, Comparable, 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
}

在-中, 对 类做了一些改变。 类中不再有 和 两个成员变量了。方法也不再共享 数组,而是从指定位置重新拷贝一份数组,从而解决了使用该方法可能导致的内存泄漏问题。方法实现如下:

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, 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
}

为了节省内存空间,中对的实现方式做了优化,成员变量从类型改为了类型,同时新增了一个成员变量。我们知道中类型占用的是两个字节,对于只占用一个字节的字符(例如,,)就显得有点浪费,所以中将改为来存储字符序列,而新属性 的作用就是用来表示数组中存储的是双字节编码的字符还是单字节编码的字符。 属性可以有 和 两个值, 代表 (单字节编码), 代表 (双字节编码)。在创建字符串的时候如果判断所有字符都可以用单字节来编码,则使用来编码以压缩空间,否则使用编码。主要的构造函数实现如下:

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类要设计为不可变的?

与String类相关的类

除了类之外,还有两个与类相关的的类:和,这两个类可以看作是类的可变版本,提供了对字符串修改的各种方法。两者的区别在于是线程安全的而不是线程安全的。

StringBuffer / StringBuilder的实现

和都是继承自,利用可变的数组(之后改为为数组)来实现对字符串的各种修改操作。和都是调用中的方法来操作字符串, 两者区别在于类中对字符串修改的方法都加了修饰,而没有,所以是线程安全的,而并非线程安全的。

我们以为例,看一下类的实现:

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;
}

数组用来存储字符序列,则用来存储数组中已经使用的字符数量,字符串真实的内容是数组中之间的字符序列,而之间是**未使用**的空间。需要属性记录已使用空间的原因是,中的数组并不是每次修改都会重新申请,而是会提前预分配一定的多余空间,以此来减少重新分配数组空间的次数。(这种做法类似于的实现)。

数组扩容的策略是:当对字符串进行修改时,如果当前的数组不满足空间需求时,则会重新分配更大的数组,分配的数组大小为,更加细节的逻辑可以参考如下代码:

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;
}

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

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

String对象的缓存机制

因为对象的使用广泛,为对象设计了缓存机制,以提升时间和空间上的效率。在的运行时数据区中存在一个(),在这个常量池中维护了所有已经缓存的对象,当我们说一个对象被缓存()了,就是指它进入了。

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

说明: 如未特殊指明,本文中提及的实现均指的是的,并且不考虑 逃逸分析()、标量替换()、无用代码消除()等优化手段,测试代码基于不添加任何额外参数的情况下运行。

预备知识

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

  • 运行时数据区

  • 的结构

  • 基于栈的字节码解释执行引擎

  • 类加载的过程

  • 中的几种常量池

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

类加载的过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期依次为:加载()、验证()、准备()、解析()、初始化()、使用()和卸载()7个阶段。其中验证、准备、解析3个部分统称为连接()。

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

Java中的几种常量池

1. class文件中的常量池

我们知道后缀的源代码文件会被编译为后缀的(字节码文件)。在中有一部分内容是 ,这个常量池中主要存储两大类常量:

  • 代码中的或者的值;

  • 符号引用,包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。

2. 运行时常量池

中,有一部分是[运行时常量池(Run-Time Constant Pool)](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.5),属于的一部分。是中每个类或者接口的常量池( )的运行时表示形式,的常量池中的内容会在类加载后进入方法区的。

3. 字符串常量池

()也就是我们上文提到的用来缓存对象的常量池。 这个常量池是全局共享的,属于运行时数据区的一部分。

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

在中,有两种字符串会被缓存到中,一种是在代码中定义的或者,另一种是程序中主动调用方法将当前对象缓存到中。下面分别对两种方式做简要介绍。

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

之所以称之为隐式缓存是因为我们并不需要主动去编写缓存相关代码,编译器和会帮我们完成这部分工作。

字符串字面量

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

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

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

关于更加详细的内容请参阅()。

字符串常量表达式

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

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;

会在执行过程中为创建对象并加入中。例如,上面的代码中,会分别创建和两个对象,这两个对象会被缓存到中,会指向常量池中值为的对象,会指向常量池中值为的对象。

关于更加详细的内容请参阅()。

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

除了声明为/之外,通过其他方式得到的对象也可以主动加入中。例如:

String str = new String("123") + new String("456");
str.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.

简单翻译一下:

当调用 方法时,如果常量池中已经包含相同内容的字符串(字符串内容相同由 方法确定,对于 对象来说,也就是字符序列相同),则返回常量池中的字符串对象。否则,将此 对象将添加到常量池中,并返回此 对象的引用。

因此,对于任意两个字符串 和 ,当且仅当 的结果为时,的结果为。

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

中,有一个用来记录缓存的对象的全局表,叫做,结构及实现方式都类似于中的或者,是一个使用拉链法解决哈希冲突的哈希表,可以简单理解为,注意它只存储对对象的引用,而不存储对象实例。 一般我们说一个字符串进入了其实是说在这个中保存了对它的引用,反之,如果说没有在其中就是说中没有对它的引用。

而真正的字符串对象其实是保存在另外的区域中的,在中中的对象是存储在(之前对的实现)中的,而在之后,中的对象是存储在中的。

中将中的对象移动到中的原因是在 中,中的对象在创建,而代的大小一般不会设置太大,如果大量使用字符串缓存将可能对导致发生异常。

String对象是什么时候进入字符串常量池的?

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

我们重点来研究一下会被隐式缓存的两种值(和),主要是两个问题:

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

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

字节码分析

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

Constant pool:
  #1 = Methodref          #5.#23         // java/lang/Object."":()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;

在中,有两种与字符串相关的常量类型,和。类型的常量用于表示类型的常量对象,其内容只是一个常量池的索引值,处的成员必须是类型。而类型的常量用于存储真正的字符串内容。

例如,上面的常量池中的第、项是类型,存储的索引分别为、,常量池中第、项就是,存储的值分别为,。

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

ldc           #   // 首先将常量池中指定索引位置的String对象压入栈中
astore_   // 然后从栈中弹出刚刚存入的String对象保存到本地变量的指定位置

运行过程分析

还是围绕上面的代码,我们结合 从编译到执行的过程 来分析一下和的创建及*缓存*时机。

1. 编译

首先,第一步是将源代码编译为文件。在源代码编译过程中,我们上文提到的两种值 () 和 ()这两类值都会存在编译后的的常量池中,常量类型为。值得注意的两点是:

  • 会在编译期计算出真实值存在文件的中。例如上面源代码中的这个表达式在文件的常量池中的表现形式是,这个表达式在文件的常量池中的表现形式是;

  • 值相同的或者在的常量池中只会存在一个常量项(类型和都只有一项)。例如上面源代码中,虽然声明了两个常量值分别为和,但是最后文件的常量池中只有一个值为的常量项以及一个对应的常量项。

2. 类加载

在运行时,加载类时,会根据 的常量池 创建 , 的常量池 中的内容会在类加载时进入方法区的 。对于的常量池中的符号引用,会在类加载的,会将其转化为真正的值。但在中,符号引用的并不一定是在类加载时立即执行的,而是推迟到第一次执行相关指令(即引用了符号引用的指令, )时才会去真正进行解析,这就做/()。

  • 对于一些基本类型的常量项,例如,,,,在类加载阶段会将文件常量池中的值转化为中的值,分别对应中的,,,类型;

  • 对于类型的常量项,在类加载的解析阶段被转化为对象(层面的一个对象)。同时使用(结构与类似)来缓存对象,所以在类加载完成后,中应该有所有的常量对应的对象;

  • 而对于类型的常量项,因为其内容是一个符号引用(指向类型常量的索引值),所以需要进行解析,在类加载的解析阶段会将其转化为对象对应的(可以理解为对象在层面的表示),并使用来进行缓存。但是类型的常量,属于上文提到的的范畴,也就是在类加载时并不会立即执行解析,而是等到第一次执行相关指令时(一般来说是指令)才会真正解析。

3. 执行指令

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

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

总结一下:

缓存关键源码分析

可以看到,其实指令在解析类型常量的时候与方法的逻辑很相似:

实际在内部实现上,指令 与 对应的方法 调用了相同的内部方法。我们以的源代码为例,简单分析一下其过程,代码如下(源码位置:):


// 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;
}

案例分析

说明:因为在之后从移到了中,可能在一些代码上与之后的版本表现不一致。所以下面的代码都使用和分别进行测试,如果未特殊说明,表示在两个版本上结果相同,如果不同,会单独指出。

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

解释:

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

解释:

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 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);

上面的代码在和中结果是不同的。

在中:

false
false
true

解释:

在中:

true
true
true

解释:与的区别在于,因为中中的对象是在上创建的,所以当执行第二行时不会再创建新的对象,而是直接将的引用添加到中,所以三个对象都指向常量池中的,也就是第一行中在堆中创建的对象。

下,结果为也能够用来佐证我们上面的过程。我们假设如果不是延迟解析的,而是类加载的时候解析完成并进入常量池的,的返回值应该是常量池中存在的,而不会将指向的堆中的对象加入常量池,所以结果应该是不等于而等于。

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

解释:

参考