听说你对类加载很熟?

125 阅读7分钟

背景

哈喽艾瑞宝迪,看完这篇文章,你将会详细掌握Java类加载的过程中。某个类首次调用后,执行的详细流程。

不是教你背八股,而是教你背诵呸,掌握熟悉之后的,学以致用

ps: 为啥不说Kotlin?Kotlin 代码在编译后会生成与 Java 兼容的中间字节码,遵循和Java一样的类加载流程。

抛出问题1,以下代码每一行的执行顺序是?

这里有个小坑哈,想知道答案,除了要理解类加载,还需要理解对象实例化,被标题类加载骗进来,带你多学一个应该不会介意吧。


 public static void main(String[] args) {
        System.out.printf("执行 Num.a \n");
        Num.funB();
//        String str = Num.a;
 }

static class Num {
    static String a = "11";

    static Num instance = new Num();

    {
        System.out.println("执行非静态代码块");
    }
    int v = 10;

    static {
        System.out.println("执行静态代码块");
    }

    Num(){
        System.out.println("构造器方法");
    }

    public static void funB(){
        System.out.printf("执行funB");
    }
}

前置知识

你需要对Java类加载,有一个大致的了解。比如在他加载、验证、准备、解析、初始化、使用和卸载的各个流程中大概会干什么事。

如果你对这些不太了解,可以先去看一下某大佬的这篇文章Java类加载器 — classloader 的原理及应用

不用全懂,啃个脸熟,就可以回来了哈。

类加载规则

  • 要理解,静态类加载他是首次调用到该类的时候触发的。因为静态类全局只有一个。 非静态类,new 100个可以有100个。

  • 类加载流程会经历多个阶段,其中在准备阶段,会为所有的静态变量设置类型的默认值,例如int 都是0,String ,Object 都是null;

  • 静态代码块,只在初始化阶段中执行。

  • 初始化阶段静态变量静态代码块是同级的。按照写代码顺序谁在前先执行谁

  • 类加载流程中(初始化阶段),会为静态变量赋值,例如 statis int a = 3; 这个阶段a 才从默认值0变成3。

  • 静态调用的方式,例如Num.funB(); 和 Num.a; 会触发类加载流程

以上规则不是静态变量就是静态代码块,那我要问了,非静态变量(实例变量)和实例代码块什么时候执行,什么时候赋值?

上面已经有暗示了,new Object() ,创建对象的时候。

那有的宝子又要问了,为啥实例对象,不是在首次调用的时候捏?

因为非静态方法和对象的调用,不能直接Object.funA() , Object.value; 要先创建对象Object o = new Object(); 才能调用哇。 那么继续看一下,创建对象的规则,也就是实例化规则

实例化规则

  • new Object() 会触发实例化,调用一次触发一次。

  • 实例化和类加载类似也有,各个阶段。

  • 先为新的对象分配内存空间,然后 为所有实例变量设置默认值。(此时,实例代码块是不参与的)

  • 代码顺序执行,实例变量的赋值和实例代码块

  • 执行构造器

问题1执行顺序

掌握了,以上规则那问题1的执行就很清晰了。

  • main 函数中Num被首次调用,触发类加载流程

  • 准备阶段:为所有静态变量设置默认值,也就是 a = null,instance = null;

  • 初始化阶段:按照代码顺序执行静态变量的赋值和静态代码块

    1. static String a = "11"; 给a 赋值为”11“

    2. 然后执行 static Num instance = new Num(); 要给 instance 赋值,从而触发了new Num()实例化流程。

      • 实例化流程,会为实例变量也就是int v = 10; 先设置默认值v = 0;

      • 按照代码顺序执行,实例代码块打印日志 {System.out.println("执行非静态代码块");}

      • 按照代码顺序执行 v 赋值 10;

      • 最后执行构造器方法 Num(){};

    3. 实例化结束后,instance 变成了 new Num()对象。

    4. 继续顺序执行其他静态变量和静态代码块,也就是 static {System.out.println("执行静态代码块");}

    5. 最后执行匹配方法 funB()

