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

反编译:

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

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

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

  1. // StringBuilder
  2. @Override
  3. public String toString() {
  4. // Create a copy, don't share the array
  5. return new String(value, 0, count);
  6. }
  7. // StringBuffer
  8. @Override
  9. public synchronized String toString() {
  10. if (toStringCache == null) {
  11. toStringCache = Arrays.copyOfRange(value, 0, count);
  12. }
  13. return new String(toStringCache, true);
  14. }

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

⌨️ ==测试

  1. @Test
  2. void test4() {
  3. String s1 = "a" + "b" + "c"; // 编译期优化,拼接结果存放在字符串常量池中
  4. String s2 = "abc"; // 字符串常量池中不存放相同的字符串
  5. System.out.println(s1 ==s2); // true
  6. System.out.println(s1.equals(s2)); // true
  7. }
  8. @Test
  9. void test5() {
  10. String s1 = "a";
  11. String s2 = "b";
  12. String s3 = "ab";
  13. String s4 = s1 + s2;
  14. System.out.println(s3 == s4); // false
  15. }
  16. @Test
  17. void test6() {
  18. String s1 = "boot";
  19. String s2 = "cloud";
  20. String s3 = "boot" + "cloud"; // 字符串拼接结果放在字符串常量池中
  21. String s4 = s1 + "cloud"; // 带变量的拼接结果放在堆中
  22. String s5 = "boot" + s2; // 带变量的拼接结果放在堆中
  23. String s6 = s1 + s2; // 带变量的拼接结果放在堆中
  24. System.out.println(s3 == s4); // flase
  25. System.out.println(s3 == s5); // flase
  26. System.out.println(s3 == s6); // flase
  27. System.out.println(s4 == s5); // flase
  28. // intern(): 判断字符串常量池中是否存在"bootcloud"值
  29. // 如果存在,则返回字符串常量池中"bootcloud"的地址;
  30. // 如果不存在,则在字符串常量池中添加一次"bootcloud",并返回此对象的地址
  31. String s7 = s5.intern();
  32. System.out.println(s3 == s7); // true
  33. }
  34. @Test
  35. void test7() {
  36. final String s1 = "c"; // 常量
  37. final String s2 = "d"; // 常量
  38. String s3 = "cd";
  39. String s4 = s1 + s2; // 拼接符合左右两边都是字符串常量或者常量引用,仍然使用编译期优化
  40. System.out.println(s3 == s4); // true
  41. }

⌨️效率测试

  1. public class StringAppendTest {
  2. @Test
  3. void test1() {
  4. String str = "";
  5. long start = System.currentTimeMillis();
  6. for (int i = 0; i < 100000; i++) {
  7. str = str + "k";
  8. }
  9. long end = System.currentTimeMillis();
  10. System.out.println("String拼接花费时间:" + (end - start) + "ms"); // 3935ms
  11. }
  12. @Test
  13. void test2() {
  14. StringBuilder sb = new StringBuilder(100000);
  15. long start = System.currentTimeMillis();
  16. for (int i = 0; i < 100000; i++) {
  17. sb.append("k");
  18. }
  19. long end = System.currentTimeMillis();
  20. System.out.println("StringBuilder拼接花费时间:" + (end - start) + "ms"); // 3ms
  21. }
  22. }

效率:

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. public class StringInternTest {
  2. public static void main(String[] args) {
  3. String s1 = new String("1");
  4. s1.intern();
  5. String s2 = "1";
  6. System.out.println(s1 == s2);
  7. String s3 = new String("1") + new String("1");
  8. s3.intern();
  9. String s4 = "11";
  10. System.out.println(s3 == s4);
  11. }
  12. }

结果:

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

解析:

  1. String s1 = new String("1");
  2. // s1是堆空间中创建的"1"的地址
  3. s1.intern();
  4. // 调用此方法之前,字符串常量池已经存在了"1"
  5. String s2 = "1";
  6. // s2是字符串常量池中"1"的地址
  7. System.out.println(s1 == s2);
  8. // JDK1.6: false; JDK1.7/1.8: false
  9. String s3 = new String("1") + new String("1");
  10. // s3是StringBuilder的toString方法new String("11")在堆空间中的地址,
  11. // 但是toString的new String并没有把"11"放入字符串常量池
  12. s3.intern();
  13. // jdk1.6会复制一个堆空间中的"11"对象,即创建新的对象,放入字符串常量池(全新地址)
  14. // jdk1.7/1.8会复制一个堆空间中的"11"对象的引用地址,放入字符串常量池(引用地址)
  15. String s4 = "11";
  16. // s4是字符串常量池中"11"的对象地址
  17. System.out.println(s3 == s4);
  18. // 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对象被认为是去重的候选对象