由常量池 运行时常量池 String intern方法想到的(二)之class文件及字节码指令

java 专栏收录该内容
86 篇文章 2 订阅

上一篇博文由常量池 运行时常量池 String intern方法想到的(一)引入了问题,看到了java源代码对应的字节码,本文对java字节码及java指令进行一些说明,并逐句分析上一篇博文的字节码指令,分析栈深度。

java字节码结构

上篇文章中看到了java源代码对应的字节码指令,下面看看Test.class文件的真实内容。

  • 查看二进制的工具

可以使用notepad++查看,但是需要一个HexEditor的插件。这个插件的下载地址如下:http://download.csdn.net/detail/fan2012huan/9461824
安装方法:将下载的HexEditor.dll拷贝到notePad++的安装目录下的plugin目录下,如D:\Program Files (x86)\Notepad++\plugins,然后重新启动notePad++,打开Test.class文件,点击“插件”-“Hex-Editor”-“view in HEX”。

  • Test.class

这里写图片描述
这里写图片描述

从上图可以看到这都是些二进制数据,没有什么格式,那么JVM是怎么认识他们的?
class文件其实是按照一种结构存放的,是有固定格式的。

  • 魔数

每个class文件的头4个字节称为魔数。也就是来标志这是个class文件,可以被JVM识别解析。其实每种文件格式都有类似的魔数,像图片格式JPEG或者GIF的文件头都有魔数。也就是说,计算机识别一个文件的文件类型不是通过扩展名来识别的,因为扩展名可以随意改变,而是通过魔数来识别文件格式的。
java的class文件的魔数是“cafebabe”这4个字节。这里面的字母是16进制的字母,而不是英文字母,所以是4B,而不是8B。

  • 版本号

紧随魔数的后面4个字节分别是次版本号(2B)和主版本号(2B)。从上面的2进制中可以看到其值是:00 00 00 32(偏移地址为:0x00000004)。将0x32转换成十进制也就是50,这与上一篇博文中通过javap工具看到的主次版本号是一致的。
这里写图片描述

java字节码-常量池

  • 常量池所存项目的总数

