Java命令
javac:.java文件编译成.class文件,.class文件是字节码(概念类似机器码,字节码是JVM层面)
java:执行.class文件
javap:反汇编,.class文件反汇编成JVM的指令,The Java Virtual Machine Instruction Set
IDEA执行javap
IDEA安装jclasslib插件
使用插件
第一个是插件,第二个是IDEA自带的,本质上都是执行javap命令
方式一
String s = new String("Hello World!");
JVM指令:
0 new #2 <java/lang/String> ## Create new object, #2 对应常量池中的String.class
3 dup ## Duplicate the top operand stack value
4 ldc #3 <Hello World!> ## Push item from run-time constant pool
6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
9 astore_1 ## Store reference into local variable 1
10 return
下面逐条指令分析
new #2
new指令创建了一个String对象(#2 表示常量池中String类),并且把对象引用objectref压入Operand Stack;需要注意,此时创建的对象还不是完整的对象;
dup
dup指令拷贝栈顶的值,即是dup指令执行后Operand Stack有两个objectref,该操作的意义后续再说;
ldc #3
ldc指令从常量池取出对应的值并压入Operand Stack;#3表示常量池中字符串常量"Hello World!";到该步骤说明了new String()方法会在常量池创建对应的字符串常量;
我们可以做进一步的验证,在这里我们new一个普通的对象:
Object object = new Object();
JVM指令:
0 new #7 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init> : ()V>
7 astore_1
8 return
可以看到,以上指令没有包含ldc
invokespecial #4
invokespecial指令实例化对象,该步骤会将栈顶的字符串常量"Hello World!"弹出,调用String类的父类和它本身的构造方法;#4 对应String类的init方法;此时得到一个完整的String对象;
astore_1
astore命令将Operand Stack的栈顶值弹出,写入局部变量表,_1表示局部变量表中的Nr.1;我们回顾前面的dup指令拷贝了一份引用,此时弹出一份引用,栈顶还有一份引用,因为从历史经验来看,我们通常会在定义了一个变量后紧接着就会使用它;所以JVM拷贝引用便于后续继续使用该变量;
总结
从以上指令可以得出:
- new Object()方法不是一个原子性操作(双重检查锁为什么要volatile关键字)
- new String()方法会创建两个对象,一个在常量池(如果没有则创建),一个在堆,还有一个栈中的引用;
方式二
String s = "Hello World!";
JVM指令:
0 ldc #3 <Hello World!>
2 astore_1
3 return
该方式只会在常量池创建(如果没有则创建),不会在堆上创建;
总结
- 方式二比方式一更加简洁,所需要的空间更少,指令更少,所以我们在没有特殊情况下,尽可能使用方式二创建字符串;IDEA编译器也会给出相应的提示:
方式三
private static void internString() {
String s1 = "Hello World!";
String s2 = s1;
String s3 = s1.intern();
System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
}
private static void newString() {
String s1 = new String("Hello World!");
String s2 = s1;
String s3 = s1.intern();
System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
}
打印结果:
因为String类的hashCode()方法被重写了,此处使用System.identityHashCode()方法,该方法说明不管hashCode()是否被重写,都会返回对象的默认的hashCode()方法;
总结
- 从打印结果可以看出,intern()方法是指向常量池(如果没有则创建);所以当两个字符串的字面量相等,那它们的intern()结果一定是一致的;
那么问题来了,从上面的指令来看,不管哪种方法创建字符串都会在常量池有一份,intern()方法中的常量池中不存在的情况是怎么出现的呢?
方式四
String s = new String("Hello ") + new String("World!");
JVM指令:
字节码分析可以得到,该方式创建的字符串,常量池只会存每个分量,整体的值不会存入常量池,并且JVM进行了优化,通过StringBuilder拼接字符串再toString();在此之后调用s.intern()方法才会将整体的值写入常量池,此处不再赘述;
方式五
String s = "Hello " + "World!";
JVM指令:
该方式的指令跟方式二是一样的,并且该步骤是在编译阶段完成的,可以去看一下反编译的.class文件:
.java文件:
此处也说一下反编译和反汇编的区别:
如上图所示,我们编写的.Java源代码,经过Java编译器(即Javac)编译后得到.class字节码文件(要注意这里的字节码文件是我们看不懂的二进制表示,而我们平时看到的.class文件是Decompiled .class文件,跟我们编写的.Java代码很像,这就是反编译)
真正的.class文件是纯纯的字节码,想看纯纯的字节码可以IDEA安装插件:
然后Build Project
.class文件Open As Binary
字节码文件(即.class文件)都会以CAFE BABE开头
而我们通常说的汇编,是从CPU汇编指令到机器码的过程。所以反汇编就是从机器码得到汇编指令的过程。而从JVM层面来看的话,JVM指令跟字节码也有着对应关系,从字节码到JVM的过程,也许可以理解为JVM层面的反汇编,但是感觉怪怪的,因为我们通常讲的反汇编都是得到汇编指令。
不同于C语言,它们是源代码经过编译之后得到汇编指令,汇编指令汇编后得到机器码。而Java语言在中间多了一层JVM的字节码,这也是Java跨平台的特性。
这里也要强调一下,JVM指令跟汇编指令不是一一对应的关系,不是说每条JVM指令都会编译成一条汇编指令,通常情况下是一条JVM指令被编译成多条汇编指令(所以JVM指令比汇编指令更容易看懂)
方式六
String s = new String("Hello ") + "World!";
JVM指令:
最后补充一点
当String是final变量,如果在编译期间能知道它的确切值,则编译器会把它当做编译期常量使用(也就是后续代码用到该变量的地方都会被直接替换成常量)。
String a = "Hello World!";
final String b = "Hello ";
String d = "Hello ";
String c = b + "World!";
String e = d + "World!";
System.out.println((a == c)); // true
System.out.println((a == e)); // false
我们可以从反编译结果看到,c字符串中的b已经被替换了。
总结
该讲的都讲完啦,因为intern()是native方法,想要了解底层实现,可以参考:
美团技术团队深入解析String#intern