1. String 的特性

1.1 基本特性

  • String 类型的数据表现形式是使用” “表示的字符串
  • String声明为final,即字符串是不可改变的,不可继承的
  • String实现了Serializable接口和Comparable接口,从而支持序列化和大小比较

    1. public final class String
    2. implements java.io.Serializable, Comparable<String>, CharSequence {
    3. private static final long serialVersionUID = -6849794470754667710L;
    4. ...
    5. }
  • String底层采用final char[] value保存数据(JDK8及以前),或是final byte[] value保存(JDK9及之后)

    1. // JDK8
    2. public final class String
    3. implements java.io.Serializable, Comparable<String>, CharSequence {
    4. /** The value is used for character storage. */
    5. private final char value[];
    6. ...
    7. }


之所以将char型数组变成byte型数组,原因是:字符串中通常只存在拉丁文。因此,使用byte就够了,再去使用char就显得空间有些浪费。所以,JDK9及之后修改为byte[]存储,并且加入了字符码标识。

  • String类型数据在字符串常量池中存在且唯一

1.2 字符串的修改

首先需要清楚地是字符串是不可改变的,因此,针对于字符串所做的修改操作并不会影响原先的字符串。具体来说,常见的修改操作有:

  • 当字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值,value指的是保存数据的底层数组
    例如在下面的程序中,s1在重新赋值前和s2指向的都是字符串常量池中的”Forlogen”,因此,它们两者是相等的。当s1重新赋值后,实际是在字符串常量池中新建了一个字符串”Kobe”,s1此时是指向的它。因此,此时s1和s2不再相等。

    1. @Test
    2. public void test2(){
    3. String s1 = "Forlogen";
    4. String s2 = "Forlogen";
    5. System.out.println(s1 + " " + s2); // Forlogen Forlogen
    6. System.out.println(s1 == s2); // true
    7. s1 = "Kobe";
    8. System.out.println(s1 + " " + s2); // Kobe Forlogen
    9. System.out.println(s1 == s2); // false
    10. }
  • 当对现有的字符串进行拼接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
    例如在下面的程序中,s1在执行拼接操作前和s2指向的都是字符串常量池中的”Forlogen”,因此,它们两者是相等的。和上面同理,拼接后会在字符串常量池中新建一个字符串”Forlogen kobe”,s1指向的是它,自然s1和s2此时就不再相等了。

    1. @Test
    2. public void test3(){
    3. String s1 = "Forlogen";
    4. String s2 = "Forlogen";
    5. System.out.println(s1 + " " + s2); // Forlogen Forlogen
    6. System.out.println(s1 == s2); // true
    7. s1 += " kobe";
    8. System.out.println(s1 + " " + s2); // Forlogen kobe Forlogen
    9. System.out.println(s1 == s2); // false
    10. }
  • 当调用String的replace()修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
    例如在下面的程序中,s1初始为”Forlogen”,它会在字符串常量池中存在该字符串。当使用replace()进行字符替换后,s2指向的是字符串常量池中新建的字符串,内容就是替换后的结果。因此,此时s1和s2不再相等。

    1. @Test
    2. public void test4(){
    3. String s1 = "Forlogen";
    4. System.out.println(s1); // Forlogen
    5. String s2 = s1.replace("F", "R");
    6. System.out.println(s2); // Rorlogen
    7. System.out.println(s1 == s2); // false
    8. }

最后再看一个特性:通过字面量方式(区别于new)给一个字符串赋值,此时的字符串声明在字符串常量池中。同样通过程序辅助理解,程序如下所示:

  1. @Test
  2. public void test1(){
  3. String s2 = new String("Forlogen");
  4. String s1 = "Forlogen"; // Forlogen保存在字符串常量池中
  5. System.out.println(s1.hashCode()); // 538205156
  6. System.out.println(s2.hashCode()); // 538205156
  7. System.out.println(s1 == s2); // false
  8. }

虽然s1和s2的字符串内容相同,因为它们的哈希值相同。但是s1位于字符创常量池中,s2是在堆中,因此两者是不等的。至于为什么会出现这种情况,后续接着详细解释。

2. 字符串常量池

字符串常量池中不会存储相同内容的字符串,也就是说同样的字符串只会在常量池中存在一份,拥有相同字符串的变量指向的是相同的地址。这是因为字符创常量池的数据结构是HashTable,它是一个键值结构,而键值结构中键存储的内容是不能有重复的。如果放入常量池中的字符串非常多,大概率会出现哈希碰撞,从而使得保存相同哈希值的字符串的链表很长,而链表很长后直接会造成的影响就是当调用String.intern()时,性能大幅度下降。通常可以根据对性能的要求,使用-XX:StringTableSize参数设置大小。

JDK6:常量池大小是确定的1009,如果常量池中的字符串过多就会导致效率下降很快 JDK7:常量池大小默认是60013,也可以通过参数自行调整 JDK8:1009是可设置的最小值

