虚拟机类加载机制

目录

前言

类加载概述

类初始化的时机

主动引用案例

加载

链接

验证

文件格式验证

元数据验证

字节码验证

符号引用验证

准备

解析

初始化

本文脑图

前言

在上一章Java运行时数据区我们了解Java虚拟机在执行Java程序过程中如果对它所管理的内存进行划分的。有了这个基础今天我们来了解Java虚拟机是如何将一个Java代码加载进内存的。

类加载概述

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

Class类通常是以文件的形式存在的(当然,任何形式的二进制流都可以是Class类型),只有装载进Java虚拟机的Class类型才可以被程序使用。

虚拟机装载Class类型主要分为加载、连接和初始化这3个步骤,而这3步又可以在程序运行期间完成,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性。

 

类初始化的时机

Class只有在必须使用的时候在会被装载,Java虚拟机不会无缘无故的装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里的“使用”指的是主动使用,被动使用不会引起初始化。通常情况下主动使用只有下列几种情况:

  1. 当创建一个类的实体方法时,比如使用new关键字,或者通过反射、克隆、反序列化
  2. 当调用类的静态方法,既当使用的字节码invokestatic指令
  3. 当使用类或接口的静态字段时(final常量除外),比如使用getstatic或者putstatic指令
  4. 当使用java.lang.reflect包的方法对类型进行反射调用的时候
  5. 当初始化子类时,要求先初始化父类
  6. 作为虚拟机,含有main()方法的那个类。

主动引用案例

public class 主动引用 {
    public static void main(String[] args) {
        Child child = new Child();
    }
}
class Parent {
    static {
        System.out.println("Parent init");
    }
}
class Child extends Parent {
    static {
        System.out.println("Child init");
    }
}

上面代码声明了3各类,Child是Parent的子类。当我们要加载Child类的时候,系统发现当前父类还未初始化,首先会初始化父类,符合我们上面的条件5。所以后的结果为:

Parent init
Child init

被动引用案例

主动引用比较好判断,但是被动引用不会引起类装载,我们看如下例子:

public class 被动引用01 {
    public static void main(String[] args) {
        System.out.println(Child.v);
    }
}
class Parent {
    static {
        System.out.println("Parent init");
    }
    public static int v = 100;
}
class Child extends Parent {
    static {
        System.out.println("Child init");
    }
}

Parent中设置了一个静态变量v,然后我们使用子类去调用父类的静态方法,运行上面的代码,我们输出了结果如下:

Parent init
100

我们可以看出,虽然我们直接访问了子类,但是Child并没有被初始化(但是此时Child已经被系统加载),只有父类Parent被初始化。所以我们引用一个静态变量,只有直接定义该字段的类才会被初始化。

引用一个静态变量时,只有直接定义该字段的类才会被初始化。但是如果这个引用的这个静态被final标记的话,则不会引起类初始化。

ublic class 被动引用02 {
    public static void main(String[] args) {
        System.out.println(FinalFieldClass.constString);
    }
}
class FinalFieldClass {
    public static final String constString = "CONST";
    static {
        System.out.println("FinalFieldClass init");
    }
}

上面代码结果输出为

CONST

如果一个static的静态变量被final标记,那么这个变量就会升级为常量。因为常量不可变,所以JVM就做了优化,既然这个常量不会变了,那么我就在编译阶段直接把这个常量存入我的常量池中。后续使用都在我自己的常量池中调用,所以本质上没有直接引用定义常量的类,所以不会触发定义常量类FinalFieldClass的初始化。

我们反编译代码后,查看上述代码中main方法的字节码文件,这时候我们注意偏移量3的字节码。ldc字节码指令中表示将常量池中的常量入栈,而我们发现常量池#4中的常量刚好是“CONST”,所以也符合我们之前的解释,对于引用的是常量,编译阶段直接就被放入我们的常量池了。

 

加载

类加载的第一个阶段是加载,加载是指查找字节流,并根据此创建类的过程。加载阶段主要完成3件事情。

  1. 通过一个类的全限定名来获取次类的二进制字节流

