Java String 面面观

本文主要介绍Java中与字符串相关的一些内容,主要包括String类的实现及其不变性、String相关类(StringBuilder、StringBuffer)的实现 以及 字符串缓存机制的用法与实现。
String 类的设计与实现
String类的核心逻辑是通过对char型数组进行封装来实现字符串对象,但实现细节伴随着Java版本的演进也发生过几次变化。
Java 6
在Java 6中,String类有四个成员变量:char型数组value、偏移量 offset、字符数量 count、哈希值 hash。value数组用来存储字符序列, offset 和 count 两个属性用来定位字符串在value数组中的位置,hash属性用来缓存字符串的hashCode。
使用offset和count来定位value数组的目的是,可以高效、快速地共享value数组,例如substring()方法返回的子字符串是通过记录offset和count来实现与原字符串共享value数组的,而不是重新拷贝一份。substring()方法实现如下:
但是这种方式却很有可能会导致内存泄漏。例如在如下代码中:
在bigStr被设置为null之后,其中的value数组却仍然被subStr所引用,导致垃圾回收器无法将其回收,结果虽然我们实际上仅仅需要2个字符的空间,但是实际却占用了100000个字符的空间。
在Java 6中,如果想要避免这种内存泄漏情况的发生,可以使用下面的方式:
在语句执行完之后,substring方法返回的匿名String对象由于没有被别的对象引用,所以能够被垃圾回收器回收,不会继续引用bigStr中的value数组,从而避免了内存泄漏。
Java 7 & Java 8
在Java 7-Java 8中,Java 对 String 类做了一些改变。String 类中不再有 offset 和 count 两个成员变量了。substring()方法也不再共享 value数组,而是从指定位置重新拷贝一份value数组,从而解决了使用该方法可能导致的内存泄漏问题。substring()方法实现如下:
Java 9
为了节省内存空间,Java 9中对String的实现方式做了优化,value成员变量从char[]类型改为了byte[]类型,同时新增了一个coder成员变量。我们知道Java中char类型占用的是两个字节,对于只占用一个字节的字符(例如,a-z,A-Z)就显得有点浪费,所以Java 9中将char[]改为byte[]来存储字符序列,而新属性 coder 的作用就是用来表示value数组中存储的是双字节编码的字符还是单字节编码的字符。coder 属性可以有 0 和 1 两个值,0 代表 Latin-1(单字节编码),1 代表 UTF-16(双字节编码)。在创建字符串的时候如果判断所有字符都可以用单字节来编码,则使用Latin-1来编码以压缩空间,否则使用UTF-16编码。主要的构造函数实现如下:
String 类的不变性
我们注意到String类是用final修饰的;所有的属性都是声明为private的;并且除了hash属性之外的其他属性也都是用final修饰。这保证了:
String类由final修饰,所以无法通过继承String类改变其语义;所有的属性都是声明为
private的, 所以无法在String外部直接访问或修改其属性;除了
hash属性之外的其他属性都是用final修饰,表示这些属性在初始化赋值后不可以再修改。
上述的定义共同实现了String类一个重要的特性 —— **不变性**,即 String 对象一旦创建成功,就不能再对它进行任何修改。String提供的方法substring()、concat()、replace()等方法返回值都是新创建的String对象,而不是原来的String对象。
hash属性不是final的原因是:String的hashCode并不需要在创建字符串时立即计算并赋值,而是在hashCode()方法被调用时才需要进行计算。
为什么 String 类要设计为不可变的?
保证
String对象的安全性。String被广泛用作JDK中作为参数、返回值,例如网络连接,打开文件,类加载,等等。如果String对象是可变的,那么String对象将可能被恶意修改,引发安全问题。线程安全。
String类的不可变性天然地保证了其线程安全的特性。保证了
String对象的hashCode的不变性。String类的不可变性,保证了其hashCode值能够在第一次计算后进行缓存,之后无需重复计算。这使得String对象很适合用作HashMap等容器的Key,并且相比其他对象效率更高。实现
字符串常量池。Java为字符串对象设计了字符串常量池来共享字符串,节省内存空间。如果字符串是可变的,那么字符串对象便无法共享。因为如果改变了其中一个对象的值,那么其他对象的值也会相应发生变化。
与 String 类相关的类
除了String类之外,还有两个与String类相关的的类:StringBuffer和StringBuilder,这两个类可以看作是String类的可变版本,提供了对字符串修改的各种方法。两者的区别在于StringBuffer是线程安全的而StringBuilder不是线程安全的。
StringBuffer / StringBuilder 的实现
StringBuffer和StringBuilder都是继承自AbstractStringBuilder,AbstractStringBuilder利用可变的char数组(Java 9之后改为为byte数组)来实现对字符串的各种修改操作。StringBuffer和StringBuilder都是调用AbstractStringBuilder中的方法来操作字符串, 两者区别在于StringBuffer类中对字符串修改的方法都加了synchronized修饰,而StringBuilder没有,所以StringBuffer是线程安全的,而StringBuilder并非线程安全的。
我们以Java 8为例,看一下AbstractStringBuilder类的实现:
value数组用来存储字符序列,count则用来存储value数组中已经使用的字符数量,字符串真实的内容是value数组中[0,count)之间的字符序列,而[count,length)之间是**未使用**的空间。需要count属性记录已使用空间的原因是,AbstractStringBuilder中的value数组并不是每次修改都会重新申请,而是会提前预分配一定的多余空间,以此来减少重新分配数组空间的次数。(这种做法类似于ArrayList的实现)。
value数组扩容的策略是:当对字符串进行修改时,如果当前的value数组不满足空间需求时,则会重新分配更大的value数组,分配的数组大小为min( 原数组大小×2 + 2 , 所需的数组大小 ),更加细节的逻辑可以参考如下代码:
当然AbstractStringBuilder也提供了trimToSize方法去释放多余的空间:
String 对象的缓存机制
因为String对象的使用广泛,Java为String对象设计了缓存机制,以提升时间和空间上的效率。在JVM的运行时数据区中存在一个字符串常量池(String Pool),在这个常量池中维护了所有已经缓存的String对象,当我们说一个String对象被缓存(interned)了,就是指它进入了字符串常量池。
我们通过解答下面三个问题来理解String对象的缓存机制:
哪些
String对象会被缓存进字符串常量池?String对象被缓存在哪里,如何组织起来的?String对象是什么时候进入字符串常量池的?
说明: 如未特殊指明,本文中提及的
JVM实现均指的是Oracle的HotSpot 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)](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.5),属于方法区的一部分。运行时常量池是class文件中每个类或者接口的常量池(Constant Pool )的运行时表示形式,class文件的常量池中的内容会在类加载后进入方法区的运行时常量池。
3. 字符串常量池
字符串常量池(String Pool)也就是我们上文提到的用来缓存String对象的常量池。 这个常量池是全局共享的,属于运行时数据区的一部分。
哪些 String 对象会被缓存进字符串常量池?
在Java中,有两种字符串会被缓存到字符串常量池中,一种是在代码中定义的字符串字面量或者字符串常量表达式,另一种是程序中主动调用String.intern()方法将当前String对象缓存到字符串常量池中。下面分别对两种方式做简要介绍。
1. 隐式缓存 - 字符串字面量 或者 字符串常量表达式
之所以称之为隐式缓存是因为我们并不需要主动去编写缓存相关代码,编译器和JVM会帮我们完成这部分工作。
字符串字面量
第一种会被隐式缓存的字符串是 字符串字面量。字面量 是类型为原始类型、String类型、null类型的值在源代码中的表示形式。例如:
字符串字面量是由双引号括起来的0个或者多个字符构成的。 Java会在执行过程中为字符串字面量创建String对象并加入字符串常量池中。例如上面代码中的"hello"就是一个字符串字面量,在执行过程中会先 创建一个内容为"hello"的String对象,并缓存到字符串常量池中,再将s引用指向这个String对象。
关于字符串字面量更加详细的内容请参阅Java语言规范(JLS - 3.10.5. String Literals)。
字符串常量表达式
另外一种会被隐式缓存的字符串是 字符串常量表达式。常量表达式指的是表示简单类型值或String对象的表达式,可以简单理解为常量表达式就是在编译期间就能确定值的表达式。字符串常量表达式就是表示String对象的常量表达式。例如:
Java会在执行过程中为字符串常量表达式创建String对象并加入字符串常量池中。例如,上面的代码中,会分别创建"abc123"和"abc456"两个String对象,这两个String对象会被缓存到字符串常量池中,str1会指向常量池中值为"abc123"的String对象,str2会指向常量池中值为"abc456"的String对象。
关于常量表达式更加详细的内容请参阅Java语言规范(JLS - 15.28 Constant Expressions)。
2. 主动缓存 - String.intern()方法
除了声明为字符串字面量/字符串常量表达式之外,通过其他方式得到的String对象也可以主动加入字符串常量池中。例如:
在上面的代码中,在执行完第一句后,常量池中存在内容为"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对象的引用。因此,对于任意两个字符串
s和t,当且仅当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()方法的时候进入常量池的。
我们重点来研究一下会被隐式缓存的两种值(字符串字面量和字符串常量表达式),主要是两个问题:
我们并没有主动调用
String类的构造方法,那么它们是在何时被创建?它们是在何时进入
字符串常量池的?
我们以下面的代码为例来分析这两个问题:
字节码分析
我们对上述代码编译之后使用javap来观察一下字节码文件,为了节省篇幅,只摘取了相关的部分:常量池表部分以及main方法信息部分:
在常量池中,有两种与字符串相关的常量类型,CONSTANT_String和CONSTANT_Utf8。CONSTANT_String类型的常量用于表示String类型的常量对象,其内容只是一个常量池的索引值index,index处的成员必须是CONSTANT_Utf8类型。而CONSTANT_Utf8类型的常量用于存储真正的字符串内容。
例如,上面的常量池中的第2、3项是CONSTANT_String类型,存储的索引分别为24、25,常量池中第24、25项就是CONSTANT_Utf8,存储的值分别为"123123","123456"。
class文件的方法信息中Code属性是class文件中最为重要的部分之一,其中包含了执行语句对应的虚拟机指令,异常表,本地变量信息等,其中LocalVariableTable是本地变量的信息,Slot可以理解为本地变量表中的索引位置。ldc指令的作用是从运行时常量池中提取指定索引位置的数据并压入栈中;astore_<n>指令的作用是将一个引用类型的值从栈中弹出并保存到本地变量表的指定位置,也就是<n>指定的位置。可以看出三条赋值语句所对应的字节码指令其实都是相同的:
运行过程分析
还是围绕上面的代码,我们结合 从编译到执行的过程 来分析一下字符串字面量和字符串常量表达式的创建及*缓存*时机。
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文件的常量池中只有一个值为123456的CONSTANT_Utf8常量项以及一个对应的CONSTANT_String常量项。
2. 类加载
在JVM运行时,加载Main类时,JVM会根据 class文件的常量池 创建 运行时常量池, class文件的常量池 中的内容会在类加载时进入方法区的 运行时常量池。对于class文件的常量池中的符号引用,会在类加载的解析(resolve)阶段,会将其转化为真正的值。但在HotSpot中,符号引用的解析并不一定是在类加载时立即执行的,而是推迟到第一次执行相关指令(即引用了符号引用的指令,JLS - 5.4.3. Resolution )时才会去真正进行解析,这就做延迟解析/惰性解析("lazy" or "late" resolution)。
对于一些基本类型的常量项,例如
CONSTANT_Integer_info,CONSTANT_Float_info,CONSTANT_Long_info,CONSTANT_Double_info,在类加载阶段会将class文件常量池中的值转化为运行时常量池中的值,分别对应C++中的int,float,long,double类型;对于
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指令执行时相应位置的常量已经解析过了,直接压入栈中即可。
总结一下:
在编译阶段,源码中
字符串字面量或者字符串常量表达式转化为了class文件的常量池中的CONSTANT_String常量项。在类加载阶段,
class文件的常量池中的CONSTANT_String常量项被存入了运行时常量池中,但保存的内容仍然是一个符号引用,未进行解析。在指令执行阶段,当第一次执行
ldc指令时,运行时常量池中的CONSTANT_String项还未解析,会真正执行解析,解析过程中会创建String对象并加入字符串常量池。
缓存关键源码分析
可以看到,其实ldc指令在解析String类型常量的时候与String.intern()方法的逻辑很相似:
ldc指令中解析String常量:先从字符串常量池中查找是否有相同内容的String对象,如果有则将其压入栈中,如果没有,则创建新对象加入字符串常量池并压入栈中。String.intern()方法:先从字符串常量池中查找是否有相同内容的String对象,如果有则返回该对象引用,如果没有,则将自身加入字符串常量池并返回。
实际在HotSpot内部实现上,ldc指令 与 String.intern()对应的native方法 调用了相同的内部方法。我们以OpenJDK 8的源代码为例,简单分析一下其过程,代码如下(源码位置:src/share/vm/classfile/SymbolTable.cpp):
案例分析
说明:因为在
Java 6之后字符串常量池从永久代移到了堆中,可能在一些代码上Java 6与之后的版本表现不一致。所以下面的代码都使用Java 6和Java 7分别进行测试,如果未特殊说明,表示在两个版本上结果相同,如果不同,会单独指出。
结果:
解释:
第三行,因为
a被定义为常量,所以"123" + a + "567"是一个常量表达式,在编译期会被编译为"1234567",所以会在字符串常量池中创建"1234567",s1指向字符串常量池中的"1234567";第四行,
b被定义为变量,"123"和"567"是字符串字面量,所以首先在字符串常量池中创建"123"和"567",然后通过StringBuilder隐式拼接在堆中创建"1234567",s2指向堆中的"1234567";第五行,
"1234567"是一个字符串字面量,因为此时字符串常量池中已经存在了"1234567",所以s3指向字符串字符串常量池中的"1234567"。
结果:
解释:
第一行,
"123"是一个字符串字面量,所以首先在字符串常量池中创建了一个"123"对象,然后使用String的构造函数在堆中创建了一个"123"对象,s1指向堆中的"123";第二行,因为
字符串常量池中已经有了"123",所以s2指向字符串常量池中的"123";第三行,同样因为
字符串常量池中已经有了"123",所以s3指向字符串常量池中的"123"。
结果:
解释:与上一种情况的区别在于,String.valueOf()方法在参数为String对象的时候会直接将参数作为返回值,不会在堆上创建新对象,所以s1也指向字符串常量池中的"123",三个变量指向同一个对象。
上面的代码在Java 6和Java 7中结果是不同的。
在Java 6中:
解释:
第一行,
"123"和"456"是字符串字面量,所以首先在字符串常量池中创建"123"和"456",+操作符通过StringBuilder隐式拼接在堆中创建"123456",s1指向堆中的"123456";第二行,将
"123456"缓存到字符串常量池中,因为Java 6中字符串常量池中的对象是在永久代创建的,所以会在字符串常量池(永久代)创建一个"123456",此时在堆中和永久代中各有一个"123456",s2指向字符串常量池(永久代)中的"123456";第三行,
"123456"是字符串字面量,因为此时字符串常量池(永久代)中已经存在"123456",所以s3指向字符串常量池(永久代)中的"123456"。
在Java 7中:
解释:与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。
结果:
解释:
第一行,
"123"和"456"是字符串字面量,所以首先在字符串常量池中创建"123"和"456",+操作符通过StringBuilder隐式拼接在堆中创建"123456",s1指向堆中的"123456";第二行,
"123456"是字符串字面量,此时字符串常量池中不存在"123456",所以在字符串常量池中创建"123456",s2指向字符串常量池中的"123456";第三行,因为此时字符串常量池中已经存在
"123456",所以s3指向字符串常量池中的"123456"。
参考
java - substring method in String class causes memory leak - Stack Overflow
(Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的? - 木女孩的回答 - 知乎
版权声明: 本文为 InfoQ 作者【keaper】的原创文章。
原文链接:【http://xie.infoq.cn/article/d42cf36356a52cf3ac819372a】。文章转载请联系作者。











评论 (4 条评论)