# 三、类加载、连接和初始化

# 1. 类加载要完成的功能

  1. 通过类的全限定名来获取该类的二进制字节流;
  2. 把二进制字节流转为方法区的运行时数据结构;
  3. 在堆上创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构,并向外提供了访问方法区内数据结构的接口;

# 2. 加载类的方式

  1. 最常见的方式:本地文件系统中加载、从 jar 等归档文件中加载;
  2. 动态方式:将 java 源文件动态编译成 class;
  3. 其他方式:网络下载(Applet)、从专有数据库中加载等等;

# 3. 类加载器

# [Java 8]

image-20210204174618989

# [Java 13]

image-20210219163407768

启动类加载器:

  • 用户加载启动的基础模块类,比如:java.base、java.management、java.xml 等等;

平台类加载器:

  • 用户加载一些平台相关的模块,比如:java.scripting、java.compiler*、java.corba* 等等;

应用程序类加载器:

  • 用户加载应用级别的模块,比如:jdk.compiler、jdk.jartool、jdk.jshell 等等;
  • 加载 classpath 路径中的所有类库
# 类加载器说明
1. Java 程序不能直接引用启动类加载器,直接设置 classLoader 为 null,默认就使用启动类加载器;
2. 类加载器并不需要等到某一个类“首次主动使用”的时候才加载它,JVM 规范允许类加载器在预料到某个类将要被使用的时候就预先加载它;
3. 如果在加载的时候 .class 文件缺失,会在该类首次主动使用时报 LinkageError 错误,如果一直没有被使用,就不会报错;

# 4. 双亲委派模型

# [Java 13]

JVM 中的 ClassLoader 通常采用双亲委派模型,要求除了启动类加载器外,其余的类加载器都应该有自己的父级加载器。

这里的父子关系是组合而不是继承,工作过程如下:

  1. 当一个类加载器收到类加载请求后,首先搜索它的内建加载器定义的所有“具名模块”;
  2. 如果找到了合适的模块定义,将会使用该类加载器来加载;
  3. 如果 class 没有在这些加载器定义的具名模块中找到,那么将委托给父级加载器,直到启动类加载器;
  4. 如果父级加载器反馈它不能完成加载请求,比如在它的搜索路径下找不到这个类,那子的类加载器才自己来加载;
  5. 在类路径下找到的类将成为这些加载器的无名模块;
  6. 都没有找到的话就报 ClassNotFoundException 错误

# [Java 8]

Java 8 由于没有模块化开发,所以它没有去搜索自己的“具名模块”,而是直接委派给父类加载器,一级一级委派上去,如果父类加载器反馈不能完成类的加载,父类加载器才进行反馈,然后由子类加载器自己来完成类的加载过程。

# 说明

# 双亲委派模型的优势
1. 采用双亲委派模式的是好处是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次。
2. 其次是考虑到安全因素,Java 核心 API 中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心 Java API 发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的 java.lang.Integer,而直接返回已加载过的 Integer.class,这样便可以防止核心 API 库被随意篡改。

# 双亲委派模型的说明
1. 实现双亲委派模型的代码在 java.lang.ClassLoader 的 loadClass() 方法中,如果自定义类加载器的话,推荐覆盖实现 findClass() 方法。
2. 如果有一个类加载器能加载某个类,则称其为定义类加载器,所有能成功返回该类的 Class 的类加载器都被称为初始类加载器。
3. 如果没有指定父加载器,默认就是启动加载器。
4. 每个类加载器都有自己的命名空间,命名空间由该加载器及其父加载器所加载的类构成,不同的命名空间,可以出现类的全路径名相同的情况(多 Module 情况下)。
5. 运行时包由同一个类加载器的类构成,决定两个类是否属于同一个运行时包,不仅要看全路径名是否一样,还要看定义类加载器是否相同。只有同一个运行时包的类才能实现相互包内可见。

# 破坏双亲委派模型

# 双亲模型的问题
- 父加载器无法向下识别子加载器加载的资源

