Java 虚拟机 —— 类的加载机制,linux 操作系统实用教程第二版课后答案
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保 class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证: 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的
3 个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。
元数据验证: 对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。这个主要目的是对类的元数据信息进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。
字节码验证: 对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
符号验证: 对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,这个阶段发生在将符号引用转化为直接引用的时候(解析阶段中发生),目的是确保解析动作能正常执行。
准备
准备阶段是正式为类变量(静态变量)分配内存并设置初始值的阶段,这些类变量所使用的内存都将在方法区中进行分配。
这里有两点需要注意:
成员变量不是在这里分配内存的,成员变量是在类实例化对象的时候在堆中分配的。
这里设置初始值是指类型的零值(比如 0,null,false 等),而不是代码中被显示的赋予的值。
比如:
public class Test {
public int number = 111;
public static int sNumber = 111;
}
成员变量number
在这个阶段就不会进行内存分配和初始化。而类变量sNunber
会在方法区中分配内存,并设置为 int 类型的零值 0 而不是 111,赋值为 111 是在初始化阶段才会执行。
Java 基本数据类型和引用数据类型零值
但是呢,如果类变量如果是被 final 修饰,为静态常量,那么在准备阶段也会在方法区中分配内存,并且将其值设置为显示赋予的值。
比如:
public class Test {
public static final int NUMBER = 111;
}
此时,就会在准备阶段将NUMBER
的值设置为 111。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用: 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
解析动作主要就是在常量池中寻找类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符等 7 类符号引用,把这些符号引用替换为直接引用。下面主要介绍下类或接口、字段、类方法、接口方法的解析:
类或接口解析: 假设当前的类
A
通过符号 X 引用了类B
,虚拟机会把代表类B
的全限定名传递给A
的类加载器去加载B
,B
经过加载、验证、准备过程,在解析过程又可能会触发B
引用的其他的类的加载过程,相当于一个类引用链的递归加载过程,整个过程只要不出现异常,B
的就是一个加载成功的类或接口了,也就是可以获取到代表B
的java.lang.Class
对象。在验证了A
具备对B
的访问权限后,就将符号引用 X 替换为B
的直接引用。字段解析: 解析未被解析过的字段,要先解析字段所属的类或接口的符号引用。如果类本身就包含了简单的名称和字段描述与目标字段相匹配,就直接返回这个字段引用;如果实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段;如果是继承自其他类的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。
类方法解析:类方法解析和字段解析的方式类似,也是依据继承和实现关系从小到上搜索,只不过是先搜索类,后搜索接口。如果有简单名称和字段描述符都与目标相匹配的字段,就返回字段引用。
接口的方法解析: 与类方法解析类似,从小到上搜索接口(接口没有父类,只可能有父接口)。如果存在简单名称和字段描述符都与目标相匹配的字段,就返回字段引用。
初始化
类的初始化类加载过程的最后一步,在前面的过中,除了在加载阶段开发者可以自定义加载器之外,其余的动作都是完全有虚拟机主导和控制完成。到了初始化阶段,才真正开始执行类中定义的 Java 代码。
在准备阶段,类变量已经设置了系统要求的零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中所有的类变量(static
变量)和静态代码块(static{}
块)中的语句合并生成的。编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态代码块中只能访问到定义在静态代码块之前的变量,定义在它之后的变量,在前面的静态代码块可以赋值,但是不能访问。
public class Test {
static {
number = 111; // 可以赋值
System.out.println(number); // 不能读取,编辑器或报错 Illegal forward reference
}
static int number;
}
<clinit>()
方法与类的构造函数(或者说实例构造器<init>()
方法)不同,它不需要显式地调用父类的<clinit>()
方法,虚拟机会保证在子类的<clinit>()
方法执行之前,父类的<clinit>()
方法已经执行完毕。所以,父类定义的静态代码块要先与子类的赋值操作。
class Parent {
public static int A = 1;
static {
A = 2;
}
}
class Sub extends Parent {
public static int B = A;
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
<clinit>()
方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。
虚拟机会保证一个类的<clinit>()
方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()
方法完毕。如果在一个类的<clinit>()
方法中有耗时很长的操作,就可能造成多个进程阻塞。
类加载器
在之前的加载过程中,提到了类加载器通过一个类的全限定名来获取描述此类的二进制字节流,这个过程可以让开发中自定义类加载器来决定如何获取需要的字节流。那么,什么是类加载器呢?
对于任意一个 Java 类,都必须通过类加载器加载到方法区,并生成java.lang.Class
对象才能使用类的各个功能,所以我们可以把类加载器理解为一个将class
类文件转换为java.lang.Class
对象的工具。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。也就是说,如果两个类“相等”,那么这两个类必须是被同一个虚拟机中的同一个类加载器加载,并且来自同一个class
文件。
在 Java 当中,已经有 3 个预制的类加载器,分别是BootStrapClassLoader
、ExtClassLoader、AppClassLoader
。
BootStrapClassLoader: 启动类加载器,它是由 C++来实现的,在 Java 程序中不能显氏的获取到。它负责加载存放在 JDK\jre\lib(JDK 代表 JDK 的安装目录,下同)下的类。
ExtClassLoader: 扩展类加载器,它是由 sun.misc.Launcher$ExtClassLoader 实现,负责加载 JDK\jre\lib\ext 目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库。开发者可以直接使用它。
AppClassLoader: 应用程序类加载器,由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器。一般来说,开发者自定义的类就是由应用程序类加载器加载的。
ExtClassLoader
作为类加载器,但它也是一个 Java 类,是由BootStrapClassLoader
来加载的,所以,ExtClassLoader
的 parent 是BootStrapClassLoader
。但是由于BootStrapClassLoader
是c++
实现的,我们通过ExtClassLoader.getParent
获取到的是null
。同样地,AppClassLoader
是由ExtClassLoader
加载,AppClassLoader
的 parent 是ExtClassLoader
。
public class Test {
public static void main(String[] args) {
ClassLoader cl = Test.class.getClassLoader();
while (cl != null) {
System.out.println(cl);
cl = cl.getParent();
}
}
}
打印结果:
sun.misc.Launcher$AppClassLoader@232204a1
sun.misc.Launcher$ExtClassLoader@74a14482
同时我们可以定义自己的类加载器CustomClassLoader
,那么它的 parent 肯定就是AppClassLoader
了。类加载器的这种层次关系称为双亲委派模型。
类加载器
双亲委派模型
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系不是以继承的关系来实现,而是都使用递归的方式来调用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
ClassLoader 的源码:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null pa
rent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()
方法,依次向上递归。若父类加载器为空则说明递归到启动类加载器了。如果从父类加载器到启动类加载器的上层次的所有加载器都加载失败,则调用自己的findClass()
方法进行加载。
使用双亲委派模型能使 Java 类随着加载器一起具备一种优先级的层次关系,保证同一个类只加载一次,避免了重复加载,同时也能阻止有人恶意替换加载系统类。
自定义类加载器
一般地,在ClassLoader
方法的loadClass
方法中已经给开发者实现了双亲委派模型,在自定义类加载器的时候,只需要复写findClass
方法即可。
评论