众所周知,java中有个叫 字符串常量池 的东西,正是常量池的存在,让 String 相比较与其他对象变得有趣起来。

一些题目


题目1:下列每次比较输出的是 true 还是 false
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a" + "b";
String s5 = "a" + s2;
String s6 = s1 + "b";
String s7 = s1 + s2;

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

System.out.println(s4 == s5);
System.out.println(s4 == s6);
System.out.println(s4 == s7);

System.out.println(s5 == s6);
System.out.println(s5 == s7);

System.out.println(s6 == s7);



题目2:下列代码创建了几个对象?

String s = new String("ab");



题目3:下列代码创建了几个对象?

String s = new String("a") + new String("b");



题目4:

String s1 = new String("a");
s1.intern();
String s2 = "a";
System.out.println(s1 == s2); // 输出的是啥?



题目5:

String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // 输出的是啥?







字节码分析

目前我所知道的从字节码文件得到字节码指令的方式有两种:

  1. 命令行命令: javap -v 字节码文件

会在命令行输出字节码文件的所有信息,也可以配合 > 将结果保存为文本文件,然后查看。

  1. 使用 idea 的插件 Jclasslib 。

    安装插件后,需要先编译 java 代码,生成字节码文件;然后注意焦点要在要查看的 java 文件里,依次点击菜单栏 View -> Show Bytecode With Jclasslib -> 方法 -> main -> Code 就可以看到字节码指令。

    使用插件的方式更加方便,而且将 字节码指令 与 局部变量表 分开,看起来也更加方便直观。




1. 字面量定义字符串

代码:

public class StringTest {
    public static void main(String[] args) {
        String s1 = "abc";
    }
}

使用 javap -v 字节码文件 命令可得到如下字节码,但只需要关注Code对应的部分,也就是字节码指令部分

Constant pool:  // 常量池部分
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // abc
   #3 = Class              #22            // string_test/StringCompare
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lstring_test/StringCompare;
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               args
  #15 = Utf8               [Ljava/lang/String;
  #16 = Utf8               s1
  #17 = Utf8               Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               StringCompare.java
  #20 = NameAndType        #5:#6          // "<init>":()V
  #21 = Utf8               abc
  #22 = Utf8               string_test/StringCompare
  #23 = Utf8               java/lang/Object
{
  public string_test.StringCompare();
    descriptor: ()V
    flags: ACC_PUBLIC

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:                  // 字节码指令
      stack=1, locals=2, args_size=1
         0: ldc           #2                  // String abc
         2: astore_1
         3: return
      LineNumberTable:    // 字节码指令行号  java代码行号 对应表
        line 10: 0
        line 11: 3
      LocalVariableTable: // 局部变量表
        Start  Length  Slot  Name   Signature
            0       4     0  args   [Ljava/lang/String;
            3       1     1    s1   Ljava/lang/String;
}
SourceFile: "StringTest.java"

字节码指令及解释为:

0: ldc #2     // 将常量池中序号为2的值(就是“abc”)压入栈顶
2: astore_1   // 将栈顶引用类型值保存到局部变量1中
3: return     // 返回

总结:

也就是说,使用字面量方式定义字符串,创建的对象指向常量池中的字符串,二者的地址是相同的




2. new 关键字定义字符串

代码

public class StringTest {
    public static void main(String[] args) {
        String s1 = new String("abc");
    }
}

对应字节码指令 及 解释

 0 new #2 <java/lang/String>  // 创建新一个新的对象,类型为常量池中序号2位置的类型(这里是java.lang.String)
 
 3 dup                        // 复制栈顶一个字长的数据,将复制后的数据压栈,
 			      // 这个操做个人猜想是因为引用类型在局部变量表中占两个 slot
 							  
 4 ldc #3 <abc>               // 将常量池中序号为3的数据("abc")压栈
 
 6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
 			      // 调用 java.lang.String.<init> 方法
 							  
 9 astore_1                   // 将栈顶引用类型值保存到局部变量1(s1)中
10 return

总结:

使用 new 关键字会先创建一个新的空对象,然后将常量池中的 字符串的值赋值给这个对象,二者是不同的。所以可以说创建了两个对象。

但是个人觉得常量池中的数据,是在类的加载阶段就创建了,不然为什么可以直接就使用 new 关键字,然后就从常量池里取数据。感觉说创建了两个对象,还是有点牵强。




3. 字符串拼接操作1

public class StringTest {
    public static void main(String[] args) {
        String s1 = new String("abc") + new String("def");
    }
}

字节码指令

 0 new #2 <java/lang/StringBuilder>  // 创建新一个新的java.lang.StringBuilder对象
 3 dup				     // 
 4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
 				     // 调用StringBuilder.<init>方法
 									 
 7 new #4 <java/lang/String>         // 创建新一个新的java.lang.String对象
10 dup                               // 
11 ldc #5 <abc>                      // 从常量池中加载“abc”,压入栈底

13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
                                     // 调用String.<init>方法
                                     
16 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                                     // 调用StringBuilder.append方法,此时StringBuilder为“abc”
                                     
19 new #4 <java/lang/String>         // 创建新一个新的java.lang.String对象
22 dup                               // 
23 ldc #8 <def>                      // 从常量池中加载“def”,压入栈底

25 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
                                     // 调用String.<init>方法
                                     
28 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                                     // 调用StringBuilder.append方法,此时StringBuilder为“abcdef”
                                     
31 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
                                     // 调用StringBuilder.toString方法,创建新的String对象“abcdef”
                                     
34 astore_1                          // 弹出栈顶元素(“abcdef”),将值保存到局部变量1(s1)中
35 return

总结

这里可以发现字符串的拼接,也就是 + 操作,实际上是通过 StringBuiler 的 append 方法实现的。

所以这个过程中创建了:

  1. 第1个对象:StringBuiler 对象
  2. 第2个对象:new String("abc")
  3. 第3个对象:new String("abc")
  4. 第4个对象:StringBuiler.toString 方法也会创建一个新的 String 对象

如果把常量池中的两个对象( "abc" 和 "def" )也算上的话,一共就是 6 个对象。




4. 字符串拼接操作2

代码

public class StringTest {
    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "def";
        String s3 = "abc" + "def";
        String s4 = s1 + "def";
        String s5 = "abc" + s2;
        String s6 = s1 + s2;
    }
}

