JAVA基础学习【JVM篇】——类加载机制

JAVA虚拟机把描述类的数据从Class文件加载到内存中,并对数据进行校验,转换解析和初始化,最终形成能被JVM直接使用的JAVA类型,这个过程就叫类的加载。

类的生命周期

类的生命周期
包括以下7个阶段

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

前五个过程被称为类的加载过程,其中解析和初始化的顺序可能反过来,这是为了支持JAVA的动态绑定。这5个过程会按照顺序开始,但是不一定是一个完成后再开始下一个,很有可能在前一个过程还没结束,下一个过程就开始了。

类的初始化时机

JVM中并没有明确规定何时进行加载,但是严格规定了有且只有以下5中情况必须对类进行初始化(很自然的,加载,验证,准备都要在这之前开始)

  1. 遇到new、getstatic、setstatic、invokestatic这四条字节码指令时,如果类没有被初始化,那么必须执行初始化,最常生产这4条指令的语句是:
    • 使用new关键字实例化对象的时候
    • 设置或读取类的一个静态字段(如果被final修饰,在编译期间就已经被放入当前类的常量池的除外)
    • 调用一个类的静态方法的时候
  2. 使用java.lang.reflect包对类进行反射调用的时候,如果类没有被初始化,那么需要出发类的初始化
  3. 当初始化一个类的时候,如果其父类还没有被初始化,那么要先初始化其父类
  4. 当虚拟机启动时,需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先出发这个类的初始化
  5. 当使用 JDK.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

以上5种场景的任何一种都称为对类的主动引用。除此之外,所有引用类的方式都不会触发类的初始化,被称为被动引用,常见场景有以下:

  • 通过子类引用父类字段,不会导致子类初始化
class SuperClass{
    String value;
    //...
}

class SubClass extends SuperClass{
    //...
}

System.out.println(SubClass.value); // value 字段在 SuperClass 中定义

对于静态字段,只有直接定义这个字段的类才会被初始化

  • 通过数组对象来引用类,不会触发被引用类的初始化 。这个过程会触发数组类的初始化,数组类是由虚拟机自动生成的,直接继承自Object的类,并且有数组的属性和方法,比如length属性
SuperClass[] scs = new SuperClass[10];

这个类是一个一维数组类,数组中应有的方法和属性都在这个类里面

  • 常量在编译器会被引入到调用类的常量池中,本质上没有引用到定义常量的类,因此不会触发定义常量的类的初始化
public class ConstClass{
    static{
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}
public class NotInitialization{
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}

虽然在代码中引用类ConstClass类中的常量HELLOWORLD,但是在编译器时,由于传播优化,常量值”hello wold”已经储存到了NotInitialization类的常量池中去了,之后对于ConstClass.HELLOWORLD的引用实际上都转化到了对NotInitialization自身的常量池中的引用

对于接口的初始化与类的初始化有所不同,主要的不同点在于,类的初始化需要父类在此之前完成初始化,但是接口不用,只有在实际使用到父类接口时才去初始化父类,比如调用父类的方法时

类加载过程

加载

类加载和加载是两个不同的概念,加载是类加载的一个阶段

加载主要完成以下三个阶段
  1. 通过一个类的全额限定名来获取该类的二进制流
  2. 将该静态存储的二进制流转化为方法区中运行时存储结构
  3. 在内存中生成一个该类的class对象,作为方法区这个类各种数据访问的接口。(这里说了是在内存中生成,有一种说法是class对象是存在方法区的,但笔者认为这种说法是错误的,或者说是过时的,class对象依然是存放在堆中的)
    其中,二进制字节流可以通过以下几种方法获取

    • 从ZIP包中获取,很常见,成为日后jar、war格式的基础
    • 从网络中获取,典型的就是Applet
    • 运行时动态生成,最常见的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass的代理类的二进制字节流
    • 由其他类型生成,典型场景是JSP,即由JSP文件生成class流
    • 从数据库读取
数组类的加载

数组类本身不通过类加载器创建,它是由JVM直接创建的,但是数组类的元素类型(去掉所有维度后的类型)最终是需要通过类加载器创建。

  • 如果数组的组件类型(去掉一个维度后的类型)是引用类型,那就递归采用本节中定义的类加载过程进行加载,该数组将在加载该组件类型的类加载器的类名称空间上被标识(一个类必须和一个类加载器共同确定其唯一性)
  • 如果数组的组件类型不是引用类型(例如int[]),Java虚拟机会把该数组标记为与引导类加载器相关联
  • 数组类的可见性与他的组件类型相同,如果组件类型不是引用类型,那么数组类的可见性默认为public

验证

确保class文件符合虚拟机的格式规范,并且不会威胁到虚拟机自身的安全

  1. 文件格式验证
    验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
  2. 元数据检验
    对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
  3. 字节码验证
    通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。
  4. 符号引用验证
    发生在虚拟机将符号引用转换为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

符号引用(Symbolic References):
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

准备

  • 准备阶段为类变量分配内存并设置初始值,所用到的内存都在方法区分配。
  • 在该阶段只为类变量分配内存,实例变量会随着对象的创建在堆中分配内存。

初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。

public static int value = 123;

如果类变量是常量,那么会按照表达式来进行初始化,而不是赋值为 0。

public static final int value = 123;//编译时Javac会为value生成ConstantValue属性

顺便一提,boolean的初始值是false

解析

解析过程是将常量池中的符号引用替换为直接引用的过程。在验证阶段,已经详细的解释了什么是符号引用,下面说一些直接引用是什么

直接引用(Direct References): 直接引用可以是直接目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局有关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在。

虚拟机规范并未规定解析动作发生的具体时间,仅要求在执行anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic这13个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号进行解析,还是等到一个符号引用将要被使用前才去解析它

初始化

在初始化阶段才真正开始执行类中的JAVA代码,初始化阶段即虚拟机执行执行类构造器<clinit()>方法的过程。在准备阶段,我们已经为类变量分配了内存和初始值,在初始化阶段,我们为它真正的赋值。

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

  • 由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{})中的语句产生的,收集的顺序按照在源代码中的顺序决定。特别注意的是,静态语句块只能访问在它之前定义的变量,对于在它之后的变量只能赋值.
public class Test {
    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}
  • 与类的构造函数不同,类的<clinit()>方法在调用时,能保证父类的<clinit()>方法已经调用完毕,所以不需要显示的调用父类的<clinit()>方法
  • <clinit()>方法对于类或接口来说不是必须的,如果没有对类变量的赋值,也没有静态语句块,那么编译器可以不生成<clinit()>方法
  • 接口中没有静态语句块,但是可以有静态变量赋值操作,所以也可以有<clinit()>方法,但是接口的父类不一定要在接口<clinit()>方法执行前执行完毕,可以在实际需要使用父类的变量时才执行父类的<clinit()>方法
  • 虚拟机会保证一个类的 () 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 () 方法,其它线程都会阻塞等待,直到活动线程执行 () 方法完毕。如果在一个类的 () 方法中有耗时的操作,就可能造成多个进程阻塞,在实际过程中此种阻塞很隐蔽。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值