3. String的内存分配

Java中的八大数据类型和String类型都提供了常量池的概念,目的是为了使它们在运行过程中速度更快、更节省内存。常量池就类似一个Java系统级别提供的缓存,八种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中
  • 如果不是使用双引号声明的String对象,可以使用String提供的intern()方法

Java 6及以前,字符串常量池存放在永久代。Java 7将字符串常量池的位置调整到Java堆内。Java8对于方法去的实现从永久代变成了元空间,但是字符串常量池仍然在堆中。之所以将其从永久代转移到堆中,主要原因有两个:

  • PermSize默认比较小,而字符串会在程序的运行中大量的使用,可能就会出现OOM
  • 永久代垃圾回收效率低,如果将字符串常量池放在永久代中,大量的字符串会占有内存空间,不仅会出现OOM,而且会造成性能下降

Java内存溢出的几种情况

4. String基本操作

4.1 实例化

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

例如下面的程序

  1. public class Memory {
  2. public static void main(String[] args) {//line 1
  3. int i = 1;//line 2
  4. Object obj = new Object();//line 3
  5. Memory mem = new Memory();//line 4
  6. mem.foo(obj);//line 5
  7. }//line 9
  8. private void foo(Object param) {//line 6
  9. String str = param.toString();//line 7
  10. System.out.println(str);
  11. }//line 8
  12. }

编译后使用jclasslib查看反编译后的局部变量表,如下所示:
StringTable.png

可以看到,param和str存放于局部变量表的不同位置。而且从内存空间出发也可以验证,如下所示:
String不变性.png

4.2 拼接

字符串的拼接遵循如下规范:

  • 常量和常量的拼接结果在常量池,原理是编译期优化操作。例如在下面的程序中,s1的来源是字符常量的拼接,s2是定义的字符串字面量,它们指向的是字符串常量池中的同一个字符串”abc”

    1. @Test
    2. public void test1(){
    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. }
  • 只要有一个是变量,结果就在堆中,原理是StringBuilder的使用

    1. @Test
    2. public void test2(){
    3. String s1 = "Hello";
    4. String s2 = "World";
    5. String s3 = "HelloWorld";
    6. String s4 = "Hello" + "World";
    7. System.out.println(s3 == s4); // true
    8. System.out.println("-----------------");
    9. String s5 = s1 + "World";
    10. System.out.println(s3 == s5); // false
    11. System.out.println("-----------------");
    12. String s6 = "Hello" + s2;
    13. System.out.println(s3 == s6); // false
    14. System.out.println("-----------------");
    15. String s7 = s1 + s2;
    16. System.out.println(s3 == s7); // false
    17. System.out.println("-----------------");
    18. System.out.println(s5 == s6); // false
    19. System.out.println(s5 == s7); // false
    20. System.out.println(s6 == s7); // false
    21. System.out.println("-----------------");
    22. }
    23. }


例如在上面的程序中:

  • s4进行的常量的拼接,那么它和s3指向的都是字符串常量池中的”HelloWorld”
  • s5、s6、s7都涉及变量和常量、变量和变量直接的拼接,则相当于在堆空间中new String(),那么得到的结果就在堆中,自然和s3是不等的
  • s5、s6、s7是经过不同的操作得到的字符串,那么它们在堆中保存在不同的位置,因此,它们彼此之间也是不同的

    • 如果调用的是intern(),则主动的将常量池中还没有的字符串对象放入池中,并返回此对象的地址。例如在下面的程序中调用intern(),它判断字符串常量池中是否存在”HelloWorld”值,如果存在,则返回常量池中”HelloWorld”的地址;如果字符串常量池中不存在”HelloWorld”,则在常量池中加载一份”HelloWorld”,并返回此对象的地址

      1. @Test
      2. public void test2(){
      3. String s1 = "Hello";
      4. String s2 = "World";
      5. String s3 = "Hello" + s2;
      6. String s4 = s3.intern();
      7. System.out.println(s3 == s4); // false
      8. }
      9. }


那么变量之间的拼接原理是什么呢?假设程序如下所示:

  1. @Test
  2. public void test3(){
  3. String s1 = "Hello";
  4. String s2 = "World";
  5. String s3 = "HelloWorld";
  6. String s4 = s1 + s2;
  7. System.out.println(s3 == s4); //false
  8. }


它对应的反编译后的指令为:

  1. 0 ldc #6 <Hello>
  2. 2 astore_1
  3. 3 ldc #7 <World>
  4. 5 astore_2
  5. 6 ldc #8 <HelloWorld>
  6. 8 astore_3
  7. 9 new #11 <java/lang/StringBuilder>
  8. 12 dup
  9. 13 invokespecial #12 <java/lang/StringBuilder.<init>>
  10. 16 aload_1
  11. 17 invokevirtual #13 <java/lang/StringBuilder.append>
  12. 20 aload_2
  13. 21 invokevirtual #13 <java/lang/StringBuilder.append>
  14. 24 invokevirtual #14 <java/lang/StringBuilder.toString>
  15. 27 astore 4
  16. 29 getstatic #3 <java/lang/System.out>
  17. 32 aload_3
  18. 33 aload 4
  19. 35 if_acmpne 42 (+7)
  20. 38 iconst_1
  21. 39 goto 43 (+4)
  22. 42 iconst_0
  23. 43 invokevirtual #4 <java/io/PrintStream.println>
  24. 46 return


