浅谈JVM类加载机制

时间:2022-12-29 09:33:29

引言

jvm在加载类的时候主要分为以下三步:加载,连接,初始化。

类加载器加载字节码文件

  • 在这个阶段,JVM需要完成以下两件件事情:

    1. 通过一个类的全限定名来获取一个类的二进制字节流,并把它读进JVM内部的方法区中。
    2. 在内存的方法区中生成这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 什么是类加载器?


    简单的说:通过一个类的全限定名(包名.类名)来读取此类的二进制字节流进JVM内部的方法区,并把它转化为一个Class对象,把实现这个动作的代码模块称为“类加载器”。例如下面要介绍的常用的三种类加载器:启动类加载器,扩展类加载器,应用类加载器。后两种都继承与抽象类ClassLoader,ClassLoader有以下几个常用方法:

    1. getParent():获取该类加载器的父类加载器
    2. loadClass(String name):加载name类,返回该类的Class实例,该方法其实也就是双亲委派模型在语法层面上的具体实现。
    3. findClass(String name):以流的形式读取一个类的二进制字节码进JVM内部并存储在方法区中(以字节数组的形式),最终返回一个Class实例。loadClass()方法内部调用了该方法。
    4. findLoadedClass(String name):在已经加载的类中查找name类,如果找到就返回该类实例,没有找到返回null。
    5. defineClass(String name,byte[] b,int off,int len):将字节数组b中的内容转换成一个Class实例,并返回。
  • 常见的三种类加载器:


    在程序中常见的类加载器主要由三种:BootstrapClassLoader(启动类加载器),ExtClassLoader(扩展类加载器),AppClassLoader(应用类加载器)。除了顶层的启动类加载器,其余的类加载器都应当有自己的父类加载器,这里的类加载器之间的父子关系一般不会以继承关系来实现,而是以组合关系(一个类中持有另一个类的对象的引用)来实现的。

    1. 启动类加载器并不是由java语言编写的,而是由C++语言编写的,嵌套在JVM内部的,主要负责加载"JAVA_HOME/lib"(这里的JAVA_HOME是jre所在的目录)目录中的所有.class文件
    2. 扩展类加载器主要负责加载"JAVA_HOME/lib/ext"目录下的.class文件
    3. 应用类加载器(在没有显示的指定类的加载器的时候,该加载器是默认的类加载器),主要负责加载CLASSPATH路径下的.class文件,由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。
  • 双亲委派模型:


    那么什么是双亲委派模型呢?举一个贴近生活的例子。小娇前些天在一家手机专营店购买了一台某个品牌的智能手机,但是在使用的过程中由于非人为损坏导致手机无法正常使用,于是小娇准备拿着手机保修单,要求商家按照手机保修单上的承诺免费为其更换一台全新的同款手机。当小娇来到所购手机的专营店后,商家只是审核了手机之前的退换货记录(手机只能免费更换一次),但没有立即给予小娇明确答复,也没有对手机进行损坏检测,而是直接将小娇的情况反映给了这家手机专营店的总店,并告知小娇前往总店商量相关的退换货事宜。尽管非常的麻烦和极度不情愿,小娇还是按照商家的要求来到了总店,当总店的维修人员对手机做了详细的损坏检测后,最终同意为小娇免费更换一台全新的同款智能手机。但由于小娇并非是在总店处购买的,因此只能前往分店进行更换,于是小娇辗转反侧由重新回到了手机的购买处,当填写完相关的退换货资料并存档后,小娇终于高兴的拿着新手机回家了。

    尽管这个故事很无聊,但是这和类加载器的加载机制非常的相似。JVM中便是通过一种被称之为双亲委派模型的委派机制来约定类加载器的加载机制:

    1. 当一个类加载器接收到一个加载任务(.class文件,相当于上述故事的待修手机)的时候它并不是马上去执行,而是把这个请求委派给父类加载器去执行,每一个层次的类加载器都是如此,因此所有的请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(在该加载器搜索的目录范围内没有找到所需的类,总店无法更换)的时候,子加载器才会尝试自己去加载。
    2. 使用双亲委派模型来组织类加载器之间的关系时,无论哪一个类加载器要加载一个类,都会首先到JAVA_HOME/lib文件夹下查找,如果你自己在CLASSPATH路径下写了个java.lang.Object类,那么你写的Object类永远也不会加载,加载的始终是lib文件夹中rt.jar中的Object类,从而有效的保证一个类的全局唯一性。
    3. 附上一张层次关系图:
      浅谈JVM类加载机制

    双亲委派模型在语法层面上是由loadClass方法实现的(代码如下):


    protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException
    {
    synchronized (getClassLoadingLock(name)) {
    // 首先检查目标类型之前是否已经被成功加载过了
    Class c = findLoadedClass(name);
    if (c == null) {
    long t0 = System.nanoTime();
    try {
    // 如果存在父类加载器,就委派给父类加载器执行加载
    if (parent != null) {
    c = parent.loadClass(name, false);
    } else {
    // 如果不存在父类加载器,就直接委派给顶层的启动类加载器执行加载
    c = findBootstrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    // 当抛出ClassNotFoundException异常时,则意味着父类加载器加载失败
    // ClassNotFoundException thrown if class not found
    // from the non-null parent class loader
    }

    if (c == null) {
    // 如果父类加载器无法加载时,则自行加载
    // If still not found, then invoke findClass in order
    // to find the class.
    long t1 = System.nanoTime();
    c = findClass(name);

    // this is the defining class loader; record the stats
    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
    sun.misc.PerfCounter.getFindClasses().increment();
    }
    }
    if (resolve) {
    resolveClass(c);
    }
    return c;
    }
    }

    注:Tomcat中的类加载器采用的并不是双亲委派机制,当默认的类加载器接收到一个类加载任务的时候先会自行加载,如果加载失败然后才会委派给父类加载器去执行加载任务。

连接

连接又可以分为三步:验证,准备,解析

  • 验证

    1. 格式验证:

      格式验证的主要内容是验证当前正在加载的字节码文件是否符合JVM的规范,是否是一个有效的字节码文件。
      主要是检查.class文件的前四个字节是否是0xCAFEBABE,CAFEBABE “咖啡baby”是JVM是用来校验一个字节码文件是否是一个合法的.class文件的标识。后面的第5个和第6个字节代表编译的次版本号,第7个和第8个字节代表编译的主版本号,对字节码文件版本号的验证和“咖啡baby”的验证同样重要,因为高版本JDK编译的字节码文件是不能在低版本的JVM中运行的。
      注:格式验证是在类加载器加载.class文件进内存的时候就进行了验证!

    2. 元数据验证:
      元数据验证的主要内容是验证字节码信息是否符合Java语法规范,比如:

      • 检查final修饰的类是否有子类
      • 检查final修饰的方法是否被重写
      • 检查是否出现了不符合规则的方法重载
      • 一个非抽象类继承了抽象类或者implements了一个接口的时候,是否实现了要求的所有方法

    3. 字节码验证:
      这个阶段将对类的方法体进行校验分析,保证该方法在运行的时候不会做出危害虚拟机安全的事。比如:检测方法体中的类型转换是否有效,把一个父类对象直接赋值给子类引用就是不合法的。
    4. 符号引用验证:
      在介绍符号引用验证之前先说一下解析阶段的主要任务:将常量池中的所有符号引用验证全部转换为直接引用。而符号引用验证的主要任务就是验证这些需要被转化为直接引用的这些符号引用是否正确,如:
      • 符号引用中的类、字段方法的访问性(private , protected , public , default)是否可被当前类访问
      • 在指定的类中是否存在相应的方法或字段
    注:验证阶段的元数据验证,字节码验证,符号引用验证都是在方法区中进行的,格式验证是在类加载器加载.class文件的时候就进行了验证(还没进方法区),还有大家要注意的一点的是:类加载阶段生成的Class对象是存储在方法区中的。

    在这里顺便说一下:对象不仅可以存储在堆,方法区中,还可以通过逃逸分析在栈上为对象分配空间。逃逸分析是JVM在执行性能优化之前的一种分析技术,比如:在一个方法内部创建了一个对象,但是它的引用被赋值给了外部变量,这个对象就发生了逃逸,反之,如果定义在方法体内部的变量没有赋值给任何的外部变量,那么就没有发生逃逸,JVM就会在栈上为其分配内存空间。栈上分配的优点:由于对象直接在栈上分配的内存,所以栈上分配的对象所占用的内存空间会随着出栈而被释放,而不会调用GC进行垃圾回收,从而降低了GC的回收频率和提升了GC的回收效率。在JDK 6u23版本之后,HotSpot中默认就已经开启了逃逸分析。

    总结:对象可以存储在堆,栈,方法区中。
  • 准备

    上面稍微扩展了一下,下面回归正题。在准备阶段要做的事就是:对类中的静态变量进行“初始化”。这里的初始化操作仅仅只是对类中的静态变量分配内存空间,并且设置一个默认的初始值,“通常情况”下初始值就是下表中的初始值,“特殊情况”如果静态变量被final修饰,那么它在准备阶段就被赋予了指定的值,下面代码中的value在准备阶段的值就已经是123了。

    public static final int value = 123;

    变量类型 初始值
    byte (byte)0
    short (short)0
    int 0
    long 0L
    float 0.0f
    double 0.0d
    char '\u0000'
    boolean false
    引用 null
  • 解析

    解析阶段的主要任务就是将字节码常量池中的符号引用全部转化为直接引用,比如说下面第二行代码在类执行到解析阶段的时候,DEMO_NUM那个位置就相当于你在源程序里面直接写了个5.

    public static final int DEMO_NUM = 5;
    System.out.println(DEMO_NUM);

初始化

类加载的最后一个阶段就是初始化,这个阶段做了两件事:

  1. 静态变量,会使用用户指定的值覆盖掉之前准备阶段默认初始化的值(也就是显示初始化),如果用户没有显示的为静态变量赋值,那么相应的静态变量持有的仍然是准备阶段默认初始化的值。
  2. 静态代码块里面的内容会被执行。
  3. 还有一件事: 上面两件事的执行顺序是按照源代码中的顺序锁决定的--自上而下。
  4. 还要注意一个非法向前引用变量的问题:静态语句块中只能访问到定义在静态语句块之前的静态变量,定义在它之后的静态变量只可以赋值,但是不可以访问。代码如下:

    public class Test{
    static{
    i = 0; //给变量赋值可以正常编译通过
    System.out.println(i); //这句编译器会提示“非法向前引用”
    }
    static int i = 1;
    }

类加载的时机

在说完了JVM类加载的全过程之后,再来说一下什么情况下类会初始化(加载,连接会在这之前就开始)?

  1. 当使用new关键字实例化对象的时候,读取或者设置一个类的静态字段(被final修饰,已在编译时期把结果放入常量池的静态字段除外)的时候,调用一个类的静态方法时。
  2. 对类进行反射调用的时候,如:Class.forName("xxx"),如果xxx类没有进行初始化,就会触发初始化。
  3. 初始化一个类的时候,如果父类还没有被初始化,需要先初始化父类。(接口有点特殊,后面会细说)
  4. JVM启动的时候,虚拟机会先初始化主类(有main方法的类),当然也得符合第三条的规定。
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后解析出REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,但这个方法句柄对应的类并没有初始化,则首先需要对这个类进行初始化。

注:“对上述的5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:有且只有”,这5中场景中的行为称为对一个类的主动引用。--《深入理解Java虚拟机》 周志明著。
除此之外,所有引用类的方式都不会触发类的初始化,举几个被动引用的例子:

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

    public class SubClass extends SuperClass{
    static{
    System.out.println("SubClass init!");
    }
    }

    /**
    * 被动使用字段演示
    */

    public class NotInitialization{
    public static void main(String[] args){
    System.out.println(SubClass.value);
    }
    }
    输出:SuperClass init!
  2. 定义引用类数组对象的时候,不会触发类的初始化
    publci class NotInitialization{
    public static void main(String[] args){
    SuperClass[] sca = new SuperClass[10];
    }
    }
    啥也没输出,因为数组类本身不通过类加载器创建,是由Java虚拟机直接创建的
  3. 调用final修饰的常量的时候不会触发常量所在的类初始化
    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);
    }

    }
    输出:hello world
    因为ConstClass中的HELLOWORLD常量在编译阶段通过常量传播优化,已经将此常量的值"hello world"存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用实际都转换为NotInitialization类对自身常量池的引用。
  4. 接口初始化的时候,并不要求父接口全部完成初始化,只有在真正用到父接口的时候(如引用接口中定义的常量)才会初始化父接口。 
    注:接口中不能使用"static{}"语句块。