本章讲解虚拟机如何加载class文件以及class文件进入虚拟机后会发生什么变化。
虚拟机把描述类的数据从class文件加载到内存,并对数据校验、转换解析和初始化,最终成为虚拟机可以使用的Java类型。而这些工作不需要进行连接,直接在运行时完成,这种方式灵活性强但会增加性能开销。
类从被加载到虚拟机内存开始到卸载出内存的生命周期有七个阶段。
1.加载
2.验证
3.准备
5.初始化
7.卸载五个阶段的顺序是确定的,同样的,解析阶段是不一定的,某些情况下可以在初始化后开始,这是为了支持动态绑定。
什么时候开始加载虚拟机并没有强制约束,但是在以下5种情况必须立即对类进行初始化:
这五种场景称为对一个类的主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用
被动引用例子一
/**
*通过子类引用父类的静态字段不会触发初始化
*/
public class SuperClass{
static{
System.out.println(SuperClass init);
}
public static int value = 123;
}
public class SubClass extends SuperClass{
static{
System.out.println(SubClass init);
}
}
public class NoInitialization{
public static void main(String[] args){
System.out.println(SubClass.value);
}
}
上述代码运行后会直接输出"SuperClass init"而不会输出"SubClass init"。
对于静态字段,只有直接定义这个字段的类才会被初始化,所以当子类引用父类的静态字段时,只会触发父类的初始化而不是子类的。
被动引用例子二
/**
*通过数组定义引用类,不会触发此类的初始化
*/
public class NoInitialization{
public static void main(String[] args){
System.out.println(SubClass.value);
}
}
这段代码复用了上面的类定义,运行之后并没有输出"SuperClass inti",但是这段代码会触发一个由虚拟机自动生成的另一个类名相同但包名不同直接继承自Object的子类,这个类代表了一个元素类型为原类的一维数组。
被动引用例子三
/**
*常量会在编译阶段存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发类的初始化
*/
public class ConstClass{
static{
System.out.println("ConstClass init");
}
public static final String HELLOWORLD = "hello world";
}
public class NoInitialization{
public static void main(String[] args){
System.out.println(ConstClass.HELLOWORLD);
}
}
上述代码执行时不会输出"ConstClass init",这是因为编译时通过常量传播优化,将此常量的值存储到了ConstCLass的类常量池中。
另外接口的初始化过程与类基本一致,真正的区别只在五种情况的第三种:接口初始化时不要求其父接口全部初始化,只有在用到父接口时才会初始化。
这一章详解了类加载的全过程,也就
sequenceDiagram
A->>B: How are you?
B->>A: Great!
graph LR
A-->B
是加载、验证、准备、解析和初始化5个阶段的具体动作。
这里不要混淆,加载是类加载的一个过程。
加载阶段虚拟机要完成三件事情
一个非数组类的加载阶段是程序员可控性最强的,因为加载阶段可以通过系统的引导类加载器完成或者由用户自定义的类加载器完成。
对于数组类,它是由Java虚拟机直接创建的(不通过类加载器)。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区当中,格式由虚拟机自行定义。
验证是连接阶段的第一步,这一阶段确保Class文件的字节流种包含的信息符合当前虚拟机的要求,并且不会危害虚拟机本身。
虽然Java语言本身是相对安全的,但是例如数组越界,跳转不存在的代码行之类的操作会让编译器拒绝编译。Class文件可以来自于任何地方不仅限于Java代码,甚至可以自己编写,也就是说上述操作可以直接通过编辑字节码实现。在字节码语言层面上这些操作如果虚拟机不加以检查而直接通过的话会导致系统崩溃,所以验证阶段是自我保护的一项必要工作。
验证阶段大致包括四个动作
准备阶段是正式为类变量分配内存并设置变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这里分配的时候包括类变量而不包括实例变量,而初始值通常情况下是数据类型的零值,真正赋值的动作是在初始化阶段才执行。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,书中介绍了前面4种,后面三种与动态语言相关。
初始化阶段才真正开始执行类中定义的Java程序代码。
在准备阶段,变量赋过一次初始值,在初始化阶段,根据程序员的通过程序制定的计划去初始化类变量和其他资源也就是执行类构造器()方法的过程。静态语句块只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问。
public class Test{
static{
i = 0;//给变量赋值可以正常编译通过
System.out.println(i);//这句编译器提示"非法向前引用“
}
static int i = 1;
}
虚拟机会保证子类的()方法执行之前,父类的()方法已经执行完毕。
也就意味着父类的静态代码块要优于子类的变量赋值操作。
类加载阶段中的"通过一个类的全限定名来获取描述此类的二进制字节流"这个操作放大虚拟机外部实现,以便让程序自己决定如何获取所需的类。实现这个动作的代码块叫做 “类加载器”。
比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来自同一个Class文件,只要它们类加载器不同,这两个类必定不相等。
从Java虚拟机角度讲,只存在两种不同的类加载器。
从开发人员的角度看,类加载可以划分的更细致,以下三种是常用的:
/**
*java.lang.ClassLoader.getClassLoader代码片段
*/
public ClassLoader getClassLoader(){
ClassLoader cl = getClassloader();
if(cl == null){
ClassLoader ccl = ClassLoader.getCallerClassLoader();
if(ccl!=null&&ccl!=cl&&!cl.isAncestor(ccl)){
sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
}
}
return cl;
}
这些类加载器关系如图
这种层次关系称为类加载器的双亲委派模型(Parents Delegation Model)。这个模型要求除了启动类加载器,其他的类加载器都应当有自己的父类加载器。
它的工作过程是:如果一个类收到了类加载的请求,首先不会自己尝试加载这个类,而是委派给父类加载器完成,因此所有的请求都会传送到顶层的启动类加载器当中,当父类加载器反馈自己无法完成时,子加载器才会自己尝试完成。
实现模型的代码很简单:先检查是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
protected synchronized Class<?>loadClass(String name,boolean resolve)throws ClassNotFoundException {
//首先,检查请求的类是否已经被加载过了 Class
c = findLoadedClass(name);
if(c==null){
try{
if(parent!=null){
c=parent.loadClass(name,false);
}else{
c=findBootstrapClassOrNull(name);
}
}catch(ClassNotFoundException e){
//如果父类加载器抛出ClassNotFoundException
//说明父类加载器无法完成加载请求
}
if(c==null){
//在父类加载器无法加载的时候
//再调用本身的findClass方法来进行类加载
c=findClass(name);
}
}
if(resolve){
resolveClass(c);
}
return c;
}
双亲委派模型有过三次较大规模的被破坏的情况。