之前在介绍 JDK 源码之基础类 Objectequals() 方法的时候,稍微提到了关于在声明一个 String 变量的时候,通过其内存地址或具体内容来比较两个变量是否相同。本篇文章是对之前文章的进一步理解。

深入 String

先来个例子:

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

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

    String s3 = new String("abc");
    String s4 = new String("abc");
    System.out.println(s3 == s4); // false

}

从输出结果可以看到,s1 == s2 此时比较的是这两个对象的引用是否相等,而不是比较具体的内容。

对于 s3 == s4 来说虽然内容相等,但是 s3s4 是两个不同的引用,因此返回 false

在文章 Java 内存区域概述 里提到过,常量池 里存放的数据是 在编译期间生成的各种字面量和符号引用,这部分内容(基本数据类型、String、数组)将在类加载后进入方法区的运行时常量池中存放。

对于字面量,也就是说对于 String s1 = "123"; 来说,其中 String 表示引用的类型;s1 表示一个引用,该引用指向的是字面量 123 在常量池中的地址; 123 表示在编译期间可被确定的内容,存放在 常量池 中。

关于 常量池 的区别,我也在网上搜集了不少文章,比如在知乎上的回答。同时,我也想起了 阿里巴巴Java开发手册编程规约 -> OOP 规约 下的第 7 条,如下所示:

所有的相同类型的包装类型对象之间 值的比较,全部使用 equals 方法。


  • 对于 Integer var = ? ,在 -128 至 127 范围内的赋值,Integer 对象是在 IntegerCache.cache 产生,会复用已有对象,这个区间内的 Integer 值可以直接使用 == 进行判断。
  • 但是 这个区间之外的所有数据,都会在 上产生,并不会复用已有对象,这是一个大坑,推荐使用 equals 方法进行判断。

对于这条约定,我们可以用以下代码说明:

1
2
3
4
5
6
Integer i1 = 100;
Integer i2 = 100;
System.out.println(i1 == i2); // true
Integer i3 = 200;
Integer i4 = 200;
System.out.println(i3 == i4); // false

产生这种现象的原因是由于 自动装箱 造成的:

  • Integer i1 = 100; 产生了自动装箱,由基本数据类型 int = 100 自动装箱成了对应的包装类型 Integer = 100,具体转换过程就是在源码中对应的 Integer i1 = Integer.valueOf(100);
  • 这里涉及到 IntegerCache 的大小,即 IntegerCache.low 默认为 -128IntegerCache.high 默认为 127
  • 这个容量大小表示:在此范围内的对象直接放到缓存中,等到下次再取这个对象的时候,直接复用即可。这种缓存机制可以提高程序的运行效率、节省内存空间。
  • 所以,100 是在这个区间范围里面的。而 200 不在此范围中,它会在堆中产生(new)新的对象,并不会复用原有的对象,这也是返回 false 的原因。

好像有点扯远了。

回到 常量池 具有 数据共享 这一特点,对于语句 String s1 = "123";

  • JVM 首先会在 常量池 中查找该字符串是否已经存在,如果存在则直接返回该字符串对应的引用(也就是地址);
  • 然后语句 String s2 = "123"; 也会在 常量池 中查看有没有这个字符串,结果发现已经存在了;
  • 由于 数据共享,所以 s2 就指向了 123,而不是再次创建一个 123。因此,此时返回的是 true

对于 String s3 = new String("abc"); 来说:

  • 由于使用了 new 关键字,JVM 不会再查询 常量池 了,而是被 new 出来的对象会被放置在 堆内存 中,并用 s3 指向这块内存,即 s3 代表的是 当前对象的引用
  • new 一次,就从堆中开辟一块内存。由于是两块不同的内存,所代表的是两个完全不同的对象,所以 s3s4 也就不相等了。

使用符号 “+” 连接字符串会发生什么?

我们首先使用 javap -c Demo 反编译 Demo.class 文件,得到 Java 编译器生成的字节码:

源文件:

1
2
3
4
5
6
7
8
9
public class Demo{

    public static void main(String[] args) {
        String s = "123";
        s += "456";
        s += "789";
        System.out.println(s);
    }
}

编译后的字节码:

 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