在 idea 中打开编译后的 class 文件,也就是执行反编译操作后,结果如下:

public class StringTest {
    public StringTest() {
    }

    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "def";
        String s3 = "abcdef";
        String s4 = s1 + "def";
        (new StringBuilder()).append("abc").append(s2).toString();
        (new StringBuilder()).append(s1).append(s2).toString();
    }
}

/*
  这里就可以看见,如果是常量与常量的拼接,比如 "abc" + "def" 在编译阶段就会优化,将两个字符串链接在一起
  而变量一旦有字符串变量参与到 + 的操做中,就会调用StringBuilder.append方法
  
  但是这里有出现了一个问题,为什么 s4 对应的 + 操做没有使用 StringBuilder 呢???
*/

字节码指令及解释:

******************************  s1、s2、s3初始化操做  ******************************

 0 ldc #2 <abc>     // 从常量池中加载“abc”,压入栈底
 2 astore_1         // 弹出栈顶元素(“abc”),将值保存到局部变量1(s1)中
 3 ldc #3 <def>     // 从常量池中加载“def”,压入栈底
 5 astore_2         // 弹出栈顶元素(“def”),将值保存到局部变量2(s2)中
 6 ldc #4 <abcdef>  // 从常量池中加载“abcdef”,压入栈底
 8 astore_3         // 弹出栈顶元素(“abcdef”),将值保存到局部变量3(s3)中
 
 
******************************  s4初始化操做  ******************************

 9 new #5 <java/lang/StringBuilder>   
                   // 1.创建新一个新的java.lang.StringBuilder对象
                   
12 dup             // 2.复制栈顶一个字长的数据

13 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
                   // 3.调用 StringBuilder.<init> 方法,也就是初始化 StringBuilder
                   
16 aload_1         // 4.将局部变量1(s1,也就是“abc”)中装载引用类型值 入栈

17 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                   // 5.调用StringBuilder.append方法,此时 StringBuilder 为 “abc”
                   
20 ldc #3 <def>    // 6.从常量池中加载“def”,压入栈底

