12.1 📖 概述

🌠 基本特性

  • String字符串使用一对””引起来表示。
  • String声明为final,不可被继承。
  • String实现了Serializable接口:表示字符串是支持序列化的。
  • String实现了Comparable接口,表示String可以比较大小。
  • String在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9时改为byte[]

❔ String存储结构变更的原因

JDK8及以前中String类的实现将字符存储在char数组中,每个字符占用两个字节。从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且,大多数字符串对象只包含拉丁字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部char数组中有一半的空间将不会使用。

JDK9将String内部表示从UTF-16的char数组改为byte数组+编码标志字段。新的String类将根据字符串的内容存储编码为ISO-8859-1/Latin-1(每个字符一个字节)或UTF-16(每个字符两个字节)的字符。编码标志将指示使用哪种编码。

总结:String由char[]+UTF-16改为byte[]+encoding-flag,节约了一些空间。

🔘 不可变性

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
  • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
  • 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
  • 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串声明在字符串常量池中。

👁‍🗨 字符串常量池

字符串常量池中是不会存储相同内容的字符串的。

  • String的String Pool是一个固定大小的HashTable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。
  • 使用-XX:StringTableSize可设置StringTable的长度
  • 在JDK6中StringTable是固定的。就是1009的长度,所以常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求。
  • 在JDK7中,StringTable的长度默认值是60013。
  • JDK8开始,设置StringTable的长度的话,1009是可设置的最小值。

12.2 🌌 内存分配

💬 说明

  • 在Java语言中有8中基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
  • 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:一种是使用字面量声明,另一种是使用String.intern()方法
  • 在JDK6及以前,字符串常量池存放在永久代(PermGen)。
  • Java7中Oracle的工程师对字符串池的逻辑做了很多的改变,即将字符串常量池的位置调整到Java堆内。
    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()。
  • Java8元空间,字符串常量池在堆。

❔ StringTable调整内存位置的原因

  • PermSize默认比较小
  • 永久代垃圾回收频率低

12.3 🛠️ 字符串操作

🎗️ 字符串拼接

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

🔍 查看底层

Java代码:

1
2
3
4
5
6
7
8
@Test
void test5() {
String s1 = "a";
String s2 = "b";
String s3 = "ab"; // 字符串常量池
String s4 = s1 + s2; // 堆空间
System.out.println(s3 == s4); // false
}

反编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 0 ldc #3 <a>  // 将a从运行时常量池中压入操作数栈
2 astore_1 // 将a存放在局部变量表索引为1的位置
3 ldc #17 <b> // 将b从运行时常量池中压入操作数栈
5 astore_2 // 将b存放在局部变量表索引为2的位置
6 ldc #18 <ab>// 将ab从运行时常量池中压入操作数栈
8 astore_3 // 将ab存放在局部变量表索引为3的位置
9 new #19 <java/lang/StringBuilder> // new一个StringBuilder
12 dup // 复制操作操作数栈顶的StringBuilder的引用
13 invokespecial #20 <java/lang/StringBuilder.<init>> // 调用StringBuilder的初始化方法
16 aload_1 // 从局部变量表索引为1的位置装载一个对象引用(a)到操作数栈顶
17 invokevirtual #21 <java/lang/StringBuilder.append> // 调用StringBuilder的append方法,连接a
20 aload_2 // 从局部变量表索引为2的位置装载一个对象引用(b)到操作数栈顶
21 invokevirtual #21 <java/lang/StringBuilder.append> // 调用StringBuilder的append方法,连接b
24 invokevirtual #22 <java/lang/StringBuilder.toString> // 调用StringBuilder的toString方法,将该字符串放在堆中,引用放在操作数栈中
27 astore 4 // 将ab存放在局部变量表索引为4的位置
---

字符串拼接,JDK5.0之前使用的是StringBuffer,JDK5.0之后使用的是StringBuilder。

关键就在于toString()方法,可以看一下源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// StringBuilder
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}

// StringBuffer
@Override
public synchronized String toString() {
if (toStringCache == null) {
toStringCache = Arrays.copyOfRange(value, 0, count);
}
return new String(toStringCache, true);
}

可以看到,两个方法中均采用了new String()的方法。因此对于非字面量的拼接操作,都在堆中new了一个String对象。

⌨️ ==测试

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
@Test
void test4() {
String s1 = "a" + "b" + "c"; // 编译器优化,拼接结果存放在字符串常量池中
String s2 = "abc"; // 字符串常量池中不存放相同的字符串
System.out.println(s1 ==s2); // true
System.out.println(s1.equals(s2)); // true
}

@Test
void test5() {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // false
}

@Test
void test6() {
String s1 = "boot";
String s2 = "cloud";
String s3 = "boot" + "cloud"; // 字符串拼接结果放在字符串常量池中
String s4 = s1 + "cloud"; // 带变量的拼接结果放在堆中
String s5 = "boot" + s2; // 带变量的拼接结果放在堆中
String s6 = s1 + s2; // 带变量的拼接结果放在堆中
System.out.println(s3 == s4); // flase
System.out.println(s3 == s5); // flase
System.out.println(s3 == s6); // flase
System.out.println(s4 == s5); // flase
// intern(): 判断字符串常量池中是否存在"bootcloud"值
// 如果存在,则返回字符串常量池中"bootcloud"的地址;
// 如果不存在,则在字符串常量池中添加一次"bootcloud",并返回此对象的地址
String s7 = s5.intern();
System.out.println(s3 == s7); // true
}

