【JVM】JVM 字符串常量池
String 的基本特性
String:字符串,使用一对 ""
引起来表示。Java 程序中的所有字符串字面值(如"abc"
)都作为此类的实例实现。
1 | String s1 = "hello"; // 字面量的定义方式 |
String
是一个final类,代表其不可被继承,从而保护其线程安全性(避免被子类继承后破坏线程安全性)字符串是常量,用双引号引起来表示。它们的值在创建之后不能更改String
对象的字符内容是存储在一个字符数组常量final char value[]
中的String
实现了Serializable
接口:表示字符串是支持序列化的。实现了Comparable
接口:表示String
可以比较大小
String 的不可变性
String:代表不可变的字符序列。简称:不可变性。
- 当对字符串重新赋值时,需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用
String
的replace()
方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。 - 通过字面量(如
"abc"
)的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中 - 字符串常量池中是不会存储相同内容的字符串的。
字面量包含:文本字符串、声明为 final 的常量值
代码
1 |
|
String 的实例化方式
- 方式一:通过字面量定义的方式
String str1 = "abc";
,此对象存储在字符串常量池中 - 方式二:通过new + 构造器的方式
String str2 = new String("abc");
,此时创建两个对象,一个 String 对象"abc "
存储在字符串常量池中,另一个str2
存储在普通堆空间中
二者区别:
- 下图中的两个橙色块代表两个 String 对象
- 直接用字面量形式创建的对象
str1
存在于字符串常量池中,其内维护了一份唯一的字符串数据char[] value = "abc"
- 用 new 的方式创建的 String 对象存在于堆区中,和上一种方式的对象是不同的
二者的联系在于普通堆区内的 String 对象内部维护的 char[] value
数组其实指向的是常量池中的 String 对象内部维护的 char[] value
,即同一个字符串数据只存在一份于字符串常量池中,普通堆区的 String 对象只是引用了该数据
问:String str = new String("abc");
方式创建对象,在内存中创建了几个对象?
答:两个。一个 String
对象 "abc "
存储在字符串常量池中(在常量池的 Hashtable
结构中),另一个 str
存储在普通堆空间中。
注意这个 str
内部的 char[]
数组的地址就是在字符串常量池中 "abc"
对象的 char[]
数组地址,即在普通堆区 new 出来的多个 String 对象,他们如果内容相同,那么内部的 char[]
数组的地址就是同一个地址(指向存储在字符串常量池中的真正数据 "abc"
),本身不会再去创建新的char[]
数据了,而是共享常量池中的同一份 char[]
。
同时,在普通堆内存空间中存在的 String 对象 str
是会被 GC 的,但是在字符串常量池中的匿名 String
对象 "abc "
不会被轻易 GC,只有当所有引用该对象内的 char[] value
的 String 对象都不存在了才会被回收。
我们也可以从字节码角度分析这两种创建方式的差异:
1 | public static void main(String[] args) { |
0 ldc #2 <a>
:从字符串常量池中加载"a"
这个字符串到操作数栈中2 asrore_1
:将操作数栈中存储的"a"
保存到局部变量表中索引位置为1的位置(即赋值给对象 a)3 new #3 <java/lang/String>
:new 一个 String 对象(即 b 对象,但此时还未初始化,对象属性只是初始值)放到操作数栈中6 dup
:将 new 的 String 对象复制一份同样放到操作数栈中7 ldc #4 <b>
:从字符串常量池中加载"b"
这个字符串到操作数栈中9 invokespecial #5 <java/lang/String.<init>>
:调用 String 类的<init>
方法(包含构造器),将 6 中复制出的 String 和 7 中载入的 “b” 字符串弹栈,传入到该方法中,从而真正地完成了一个 String 类型对象(此时已经做了初始化)的创建和初始化,将该对象放回到操作数栈中12 astore_2
:将 9 中初始化完毕的 String 对象存储到局部变量表中索引位置为1的位置(即赋值给对象 b)13 return
:方法返回
从上述流程中可以看出:
- 字面量形式的对象在程序启动加载阶段就已经被创建好放到了字符串常量池中,而不是等到执行该方法时才创建
- 字面量形式没有再 new 一个对象,而是把字符串常量池里已经存在的对象的地址赋给了引用对象 a
字符串对象存储方式
下图中堆区value指向 "javaEE"
的红色箭头代表每个对象内的 char[] value
都是共享的常量池中的唯一一份 char[] value
(它同样存储在一个 String 对象中):
字符串的特性:
结论:
- 常量与常量的拼接结果在常量池。且常量池中不会存在相同内容的常量。
- 只要其中有一个是变量,结果就在堆中。原理是底层创建了一个
StringBuilder
对象,将字符串拼接完毕后返回了一个存储在堆中的String
类型对象 - 如果拼接的结果调用
intern()
方法,返回值就在常量池中。
测试代码:
1 |
|
面试题
1 | public class StringExer { |
输出结果
1 | good |
原因:
ex.str
传入change()
方法后,在栈中创建了一个局部变量str
,其同样指向字符串常量池中的"good"
匿名对象,此时str = "test ok"
执行后,将在字符串常量池中新建一个匿名字符串对象"test ok"
,并且被局部变量str
所指向,但ex.str
的内容保持不变(仍然指向字符串常量"good"
);ex.ch
传入change()
方法后,在栈中创建了局部变量ch[]
,其同样指向ex.ch
数组,但修改ch[0]
会导致ex.ch
的内容同样被修改。
二者的区别在于:String
类对象和char[]
数组对象作为形参传入时都是引用类型,修改形参时原本对象也应该被修改,但String
类的不可变性导致修改形参时在常量池中创建了新的字符串内容,因此原本对象内容没有改变,但char
数组并无此特性,因此会被修改。
String 的底层结构
字符串常量池是不会存储相同内容的字符串的
String 的 String Pool(字符串常量池)是一个固定大小的Hashtable
,默认值大小长度是1009。如果放进String Pool的String对象非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了以后直接会造成的影响就是当调用String.intern()
方法时去常量池中查找是否已经存在某个字符串时性能会大幅下降。
使用-XX:StringTablesize
可设置StringTable的长度
- 在JDK 6中
StringTable
是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快,StringTablesize
设置没有要求 - 在JDK 7中,
StringTable
的长度默认值是60013,StringTablesize
设置没有要求 - 在JDK 8中,
StringTable
的长度默认值是60013,StringTable
可以设置的最小值为1009
为什么 JDK 9 改变了 String 结构
为什么改为 byte[] 存储?
String类的当前实现将字符存储在char[]数组中,每个字符使用两个字节(16位)。
从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符(Latin-1)。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部char数组中有一半的空间将不会使用,产生了大量浪费。
之前 String 类使用 UTF-16 的 char[]
数组存储,JDK 9改为 byte[]
数组 外加一个编码标识存储。该编码表示如果你的字符是ISO-8859-1或者Latin-1,那么只需要一个字节存。如果你是其它字符集,比如UTF-8,你仍然用两个字节存
结论:在 JDK 9 之后,String再也不用 char[]
来存储了,改成了 byte []
加上编码标记,节约了一些空间。
1 | // 之前 |
同时基于String的数据结构,例如StringBuffer
和StringBuilder
也同样做了修改。
String 的内存分配
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的字面量String对象会直接存储在常量池中。比如:
String info="hello";
- 如果不是用双引号声明的String对象,可以使用String提供的
intern()
方法将对象存储在常量池中。这个后面重点谈
存放位置调整:
- Java 6及以前,字符串常量池存放在永久代
- Java 7后oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内
所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()。
Java 8 元空间,字符串常量在堆区:
为什么 StringTable 从永久代调整到堆中
为什么要调整位置?
- 永久代的默认空间大小比较小
- 永久代垃圾回收频率低,大量的字符串无法及时回收,容易进行Full GC产生STW或者容易产生
OOM:PermGen Space
- 堆中空间足够大,字符串可被及时回收
在JDK 7中,interned字符串不再在Java堆的永久代中分配,而是在Java堆的主要部分(称为年轻代和年老代)中分配,与应用程序创建的其他对象一起分配。
此更改将导致驻留在主Java堆中的数据更多,驻留在永久生成中的数据更少,因此可能需要调整堆大小。
String 的基本操作
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
1 | //官方示例代码 |
分析运行时内存(foo()
方法是实例方法,其实图中少了一个 this
局部变量)
字符串拼接操作
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的变量
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是使用
StringBuilder
将两个 String 中的char[]
数组拼接起来,创建一个新的对象存放合并后的char[]
- 如果拼接的结果调用
intern()
方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
字符串常量间的拼接
字符串常量与常量的拼接结果在常量池,原理是编译期优化
1 |
|
从字节码指令看出:编译器做了优化,将 "a" + "b" + "c"
优化成了 "abc"
1 | 0 ldc #2 <abc> <-------- 这里直接载入的是 "abc" |
字符串变量间的拼接
- 拼接前后,只要其中有一个是变量,结果就在堆中
- 调用
intern()
方法,则主动将字符串对象存入字符串常量池中,并将其地址返回
1 |
|
底层原理:字符串变量间的拼接操作的底层其实使用了 StringBuilder
s1 + s2
的执行细节:
StringBuilder s = new StringBuilder();
s.append(s1);
s.append(s2);
s.toString();
-> 类似于new String("ab");
在JDK 5之后,使用的是 StringBuilder
,在JDK5之前使用的是 StringBuffer
。
注意:StringBuilder.toString()
的调用,在字符串常量池中,没有生成 “ab”。这是因为 StringBuilder
的拼接机制是在运行期间动态执行的,因此编译器无法事先知道拼接后的字符串值是什么,也就无法在 JVM 启动时提前将字面量加载到字符串常量池里,从而只能直接在堆中存储该字符串对象的值。而静态的拼接方式是可以事先得知并存储在字符串常量池中的。
源码解释:StringBuilder
的 toString()
方法是直接将其内维护的 char[] value
数组内的元素通过构造器赋值给新建的 String 对象,而并没有以字面量的形式出现这个拼接后的字符串,因此不会在字符串常量池中创建 "ab"
对象,只有通过 intern()
方法才会将该对象存储到常量池中。
注意:左右两边如果是变量的话,就是需要new StringBuilder
进行拼接,但是如果使用的是 final
修饰,则是从常量池中获取。
所以说拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译器优化。也就是说被 final
修饰的变量,将会变成常量。
在开发中,能够使用 final
的时候,建议使用上,就可以在编译阶段进行代码优化,避免运行时动态创建 StringBuilder
合并字符串:
1 | public static void test4() { |
运行结果
1 | true |
拼接操作和 append() 性能对比
1 | public static void method1(int highLevel) { |
方法1耗费的时间:4005ms,方法2消耗时间:7ms
结论:通过 StringBuilder
的 append()
方式添加字符串的效率,要远远高于 String 的字符串拼接方法
StringBuilder
的 append()
方式,自始至终只创建一个 StringBuilder
的对象
而使用String的字符串拼接方式:
- 还需要创建很多
StringBuilder
对象和调用toString()
时候创建的 String 对象 - 内存中由于创建了较多的
StringBuilder
和 String 对象,内存占用过大,如果进行GC那么将会耗费更多的时间
改进的空间:
- 在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel); // new char[highLevel]
- 这样可以避免频繁扩容
intern() 的使用
intern():将字符串对象放到字符串常量池的内部,作用:节省内存空间
1 | public native String intern(); |
intern()
是一个 native
方法,调用的是底层C的方法
字符串池最初是空的,由String类私有地维护。在调用 intern()
方法时,如果池中已经包含了由 equals(object)
方法确定的与该字符串对象相等的字符串,则返回池中的字符串对象的地址。否则,该字符串对象将被添加到池中,并返回对该字符串对象的引用。
如果不是用双引号声明的String对象,可以使用String提供的 intern()
方法:intern()
方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中(好处是可以减少堆中存放的大量冗余字符串对象),并将常量池中该对象的地址返回给引用对象。
比如:
1 | String myInfo = new string("hello").intern(); |
也就是说,如果在任意字符串上调用 String.intern()
方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true
1 | ("a"+"b"+"c").intern() == "abc" |
通俗点讲,intern()
就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)
注意:下面例子中,当调用 intern()
没有返回值时,s 指向的仍然不变,只有 intern()
方法的返回值(s1)才会指向常量池里的地址,调用者自身不会改变地址:
1 | s.intern(); // s 的指向不会改变 |
intern() 的空间效率测试
我们通过测试一下,使用了 intern()
和不使用的时候,其实相差还挺多的
1 | public class StringIntern2 { |
结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用 intern()
方法能够节省内存空间,因为其将冗余的对象都GC,只在常量池里保存一份字符串数据。
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern()
方法,就会很明显降低内存的大小。
String 相关的面试题
new String(“ab”) 会创建几个对象
1 | /** |
我们转换成字节码来查看
1 | 0 new #2 <java/lang/String> |
这里面就是两个对象
- 一个对象是:new关键字在堆空间中创建
- 另一个对象:字符串常量池中的对象
new String(“a”) + new String(“b”) 会创建几个对象
1 | /** |
toString()的调用,在字符串常量池中,没有生成 “ab”。这是因为
StringBuilder
的拼接机制是在运行期间动态执行的,因此编译器无法事先知道拼接后的字符串值是什么,也就无法在 JVM 启动时提前将字面量加载到字符串常量池里,从而只能直接在堆中存储该字符串对象的值。而静态的拼接方式是可以事先得知并存储在字符串常量池中的。
字节码文件为
1 | 0 new #2 <java/lang/StringBuilder> |
我们创建了6个对象
- 对象1:new StringBuilder()
- 对象2:new String(“a”)
- 对象3:常量池的 a
- 对象4:new String(“b”)
- 对象5:常量池的 b
- 对象6:toString中会创建一个 new String(“ab”)
- 调用toString方法,不会在常量池中生成ab
字节码指令分析:
0 new #2 <java/lang/StringBuilder>
:拼接字符串会创建一个StringBuilder
对象7 new #4 <java/lang/String>
:创建 String 对象,对应于new String("a")
11 ldc #5 <a>
:在字符串常量池中放入"a"
(如果之前字符串常量池中没有"a"
的话)19 new #4 <java/lang/String>
:创建 String 对象,对应于new String("b")
23 ldc #8 <b>
:在字符串常量池中放入"b"
(如果之前字符串常量池中没有"b"
的话)31 invokevirtual #9 <java/lang/StringBuilder.toString>
:调用StringBuilder
的toString()
方法,会生成一个 String 对象
但是需要注意,StringBuilder
的 toString()
方法是直接将其内维护的 char[] value
数组内的元素通过构造器赋值给新建的 String 对象,而并没有以字面量的形式出现这个拼接后的字符串,因此不会在字符串常量池中创建 "ab"
对象,只有通过 intern()
方法才会将该对象存储到常量池中。
intern() 相关的面试题
1 | /** |
解释的已经比较清楚了,下面看一下内存图
内存分析
JDK 6 :正常眼光判断即可
new String()
即在堆中str.intern()
则把字符串放入常量池中
因为 JDK 6 中字符串常量池存储在永久代,其不在堆中,因此就是单纯的将字符串放到常量池中(不像 JDK 7 后的操作,将引用保存在常量池里)
JDK 7 及后续版本,注意大坑:因为字符串常量池移动到了堆区,因此为了节省空间,就不再常量池里拷贝一份字符串副本了,而是直接创建一个对象引用已经存在于堆区中的字符串对象(后续再引用该常量池里的对象时,其地址直接等于了存在于堆区的对象)
面试题的拓展
1 | /** |
intern() 方法的练习
练习 1
1 | public class StringExer1 { |
JDK 6
JDK 7/8
练习 2
1 | public class StringExer1 { |
这种情况下,因为已经使用字面量的形式将 "ab"
添加到了常量池中,所以后续的 intern()
就不会再像 练习1 一样指向 s
了
练习 3
1 | public class StringExer2 { |
练习 4
1 | public class StringExer2 { |
输出结果:
1 | 1836019240 |
字符串常量池 StringTable 的垃圾回收
1 | /** |
G1 中的 String 去重操作
注意这里说的重复,指的是在堆中的数据,而不是常量池中的,因为常量池中的本身就不会重复
描述
背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
- 堆存活数据集合里面String对象占了25%
- 堆存活数据集合里面重复的String对象有13.5%
- String对象的平均长度是45
许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半String对象是重复的。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存。
实现
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
- 使用一个Hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个Hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到Hashtable,这样以后的时候就可以共享这个数组了。
命令行选项
UseStringDeduplication(bool)
:开启String去重,默认是不开启的,需要手动开启。PrintStringDeduplicationStatistics(bool)
:打印详细的去重统计信息stringDeduplicationAgeThreshold(uintx)
:达到这个年龄的String对象被认为是去重的候选对象