执行main后日志打印如下

执行 Num.a 
执行非静态代码块
构造器方法
执行静态代码块
执行funB

扩展

类加载 和 实例化,过程如果有父类,会按照上述规则先执行父亲,在执行自己。
例子栗子🌰

public static void main(String[] args) {
        System.out.printf("执行 Num.a \n");
        Num.funB();
//        String str = Num.a;
    }

    static class NumParent{
        static {
            System.out.println("父亲的静态代码块");
        }
        static String aParent = "父亲A";

        int vParent = 9;

        NumParent(){
            System.out.println("父亲的构造方法");
        }

        {
            System.out.println("父亲的实例代码块");
        }
    }

    static class Num extends NumParent {
        static String a = "11";

        static Num instance = new Num();

        {
            System.out.println("执行非静态代码块");
        }
        int v = 10;

        static {
            System.out.println("执行静态代码块");
        }

        Num(){
            System.out.println("构造器方法");
        }

        public static void funB(){
            System.out.printf("执行funB");
        }
    }

以上代码执行顺序如下:

  • Num.funB();首次调用触发类加载,流程。

  • Num存在父NumParent,先执行NumParent类加载。

  • 为NumParent所有静态变量设置默认值 aParent = null;

  • 顺序执行静态代码块和静态变量的赋值 aParent = "父亲A"。

  • NumParent类加载结束,开始执行Num类加载。

    • 为Num所有静态变量设置默认值
    • 顺序执行静态变量和静态代码块,
      • String a 赋值了 “11”

      • 顺序执行 static Num instance = new Num(); 开始。触发了实例化,因为Num存在父类,所以优先执行父类实例化

        • 开始父类实例化流程。

        • 所有实例变量置默认值

        • 按照代码顺序执行实例变量的赋值,和实例代码块

        • int vParent 被赋值 9; 然后 打印实例代码块日志。

        • 最后执行父类实例化方法

        • 父类实例化流程结束

        • 开始当前类(Num)实例化流程

        • 所有实例变量设置默认值

        • 按代码顺序执行实例变量实例代码块

        • 打印了实例代码块日志,v 被赋值了 10;

        • 执行Num 构造器方法。

        • 创建了新的对象赋值给了instance

      • static Num instance = new Num();结束,继续按照代码顺序执行静态变量和静态代码块

      • 执行静态代码块,打印日志 static {System.out.println("执行静态代码块");}

  • 类加载流程结束,执行最后的匹配方法 funB();

图解

sequenceDiagram
    participant M as main()
    participant NP as NumParent Class
    participant N as Num Class
    participant Obj as Num Instance
    
    M->>N: 调用 Num.funB()
    Note over N: 触发 Num 类初始化<br/>需先初始化父类
    
    %% 父类初始化
    N->>NP: 初始化父类 NumParent
    NP->>NP: 准备阶段:静态变量默认值<br/>aParent = null
    NP->>NP: 初始化阶段开始
    NP->>NP: 执行静态代码块<br/>输出"父亲的静态代码块"
    NP->>NP: 静态变量赋值<br/>aParent = "父亲A"
    Note over NP: 父类静态初始化完成
    
    %% 子类初始化
    NP-->>N: 父类初始化完成
    N->>N: 准备阶段:静态变量默认值<br/>a = null, instance = null
    N->>N: 初始化阶段开始
    N->>N: 静态变量赋值<br/>a = "11"
    N->>N: 执行 static Num instance = new Num()<br/>触发实例化
    
    %% 实例化过程
    N->>Obj: 创建实例
    Note over Obj, NP: 实例化父类部分
    Obj->>NP: 父类实例化
    NP->>NP: 默认初始化<br/>vParent = 0
    NP->>NP: 执行实例代码块<br/>输出"父亲的实例代码块"
    NP->>NP: 实例变量赋值<br/>vParent = 9
    NP->>NP: 执行构造方法<br/>输出"父亲的构造方法"
    Note over NP: 父类实例化完成
    
    Note over Obj, N: 实例化子类部分
    Obj->>N: 子类实例化
    N->>N: 默认初始化<br/>v = 0
    N->>N: 执行实例代码块<br/>输出"执行非静态代码块"
    N->>N: 实例变量赋值<br/>v = 10
    N->>N: 执行构造方法<br/>输出"构造器方法"
    Note over Obj: 实例化完成
    
    %% 继续类初始化
    N->>N: 返回实例赋值给 instance
    N->>N: 执行静态代码块<br/>输出"执行静态代码块"
    Note over N: 类初始化完成
    
    %% 执行静态方法
    N-->>M: 类初始化完成
    M->>N: 执行 funB()<br/>输出"执行funB"

