一个例子学通JAVA编译的原理

来源:互联网 发布:java 实现syslog 编辑:程序博客网 时间:2024/05/21 14:50

本文用一个简单的例子深度分析JAVA是如何将.java文件进行编译得到字节码,需要用到的工具有:

  1. NotePad++
  2. Cmder(用windows自带cmd亦可)
  3. UltraEdit
  4. java环境配置

希望大家有兴趣可以跟着本文动手实战,必能深刻的理解JAVA编译之详细流程。

第一步:配置java环境

相信大家配置最基本的java环境没有任何问题,这里需要说明的一点是,配置环境后在CMD中java和javac都要有列表输出,如果其中任何一个报错,则说明你的环境没配好。如果你没有配置环境,可以按照链接中的方式操作。

第二步:安装NotePad++、UltraEdit与Cmder

我们电脑中自带的记事本的英文名字叫做NotePad,而市面上这款软件叫做NotePad++自然是其升级版。我们完全可以用它代替记事本,它的功能非常强大。我们在NotePad++官网的下载页中点击download即可下载安装。
UltraEdit是一款编辑工具,我们主要用它来将文件翻译为二进制文件,这个文件官网下载有些麻烦,我们直接在百度首页普通下载即可。这款软件使用起来也很方便,打开需要转换格式的文件即可,自动会转换成用十六进制表示的二进制文件。
除此之外,windows的CMD命令行窗口也很简陋,我们可以使用Cmder来代替它,Cmder是一种用来替代CMD的工具,功能更加全面,界面也更加美观。同样,我们在Cmder的官网中点击Download Full按钮即可下载完整版。这里有个小技巧,我们可以把Cmder在你电脑中的路径放入环境变量Path中,这样我们可以win+R键呼出运行,直接输入Cmder使用该工具。

这里写图片描述

在Path环境变量的最下面我们添加了D:\CMDER的路径

这里写图片描述

在win+R弹出的运行窗口中即可直接打开cmder

第三步:编写java程序

为了学习JAVA编译的原理,我们用一个简单的java程序来做例子。

1.我们在D盘中创建一个txt文件,去掉txt后缀并重命名为TestClass.java。

2.右键点击该文件选择用NotePad++打开(Edit with NotePad++)进行编辑。

3.Notepad++能编写并提示上百种语言。它会自动识别我们文件的后缀,并将模式改为java语言的编辑模式。

4.我们将下面的代码片段复制入NotePad++中 。

package com.Test;public class TestClass {    private int m;    public int inc() {        return m+1;    }}

这里写图片描述

5.保存并退出NotePad++即可。

第四步:编译java文件

在这一步里我们要获取到所有需要的信息,并在下一步开始分析数据。

1.打开Cmder,并将路径转换到D盘。

2.输入命令:javac -g TestClass.java将.java文件进行编译。

这里写图片描述

3.上一步结束后会在D盘中发现生成了一个名为:TestClass.class的文件,没错这就是编译的结果,程序在运行时会将这个.class文件读取入JVM虚拟机中进行操作。换句话说,JVM虚拟机并不认.java文件,JVM只能看得懂.class文件。

4.输入命令javap -verbose TestClass将.class文件以固定易懂的格式输出。
这里写图片描述

输出内容如下:

javap -verbose TestClass
警告: 二进制文件TestClass包含com.Test.TestClass
Classfile /D:/TestClass.class
Last modified 2017-7-18; size 371 bytes
MD5 checksum 49a28c769d5fb854fd7b168c3a98fac3
Compiled from “TestClass.java”
public class com.Test.TestClass
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object.””:()V
#2 = Fieldref #3.#19 // com/Test/TestClass.m:I
#3 = Class #20 // com/Test/TestClass
#4 = Class #21 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/Test/TestClass;
#14 = Utf8 inc
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 TestClass.java
#18 = NameAndType #7:#8 // “”:()V
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/Test/TestClass
#21 = Utf8 java/lang/Object
{
public com.Test.TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object.””:()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/Test/TestClass;
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/Test/TestClass;
}
SourceFile: “TestClass.java”

5.第五步,用UltraEdit工具打开TestClass.class文件。
这里写图片描述

第五步:分析class文件信息,了解java编译的原理

这一步中我们结合上述的二进制文件、Cmder中输出的信息、java源代码这三者来分析java的编译机制的原理。

数据格式