从指令可以看到,首先s1、s2、s3在字符串常量池中创建对应的字符串。而s4 = s1 + s2;对应的执行细节是:

  • 首先创建一个StringBuilder对象,StringBuilder s = new StringBuilder();
  • 然后调用append()s.append("Hello")
  • 然后调用append()s.append("World")
  • 调用toString()返回字符串形式的结果

    在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer

但字符串拼接操作不一定使用的是StringBuilder!

  • 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式
  • 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上
  1. @Test
  2. public void test4(){
  3. String s1 = "Hello";
  4. String s2 = "World";
  5. String s3 = "HelloWorld";
  6. String s4 = s1 + s2;
  7. System.out.println(s3 == s4); // false
  8. }
  9. @Test
  10. public void test5(){
  11. final String s1 = "Hello";
  12. String s2 = "HelloWorld";
  13. String s3 = s1 + "World";
  14. System.out.println(s2 == s3); // true
  15. }

而且通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式,这是因为:

  • StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
  • 使用String的字符串拼接方式:创建过多个StringBuilder和String的对象

因此,使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。所以在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化。

  1. StringBuilder s = new StringBuilder(highLevel);

5. intern方法

5.1 概述

intern的方法描述:public native String intern();


Returns a canonical representation for the string object.

A pool of strings, initially empty, is maintained privately by the class String.

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.

All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.

  • Returns:
    a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.

如果不是用双引号声明的String对象, 可以使用String提供的intern()intern()会从字符串常量池中查询当前字符串是否存在,如果存在,则返回常量池中该字符串的地址;如果不存在就会将当前字符串放入常量池中,并返回字符串的地址。

如果在任意字符串上调用string.intern方法, 那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。即下面程序中==两端是相等的:

  1. ("a" + "b" + "c").intern() == "abc"

通过调用intern()确保字符串在内存中只有一份拷贝,这样可以节约内存空间,加快字符串操作的执行速度。

  1. public class StringTest4 {
  2. public static void main(String[] args) { {
  3. String s = new String("1");
  4. //调用此方法之前,字符串常量池中已经存在了"1"
  5. s.intern();
  6. String s2 = "1";
  7. System.out.println(s == s2); // false
  8. //s3变量记录的地址为:new String("11")
  9. String s3 = new String("1") + new String("1");
  10. //执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
  11. //jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址
  12. s3.intern();
  13. //s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
  14. String s4 = "11";
  15. System.out.println(s3 == s4); //true
  16. }
  17. }
  18. }

它所对应的的内存图如下所示:
intern方法1.png

5.2. 面试题

5.2.1 new String

new String创建了几个对象呢?假设程序如下所示:

  1. @Test
  2. public void test2(){
  3. String s = new String("Forlogen");
  4. }

它所对应的的字节码指令为:

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

因此,new String操作创建了两个对象:

  • new 关键字在堆中创建
  • 字符串常量池中的对象”Forlogen”,对应指令ldc

5.2.2 new String(“a”) + new String(“b”)

new String(“a”) + new String(“b”)创建了几个对象呢?假设程序如下所示:

  1. @Test
  2. public void test3(){
  3. String str = new String("a") + new String("b");
  4. }

对应的字节码指令为:

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

一般来说,它创建了5个对象:

  • new StringBuilder
  • new String(“a”)
  • 常量池中的”a”
  • new String(“b”)
  • 常量池中的”b”

如果再细说,在调用toString()返回字符串的时候还创建了另一个对象,new String(“ab”),但此时常量池中是没有”ab”存在的。

5.3 总结

总结String的intern()的使用:

  • Jdk 1.6 中, 将这个字符串对象尝试放入常量池。如果常量池中有,则并不会放入,返回已有的常量池中的对象的地址;如果没有,会把此对象复制一份,放入常量池,并返回常量池中的对象地址
  • Jdk 1.7 起, 将这个字符串对象尝试放入常量池。如果常量池中有,则并不会放入,返回已有的常量池中的对象的地址;如果没有,则会把对象的引用地址复制一份,放入常量池,并返回常量池中的引用地址

对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。

6. 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数组会被插入到hash table,这样以后的时候就可以共享这个数组

命令行选项

  • Use String De duplication (bool) :开启String去重, 默认是不开启的, 需要手动开启
  • PrintString De duplication Statistics (bool) :打印详细的去重统计信息
  • String De duplication Age Threshold (uint x):达到这个年龄的String对象被认为是去重的候选对象