# 解决
- 为了解决这个问题,JDBC 引入了线程上下文类加载器,可以通过 Thread 的 setContextClassLoader() 进行设置;

# 自定义类加载器

/**
 * 自定义类加载器
 *
 * @author Hedon Wang
 * @create 2021-02-19 4:54 PM
 */
public class MyClassLoader extends ClassLoader{

    private String myName = "";

    public MyClassLoader(String myName){
        this.myName = myName;
    }

    /**
     * 将二进制数据转为 Class 类
     * @param name
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        byte[] data = this.loadClassData(name);

        //将二进制数据转为 Class 类
        return this.defineClass(name,data,0,data.length);
    }

    /**
     * 加载 .class 文件为二进制数据
     * @param className
     * @return
     */
    private byte[] loadClassData(String className){

        byte[] data = null;
        InputStream in = null;
        ByteArrayOutputStream aout = null;
        className = className.replace(".","/");
        try {
            in = new FileInputStream(new File("classes/" + className + ".class"));
            aout = new ByteArrayOutputStream();
            int a = 0;
            while ((a = in.read()) != -1){
                aout.write(a);
            }
            data = aout.toByteArray();

        }catch (Exception err){
            err.printStackTrace();
        }
        return data;
    }
}

# 5. 类的加载过程

当程序主动使用某个类时,如果该类还未被加载到内存中,则系统会通过如下三个步骤来对该类进行初始化:

image-20210204170647543

# 加载

程序经过 javac.exe 命令后,会生成一个或多个字节码文件(.class 结尾)。

接着我们使用 java.exe 命令对某个字节码文件进行解析运行,就相当于将 class 文件字节码内容加载到内存中,并将这些静态数据转换成方法区的运行时 数据结构,然后生成一个代表这个类的 java.lang.Class 对象,作为方法区中类数据的访问入口(即引用地址)。所有需要访问和使用类数据只能通过这个 Class 对象。这个加载的过程需要类加载器参与。此过程就称为类的加载。加载到内存中的类,我们就称为运行时类

# 链接

将 Java 类的二进制代码合并到 JVM 的运行状态之中的过程。

  • 验证

    确保加载的类信息符合 JVM 规范:

    ​ ※ 类文件结构检查:按照 JVM 规范规定的类文件结构进行;

    ​ ※ 元数据验证:对字节码描述的信息进行语义分析,保证其符合 Java 语言规范要求;

    ​ ※ 字节码验证:通过对数据流和控制流进行分析,确保程序语义是合法和符合逻辑的,这样主要对方法体进行校验;

    ​ ※ 符号引用验证:对类自身以外的信息,也就是常量池中的各种符号引用,进行匹配校验;

  • 准备

    正式为类静态变量(static)分配内存并设置类变量默认初始值的阶段,这些内存都将在方法区中进行分配。

  • 解析

    虚拟机常量池内的符号引用(常量名)替换为直接引用(地址)的过程。

# 初始化

执行类构造器 <clinit>() 方法的过程。类构造器 <clinit>() 方法是由编译期自动收集类中所有类的静态变量的赋值动作和静态代码块中的语句合并产生的。(类构造器是构造类信息的,不是构造该类对象的构造器)。

当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。

当初始化一个类的时候,并不会初始化它所实现的接口。

当初始化一个接口的时候,并不会初始化它的父接口。

只有当程序首次使用接口里面的变量或者是调用接口里面的方法的时候才会导致接口的初始化。

调用 ClassLoader 类的 loadClass() 方法来装载一个类,并不会初始化这个类,因为这不是对类的主动使用。

虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确加锁和同步。

# 类的初始化时机

※ 类的主动引用(一定会发生类的初始化)

  1. 当虚拟机启动,先初始化 main() 方法所在的类
  2. new 一个类的对象
  3. 调用类的静态成员(除了 final 常量)和静态方法
  4. 使用 java.lang.reflect 包的方法对类进行反射调用
  5. 当初始化一个类,如果其父类没有被初始化,则先会初始化它的父类
  6. 定义了 default 方法的接口,当接口实现类初始化时,会初始化接口