22 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                   // 7.调用StringBuilder.append方法,此时 StringBuilder 为 “abcdef”
                   
25 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
                   // 8.调用StringBuilder.toString方法,创建新的String对象“abcdef”
                   
28 astore 4        // 9.弹出栈顶元素(“abcdef”),将值保存到局部变量4(s4)中


******************************  s5初始化操做  ******************************

30 new #5 <java/lang/StringBuilder>  
				  // 1.创建新一个新的java.lang.StringBuilder对象
33 dup            // 
34 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
                  // 调用StringBuilder.<init>方法,初始化一个空的调用StringBuilder对象
                  
37 ldc #2 <abc>   // 从常量池中加载“abc”,压入栈底

39 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                  // 调用StringBuilder.append方法,此时StringBuilder为“abc”
                  
42 aload_2        // 将局部变量2(s2,也就是"def")中装载引用类型值 入栈

43 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                  // 调用StringBuilder.append方法,此时StringBuilder为“abcdef”
                  
46 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
                  // 调用StringBuilder.toString方法,创建新的String对象“abcdef”
                  
49 astore 5       // 弹出栈顶元素(“abcdef”),将值保存到局部变量5(s5)中


******************************  s6初始化操做  ******************************

51 new #5 <java/lang/StringBuilder>
                  //  1.创建新一个新的java.lang.StringBuilder对象
54 dup            //  
55 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
                  // 调用StringBuilder.<init>方法,初始化一个空的调用StringBuilder对象
                  
58 aload_1        // 将局部变量1(s1,也就是“abc”)中装载引用类型值 入栈
59 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                  // 调用StringBuilder.append方法追加"abc",此时StringBuilder为“abc”
                  
62 aload_2        // 将局部变量2(s2,也就是"def")中装载引用类型值 入栈
63 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
                  // 调用StringBuilder.append方法追加"def",此时StringBuilder为“abcdef”
                  
66 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
                  // 调用StringBuilder.toString方法,创建新的String对象“abcdef”
69 astore 6       // 弹出栈顶元素(“abcdef”),将值保存到局部变量6(s6)中

71 return

总结

可以看出,除了常量 与 常量之间的拼接创建了一个指向常量池的的对象,其他的 s4、s5、s6 只要有变量参与到字符串的拼接操作中,都会创建 StringBuffer 对象,然后调用 append 方法,最后在用 toString 方法转换从成 String 对象,当然 StringBuffer 的 toString 方法原理还是创建一个新的对象,然后将 字符数组中的值一个一个复制过去。


所以使用 + 进行字符串的过程中创建了大量的中间对象,消耗了大量的内存空间,操作不当的话就很容易造成内存溢出(OOM)。如果在编码阶段就显式的使用 StringBuffer ,就可以避免创建很多无用的中间对象。再进一步优化的话,就可以考虑再一开始就指定 StringBuffer 的长度,避免后面长度不够的扩容操作。


比如下面三个方法:

// 每次拼接操作都要创建创建 StringBuffer 对象,然后调用 toString 方法,还要创建 String 对象
public void method1(int n) { 
    String str = "";
    for (int i = 0; i < n; i++) {
    str = str + "a";
    }
}


// 创建了一个 StringBuffer 对象,后续如果不够会扩容
public void method2(int n) { 
    StringBuffer str = new StringBuffer();
    for (int i = 0; i < n; i++) {
    str.append("a");
    }
}