这句话初读没什么特点,但是有很魔性,因为“通过一个类的全限定名来获取次类的二进制字节流”,它并没有指定从哪里获取,怎么获取。常见的话就是zip包中;也可以从网络中获取;也可以通过运行时动态代理技术生成二进制字节流。

  1. 将这个字节流所代表的静态存储结构转化为方法区运行时的数据结构
  2. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类各种数据的访问入口

链接

链接是指将创建成的类合并至Java虚拟机中,使之能够执行的过程。它又可分为验证、准备以及解析3个阶段。

验证

验证是链接操作的第一步,主要是为了保证加载的字节码是合法、合理并符合规范的。确保输入的Class文件的字节流能正确解析并存储于方法区之类,格式上符合一个Java类型的信息要求,并且不会危害虚拟机自身的安全。验证阶段是否严谨,直接决定了Java虚拟机能够承受恶意代码的攻击。从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

第一阶段主要验证字节流是否符合Class文件格式的规范,并且是否能够当前版本的虚拟机处理。比如,是否是以魔数0xCAFEBABE开头,主版本号和小版本号是否在当前Java虚拟机支持的范围,数据中每一个项是否都拥有正确的长度等等。

这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储。

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。比如是否所有的类都有父类存在(在Java里除了Object外,其他类都应该有父类),是否有一些被定义为final的方法被重载或继承了,非抽象类是否实现了所有的抽象方法或者接口方法。

但凡在语义上不符合规范的,虚拟机也不会给予验证通过。

字节码验证

第三阶段是对字节码进行逻辑验证,字节码验证是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如,在字节码执行过程中,是否会跳转到一条不存在的指令,函数调用是否传递了正确的参数,变量的赋值是不是给了正确的数据类型。

遗憾的是100%准确地判断一段字节码是否可以被安全的执行是无法实现的,只能说是尽可能的检查出已知的问题。没有通过这个阶段,虚拟机不会正确的装载这个类,但是通过了也不能说明这个类是完全没问题的。

符号引用验证

最后一个阶段发生在虚拟机将符号引用转化成直接引用的时候。Class文件再其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在检验阶段,虚拟机就会检查这些类或者方法是否真的存在,是否有权限访问。

如果一个需要使用的类无法在系统中找到,则会抛出NoClassDefFoundError;如果一个方法无法被找到,则会抛出NoSuchMethodError。

准备

当一个类验证通过时,虚拟机就会进入准备阶段。准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存,并设置类变量(没加static的变量)初始值的阶段。Java虚拟机会为各类变量默认的初始值如下表:

 

需要注意的是,准备阶段进行内存分配的仅包括类变量(也叫静态变量,被static修饰),而不包括实例变量(没加static的变量),实例变量将会在对象实例化时随着对象一起分配在Java堆中。

假设有一个类变量的定义如下:

public class Test {
    public static int value = 123;
}

变量value在准备阶段过后设置的初始值为0而不是123,而把value赋值为123是类被编译后,初始化阶段中类构造器中执行的。如何证明这个观点呢?我们把Test类进行反编译后,得到字节码文件,构造器的字节码指令如下图:

字节码指令也很简单只有3个,首先bipush将有符号的int值123存入操作数栈。之后putstatic给静态字段(static value)赋值。而执行构造器是在初始化阶段。

 

如果是常量字段,那么常量字段会在准备阶段被赋上正确的值,这个赋值属于Java虚拟机的行为。而准备阶段不会有任何Java代码被执行。比如我们定义了以下常量:

public class Test {
    public static final String constString = "Hello World";
}

那么此时生成的Class文件,就可以看到该字段含有的ConsantValue属性,直接存放于常量池中。该常量池constString在准备阶段附上了字符串“Hello World”了。

 

解析

准备阶段完成之后,就进入了解析阶段。解析阶段的工作就是将类、接口、字段和方法的符号引用转化为直接引用。符号引用就是一些字面量引用,和虚拟机内部数据结构和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。

为了解释清楚什么是符号引用什么是间接引用,我们准备如下代码:

