【注解与反射】7 - 4 类加载器

§7-4 类加载器

7-4.1 类的加载时机

每个 Java 程序至少包含一个类,每当想要运行一个 Java 程序时,源代码 .java 文件首先要由编译器编译为 .class 二进制字节码文件,最后由 Java 虚拟机运行程序。而 Java 虚拟机想要运行一个程序,就需要将所需要的类的 .class 文件加载到内存中,这个过程由类加载器完成。

类加载器会在需要将类加载到内存时被调用。因此,类加载器的运行时机实际上就是类加载的时机:要用就加载,不用不加载

类的加载时机有以下几种:

  • 创建类的实例(对象);
  • 调用类的类方法(静态方法);
  • 访问类或接口的类变量(静态变量),或为该类变量(静态变量)赋值;
  • 利用反射机制强制创建某个类或接口对应的 java.lang.Class 对象;
  • 初始化某个类的子类时;
  • 直接使用 java.exe 命令运行某个主类;

7-4.2 类的加载过程

类的加载要经历三个主要过程:加载链接初始化。一旦一个类被加载,这三个步骤就不会再次进行,也就是说,这三个步骤只会进行一次。

  • 加载:将类的数据从硬盘中加载到内存当中的过程,分为下列三个步骤:

    • 根据类的全限定名(包名 + 类名)获取这个类的二进制字节流(获取这个类的 .class 文件,使用流传输);
    • 将字节流中的静态存储结构转化为运行时数据结构(将字节码中类的数据加载到内存中);
    • 虚拟机为该类创建一个 java.lang.Class 对象,对象存储了这个类的运行时数据结构(静态变量、静态方法、常量池、代码等)。

    因此,当一个类被加载到内存之后,这个类有且仅有一个 java.lang.Class 对象,这个对象存储了该类 .class 字节码中所存储的所有信息。

  • 链接:链接也分为三个步骤进行:

    • 验证:检查类的数据是否符合虚拟机的规范,并检查类数据是否具有安全隐患(危害虚拟机自身安全);
    • 准备:为类中的类变量(静态变量)分配内存(方法区)并赋默认初始值;
    • 解析:将类二进制数据流中的符号引用替换为直接引用。

    其中,准备阶段中赋值会为变量赋其所对应类型的默认初始值,而不是在源码中手动定义的初值。如:

    public class Student {
        private static int UUID = 10249328;
    }
    

    在准备阶段静态变量 int 会被默认赋值为 0 而不是 10249328。对于 boolean 型变量,默认为 false,引用类型变量默认为 null

    若静态变量由 final 修饰,该变量就会被用源码指定的初值初始化并存入常量池中。

    解析阶段的引用替换指的是,若类中变量含有其他类的变量(如 String name),加载器会找到变量所属的类,并将其在加载阶段时的符号引用(假设用符号 &&& 临时代替 String)替换为类的地址(称为直接引用,假设为地址 0x0012)。

    private &&& name;  ---> private String name;		// 加载所需的类并将其符号引用替换为地址引用
    
  • 初始化:根据程序员通过程序制定的主观计划,初始化类变量和其他资源。

    为静态变量赋值及初始化其他资源。

    • 虚拟机会执行类构造器 <clinit>() 方法。<clinit>() 方法由编译器自动收集类中所有变量的赋值动作和静态代码块中的语句合并产生。
    • 初始化类时,若发现其父类未被初始化,则先触发其父类的初始化;
    • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确加锁和同步。

    类构造器用于构造类的信息,不是该类对象的构造器。类构造器中含有合并了所有变量的赋值动作和静态代码块,此时会发生静态变量的值覆盖。如:

    public class Student {
        private static String school = "清华大学";
    }
    

    <clinit>() 方法会在此时为静态变量 school 覆盖新值为 "清华大学"(此前为默认初值 null)。

    但若含有静态代码块,上述类型的赋值动作会和静态代码块一并合并到 <clinit>() 方法中。代码合并遵循源码中的顺序,按照顺序结构依次执行。

    测试对静态变量在不同赋值顺序下的最终值:

    public class A {
        // 静态代码块在前
    	static {
            System.out.println("初始化静态代码块。");
            System.out.println(A.test);
            
            test = 300;
        }
        
        public static int test = 100;	// A 的静态变量
        
        public A() {
            System.out.println("A 的无参构造初始化。")
        }
    }
    

    在测试类中实例化上述类的对象并打印静态变量 A 的值:

    public static void main(String args[]) {
        // 测试类
        A a = new A();	// 创建对象,触发类加载
        System.out.println(A.test);	// 打印静态变量 test 的值
    }
    

    运行得到:

    初始化静态代码块。		// 静态代码块先被执行
    0					 // test 起初具有默认初始值
    A 的无参构造初始化。		// 调用构造器实例化对象
    100					 // test 最终为 100
    

    现更换静态代码块与静态变量声明顺序:

    public class A {
        public static int test = 100;	// A 的静态变量
        
        // 静态代码块在后
        static {
            System.out.println("初始化静态代码块。");
            System.out.println(A.test);
            
            test = 300;
        }
        
        public A() {
            System.out.println("A 的无参构造初始化。")
        }
    }
    

    运行同样的测试类,得到:

    初始化静态代码块。		// 静态代码块先被执行
    100					 // test 已被赋值 100
    A 的无参构造初始化。		// 调用构造器实例化对象
    300					 // test 最终为 300
    

    可见,<clinit>() 方法的代码合并顺序同源代码的顺序,执行时按照顺序结构依次从上到下执行。

    静态变量在链接阶段(准备)具有默认初值(0)。静态代码块在前,在执行静态代码块,然后再到类中声明的变量赋值。静态代码块在后,先执行类中声明的变量赋值,然后执行静态代码块。

    但是,若该静态变量为 final 最终常量,则声明该变量时必须要为它赋初值。因此,尝试将含有访问该 final 静态变量的静态代码块会抛出编译异常:非法的前向引用。即:

    public class A {
        // 非法前向引用示例:访问静态常量的静态代码块在前
        static {
            System.out.println("CONSTANT = " + CONSTANT);		// 错误:非法前向引用
        }
        
        public static final int CONSTANT = 200;
    }
    

