之前某次面试,我说自己对Java比较熟,面试官问了我一个问题:假设你自己写一个String类,包名也是java.lang.String,代码里使用String的时候,这个String类能编译成功吗?能运行成功吗?
好了,我当时又是一脸懵逼o((⊙﹏⊙))o,因为我只是看了些Java的面试题目,而且并没有涉及类加载方面的内容(ps:我是怎么敢说我对Java比较熟的)。
题目分析
这里涉及3个知识点:
- Java代码的编译过程
- Java代码的运行过程
- 类加载器(详见文章:{还没写})
以上3个内容基本上是涉及代码运行的整个流程了。接下来就结合实战操作一步步分析具体的过程,然后再从全局了解整个流程。
Java代码的编译过程
平时我都是通过IDEA直接运行代码,像我这样的菜鸡都没注意过编译的过程。所以结合平时的操作说明一下编译的过程。
什么是Java的编译
Java的编译过程,是将.java源文件转换为.class字节码文件的过程。
如何将.java源文件编译成.class字节码文件
-
IDEA工具中,点击BUILD按钮
-
执行命令
javac xx.java
如何查看字节码文件
-
如果我们直接用文本工具打开字节码文件,将会看到以下内容:
这是因为Class文件内部本质上是二进制的,用不同的工具打开看,展示的效果不一样。下图是用xx工具打开的class文件,展示的是十六进制格式,其实可以自己一点点翻译出来源码了。(class文件的这个二进制串,计算机是不能够直接读取并且执行的。也就是说,计算机看不懂,而我们的JVM解决了这个问题,JVM可以看作是一个翻译官,它可以看懂,而且它也知道计算机想要什么样子的二进制,所以它可以把Class文件的二进制翻译成计算机需要的样子)
-
我们可以通过命令的方式将class文件反汇编成汇编代码。
javap是JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。
javap -v xx.class
,javap -c -l xx.class
字节码文件中包含哪些内容
这个有很多文章说了,可以自己搜索一下,也可以看我总结的文章:xxx(还没写)。
Java代码的运行过程
java类运行的过程大概可分为两个过程:1)类的加载;2)类的执行。
需要说明的是:JVM主要在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。
类加载过程
Class文件需要加载到虚拟机中之后才能运行和使用。系统加载Class文件主要有3步:加载->连接->初始化 。连接过程又可分为3步:验证->准备->解析。
(图源:javaguide.cn)
加载
类加载过程的第一步,主要完成3件事情:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口。
加载这一步的操作主要是通过类加载器完成的。类加载器详情可参考文章:xxx。
每个Java类都有一个引用指向加载它的ClassLoader。不过数组类不是通过ClassLoader创建的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的。
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()
方法)。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
连接
验证
验证是连接阶段的第一步,这步的目的是为了保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机的安全。
验证阶段所要耗费的资源相对还是多的,但验证阶段也不是必要的。如果程序运行的全部代码已经被反复使用和验证过,那在生产环境的实施阶段可以考虑使用-Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证阶段主要由4个检验阶段组成:
-
文件格式验证。要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。比如以下验证点:
- 是否以魔数CAFEBABE开头
- 主、次版本号是否在当前Java虚拟机接收范围内
- 常量池的常量是否有不被支持的常量类型
- 。。。
该阶段验证的主要目的是保证输入的字节流能够被正确地解析并存储于方法区。只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中存储。后面3个阶段的验证是在方法区的存储信息上进行的,不会再直接读取和操作字节流了。
-
元数据验证。对字节码描述的信息进行语义分析,保证其描述的信息符合《Java语言规范》的要求。这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了Object类之外,所有的类都应该有父类)
- 这个类or其父类是否继承了不允许继承的类(比如final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
-
字节码验证。是整个验证过程中最复杂的,主要目的是通过分析字节码,判断字节码能否被正确执行。比如会验证以下内容:
- 在字节码的执行过程中,是否会跳转到一条不存在的指令
- 函数的调用是否传递了正确类型的参数
- 变量的赋值是不是给了正确的数据类型
- 。。。
如果一个方法体通过了字节码验证,也仍然不能保证它一定是安全的。
-
符号引用验证。该动作发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段--解析阶段中发生(所以说符号引用验证是在解析阶段发生???)。
符号引用验证的主要目的是确保解析行为能正常执行。
符号引用验证简单来说就是验证当前类是否缺少或者被禁止访问它依赖的外部类、方法、变量等资源。该阶段通常要校验以下内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。(没太明白什么意思)
- 符号引用中的类、变量、方法是否可被当前类访问。
如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:
- java.lang.IllegalAccessError
- java.lang.NoSuchFieldError
- java.lang.NoSuchMethodError等。
准备
准备阶段是正式为类中的静态变量分配内存并设置类变量初始化值的阶段。从概念上来说,这些变量所使用的内存都应当在方法区中分配,但方法区本身是一个逻辑概念。在JDK7及以前,HotSpot使用永久代来实现方法区。在JDK8及以后,类变量会随着Class对象一起放入Java堆中(也是叫做方法区的概念?)
注意点:
-
准备阶段仅为类变量分配内存并初始化。实例变量会在对象实例化时随着对象一起分配在堆内存中。
-
非final修饰的类变量 ,在初始化之后,是赋值为0,而不是程序中的赋值。比如:
javapublic static int value = 123;
初始化之后的值是0,而不是10。因为这时候程序还未运行。把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器() 方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。
-
final修饰的类变量,初始化之后会赋值为代码中的值。因为:如果类字段被 final 修饰,那么类阻断的属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为ConstantValue 属性所指定的初始值,假设上面类变量 value 的定义修改为 123 ,而不是 "零值"
解析
解析阶段是将符号引用转化为直接引用的过程。也就是得到类或者字段、方法在内存中的指针或者偏移量。
- 符号引用(Symbolic References):用一组字符串来表示所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用(Direct Reference) :是可以直接指向目标的指针,相对偏移量、或者可以间接定位到目标的句柄?直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
初始化
初始化阶段是执行初始化方法 <clinit> ()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
说明:
<clinit> ()
方法是编译之后自动生成的。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
-
当遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时,比如new
一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。- 当 jvm 执行
new
指令时会初始化类。即当程序创建一个类的实例对象。 - 当 jvm 执行
getstatic
指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 - 当 jvm 执行
putstatic
指令时会初始化类。即程序给类的静态变量赋值。 - 当 jvm 执行
invokestatic
指令时会初始化类。即程序调用类的静态方法。
- 当 jvm 执行
-
使用
java.lang.reflect
包的方法对类进行反射调用时如Class.forname("...")
,newInstance()
等等。如果类没初始化,需要触发其初始化。 -
初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
-
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。 -
MethodHandle
和VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,就必须先使用
findStaticVarHandle
来初始化要调用的类。 -
当一个接口中定义了 JDK8 新加入的默认方法(default) ,那么实现该接口的类需要提前初始化
运行
java
//MainApp.java
public class MainApp {
public static void main(String[] args) {
Animal animal = new Animal("Puppy");
animal.printName();
}
}
//Animal.java
public class Animal {
public String name;
public Animal(String name) {
this.name = name;
}
public void printName() {
System.out.println("Animal ["+name+"]");
}
}
- 在编译好java程序得到MainApp.class文件后,在命令行上敲java AppMain。系统就会启动一个jvm进程,jvm进程从classpath路径中找到一个名为AppMain.class的二进制文件,将MainApp的类信息加载到运行时数据区的方法区内,这个过程叫做MainApp类的加载。
- 然后JVM找到AppMain的主函数入口,开始执行main函数。
- main函数的第一条命令是Animal animal = new Animal("Puppy");就是让JVM创建一个Animal对象,但是这时候方法区中没有Animal类的信息,所以JVM马上加载Animal类,把Animal类的类型信息放到方法区中。
- 加载完Animal类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Animal实例分配内存, 然后调用构造函数初始化Animal实例,这个Animal实例持有着指向方法区的Animal类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。
- 当使用animal.printName()的时候,JVM根据animal引用找到Animal对象,然后根据Animal对象持有的引用定位到方法区中Animal类的类型信息的方法表,获得printName()函数的字节码的地址。
- 开始运行printName()函数。