@Test
void test7() {
final String s1 = "c"; // 常量
final String s2 = "d"; // 常量
String s3 = "cd";
String s4 = s1 + s2; // 拼接符合左右两边都是字符串常量或者常量引用,仍然使用编译期优化
System.out.println(s3 == s4); // true
}

⌨️ 效率测试

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
public class StringAppendTest {

@Test
void test1() {
String str = "";
long start = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
str = str + "k";
}
long end = System.currentTimeMillis();
System.out.println("String拼接花费时间:" + (end - start) + "ms"); // 3935ms
}

@Test
void test2() {
StringBuilder sb = new StringBuilder(100000);
long start = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
sb.append("k");
}
long end = System.currentTimeMillis();
System.out.println("StringBuilder拼接花费时间:" + (end - start) + "ms"); // 3ms
}
}

效率:

StringBuilder的appendAPI >> String的字符串拼接

原因:

(1) StringBuilder方式自始至终只创建一个StringBuilder对象,String拼接会创建多个StringBuilder对象。

(2) 使用String字符串拼接,内存中由于创建了多个StringBuilder和String对象,会增加GC的频率,影响执行效率。

改进:

StringBuilder无参创建的数组空间是16,如果实际开发中长度大于16,则需要不断进行数组扩容。

如果明确拼接后的字符串长度不高于某个限定值highLevel,在定义StringBuilder的时候即可赋予响应空间,即new StringBuilder[highLevel]

💡 intern()方法

美团技术团队沙龙-intern:https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html

intern是一个native方法,调用的是底层C语言实现的方法。

JDK1.8的API说明:字符串池最初是空的,由String类私有地维护。在维护intern方法时,如果池中已经包含了由equals(object)方法确定的与该字符串对象相等的字符串,则返回池中的字符串。否则,该字符串对象将被添加到池中,并返回对该字符串对象的引用。如果不是用双引号声明的string对象,可以使用string提供的intern方法:intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

在JDK1.6中,String.intern()方法尝试将字符串放入字符串常量池,如果没有,会把此对象复制一份,放入字符串常量池,并返回字符串常量池中的对象地址。

自JDK1.7起,String.intern()方法尝试将字符串放入字符串常量池,如果没有,会把此对象的引用地址复制一份,放入字符串常量池,并返回字符串常量池中的引用地址。

至于更改原因,也不必多说了,之前是需要创建一个新的对象放在字符串常量池中,现在是存放一个堆空间中的引用,当然更加节省空间。

对于需要大量使用相同字符串的网站平台,建议使用intern保存到字符串常量池中,以节省内存空间。

12.4 ❓ 面试题

1️⃣ new String(“ab”) 会创建几个对象?

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

前者创建了两个对象:

  • 第一个是new 关键字在堆空间创建的对象
  • 第二个是在字符串常量池中创建的字符串的引用(指向堆空间的地址)

后者创建了六个对象:

  • 对象1:new StringBuilder()
  • 对象2:new String(“a”)
  • 对象3:字符串常量池中a的引用
  • 对象4:new String(“b”)
  • 对象5:字符串常量池中b的引用
  • 深入剖析
    • 对象6:StringBuilder的toString方法中创建的new String(“ab”)
    • 注意,toString方法的调用没有在常量池中生成字符串的引用

2️⃣ 下列代码的执行结果是?

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StringInternTest {
public static void main(String[] args) {
String s1 = new String("1");
s1.intern();
String s2 = "1";
System.out.println(s1 == s2);

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}
}

结果:

  • JDK6: false false
  • JDK7/8: false true

解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String s1 = new String("1");
// s1是堆空间中创建的"1"的地址
s1.intern();
// 调用此方法之前,字符串常量池已经存在了"1"
String s2 = "1";
// s2是字符串常量池中"1"的地址
System.out.println(s1 == s2);
// JDK1.6: false; JDK1.7/1.8: false

String s3 = new String("1") + new String("1");
// s3是StringBuilder的toString方法new String("11")在堆空间中的地址,
// 但是toString的new String并没有把"11"放入字符串常量池
s3.intern();
// jdk1.6会复制一个堆空间中的"11"对象,即创建新的对象,放入字符串常量池(全新地址)
// jdk1.7/1.8会复制一个堆空间中的"11"对象的引用地址,放入字符串常量池(引用地址)
String s4 = "11";
// s4是字符串常量池中"11"的对象地址
System.out.println(s3 == s4);
// JDK1.6: false; JDK1.7/1.8: true

12.5 ♻ StringTable垃圾回收

⚙️ 启动打印垃圾回收日志

  • -XX:+PrintStringTableStatistics:打印字符串常量池统计信息
  • -XX:+PrintGCDetails:打印GC日志详情

⚙️ G1的String去重操作

  • UseStringDeduplication(bool):开启String去重(默认不开启)
  • PrintStringDeduplicationStatistics(bool):打印详细的去重年龄统计信息
  • StringDeduplicationAgeThreshold(utinx):达到年龄的String对象被认为是去重的候选对象