实战java虚拟机
深入理解java虚拟机
Class文件的装载流程
Class文件通常是以文件的形式存在的(当然任何二进制流类型都可以是Class类型),只有被java虚拟机装载的Class才能在程序中使用。
类加载的条件
Class只有在必须要使用的时候才会被装载,Java虚拟机不会无条件的装载Class类型。Java虚拟机规定,一个类或接口在初次使用之前,必须要进行初始化。这里“使用”是指主动使用,主动使用包含如下几种情况:
- 当创建一个类实例时,比如使用new关键字,通过反射,克隆,可序列化
- 当调用类的静态方法时,即当使用了invokestatic指令
- 当使用类或接口的静态字段时(final常量除外),比如使用getstatic或者putstatic指令
因为在class文件生成时,由于final常量的不变性,做了适当的优化
- 当使用java.lang.reflect包中的方法反射类的方法时。
- 当子类初始化时,父类必须先初始化
- 作为启动虚拟机,含有main()方法的那个类
主动引用比较容易理解,下面看一个被动加载的例子:
public class UseParent {
public static void main(String[] args) {
System.out.println(Child.v);
}
}
class Parent {
static{
System.out.println("parent inited");
}
public static int v = 100;
}
class Child extends Parent{
static {
System.out.println("child inited");
}
}
输出的结果是:
parent inited
100
可以看到,虽然在UseParent中,直接访问了Child对象,但是Child类并没有初始化,只有Parent初始化了。可见,在引用一个字段时,只有直接定义该字段的类才会被初始化。
虽然Child没有被初始化,但是它已经被系统加载了,只是没有到初始化阶段。
使用-XX:+TraceClassLoading,可观察到上述结论。
关于final常量:
public class UseFinalField {
public static void main(String[] args) {
System.out.println(FinalFieldClass.CONST_STRING);
}
}
class FinalFieldClass{
public static final String CONST_STRING = "CONST";
static {
System.out.println("FinalFieldClass inited");
}
}
输出结果:
CONST
且FinalFieldClass也没有被加载到系统,是因为在Class文件生成时,final常量的不变性,做了适当的优化。在编译后的UseFinalField.class 并没有引用FinalFieldClass类,而是将其final常量直接存放到常量池中,因此FinalFieldClass不会被加载。
加载类
加载类处于类装载的第一阶段,在加载类时,虚拟机需要完成如下工作:
- 通过类的全名,获取类的二进制数据流 –文件系统.class文件,jar,zip数据包,或者其他二进制数据流如数据库blob字段,http数据流,甚至是运行时动态生成的Class二进制信息。
- 解析类的二进制数据流为方法区内的数据结构。
- 创建java.lang.Class类的实例,表示该类型。
类验证
当类加载到系统后,然后就开始连接操作。验证是连接的第一步操作。验证的作用是:保证加载的字节码是合法,合理并符合规范的
- 格式检查:必须判断二进制数据是否符合格式要求和规范。如魔数,版本号是否在当前java虚拟机的支持范围内等。
- 语义检查:比如每个类是否都有父类存在(java中除了Object类,其他类都应该有父类),是否一些被定义为final的方法或者类被重载或继承,非抽象类是否都实现了所有的抽象方法,是否存在一些不兼容的方法(方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度。
实际上方法签名并不包括返回值和访问修饰符,所以不存在上述说法
) - 字节码验证:字节码验证是验证过程中
最为复杂的一个过程
。它试图对字节码流的分析,判断字节码是否可以被正确的执行。比如在执行过程中,是否会跳转到一条不存在的指令;函数调用是否传递了正确的参数等等。栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。
- 符号引用验证:Class文件在其常量池会通过字符串记录记录自己将要使用的其他类或者方法。在引用验证阶段,虚拟机会检查这些类或者方法是否存在,并且当前类有权限访问这些数据,否则会抛出
NoClassDefError,NoSuchMethodError
准备
验证通过后,就会进入准备阶段。在这个阶段,虚拟机会为这个类分配相应的内存空间,并设置初始值(仅包括类变量(static),不包括实例变量)。
java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,则对应的,boolean默认值是false.
假设一个类变量定义为:
public static int value = 123;
那么变量value在准备阶段之后的值是0,而不是123.
因为这时候尚未开始执行任何java方法,而把value赋值123的putstatic指令是程序编译后,存放于类构造器<clinit>()方法值中。
相对还有一些“特视情况”,如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段变量value就会初始化为ConstantValue属性指定的值,此时需要将上述的value定义改变为:
public static final int value = 123;
解析类
解析阶段的工作就是将类、接口、字段和方法的符号引用转为直接引用。在Class文件中它以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型的常量出现。
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位目标即可。符号引用和虚拟机的内部数据结构和内存布局无关,引用的目标并不一定已经加载到内存中。各虚拟机实现的内存布局可以各不相同,但是它们接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的class文件格式中。
- 直接引用(Direct References):直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。
下面的字节码调用了System.out.println();
invokevirtual #24 <java/io/PrintStream.println>
其中,这里使用了常量池#24项,查看分析该常量池,可以看到如下图:
常量池第24项被invokevirtual使用,顺着CONSTANT_MethodRef #24的引用关系查找,最终发现,所有对于Class以及NameAndType类型的引用都是基于字符串的。
但是在程序实际运行时,只有符号引用是不够的,当println()方法被调用时,系统需要明确知道该方法的位置。以方法为例,java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法时,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转换为目标方法在类中方法表中的位置,从而使得方法被成功调用。
初始化
初始化是类装载的最后一步。初始化啊阶段的最重要工作是执行类的初始化方法<clinit>.方法<clinit>方法是编译器自动生成的,它是由类静态成员的赋值语句以及static语句块合并产生的。
代码
public class SimpleStatic{
public static int id = 1;
public static int numuber;
static{
num = 4;
}
}
java编译器会为这段代码生成如下<clinit>.:
0: iconst_1
1: putstatic #11;//field id:I
2: iconst_4
3: putstatic #13;//field number:I
4: return
java编译器并不会所有的类都产生<clinit>初始化函数。如果一个类既没有赋值语句也没有static语句块,那么生成的<clinit>函数就应该为空,因此,编译器就不会为该类插入<clinit>.
另如果类只有final常量,如:public static final int i = 1;
,而final常量在准备阶段初始化,而不是初始化阶段处理。
最后值得一提的是,<clinit>函数的调用,也就是类的初始化,虚拟机会在内部确保其在多线程环境中的安全性,也就是多当多个线程试图初始化同一个类时,只有一个线程可以进入<clinit>函数,而其它线程必须等待,如果之前的线程成功加载了类,则等在队列中的线程就没机会再执行<clinit>函数了。
正是因为函数<clinit>是带锁线程安全的,因此在多线程环境下,可能会引起死锁。