Java 类加载机制

类是在运行期间第一次使用时动态加载的,而不是编译时期一次性加载。因为如果在编译时期一次性加载,那么会占用很多的内存。

1. 类的生命周期

在这里插入图片描述

包括以下 7 个阶段:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

加载验证准备初始化卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,强调开始是因为这些阶段通常都是相互交叉地混合式进行的,通常在一个阶段执行的过程中调用另一个阶段(比如加载阶段需要验证字节码,这就需要调用验证阶段,即加载阶段还没有结束,但是验证阶段已经开始),但是两个阶段的开始时间仍然保持着固定的先后顺序。

2. 类加载过程

包含了加载验证准备解析初始化这 5 个阶段。

2.1 加载

加载是类加载的一个阶段,注意不要混淆。

加载过程完成以下三件事:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时存储结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

其中二进制字节流可以从以下方式中获取(包括不限于):

  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,最典型的应用是 Applet。
  • 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 中就是使用了 ProxyGenerator.generateProxyClass 生成代理类的二进制字节流。
  • 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。
2.2 验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

从执行性能的角度来讲,验证阶段的工作量占了类加载子系统的很大一部分。

如果所运行的全部代码都被反复验证过,可以用 -Xverify:none 参数来关闭大部分的类验证措施,缩短类加载的时间。

2.3 准备

类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值(零值),使用的是方法区的内存。

实例变量不会在这阶段分配内存,它将会在对象实例化时随着对象一起分配在中。

注意,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次

初始值一般为 0 值,如果类变量是final类型,那么会按照表达式来进行初始化。

public static final int value = 123;  // 初始化为 123
public static int value = 123;   // 初始化为0
2.4 解析

常量池的符号引用替换为直接引用的过程,包括类或接口的解析字段解析类方法解析接口方法解析

  • 符号引用符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中,各虚拟机实现的内存布局不同,但他们能接受的符号引用必须是一致的。
  • 直接引用: 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄

其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定

2.5 初始化

初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段即虚拟机执行类构造器 <clinit>() 方法的过程。

准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。

<clinit>() 方法具有以下特点:

  • <clinit>() 方法是由编译器自动收集类中所有类变量的赋值动作静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
public class Test {
    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}
  • <clinit>() 方法与类的构造函数(或者说实例构造器 <init>())不同,不需要显式的调用父类的构造器。虚拟机会自动保证在子类的 <clinit>() 方法运行之前,父类的 <clinit>() 方法已经执行结束。因此虚拟机中第一个执行 <clinit>() 方法的类肯定为 java.lang.Object
  • 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。
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>() 方法在多线程环境下被正确的加锁同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。
2.6 实例化 (自己加的)

以上已经进行了类加载过程的5个阶段:加载、验证、准备、解析和初始化。

类的实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

类在实例化时会调用<init>() 方法,<init>() 方法先执行实例代码块和实例变量赋值,再执行构造方法。其中实例代码块实例变量赋值执行的顺序由语句在源文件中出现的顺序决定。执行当前类的<init>() 方法前会执行父类的<init>() 方法。

这个阶段在中分配实例变量的内存(类变量方法区),对内存清零,执行实例变量初始化和普通语句块(按书写顺序)。最后执行构造函数。

所以类初始化的顺序是(均为第一次加载类):

  • 父类(类变量赋值,静态语句块):父类 <clinit>()
    (两者按照在源文件中出现的顺序决定执行顺序)
  • 子类(类变量赋值,静态语句块):子类 <clinit>()
    (两者按照在源文件中出现的顺序决定执行顺序)
  • 父类(实例变量,实例代码块): 父类 <init>()
    (两者按照在源文件中出现的顺序决定执行顺序)
  • 父类(构造函数):父类 <init>()
  • 子类(实例变量,实例代码块):子类 <init>()
  • (两者按照在源文件中出现的顺序决定执行顺序)
  • 子类(构造函数):子类 <init>()

代码测试:

class Test {
	public Test(String str) {
		System.out.print(str);
	}
}

class Father {
	static {	
		System.out.print(1);
	}
	
	{    
		System.out.print(4);    
	}
	
	Father() {
		System.out.print(5);
	}
}

class Son extends Father {
	
	Test test = new Test("6");
	
	static Test test2 = new Test("2"); 
	
	static {
		System.out.print(3);
	}
	
	public Son() {
		System.out.print(9);
	}
    
	{
		System.out.print(7);
	}
	
	Test test3 = new Test("8");
}

public class Main {
	public static void main(String[] args) {
    	Son s = new Son();
	}
}

执行结果:

123456789

3. 类初始化时机

3.1 五种主动引用

虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化加载验证准备都会随之发生):

(1) 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;以及调用一个类的静态方法的时候;读取或设置一个类的静态字段的时候,但这里有个例外,比如其他类只读取A类static final int这种被 final 修饰、已在编译期把结果放入常量池的静态字段,则A类无需进行初始化部分,即不需执行<clinit>()方法。如果读取A类static final Integer这种编译期无法确定的类型,则还需执行<clinit>()方法。

class Test {
    public static final int a = 1;
    public static int b = 2;
    static {
        System.out.println("init!");
    }
}
public class Main {

    public static void main(String[] args) {
        System.out.println(Test.b);  
    }
    // 这里Test.b不是final类型,需要进行“初始化”,即调用 <clinit>() 方法。
    //  输出:
    //  init!
    //  2
}
class Test {
    public static final int a = 1;
    public static int b = 2;
    static {
        System.out.println("init!");
    }
}
public class Main {

    public static void main(String[] args) {
        System.out.println(Test.b);  
    }
    //  输出:
    //  2
    // final类型的类变量在“准备”期间已经赋值,无需进行"初始化"就可以直接使用。
}

(2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

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

(4) 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

(5) 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

3.2 三种被动引用

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的三种常见例子包括:

  • 通过子类引用父类的静态字段,不会导致子类初始化。
class SuperClass {
    public static int b = 2;
    static {
        System.out.println("SuperClass init!");
    }
}

class SubClass extends SuperClass {

    static {
        System.out.println("SubClass init!");
    }
}

public class Main {

    static {
        System.out.println("Main init!");
    }

    public static void main(String[] args) {
        System.out.println(SubClass.b);  // 这里SubClass类无需初始化
    }
}

运行结果

Main init!
SuperClass init!
2
  • 通过数组定义来引用类,不会触发此类的初始化。该过程会由虚拟机自动生成一个直接继承自 java.lang.Object 的子类,这个类代表了一个一维数组,数组中应有的属性和方法都实现在这个类中。
class SuperClass {
    public static int b = 2;
    private int a = 3;
    static {
        System.out.println("SuperClass init!");
    }
    public int getA() {
        return a;
    }
}

public class Main {

    static {
        System.out.println("Main init!");
    }

    public static void main(String[] args) {
        SuperClass[] sup = new SuperClass[10]; // 不初始化SuperClass类
        System.out.println("111");
        System.out.println(sup[5].b);  // 初始化SuperClass类
    }
}

运行结果

Main init!
111
SuperClass init!
2
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。(即主动引用第一种情况中除外的内容)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值