好的有宝子又要问了

神马我new 一个Num();对象也会触发他父类的初始化方法?那难道创建一个对象占用两个内存吗,一个Num的内存一个,NumParent的内存?

new 一个对象一个内存且是连续的 类似这样

graph LR
    A[子类对象内存块] --> B[对象头]
    A --> C[父类字段]
    A --> D[子类特有字段]
    
    C --> E[Object类字段]
    C --> F[直接父类字段]
    C --> G[...其他祖先类字段]
    
    D --> H[子类新增字段]
    D --> I[子类新增字段]
    
    style A fill:#e6f7ff,stroke:#1890ff,stroke-width:2px
    style B fill:#f9f0ff,stroke:#722ed1
    style C fill:#f6ffed,stroke:#52c41a
    style D fill:#fff2e8,stroke:#fa8c16

你说一个就一个?我要查你手机(>_<)。两个类的构造器方法,加点日志,打印一下你会发现

public static void main(String[] args) {
       System.out.println("地址 "  + Num.instance);
       System.out.println("地址 父 "  + (NumParent) Num.instance);
}

\\\省略无关代码

Num(){
    System.out.println("构造器方法 地址: " + this);
}

NumParent(){
    System.out.println("父亲的构造方法 地址: " + this);
}

日志如下 指向同一个地址50134894,包是一个。

父亲的构造方法 地址: com.example.demo.Test$Num@50134894 
构造器方法 地址: com.example.demo.Test$Num@50134894
地址 com.example.demo.Test$Num@50134894
地址 父 com.example.demo.Test$Num@50134894

灵魂拷问,我知道这些有什么用

写hello world 那肯定是不需要知道这些的。但是我们工作的历史长河中,有一天你可能会遇到类似这样的问题。

fun main() {
    // 定义商品类
    data class Product(val name: String, val price: Double)

    abstract class Discount {
        var discountRate = 1.0

        init {
            // 父类初始化时尝试计算折扣
            calcDiscount()
        }
        // 子类实现
        abstract fun calcDiscount()
    }

    class NationalDiscount : Discount() {
        private var subsidyRate = 0.85 // 85折

        init {
            println("设置国补折扣率: 0.85")
        }

        override fun calcDiscount() {
            // 漏洞点:父类初始化时调用,此时 实例变量 subsidyRate尚未初始化 为0
            discountRate = subsidyRate
            println("折扣计算: $discountRate")
        }
    }

    val iPhone = Product("iPhone 15 Pro", 9.9)
    println("购买 ${iPhone.name},应享85折国补...")
    // 应用国补折扣
    val discount = NationalDiscount()
    val finalPrice = iPhone.price * discount.discountRate
    println("  原价: ${iPhone.price} 元")
    println("  实付: $finalPrice 元")
}

运行日志如下 小黑本来想九块九交个朋友盆友的iPhone 15 变成 0元购。

购买 iPhone 15 Pro,应享85折国补...
折扣计算: 0.0
设置国补折扣率: 0.85
  原价: 9.9 元
  实付: 0.0

其他用于,热更新,插件化,动态加载,安全加固。不一一列举了(等我先学一下

以上内容,若有纰漏,错误,请大佬大刀扶正

参考

Java类加载器 — classloader 的原理及应用

面试官,你要跟我聊单例?那我可有话说了

Java类加载基础