7-4.3 触发类初始化的条件

类加载过程的最后一个阶段是类的初始化,触发类的初始化条件为类的主动引用,有以下几种:

  • 虚拟机启动时,加载(初始化)main 方法所在类;
  • new 一个为加载类的对象;
  • 调用类的静态成员(除了 final 常量)和静态方法;
  • 调用 java.lang.reflect 包的方法对类进行反射调用;
  • 初始化一个类,若其父类未被初始化,则会先初始化它的父类;

而类的被动引用不会触发类的初始化:

  • 访问一个静态域时,只有真正声明这个域的类才会被初始化;(子类引用父类的静态变量,不会导致子类被初始化)
  • 通过数组定义类的引用时,不会触发类的初始化;
  • 引用常量不会触发类的初始化(常量在链接阶段分配空间并初始化,且存入常量池中)。

7-4.4 类加载器

类加载器的作用实际上就是将类的 .class 字节码文件从硬盘读取到内存当中,并将这些静态存储数据转化为运行时的数据结构,并在堆中创建一个表示该类的 java.lang.Class 对象,作为方法区中类数据的访问入口。

image

类加载器的双亲委派机制:下图介绍了类加载器的双亲委派机制。

image

JVM 规范定义了以下类型的类加载器:

  • 引导类加载器(Bootstrap ClassLoader):由 C/C++ 编写,是 JVM 内置的类加载器,负责 Java 平台核心库,用于加载核心类库(rt.jar)。该加载器无法直接获取(表示为 null);
  • 平台类加载器(Platform ClassLoader):负责加载 JDK 中的一些特殊模块。负责 jre/lib/ext 目录下的 jar 包或 -D java.ext.dirs 指定目录下的 jar 包装入工作库;
  • 系统类加载器(System ClassLoader):又称应用程序类加载器,负责加载用户类路径上所指定的类库。负责 java -classpath-D java.class.path 所指目录下的类与 jar 包 装入工作库,是最常用的加载器。