$ javap -c Demo
Compiled from "Demo.java"
public class Demo {
  public Demo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String 123
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: aload_1
      11: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      14: ldc           #6                  // String 456
      16: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_1
      23: new           #3                  // class java/lang/StringBuilder
      26: dup
      27: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      30: aload_1
      31: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      34: ldc           #8                  // String 789
      36: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      39: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      42: astore_1
      43: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
      46: aload_1
      47: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      50: return
}

通过编译结果可以看出,第 16 行对应的是源代码中的第 #4 行,也就是 String s = "123";语句。在初始化 s 的时候,其实在内部使用了 StringBuilder

然后到了第 18 行,也就是对应源代码中的第 #5 行,再执行 s += "456"; 语句的时候,调用了 StringBuilderappend 方法,最后调用 StringBuildertoString 方法生成字符串。

然而 StringBuildertoString 方法是创建一个新的 String 对象的拷贝,如下所示:

1
2
3
4
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

所以说,每次在使用符号 “+” 的时候,都会 new 一个 StringBuilder,使用这种方式连接字符串无疑是一种对内存的浪费。

如何正确的连接字符串?

我们可以使用 StringBuilder 或者 StringBufferappend 方法连接字符串。它俩的区别在于,StringBuffer 是线程安全的,因为它里面的许多方法都是使用 synchronized 修饰的,而 StringBuilder 不是线程安全的。

StringBuilder 在底层维护了一个 char 类型的数组,每次调用 append 方法的时候就往数组里面存值,在调用 toString 方法的时候,将 char 数组中的字符转换成字符串。整个过程其实只使用了一个 StringBuilder 对象,这样的话可以节省内存。

再来个例子

如下面代码所示:

1
2
3
4
5
6
7
8
public class IntegerDemo {
    public static void main(String[] args) {
        String s1 = new String("abc ") + new String("def");
        s1.intern();
        String s2 = "abc def";
        System.out.println(s1 == s2); // true
    }
}

如果将 s1.intern(); 语句注释掉后,结果则返回 false。为什么?

来分析一下第 3 行语句 String s1 = new String("abc ") + new String("def");

  • 首先由于是 new 关键字,则直接在堆中生成两个字符串 abcdef
  • 然后符号 “+” 在底层使用了 StringBuilder 进行字符串的拼接;
  • 拼接完成后产生新的 abc def 对象,并使用 s1 指向它。

第 4 行语句 s1.intern(); 的作用是:

  • 先去 常量池 中查看是否有当前字符串对应的引用:
    • 如果有的话,就直接返回该字符串;
    • 如果没有的话,则在 常量池 中生成该字符串的引用,然后再返回该字符串。

由于字符串 abc def 是在 中生成的,常量池 中没有对应的引用,所以它会在 常量池 中生成该字符串的引用。

第 5 行语句 String s2 = "abc def"; 就是前面讲过的,首先会在 常量池 中查找有没有当前字符串对应的引用,这时由于 s1.intern(); 已经生成过引用了,再加上 数据共享机制,所以 s2 就和 s1 一样共同指向字符串 abc def,即 s1s2 指向相同的对象,所以最后结果返回的是 true

如果没有 s1.intern(); 这一语句,则 s2 会看到在 常量池 中没有当前字符串对应的引用,所以它会在 常量池 中生成 abc def,此时 s1s2 指向的就是两个不同的对象了,这就是返回 false 的原因。

最后一个例子

看下列代码:

1
2
3
4
5
6
7
public static void main(String[] args) {
    String s1 = "aaa "; // 注意:aaa 后面有一个空格
    String s2 = "bbb";
    String s3 = s1 + s2;
    String s4 = "aaa bbb";
    System.out.println(s3 == s4);
}

经过前面的“修炼”,我想你应该知道上面代码最终的输出结果了。我们还是来分析一下最终的结果为什么是 fasle

  • 首先,由于在 常量池 中没有创建过 aaa,则此时 s1 就指向的是 aaas2 同理指向 bbb
  • 然后,由于符号 “+” 在底层使用的是 StringBuilder 进行字符串的拼接,其生成的字符串是调用 toString 方法在 上 new 出来的 aaa bbb,然后 s3 指向 中的字符串;
  • 一个在 常量池 中,另一个在 中,结果自然就返回 false 了。