【JVM】JVM 字符串常量池

String 的基本特性

String:字符串,使用一对 "" 引起来表示。Java 程序中的所有字符串字面值(如"abc")都作为此类的实例实现。

1
2
String s1 = "hello";                // 字面量的定义方式
String s2 = new String("hello"); // new 对象的方式
  • String是一个final类,代表其不可被继承,从而保护其线程安全性(避免被子类继承后破坏线程安全性)字符串是常量,用双引号引起来表示。它们的值在创建之后不能更改
  • String对象的字符内容是存储在一个字符数组常量final char value[]中的
  • String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小

img

String 的不可变性

String:代表不可变的字符序列。简称:不可变性。

  1. 当对字符串重新赋值时,需要重新指定内存区域赋值,不能使用原有的value进行赋值。
  2. 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
  3. 当调用Stringreplace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
  4. 通过字面量(如"abc")的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池
  5. 字符串常量池中是不会存储相同内容的字符串的。

img

字面量包含:文本字符串、声明为 final 的常量值

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
public void test1(){
String s1 = "abc"; //字面量的定义方式
String s2 = "abc";
s1 = "hello";

System.out.println(s1 == s2);//比较s1和s2的地址值 false
System.out.println(s1);//hello
System.out.println(s2);//abc

System.out.println("*****************");

String s3 = "abc";
s3 += "def";
System.out.println(s3);//abcdef
System.out.println(s2);//abc

System.out.println("*****************");

String s4 = "abc";
String s5 = s4.replace('a', 'm');
System.out.println(s4);//abc
System.out.println(s5);//mbc
}

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 对象只是引用了该数据

image-20210625103416513


问: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
2
3
4
public static void main(String[] args) {
String a = "a";
String b = new String("b");
}

image-20211013151732410

  • 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 对象中):

image-20210625103432649

image-20210625103730602

字符串的特性:

image-20210625103806711

结论:

  • 常量与常量的拼接结果在常量池。且常量池中不会存在相同内容的常量。
  • 只要其中有一个是变量,结果就在堆中。原理是底层创建了一个 StringBuilder 对象,将字符串拼接完毕后返回了一个存储在堆中的 String 类型对象
  • 如果拼接的结果调用intern()方法,返回值就在常量池中。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@Test
public void test2(){
//通过字面量定义的方式:此时的s1和s2的数据javaEE声明在方法区中的字符串常量池中。
String s1 = "javaEE";
String s2 = "javaEE";
//通过new + 构造器的方式:此时的s3和s4保存的地址值,是数据在堆空间中开辟空间以后对应的地址值。
String s3 = new String("javaEE");
String s4 = new String("javaEE");

System.out.println(s1 == s2);//true
System.out.println(s1 == s3);//false
System.out.println(s1 == s4);//false
System.out.println(s3 == s4);//false

System.out.println("***********************");
Person p1 = new Person("Tom",12);
Person p2 = new Person("Tom",12);

System.out.println(p1.name.equals(p2.name));//true
System.out.println(p1.name == p2.name);//true

p1.name = "Jerry";
System.out.println(p2.name);//Tom
}

@Test
public void test3(){
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false

String s8 = s6.intern();//返回值得到的s8使用的常量值中已经存在的“javaEEhadoop”
System.out.println(s3 == s8);//true

}

/*
结论:
1.常量与常量的拼接结果在常量池。且常量池中不会存在相同内容的常量。
2.只要其中有一个是变量,结果就在堆中。
3.如果拼接的结果调用intern()方法,返回值就在常量池中
*/
@Test
public void test4(){
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3);//false

final String s4 = "javaEE";//s4:常量
String s5 = s4 + "hadoop";
System.out.println(s1 == s5);//true

}

面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StringExer {
String str = new String("good");
char [] ch = {'t','e','s','t'};

public void change(String str, char[] ch) {
str = "test ok";
ch[0] = 'b';
}

public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);
System.out.println(ex.str);
System.out.println(ex.ch);
}
}

输出结果

1
2
good
best

原因:

  • 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 结构

官方文档http://openjdk.java.net/jeps/254

为什么改为 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
2
3
4
// 之前
private final char value[];
// 之后
private final byte[] value

同时基于String的数据结构,例如StringBufferStringBuilder也同样做了修改。

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 元空间,字符串常量在堆区:

image-20200711093546398

image-20200711093558709

为什么 StringTable 从永久代调整到堆中

为什么要调整位置?

  • 永久代的默认空间大小比较小
  • 永久代垃圾回收频率低,大量的字符串无法及时回收,容易进行Full GC产生STW或者容易产生OOM:PermGen Space
  • 堆中空间足够大,字符串可被及时回收