※ 类的被动引用(不会发生类的初始化)

  1. 当访问一个静态域时,只有真正声明这个域的类才会被初始化。当通过子类引用父类的静态变量,不会导致子类初始化
  2. 通过数组定义类引用,不会触发此类的初始化
  3. 引用常量不会触发此类的初始化(常量在链接阶段就存入调用类的常量池中了)

举例:定义一个 Father 类,然后 A 继承 Father:

class Father {
    static int b = 2;
    static {
		System.out.println("父类被加载"); 
    }
}
class A extends Father {
    static {
		System.out.println("子类被加载");
		m = 300; 
    }
    static int m = 100;
	static final int M = 1; 
}

测试类:

public class ClassLoadingTest {
  	// main() 方法主动引用
    public static void main(String[] args) { 
        //主动引用
        A a = new A();
        //主动引用
        System.out.println(A.m);
        //主动引用
        Class.forName("com.hedon.A");
        //被动引用
        A[] array = new A[5];//不会导致 A 和 Father 的初始化
        //被动引用
        System.out.println(A.b);//只会初始化 Father
        //被动引用
        System.out.println(A.M);//不会导致 A 和 Father 的初始化
    } 
}
# 类的初始化顺序
  • 先按顺序为 static 变量赋默认值;
public class MyClassA {

    private static MyClassA myClassA = new MyClassA();
		
  	//构造方法
    private MyClassA(){
        a++;
        b++;
    }

    //两个静态变量
    private static int a = 0;
    private static int b ;

    //获取单例
    public static MyClassA getInstance(){
        return myClassA;
    }

    public int getA() {
        return a;
    }

    public int getB() {
        return b;
    }
}
public class Test {
    public static void main(String[] args) throws ClassNotFoundException {
        MyClassA myClassA = MyClassA.getInstance();
        System.out.println("a == " + myClassA.getA());   // a == 0
        System.out.println("b == " + myClassA.getB());   // b == 1
    }
}

上述输出结构 a = 0 而 b = 1 的解释:

  1. 将类加载后需要做链接,链接中有一步叫做准备。准备的这个过程就是为 static 静态变量分配内存并赋默认值,所以执行到了:

    private static MyClassA myClassA; //这里还没有调用 MyClassA(),只是先分配了 MyClassA 对象需要的内存
    private static int a;  //这个时候不会执行原代码中的 a=0,但是 int 类型的值默认为 0
    private static int b;
    

    这个时候 a == 0,b == 0,myClassA == null

  2. 链接后才到了初始化阶段,而初始化阶段才会去检查是否需要为 static 变量赋初始值以及执行 static 代码块:

    按照顺序,先执行:

    private static MyClassA myClassA = new MyClassA();
    

    调用了构造方法:

    private MyClassA(){
        a++;
        b++;
    }
    

    所以这个时候 a == 1,b == 1。

    然后按顺序往下:

    private static int a = 0;
    private static int b ;
    

    这个时候检查到 a 需要赋初始值,而 b 不需要,所以初始化后:a == 0, b == 1。

# 6. OSGI

OSGI(Open Sevice Gateway Initiative)是 Java 动态化模块化系统的一系列规范,旨在为实线 Java 程序的模块化编程提供基础条件。基于 OSGI 的程序可以实现模块级的热插拨功能,在程序升级更新时,可以只针对需要更新的程序进行停用和重新安装,极大提高了系统升级的安全性和便捷性。

OSGI 提供了一种面向服务的架构,该架构为组件提供了动态发现其他组件的功能,这样无论是加入组件还是卸载组件,都能被系统的其他组件感知,以便各个组件之间更好地协调工作。

OSGI 不但定义了模块化开发的规范,还定义了实现这些规范所依赖的服务于架构,市面上也有成熟的框架对其进行实现和应用,但只有部分应用适合采用 OSGI 方式,因为它为了实现动态模块,不再遵循 JVM 类加载双亲委派机制和其他 JVM 规范,在安全新上有所牺牲。

上次更新: 9/8/2021, 4:11:30 PM