紧随主版本号之后的就是常量池了。常量池的起始字节用来表征常量池中所存项目的个数,占用两个字节。从上图的字节码二进制中看到(偏移量为0x00000008)该常量池中共0x0026(十进制为38)项。但是,常量池的编号是从1开始而不是从0开始,所以实际上只有37项。之所以不从const #0 开始编号是因为:当指向常量池的索引值要表达“不可引用任何一个常量池项目”的含义时,可以把索引值设置为0。从上一篇文章中通过javap工具看到的常量项数也是一致的。
这里写图片描述
上图的索引值是从
const #1 = Method #12.#21; // java/lang/Object."<init>":()V
开始的。
- 常量池的内容
常量池中主要存放两大类常量:字面量和符号引用。其中字面量是指文本字符串和声明为final的常量;符号引用主要包括以下3类常量:
1.类和接口的全限定名
2.字段的名称和描述符
3.方法的名称和描述符
“字段的名称和描述符”中的名称表示字段的变量名,描述符是指字段的数据类型。注意这里的字段是指类变量和实例变量,不包含方法中的局部变量。
“方法的名称和描述符”中的名称是指方法的名字(不包含参数信息和返回值类型),描述符是指参数信息(参数个数,参数类型,参数顺序)和返回值类型。
数据类型如下图所示:
这里写图片描述
对于数组而言,以一个”[“开始,后跟数组元素的类型。如String[],则是“[Ljava/lang/String”,如果是int[],则是“[I”,如果是二维数组则是两个“[[”,如String[][],则是“[[Ljava/lang/String”。
对于方法的描述符,按照先参数列表,后返回值的顺序描述。如void foo() ,应描述为“()V”;int foo(String) ,应描述为”(Ljava/lang/String)I“。

java代码在被javac进行编译时,并没有像C/C++编译器那样就行了”连接“动作,而是在JVM加载class文件时进行的动态连接。class文件中并没有存放各个字段、方法的内存布局信息,只是存放了符号引用,当JVM运行时,再从常量池中拿到这些符号引用经过解析翻译等动作映射到物理内存地址中。

  • 常量池中项的类型

常见的数据类型有class、string、method、utf8(java1.6之前是Asciz)等,详细见下表:
这里写图片描述
他们之间的关系如下图所示:
这里写图片描述
一个常量池的例子如下所示:
java代码:

public class Test {

    public int foo(String s) {
        System.out.println(s);
        return 0;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.foo("hehe");
    }
}

其常量池的部分内容如下所示:

const #7 = Method       #4.#26; //  Test.foo:(Ljava/lang/String;)I
const #4 = class        #24;    //  Test
const #24 = Asciz       Test;
const #26 = NameAndType #13:#14;//  foo:(Ljava/lang/String;)I
const #13 = Asciz       foo;
const #14 = Asciz       (Ljava/lang/String;)I;

在java1.6时常量好像是Asciz,这个就相当于1.7之后的utf-8类型。
从这里看出,Method引用了class和NameAndType,而class和NameAndType又引用了utf-8。
从Method中可以看出调用方法的所属类,方法的名称,方法的参数,方法的返回值。

java字节码-指令

通过上面的描述,常量池中的内容应该可以看得差不多,至于const #15 = Asciz Code;const #16 = Asciz LineNumberTable;等,放到后面再解释吧。这一小节对照着上一篇博文通过javap看到的内容写一写java字节码指令。

  • java字节码指令—前奏

再写java字节码指令之前,有一些需要说的。

Stack=4, Locals=2, Args_size=1

在main方法中第一行有stack(栈)的大小,Locals(局部变量)的大小。这是啥意思呢?
java字节码指令是基于栈的。java字节码指令在设计时没有像X86指令那样给出操作数,如add r1, r2(r1, r2是存放add指令操作数的寄存器),但是不是所有的java指令都没有操作数,如new等指令还是有操作数的。不带操作数的java字节码指令的操作数都是存放在栈中的。Java字节码指令基于栈来实现有什么好处呢?。
(1)跨平台,因为寄存器在物理机的CPU中,太依赖具体平台,这也是为什么java效率比较低的原因,他每次都是从内存(栈在内存中)中获取操作数,而CPU访问内存的速度是很慢的,当然java在这方面有一些优化(叫 栈顶缓存)。
(2)字节码更加紧凑,每个字节都是一条指令。
局部变量表中存放着方法的局部变量(不要忘了每个java方法都有一个隐含的this指针)。局部变量表的大小在编译器就已经确定,因为它已经写在了class文件中,在运行时不会再改变大小。在java中有一个slot的概念,基本数据类型的boolean, byte, char, short, int, float,对象引用等只占用1个slot,而占用64bit(8B)的long和double则需要占用两个slot。slot是按照序号进行存放局部变量的。slot0中存放的是this指针。从上一篇的java源代码中可以看到,main方法中只有一个s变量,再加上this指针,所以Locals = 2。

  • java字节码指令
  • new
0:   new     #2; //class java/lang/StringBuilder

其中#2可以从常量池中找到,如注释所示。
在java堆中new出StringBuilder之后,会将这个对象在堆中的地址压入栈中。
注意,在上一篇博文的java源代码中没有StringBuilder对象,这里却出现了,说明Java编译器帮我们优化了。

  • String +操作符优化

这部分内容参考《Thinking in java》第13内容。

String s = "12" + "ab" + 56;

按照我们自己的想法会认为,将”12“和“ab”连接之后会存放在一个String对象中,然后和56连接后再存放在String对象中,这个过程会产生很多需要垃圾回收的中间对象,性能很低。所以,java编译器会对String的+操作符进行优化,使用StringBuilder实现。String的”+”与”+=”是java中唯一的两个重载过的操作符,java不允许程序猿重载任何操作符。
java编译器优化是这样的:首先new一个StringBuilder对象,然后调用append方法,最后调用toString方法。

  • dup

dup指令会将栈顶的数据拷贝一份然后重新压入栈顶。也就是说栈中有两个完全一样的东西。此时栈大小为2。

  • invokespecial
4:   invokespecial   #3; //Method java/lang/StringBuilder."<init>":()V

会从栈中将刚刚new出来的StringBuilder对象出栈,作为方法的接收方。此时栈大小为1。
经过

7:   new     #4; //class java/lang/String
10:  dup

这两句话之后栈深度为3。

  • ldc
11:  ldc     #5; //String 12

ldc指令用于将存放在常量池中的常量压入栈中。
这时栈深度为4。

13:  invokespecial   #6; //Method java/lang/String."<init>":(Ljava/lang/String;)V
16:  invokevirtual   #7; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

指令13将从栈中pop出刚从常量池中压入的字符串常量作为init方法的参数,从栈中pop出刚才new出的String对象作为init方法的接收方。
这时栈的深度为2。
指令16从栈中pop出刚才new出的String对象作为append方法的参数,从栈中pop出刚才new出的StringBuilder对象作为append方法的接收方。注意append方法有一个返回值需要入栈
这时栈的深度为1。

19:  new     #4; //class java/lang/String
22:  dup
23:  ldc     #8; //String 3

经过上面的三条指令,栈的深度为4,存放着一个StringBuilder对象,两个String对象,一个字符串常量。

25:  invokespecial   #6; //Method java/lang/String."<init>":(Ljava/lang/String;)V
28:  invokevirtual   #7; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31:  invokevirtual   #9; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;

指令25,初始化String对象。这时栈深度为2。
指令28,将刚才的String对象连接到StringBuilder对象中。这时栈深度为1。
指令31,执行StringBuilder的toString方法,返回值String入栈。这时栈深度为1。

   34:  astore_1
   35:  aload_1
   36:  invokevirtual   #10; //Method java/lang/String.intern:()Ljava/lang/String;
   39:  pop
   40:  return

指令34,将栈中的String对象存放到局部变量表slot1中,此时栈深度为0。
指令35,将局部变量表slot1中的内容重新入栈,此时栈深度为1。
指令36,调用String的intern方法,返回值入栈,此时栈深度为1。
指令39,出栈。
指令40,返回。
至此,整个字节码就分析完了。

结束语

这篇博文写了一些java字节码的东西,涉及到了java编译器对String +操作符的优化,以及java字节码指令的部分解释。下篇文章 由常量池 运行时常量池 String intern方法想到的(三) ,将讨论一下

String s = new String("12") + new String("3");

在内存中发生的事情。

参考资料:
1.《Thinking in java》第四版
2.《深入理解Java虚拟机》第二版 周志明

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页

打赏作者

fan2012huan

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值