在JDK 7中,interned字符串不再在Java堆的永久代中分配,而是在Java堆的主要部分(称为年轻代和年老代)中分配,与应用程序创建的其他对象一起分配。

此更改将导致驻留在主Java堆中的数据更多,驻留在永久生成中的数据更少,因此可能需要调整堆大小。

String 的基本操作

Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//官方示例代码
class Memory {
public static void main(String[] args) {//line 1
int i = 1;//line 2
Object obj = new Object();//line 3
Memory mem = new Memory();//line 4
mem.foo(obj);//line 5
}//line 9

private void foo(Object param) {//line 6
String str = param.toString();//line 7
System.out.println(str);
}//line 8
}

分析运行时内存(foo() 方法是实例方法,其实图中少了一个 this 局部变量)

img

字符串拼接操作

  • 常量与常量的拼接结果在常量池,原理是编译期优化
  • 常量池中不会存在相同内容的变量
  • 只要其中有一个是变量,结果就在堆中。变量拼接的原理是使用 StringBuilder 将两个 String 中的 char[] 数组拼接起来,创建一个新的对象存放合并后的 char[]
  • 如果拼接的结果调用 intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址

字符串常量间的拼接

字符串常量与常量的拼接结果在常量池,原理是编译期优化

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void test1() {
String s1 = "a" + "b" + "c"; //编译期优化:等同于"abc"
String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
/*
* 最终.java编译成.class,再执行.class
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); //true
System.out.println(s1.equals(s2)); //true
}

从字节码指令看出:编译器做了优化,将 "a" + "b" + "c" 优化成了 "abc"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0 ldc #2 <abc>   <--------  这里直接载入的是 "abc" 
2 astore_1
3 ldc #2 <abc>
5 astore_2
6 getstatic #3 <java/lang/System.out>
9 aload_1
10 aload_2
11 if_acmpne 18 (+7)
14 iconst_1
15 goto 19 (+4)
18 iconst_0
19 invokevirtual #4 <java/io/PrintStream.println>
22 getstatic #3 <java/lang/System.out>
25 aload_1
26 aload_2
27 invokevirtual #5 <java/lang/String.equals>
30 invokevirtual #4 <java/io/PrintStream.println>
33 return

字符串变量间的拼接

  • 拼接前后,只要其中有一个是变量,结果就在堆中
  • 调用 intern() 方法,则主动将字符串对象存入字符串常量池中,并将其地址返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";//编译期优化
// 如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false

// intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
// 如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回此对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}

底层原理:字符串变量间的拼接操作的底层其实使用了 StringBuilder

image-20200711102231129

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 启动时提前将字面量加载到字符串常量池里,从而只能直接在堆中存储该字符串对象的值。而静态的拼接方式是可以事先得知并存储在字符串常量池中的。

源码解释StringBuildertoString()方法是直接将其内维护的 char[] value数组内的元素通过构造器赋值给新建的 String 对象,而并没有以字面量的形式出现这个拼接后的字符串,因此不会在字符串常量池中创建 "ab" 对象,只有通过 intern() 方法才会将该对象存储到常量池中。

img


注意:左右两边如果是变量的话,就是需要new StringBuilder进行拼接,但是如果使用的是 final 修饰,则是从常量池中获取。

所以说拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译器优化。也就是说被 final 修饰的变量,将会变成常量。

在开发中,能够使用 final 的时候,建议使用上,就可以在编译阶段进行代码优化,避免运行时动态创建 StringBuilder 合并字符串:

1
2
3
4
5
6
7
public static void test4() {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
}

运行结果

1
true

拼接操作和 append() 性能对比

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void method1(int highLevel) {
String src = "";
for (int i = 0; i < highLevel; i++) {
src += "a"; // 每次循环都会创建一个StringBuilder对象
}
}

public static void method2(int highLevel) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
sb.append("a");
}
}

方法1耗费的时间:4005ms,方法2消耗时间:7ms

结论:通过 StringBuilderappend() 方式添加字符串的效率,要远远高于 String 的字符串拼接方法

StringBuilderappend() 方式,自始至终只创建一个 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
2
s.intern();      // s 的指向不会改变
s1 = s.intern(); // s1 指向字符串常量池

intern() 的空间效率测试

我们通过测试一下,使用了 intern() 和不使用的时候,其实相差还挺多的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class StringIntern2 {
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];

public static void main(String[] args) {
Integer [] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
arr[i] = new String(String.valueOf(data[i%data.length])).intern();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));

try {
Thread.sleep(1000000);
} catch (Exception e) {
e.getStackTrace();
}
}
}

结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用 intern() 方法能够节省内存空间,因为其将冗余的对象都GC,只在常量池里保存一份字符串数据。

大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern() 方法,就会很明显降低内存的大小。

String 相关的面试题

new String(“ab”) 会创建几个对象

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 题目:
* new String("ab")会创建几个对象?看字节码,就知道是两个。
* 一个对象是:new关键字在堆空间创建的
* 另一个对象是:字符串常量池中的对象"ab"。 字节码指令:ldc
*
*/
public class StringNewTest {
public static void main(String[] args) {
String str = new String("ab");
}
}

我们转换成字节码来查看

1
2
3
4
5
6
 0 new #2 <java/lang/String>
3 dup
4 ldc #3 <ab>
6 invokespecial #4 <java/lang/String.<init>>
9 astore_1
10 return

这里面就是两个对象

  • 一个对象是:new关键字在堆空间中创建
  • 另一个对象:字符串常量池中的对象

new String(“a”) + new String(“b”) 会创建几个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 思考:
* new String("a") + new String("b")呢?
* 对象1: new StringBuilder()
* 对象2: new String("a")
* 对象3: 常量池中的"a"
* 对象4: new String("b")
* 对象5: 常量池中的"b"
*
* 深入剖析: StringBuilder的toString():
* 对象6 :new String("ab")
* 强调一下,toString()的调用,在字符串常量池中,没有生成"ab"
*
*/
public class StringNewTest {
public static void main(String[] args) {
String str = new String("a") + new String("b");
}
}

toString()的调用,在字符串常量池中,没有生成 “ab”。这是因为 StringBuilder 的拼接机制是在运行期间动态执行的,因此编译器无法事先知道拼接后的字符串值是什么,也就无法在 JVM 启动时提前将字面量加载到字符串常量池里,从而只能直接在堆中存储该字符串对象的值。而静态的拼接方式是可以事先得知并存储在字符串常量池中的。

字节码文件为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 0 new #2 <java/lang/StringBuilder>
3 dup
4 invokespecial #3 <java/lang/StringBuilder.<init>>
7 new #4 <java/lang/String>
10 dup
11 ldc #5 <a>
13 invokespecial #6 <java/lang/String.<init>>
16 invokevirtual #7 <java/lang/StringBuilder.append>
19 new #4 <java/lang/String>
22 dup
23 ldc #8 <b>
25 invokespecial #6 <java/lang/String.<init>>
28 invokevirtual #7 <java/lang/StringBuilder.append>
31 invokevirtual #9 <java/lang/StringBuilder.toString>
34 astore_1
35 return

我们创建了6个对象

  • 对象1:new StringBuilder()
  • 对象2:new String(“a”)
  • 对象3:常量池的 a
  • 对象4:new String(“b”)
  • 对象5:常量池的 b
  • 对象6:toString中会创建一个 new String(“ab”)
    • 调用toString方法,不会在常量池中生成ab

字节码指令分析:

  1. 0 new #2 <java/lang/StringBuilder> :拼接字符串会创建一个 StringBuilder 对象
  2. 7 new #4 <java/lang/String> :创建 String 对象,对应于 new String("a")
  3. 11 ldc #5 <a> :在字符串常量池中放入 "a"(如果之前字符串常量池中没有 "a" 的话)
  4. 19 new #4 <java/lang/String> :创建 String 对象,对应于 new String("b")
  5. 23 ldc #8 <b> :在字符串常量池中放入 "b"(如果之前字符串常量池中没有 "b" 的话)
  6. 31 invokevirtual #9 <java/lang/StringBuilder.toString> :调用 StringBuildertoString()方法,会生成一个 String 对象

但是需要注意,StringBuildertoString()方法是直接将其内维护的 char[] value数组内的元素通过构造器赋值给新建的 String 对象,而并没有以字面量的形式出现这个拼接后的字符串,因此不会在字符串常量池中创建 "ab" 对象,只有通过 intern() 方法才会将该对象存储到常量池中。

img

intern() 相关的面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 如何保证变量s指向的是字符串常量池中的数据呢?
* 有两种方式:
* 方式一: String s = "shkstart"; // 字面量定义的方式
* 方式二: 调用intern()
* String s = new String("shkstart").intern();
* String s = new StringBuilder("shkstart").toString().intern();
*/
public class StringIntern {
public static void main(String[] args) {
String s = new String("1");
s.intern(); // 调用此方法之前,字符串常量池中已经存在了"1"
String s2 = "1";
System.out.println(s == s2); // jdk6:false jdk7/8:false

/*
1、s3变量记录的地址为:new String("11")
2、经过上面的分析,我们已经知道执行完pos_1的代码,在堆中有了一个new String("11")
这样的String对象。但是在字符串常量池中没有"11"
3、接着执行s3.intern(),在字符串常量池中生成"11"
3-1、在JDK6的版本中,字符串常量池还在永久代,所以直接在永久代生成"11",也就有了新的地址
3-2、而在JDK7的后续版本中,字符串常量池被移动到了堆中,此时堆里已经有new String("11")了
出于节省空间的目的,直接将堆中的那个字符串的引用地址储存在字符串常量池中。没错,字符串常量池中存的是new String("11")在堆中的地址
4、所以在JDK7后续版本中,s3和s4指向的完全是同一个地址。
*/
String s3 = new String("1") + new String("1");//pos_1
s3.intern();

String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
}
}

解释的已经比较清楚了,下面看一下内存图

内存分析

JDK 6 :正常眼光判断即可

  • new String() 即在堆中
  • str.intern() 则把字符串放入常量池中

因为 JDK 6 中字符串常量池存储在永久代,其不在堆中,因此就是单纯的将字符串放到常量池中(不像 JDK 7 后的操作,将引用保存在常量池里)

img

JDK 7 及后续版本,注意大坑:因为字符串常量池移动到了堆区,因此为了节省空间,就不再常量池里拷贝一份字符串副本了,而是直接创建一个对象引用已经存在于堆区中的字符串对象(后续再引用该常量池里的对象时,其地址直接等于了存在于堆区的对象)

img

面试题的拓展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* StringIntern.java中练习的拓展:
*/
public class StringIntern1 {
public static void main(String[] args) {
//执行完下一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
String s3 = new String("1") + new String("1");//new String("11")
//在字符串常量池中生成对象"11",代码顺序换一下,实打实的在字符串常量池里有一个"11"对象
String s4 = "11";
String s5 = s3.intern();

// s3 是堆中的 "ab" ,s4 是字符串常量池中的 "ab"
System.out.println(s3 == s4);//false

// s5 是从字符串常量池中取回来的引用,当然和 s4 相等
System.out.println(s5 == s4);//true
}
}

intern() 方法的练习

练习 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StringExer1 {
public static void main(String[] args) {
String s = new String("a") + new String("b");//new String("ab")
// 在上一行代码执行完以后,字符串常量池中并没有"ab"
/*
1、jdk6中:在字符串常量池(此时在永久代)中创建一个字符串"ab"
2、jdk8中:字符串常量池(此时在堆中)中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回
*/
String s2 = s.intern();

System.out.println(s2 == "ab"); // jdk6:true jdk8:true
System.out.println(s == "ab"); // jdk6:false jdk8:true
}
}

JDK 6

image-20201116113423492

JDK 7/8

image-20200711151326909

练习 2

1
2
3
4
5
6
7
8
9
10
11
12
public class StringExer1 {
public static void main(String[] args) {
// 相比于 练习1 多了这一行
String x = "ab";
String s = new String("a") + new String("b");//new String("ab")

String s2 = s.intern();

System.out.println(s2 == "ab"); // jdk6:true jdk8:true
System.out.println(s == "ab"); // jdk6:false jdk8:false
}
}

这种情况下,因为已经使用字面量的形式将 "ab" 添加到了常量池中,所以后续的 intern() 就不会再像 练习1 一样指向 s

image-20200711151433277

练习 3

1
2
3
4
5
6
7
8
9
10
public class StringExer2 {
public static void main(String[] args) {
String s1 = new String("ab"); // 执行完以后,会在字符串常量池中会生成"ab"
// 注意没有返回值时,s1指向的仍然不变
// 只有intern()方法的返回值才会指向常量池里的地址,调用者自身不会改变地址
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // false
}
}

练习 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StringExer2 {
// 对象内存地址可以使用System.identityHashCode(object)方法获取
public static void main(String[] args) {
String s1 = new String("a") + new String("b"); // 执行完以后,不会在字符串常量池中会生成"ab"
System.out.println(System.identityHashCode(s1));
// 注意,上面拼接的方式没有在常量池中创建字符串
// 因此这里会在常量池里创建对象引用 s1
s1.intern();
System.out.println(System.identityHashCode(s1));

String s2 = "ab";
System.out.println(System.identityHashCode(s2));
System.out.println(s1 == s2); // true
}
}

输出结果:

1
2
3
4
1836019240
1836019240
1836019240
true

字符串常量池 StringTable 的垃圾回收

1
2
3
4
5
6
7
8
9
10
11
/**
* String的垃圾回收
* -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
*/
public class StringGCTest {
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}

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对象被认为是去重的候选对象