在看二进制文件前,我们要先知道class文件编写的数据格式。class是一个二进制文件,其中的数据都用无符号数表示,基本数据类型有4种:u1,u2,u3,u4。分别代表1、2、4、8四种字节的无符号数,这些无符号的数字用来表示各种字面量、符号引用、数值量或者utf-8构成的字符串值。除了上述四种基本数据类型还有一种复合数据类型:表。我们用_info结尾表示表结构,表是多个基本数据类型组合而成,用来表示多个层次上并列的关系的数据。实际上整个class文件也可以理解为一张表。

魔数

class文件使用魔数来标识它是可以被java虚拟机识别的文件。事实上魔数不是java发明的,很多格式的文件都会用魔数来标识文件类型,因为相比较容易被用户修改的后缀名,魔数是编辑在二进制文件内部的因而难以修改。在class文件中前4个字节表示魔数。上文class文件采用的魔数是0xCAFEBABE。这也正是所有class文件采用的魔数。

版本号

在魔数后的四个字节是版本号,五六个字节是次版本号,七八个字节是主版本号。这四个字节合起来准确的表达了当前class所需的最低jdk版本。上文class文件的版本号是:0x00000034。16进制的34转换为10进制为52,与52相对应的JDK版本是1.8。说明我们在本地环境配置的时候使用的是138版本的JDK,如果你的JDK版本较小,这里会生成一个小于34的数字。

在Cmder中的八九行也说明了版本信息。

minor version: 0(次版本号)
major version: 52(主版本号)

常量池

紧跟版本号的是常量池,常量池是存储在方法区中的“仓库”,我们使用到的类名,方法名的符号引用和一些字面量都是常量池中存放的。因为常量的数量是不固定的,所以在class文件中常量池首先由一个constant_pool_count来表示所有常量的数量。其后跟随一个共有constant_pool_count-1个数据信息的表。比如常量数量为8,则说明有7个常量(索引为1~7),之所以没有0索引是因为我们把0索引空出来表示“不引用任何常量池”的含义。这也反映出常量池计数constant_pool_count最小值为1,表示不引用任何常量池。

上文class文件中constant_pool_count的值是:0x0016。说明其中一共有十进制的21项常量。而cmder中Constant pool中也正好有21项,说明我们的分析是正确的。其中的每个常量都会由一个标志位开头说明自己是什么类型的常量,而麻烦的在于每个类型的常量又有其各自的结构,下表中包含了所有的常量类型以及他们的结构。
这里写图片描述

这里写图片描述

这里写图片描述

第一个常量的标志位是0x0A。查表发现是CONSTANT_Methodref_info类型的数据。再根据第二个表中的结构知道由:tag、index、index三部分组成。第一部分是标志位我们已经分析了,第二部分是指向生命方法的类描述符CONSTANT_Class_info的索引,说明这个类型中还包含了一个CONSTANT_Class_info的类型(真的麻烦),所以我们再看CONSTANT_Class_info的结构,它由一个tag和index组成。CONSTANT_Methodref_info的第三个index是指向NameAndType,同样的方法,我们发现我们NameAndType的结构也是三个组成。现在大概了解了情况,我们看二进制文件。

CONSTANT_Methodref_info的第二项是u2的指向CONSTANT_Class_info的索引:0x0004。0x0004也就是常量池中第四个常量代表的内容,但是我们现在只分析到第一个常量,我们还不能知道第四个常量具体是什么。但我们投机取巧的先看一眼cmder中的Constant_pool,因为cmder中的数据已经帮助我们将所有的常量池都解析好了,我们发现第四个常量确实是Class类型,并且继续分析Class得到内容是第21项的java/lang/Object。

CONSTANT_Methodref_info的第三项是u2的指向CONSTANT_NameAndType_info的索引:0x0012。即10进制的18。同样的,我们刚分析第一个常量还不知道第十八个常量是什么,为了理解方便,我们直接去看cmder中第十八个常量,发现确实是NameAndType类型,并且内容为” < init >”:()V。

这样我们就分析出了第一个常量的含义,通过类似的方法,我们可以逐个分析出常量池中所有的常量。其实上面的步骤我们可以不借助cmder表,先标记上它会被指向第四个常量和第十八个常量,等分析出这些常量时再对号入座,我们借助cmder表只是为了理解上的方便。

访问标志

所有的常量池分析完后下一项是访问标志。访问标志很简单,是用来表示此类的一些修饰符信息的。在常量池结束后,紧跟的两个字节信息就是访问标志,访问标志会唯一的标识出此类是否为public、是否定义为abstract、是否为final等信息。

