【详解】JVM之类的加载机制

1. 类加载的生命周期

生命周期如下所示

  • 其中加载、验证、准备、初始化是确定的,但是解析可以在初始化之后,为了支持Java的动态绑定机制。
  • 总得分为加载连接阶段(验证、准备、解析、初始化)

<mark>分为三大阶段</mark>

  • 加载:查找并且加载类的二进制数据
  • 链接:
    • 验证:确保被加载类的正确性
    • 准备:为类的静态变量分配内存,并将其初始化为默认值
    • 解析:把类中的符号引用转换为直接引用
  • 初始化:为类的静态变量赋予正确的初始值

2. 类的加载过程

2.1 加载

需要完成三件事情

  1. 通过一个类的全限定类名获取该类定义的二进制字节流(是最灵活的部分)
  2. 将这个字节流所代表的静态存储结构转化成方法区的运行时结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区的这个类各个数据的访问入口

获取二进制字节流的方法十分灵活

  • 从ZIP文件中获取,如JAR、EAR、WAR
  • 从网络中获取,最典型的应用是Applet
  • 运行时产生,如动态代理技术,在 java.lang.reflect.Proxy 使用ProxyGenerator.generateProxyClass的代理类的二进制字节流
  • 由其他文件产生,如JSP生成Servlet

数组类的创建规则

  • 如果一个数组的组件类型是引用类型(对象数组),采用本节定义的加载过程加载该组件,同时该类加载器类名称空间上将标识这个数组
  • 如果不是引用类型,将数组与引导类加载器关联
  • 数组类的可见性与组件相同,如果不是引用类型,则默认public

<mark>总结:</mark>

  • 根据类名或者文件名,将里面的二进制数据读取出来

2.2 验证

  • 主要目的:检查输入的字节流。为了确保当前加载的Class字节流符合虚拟机的要求,不会危害到虚拟机

检验动作

  1. 文件格式验证。判断当前字节流是否符合Class文件格式要求
  2. 原数据验证。语义分析,判断数据类型是否符合Java语言规范
  3. 字节码验证判断方法是否符合Java语言规范
  4. 符号引用验证判断能否找到所引用的类

<mark>总结:</mark>

  • 验证文件是否会对系统出现影响。
  • 类似于如果将一个后缀名为.exe的恶意文件,改为后缀名为.jpg的文件,此时用户以为是图片。双击打开时,系统如果去执行该文件,就会造成意向不到的结果。

2.3 准备

  • 目的:为类变量分配内存并设置初始值的阶段,仅仅分配static修饰的变量
  • 其他的变量随着类的初始化,跟类存放在堆中

<mark>总结</mark>

  • 给类中静态变量赋予初值
  • 比如private static int num = 2;,会为其赋予初值 num = 0

2.4 解析

  • 目的:将常量池中的符号引用变成直接引用

符号引用

  • 用符号来描述所引用的目标
  • 符号可以任意的定义,只要唯一即可
  • 所引用的对象可能没有加载

直接引用

  • 直接指向目标的指针,此时目标是一定存在的

<mark>总结</mark>

  • 将符号引用变成真正的引用
  • 比如下面代码,obj是一个助记符,不能真正的执行。在此阶段会将助记符变成真正的对象。
private Object obj = new Object();
    
    private void useObj(){
        this.obj.toString();
    }

2.5 初始化

  • 目的:执行类中的代码
  • 初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
  • <clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
public class Test {
	static {
	i = 0; // 给变量赋值可以正常编译通过
	System.out.print(i); // 这句编译器会提示“非法向前引用”
	}
	 static int i = 1;
}
  • 由于父类的 () 方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。例如以下代码:
static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}
static class Sub extends Parent {
    public static int B = A;
}
public static void main(String[] args) {
    System.out.println(Sub.B); // 2
}
  • 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法
  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 () 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

总结

  • 将原来的默认值赋予正确的值,完成加载的全部过程。
  • 比如private static int num = 2;,在准备阶段会为其赋予初值 num = 0。在此阶段会为其赋予num = 2

3. 类初始化时机

3.1 主动引用

  • 遇到new关键字实例化对象时、读取或设置一个类的静态字段(被final修饰的、已在编译期把结果放在常量池中的除外)以及调用一个类的静态方法
  • 使用反射对类进行调用时
  • 当初始化一个类时,发现其父类还没有初始化,则先初始化父类
  • 主类会先初始化(含有main方法的类)
  • 当 java.lang.invoke.MethodHandle实例调用的类没有被初始化时

<mark>总结</mark>

  • JVM规范要求所有的Java虚拟机实现必须在每个类或者接口被Java程序首次主动使用时才初始化他们
  • 当然现代JVM有可能根据程序的上下文语义推断出接下来可能初始化谁。(智能的提前加载,提高性能

测试

public class ClassActiveUse {
    
    static{
        System.out.println("我是主类.");
    }
    
    public static void main(String[] args) throws ClassNotFoundException {
        //new Obj();//new 一个对象
        //System.out.println(I.A);//调用接口的属性
        //System.out.println(Obj.num);//调用类的静态属性
        //Obj.print();//调用类的静态方法
        //Class.forName("classLoader_test.C1.Obj");//反射
        //new ObjChild();//初始化子类
    }

}


class Obj{

    public static  int num = 100;

    static {
        System.out.println("Obj 被初始化.");
    }

    public static void print(){
        System.out.println("using print. ");
    }
}

class ObjChild extends Obj{

}

interface I{
    final static int A = 10;
}

3.2 被动引用

<mark>通过子类引用父类的静态字段,不会导致子类初始化。</mark>

  • 结果会输出“SupperClass init”

<mark>通过数组定义来引用类,不会触发此类的初始化。</mark> 该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。

SuperClass[] sca = new SuperClass[10];

<mark>常量在编译阶段会存入调用类的常量池中</mark>,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

  • 结果没有输出“ConstClass init”

注意运行时赋予的数值会导致初始化,比如public static final int x = new Random().nextInt(100);

4. 类加载器

  • 对于任意一个类,需要类加载器和类本身确定其唯一性
  • 如果是不同的类加载器(自定义),即使加载的是同一个class文件,类也不“相同”。
  • 这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

4.1 类加载器分类

从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器

  • 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
  • 所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在<JRE_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。

  • 扩展类加载器(Extension ClassLoader)这个类加载器是由ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将 <JAVA_HOME>/lib/ext 或者被java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器

  • 应用程序类加载器(Application ClassLoader)这个类加载器是由AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

4.2 双亲委派模型

  • 应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。
  • 下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。

1. 工作过程
一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。

2. 好处

  • 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一
  • 防止用户编写系统已经存在的基础类破坏别的程序
  • 例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 并放到 ClassPath 中,程序可以编译通过。由于双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,这是因为 rt.jar中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。rt.jar 中的 Object 优先级更高,那么程序中所有的 Object 都是这个 Object。

3. 实现
以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

4.3 自定义类加载器实现

参考:https://blog.csdn.net/qq_43040688/article/details/104202400

全部评论

相关推荐

10-28 14:42
门头沟学院 Java
watermelon1124:因为嵌入式炸了
点赞 评论 收藏
分享
点赞 评论 收藏
分享
1 1 评论
分享
牛客网
牛客企业服务