// 自是至终都只创建了一个 StringBuffer 对象
public void method3(int n) { 
StringBuffer str = new StringBuffer(n);
for (int i = 0; i < n; i++) {
str.append("a");
}


public static void main() {
    int num = 1000000;  //堆每个方法都进行1000000次的字符串拼接操作,记录时间
    
	long start = System.currentTimeMillis();
    method1(num);
    System.out.print("method1用时:" + (System.currentTimeMillis() - start));

    start = System.currentTimeMillis();
    method2(num);
    System.out.print("\t\tmethod2用时:" + (System.currentTimeMillis() - start));

    start = System.currentTimeMillis();
    method3(num);
    System.out.println("\t\tmethod3用时:" + (System.currentTimeMillis() - start));

    // method1用时:130916		method2用时:37        method3用时:27
}

可以发现,从时间的角度看,明显使用 StringBuffer 的 method2 和 method3 短很多。

空间占用情况也可以使用 Scanner 阻塞程序,然后使用 jvisual 等软件查看。




5. String.intern 操作

这是一个 native 方法,源码中的解释为:

When the intern method is invoked, if the pool already contains a string equal to this {@code String} object as determined by the {@link equals(Object)} method, then the string from the pool is returned. Otherwise, this {@code String} object is added to the pool and a reference to this {@code String} object is returned.

大致意思就是 :

当调用intern方法时,如果常量池中已经包含一个与调用者对象相等的字符串,则返回池中的字符串。否则,将此对象将添加到常量池中,并返回对常量池中对象的引用。

看了之前的字节码指令,是不是觉得这句话就很容易理解。



接下来看第四个题目。

题目4代码:

String s1 = new String("a");
s1.intern();
String s2 = "a";
System.out.println(s1 == s2); // 输出的是啥?

按照之前的逻辑,s1 是使用 new 关键字创建的,所以常量池中 和 堆中各有一个值为 "a" 的对象,二者并不相等。

然后调用 s1 的 intern 方法,此时常量池中已经存在 "a" ,并且这里没有接收返回值,所以可以理解为代码s1.intern(); 并没有产生任何作用。

最后使用字面量的方式创建 s2 ,所以 s2 是指向常量池中的 "a" 。所以比较 s1 == s2 ,结果应该是 false。


执行程序之后结果也确实是 false 。

分析其字节码:

 0 new #2 <java/lang/String>  // 创建新一个新的java.lang.String对象
 3 dup                        // 复制栈顶一个字长的数据,将复制后的数据压栈
 4 ldc #3 <a>                 // 从常量池中加载“a”,压入栈底
 
 6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
                              // 调用String.<init>方法
                              
 9 astore_1                   // 弹出栈顶元素(“a”),将值保存到局部变量1(s1)中
10 aload_1                    // 将局部变量1(s1,也就是"a")中装载引用类型值 入栈
11 invokevirtual #5 <java/lang/String.intern : ()Ljava/lang/String;>
                              // 调用String.intern方法
                              
14 pop                        // 从栈顶弹出一个字长的数据,猜测是因为前面入栈的是引用类型,占用两个 slot,后面由于要入栈的为字符串,实际存储为int类型的,所以只占一个 slot ,所以要弹出栈顶一个空的位置。

15 ldc #3 <a>                 // 从常量池中加载“a”,压入栈底
17 astore_2                   // 弹出栈顶元素(“a”),将值保存到局部变量2(s2)中


*********************  下面的字节码指令是比较两个变量是否相等,然后输出  *********************

18 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
                              // 获取静态字段的值,也就是System.out对象
                              
21 aload_1                    // 将局部变量1(s1,也就是"a")中装载引用类型值 入栈
22 aload_2                    // 将局部变量2(s2,也就是"a")中装载引用类型值 入栈
23 if_acmpne 30 (+7)          // 若栈顶两引用类型值不相等,则跳转到30行
26 iconst_1                   // 1(int)值入栈
27 goto 31 (+4)               // 跳转到31行
30 iconst_0                   // 0(int)值入栈
31 invokevirtual #7 <java/io/PrintStream.println : (Z)V>
                              // 调用println方法,输出1或者0
34 return

可以看出字节码指令与之前分析的结果是大致相同的。




然后再来看第5个题目

题目5代码:

String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // 输出的是啥?

看了这么多字节码指令,再分析这个题目岂不是有手就行。

首先第一行创建了 new String("a")new String("b") 、s1 和一个 StringBuffer 四个对象,常量池里面有 "a" 和 "b" 两个字符串;

再执行 s1.intern() ,将 s1 的值放入常量池中,注意这里也没有接收返回值,所以 s1 还是堆中原本的对象;

使用字面量方式创建 s2 ,毫无疑问 s2 就是指向常量池中的 "ab"。

所以 s1 是堆中的对象,而 s2 是指向常量池中的对象,二者比较的话结果肯定为 false 。


然而程序运行的结果却是 true 。让我一度怀疑是不是计算机出错了。



大家上过小学二年级的一定都知道 jdk 发展历史上的一件比较重大的事件。

在 jdk6 及之前版本,字符串常量池存放在方法区中的永久代;在 jdk7 版本,将字符串常量池被移到了堆中;然后在 jdk8 中,移除了永久代的概念,改为元空间,但字符串常量池任然存放在堆空间中。



那知道这个有什么用呢,我得到的解释是:由于对象是存放在堆空间中,而字符串常量池也是存放在堆空间中,为了空间的节省,执行 s1.intern() 的时候,放入常量池的并不是一个新对象,而是 s1 对象的一个引用。所以后面初始化 s2 的时候,从常量池中取出的字符串就是堆空间的 s1 对象。

所以在 jdk6 的环境下执行,结果就是 false ,在 jdk7 之后的环境下就是 false。


看对应的字节码为:

*********************    1. 创建s1    *********************

 0 new #2 <java/lang/StringBuilder>  // 创建新一个新的java.lang.StringBuilder对象
 3 dup		                     // 
 4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
 				     // 调用StringBuilder.<init>方法

 7 new #4 <java/lang/String>	     // 创建新一个新的java.lang.String对象
10 dup				     //
11 ldc #5 <a>			     // 从常量池中加载“a”,压入栈底

13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
			   	     // 调用String.<init>方法
									 
16 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
				     // 调用StringBuilder.append方法追加"a",此时StringBuilder为“a”
									 
19 new #4 <java/lang/String>         // 创建新一个新的java.lang.String对象
22 dup				     //
23 ldc #8 <b>			     // 从常量池中加载“b”,压入栈底

25 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
				     // 调用String.<init>方法
									 
28 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
				     // 调用StringBuilder.append方法追加"b",此时StringBuilder为“ab”
									 
31 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
				     // 调用StringBuilder.toString方法,创建新的String对象“ab”
									 
34 astore_1			    // 弹出栈顶元素(“ab”),将值保存到局部变量1(s1)中


*********************    2. s1.intern()    *********************

35 aload_1			    // 将局部变量1(s1,也就是"ab")中装载引用类型值 入栈

36 invokevirtual #10 <java/lang/String.intern : ()Ljava/lang/String;>
                                    // 调用String.intern方法
            
            

*********************    3.创建s2     *********************

39 pop				    // 从栈顶弹出一个字长的数据
40 ldc #11 <ab>			    // 从常量池中加载“ab”,压入栈底
42 astore_2			    // 弹出栈顶元素(“ab”),将值保存到局部变量2(s2)中



*********************  下面的字节码指令是比较两个变量是否相等,然后输出  *********************

43 getstatic #12 <java/lang/System.out : Ljava/io/PrintStream;>
                                    // 获取静态字段的值,也就是System.out对象
46 aload_1			    // 将局部变量1(s1,也就是"ab")中装载引用类型值 入栈
47 aload_2			    // 将局部变量2(s2,也就是"ab")中装载引用类型值 入栈
48 if_acmpne 55 (+7)		    // 若栈顶两引用类型值不相等,则跳转到55行
51 iconst_1			    // 1(int)值入栈
52 goto 56 (+4)			    //  跳转到56行
55 iconst_0			    // 0(int)值入栈
56 invokevirtual #13 <java/io/PrintStream.println : (Z)V>
				    // 调用println方法,输出1或者0
59 return



看字节码好像也无法确定 s1.intern 放入常量池的是 s1 的一个引用,并且我对比了这段代码在 jdk6 环境下和 jdk8 的环境下编译产生的字节码文件,是一模一样的。




最后的两个问题



至此,我也只能看到 题目5 在 jdk7 之后的版本执行结果确实为 true,并且 String.intern 方法放入常量池的只是一个引用的说法也的确能够解释清楚。但是我自己也没有验证,也还没想到怎样去验证。




第二个问题是,个人觉得可以确定的常量,比如说 String s1 = "abc" 或者 String s2 = new String("abc") 中的 "abc" ,在类的加载阶段(链接中的解析阶段),和要加载的类名一起放入常量池的,所以在执行代码时,就可以直接从常量池里面加载。


当然这也只是我自己的想法,并没有太多的依据,我也想不到方法去验证是否正确。





字节码指令参考:
https://www.cnblogs.com/longjee/p/8675771.html