标志名 标志值 标志含义 针对对象 ACC_PUBLIC 0x0001 public类型 所有类型 ACC_FINAL 0x0010 final类型 类 ACC_SUPER 0x0020 使用新的invokespecial语义(必须为true) 类和接口 ACC_INTERFACE 0x0200 接口类型 接口 ACC_ABSTRACT 0x0400 抽象类型 类和接口 ACC_SYNTHETIC 0x1000 该类不由用户代码生成 所有类型 ACC_ANNOTATION 0x2000 注解类型 注解 ACC_ENUM 0x4000 枚举类型 枚举

在上述class文件的第d8个字节解析完毕所有的常量池,我们从这里接着看。下一个u2数据是:0x0021。查表可知,这个类是public定义,没有其他特殊的定义。

类索引

访问标志之后是类索引。类索引是一个u2类型数据,用来确定此类的全限定名。类索引通过指向一个类型为Constant_Class_Info来表述类的全限定名。

在上文class文件中的下一个数据是:0x0003。表明这个类索引指向常量池中的第三个,我们看cmder中的第三项常量信息,是:

#3 = Class #20 // com/Test/TestClass
#20 = Utf8 com/Test/TestClass

确实是这个类的全限定名,没有问题。

父类索引

类索引之后是父类索引。父类索引也是一个u2类型数据,用来确定此类的父类的全限定名。父类索引通过指向一个类型为Constant_Class_Info来表述此类的父类的全限定名,如果此类是Object类,则没有父类,该u2数据为0。

在上文class文件中的下一个数据是:0x0004

#4 = Class #21 // java/lang/Object
#21 = Utf8 java/lang/Object

说明此类的父类是Object类,此类确实没有继承其他类,没有问题。

接口索引集合

在父类索引之后是接口索引集合。接口索引集合由一个接口索引数量interfaces_count来做计数。后面的interfaces_count个u2数据表示接口的索引全限定名。

在上文class文件下一个数据是:0x0000,说明该类没有接口,这是正确的,如果有接口就在其后列出所有的接口名。

字段表集合

在接口表示完后紧接着是字段表集合。字段表用来描述接口或者类中的变量。 这里包括静态字段, 但不包括从父类继承的字段。每个字段都由五个部分组成:access_flag、name_index、descriptor_index、attributes_count、attributes。而在这五个部分之前,还有一个u2数据描述字段的个数

标志位名称 值 含义 设定者 ACC_PUBLIC 0x0001 字段被设为public 类和接口 ACC_PRIVATE 0x0002 字段被设为private 类 ACC_PROTECTED 0x0004 字段被设为protected 类 ACC_STATIC 0x0008 字段被设为static 类和接口 ACC_FINAL 0x0010 字段被设为final 类和接口 ACC_VOLATILE 0x0040 字段被设为volatile 类 ACC_TRANSIENT 0x0080 字段被设为transient 类

我们继续读取数据:0x0001,说明该表字段个数为一个。这也符合我们的源代码中的情况。
下一个数据是:0x0002,说明该字段是private。
下一个数据是:0x0005,说明该字段的名字存放在常量池的第五个位置上

#5 = Utf8 m

可见该字段的名字是m。
下一个数据是:0x0006,这个数据对应descriptor_index信息,说明这个字段的类型是:

#6 = Utf8 I

I用来表示int,说明该字段是int类型的数据。

下一个数据是:0x0000,说明该字段没有属性。

方法表集合

方法表类似于字段表,结构与属性表完全相同,但方法表的属性中有方法中的代码,比较字段更加繁琐一些。具体情况这里不在分析。

第六步:总结

C语言将.c文件编译生成与硬体相关的二进制机器指令,问题在于不同的系统中的机器指令不同,所以c语言所编写的程序可移植性差。而Java号称一次编写,到处运行(Write Once,Run Anywhere),java语言将.java文件经过编译成固定格式的.class文件,并在JVM上执行来达到这种平台无关的效果。也就是说:JVM不与任何包括java在内的语言做绑定,JVM只与固定格式的二进制class文件关联。这也正是为什么Clojure、Groovy、JRuby、Scala这么多新性语言都是在JVM上运行的原因,因为他们只要最后编译生成class文件即可。本文详细的分析了如何去研究java编译后class文件的过程与格式,本文是管中窥豹,只见一斑,具体的所有数据大家可以自己详细做一遍,或有助于对一些难以理解代码的分析。