public class Test {
    public void print() {
        System.out.println();
    }
}

方法很简单,只是调用执行了了一个打印方法,然后我们编译这个程序得到他的字节码文件

 

在偏移量为3的字节码指令为invokevirtual,他的作用是调用类的静态方法,但是程序并不知道要调用哪个方法,给了你一个常量池地址#3,这时候来到了常量池:

 

我们通过常量池#3项,并且分析这个常量池,就得到了如下结构

 

常量池的#3项目被字节码指令invokevirtual调用,顺着这个关系表我们就可以得到所有对于Class和NameAndType类型的引用都是基于字符串的。因此可以认为invokevirtual的函数调用通过字面量的引用描述已经表达清楚。这就是符号引用。

在程序实际运行的过程中,只有符号引用是不够的,因为系统并不知道符号所代表的方法是否真的存在。当println()方法被调用的时候,系统需要明确知道该方法的位置。Java虚拟机为没一个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。

通过解析操作,符号引用就可以转变成为目标方法在类中方法表的位置,从而使方法被成功调用。所以所谓的符号引用转化成为直接引用,也就是得到类或者字段、方法在类村中指针或者偏移量。因此如果直接引用存在,那么系统中肯定存在该类、方法或者字段。但是如果只存在符号引用,系统中不一定存在该对象。

初始化

初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以额顺利的装载到系统中。知道此时Java虚拟机才开始真正的执行类中变的Java程序代码,将主导权交给应用程序

在准备阶段时,变量已经被复制过一次系统的默认初始值,初始化阶段,则会根据程序员通过编码指定的主观计划去初始化变量和其他资源。举个简单的例子就是如下代码:

public class Test {
    private int aInt = 3;
}

在准备阶段aInt的值被系统设置为默认的0,而初始化阶段就是执行类构造器<clinit>()方法将其赋值为3。关于<clinit>()的执行我们有以下几点需要特别关注,

1. <clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成的。它是由静态成员的复制语句以及static语句块合并产生的。编译器在手机程序资源,生成字节码指令是由代码中出现的顺序而决定的。也就是说static语句块只能访问定义在静态语句块之前的变量,而之后的变量是无法访问,代码如下:

由于在加载一个类之前,虚拟机总会试图加载该类的父类。因此父类的<clinit>()总在子类<clinit>()之前。因此在Java虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object。

2. 由于父类的<clinit>()方法总是优与子类,也就意味着父类中定义的静态语句块一定会由于子类的赋值操作。代码如下:

public class Parent {
    static int aInt = 3;
    static {
        aInt = 0;
    }
}
static class Sub extends Parent {
    static int aInt = 2;
}
public static void main(String[] args) {
    System.out.println(Sub.aInt);
}

调用Sub首先会加载Parent构造器,Parent构造器中根据顺序先执行变量的赋值此时aInt=3,然后执行静态代码块又被赋值为0。Parent的构造器执行完毕后开始执行Sub的构造器,此时被赋值为2。所以最终打印结果为2.

3. 并不是所有的类或者方法都需要<clinit>()方法,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法.

public class StaticFinalClass {
    public static final int i = 1;    
}

例如StaticFinalClass方法中只有一个常量i,而常量再准备阶段就已经初始化过了,所以对于该类来说<clinit>()就无事可做,因此产生的class文件中也就没有该函数的存在。

4. 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

5. 对于<clinit>()函数的调用,虚拟机要确保其多线程环境中的安全性,也就是说,当多个线程试图初始化同一类时,只有一个线程可以进入<clinit>()函数,其他线程必须等待,如果之前线程成功加载了类,则等再队列中的线程就没有机会再执行<clinit>()函数了(当需要使用这个类是,虚拟机会直接返回给它已经准备好的信息)。

正是因为<clinit>()是带锁线程安全的,如果在一个类<clinit>()方法中有耗时很长的操作,那么就可以造成多个进程的阻塞,而这种阻塞网袜是很隐蔽的。同时也可能会造成死锁。

本文脑图

 

 

最后,如果感觉对你有帮助就来个二连吧:关注、点赞!

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值