不同类型的类加载器有各自的加载范围,这已经由 Java 预先定义好。除了最顶层的引导类加载器外,其余的加载器在逻辑上都有一个父级加载器。加载类时,加载器将加载任务委派给其父级加载器加载,父级加载器会根据类的加载情况和自身的类加载范围将加载任务不断逐级委派给对应的父级加载器加载,直至到达最顶层的引导类加载器。引导类加载器会判断自身加载范围,若待加载类不属于自身加载范围,不断将加载任务再逐级返回给下一级加载器,直至找到合适的加载器加载为止。

类缓存:标准的 Java SE 类加载器可以按照要求查找类,但一旦某个类被加载到类加载器中,它将维持加载(缓存)一段时间。不过 JVM 的垃圾回收机制(GC, Garbage Collection)可以回收这些 Class 对象。

双亲委派机制有效防止了同名包、同名类与 JDK 中的相冲突。因为 JDK 中的包由引导类加载器和平台类加载器加载,再次加载同名冲突类时,加载器会逐级向上查看缓存检查类是否被加载并尝试加载。这样,冲突的同名包就不会被覆盖加载,进而避免了冲突问题。

Java 中的 java.lang.ClassLoader 抽象类负责类的加载工作。可以通过反射方式查看类由什么加载器加载,也可以查看该加载器的父级加载器。

ClassLoader 的静态方法

静态方法 描述
ClassLoader getPlatformClassLoader() 返回平台类加载器
ClassLoader getSystemClassLoader() 返回系统类加载器

ClassLoader 的成员方法

成员方法 描述
InputStream getResourceAsStream(String name) 使用类加载器查找给定名称的资源,并返回其对应的字节流

示例一:查看 main 方法所属类的加载器及其父级加载器,查看系统类加载器可加载的路径,查看核心类库成员的类加载器。

package com.reflections;

public class ClassLoaderDemo {
    public static void main(String[] args) throws ClassNotFoundException {
        // 查看当前类的类加载器:系统加载器
        ClassLoader currentClassLoader = Class.forName("com.reflections.ClassLoaderDemo").getClassLoader();
        System.out.println("当前类加载器(系统加载器):" + currentClassLoader);     // jdk.internal.loader.ClassLoaders$AppClassLoader@36baf30c

        // 系统加载器的父级加载器:扩展加载器
        ClassLoader extensionClassLoader = currentClassLoader.getParent();
        System.out.println("系统加载器的父级加载器(扩展加载器):" + extensionClassLoader);   // jdk.internal.loader.ClassLoaders$PlatformClassLoader@7cc355be

        // 查看扩展加载器的父级加载器:根加载器(引导加载器)
        ClassLoader bootstrap = extensionClassLoader.getParent();
        System.out.println("扩展加载器的父级加载器(根加载器):" + bootstrap);              // null

        // 获得系统类加载器可以加载的路径:
        System.out.println("系统类加载器可加载的路径:");
        String[] paths = System.getProperty("java.class.path").split(";");
        for (String path : paths) {
            System.out.println("\t" + path);
        }

        // 查看核心类库成员的类加载器:以 Object 为例
        System.out.println("核心类库成员的加载器(根加载器):" + Object.class.getClassLoader());	// null
    }
}

示例二:使用系统类加载器,读取文件资源的字节流(以 .properties 文件为例)

注意getResourceAsStream(String) 方法若采用相对路径,文件的相对路径相对于项目的 src 文件夹而言,而不是整个项目。

prop.properties 文件:

name = 张三
age = 13

测试类:

package com.reflections;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Properties;

public class ClassLoaderResourceDemo {
    public static void main(String[] args) throws IOException {
        // 获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        // 获取资源流
        BufferedReader reader = new BufferedReader(new InputStreamReader(systemClassLoader.getResourceAsStream("prop.properties"), StandardCharsets.UTF_8));
        // 创建 Properties 对象
        Properties prop = new Properties();
        // 读取数据
        prop.load(reader);
        // 关闭流
        reader.close();
        // 打印数据
        System.out.println(prop);
    }
}

运行结果:

{name=张三, age=13}
posted @ 2024-01-28 00:48  Zebt  阅读(13)  评论(0)    收藏  举报