【JAVA进阶架构师指南】之三:深入了解类加载机制

前言

  在上一篇文章中,我们知道了JVM的内存划分,其中在说到方法区的时候说到方法区中存放的信息包括[已被JVM加载的类信息,常量,静态变量,即时编译的代码等],整个方法区其实就和类加载有关.

类加载过程

  类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段.它们开始的顺序如下图所示:
file
  而类加载是指的前五个阶段,在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它既可能在初始化前开始(静态分派),也可能初始化阶段之后开始(动态分派)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

静态分派和动态分派

  我们都知道,JAVA语言三大特性是封装、继承和多态,其中多态的底层实现原理就是静态分派和动态分派机制.那么什么是静态分派和动态分派呢?

静态分派

  所谓静态分派,指的是方法在程序真正执行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的.换句话说,调用目标在编译器进行编译时就必须确定下来,这类方法的调用称为解析.符合静态分派的方法有静态方法、私有方法、实例构造器和父类方法四类,它们在类加载时就会把符号引用解析为该方法的直接引用,对应的过程发生在类解析阶段.典型的应用是对应于多态中的方法重载:

class Human{  
}    
class Man extends Human{  
}  
class Woman extends Human{  
}  

public class StaticPai{  

    public void say(Human hum){  
        System.out.println("I am human");  
    }  
    public void say(Man hum){  
        System.out.println("I am man");  
    }  
    public void say(Woman hum){  
        System.out.println("I am woman");  
    }  

    public static void main(String[] args){  
        Human man = new Man();  
        Human woman = new Woman();  
        StaticPai sp = new StaticPai();  
        sp.say(man);  
        sp.say(woman);  
    }  
}  

  很明显,答案会是:

 I am human
 I am human

  以上结果应该不难分析,首先Human man = new Man()中,我们把Human称为静态类型,Man称为实际类型,由于编译器在方法重载时是通过参数的静态类型而不是实际类型作为判定依据的,因此执行结果如上,这就是静态分派最典型的应用.

动态分派

  所谓动态分派,是指程序在运行期根据实际类型确定方法执行版本的过程,该过程发生在类初始化阶段,典型的应用是对应于多态中的方法重写:

class Eat{  
}  
class Drink{  
}  

class Father{  
    public void doSomething(Eat arg){  
        System.out.println("爸爸在吃饭");  
    }  
    public void doSomething(Drink arg){  
        System.out.println("爸爸在喝水");  
    }  
}  

class Child extends Father{  
    public void doSomething(Eat arg){  
        System.out.println("儿子在吃饭");  
    }  
    public void doSomething(Drink arg){  
        System.out.println("儿子在喝水");  
    }  
}  

public class SingleDoublePai{  
    public static void main(String[] args){  
        Father father = new Father();  
        Father child = new Child();  
        father.doSomething(new Eat());  
        child.doSomething(new Drink());  
    }  
}  

  运行结果会是:

爸爸在吃饭
儿子在喝水

  至于虚拟机是如何实现动态分派的,从字节码指令来说是invokevirtual指令,具体的实现就不再赘述了.讲完了静态分派和动态分派,让我们再来详细描述一下类加载过程中每个阶段所做的工作.

加载

  加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
  1、通过一个类的全限定名来获取其定义的二进制字节流(class文件本质上是一组二进制字节流).
  2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构.
  3、在Java堆中生成一个代表这个类的Class对象作为对方法区中这些数据的访问入口.
  说到加载就不得不提类加载器,在java语言中的类加载器有以下几种:
file
  当一个类加载器收到了类加载的请求后,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类,我们将这种工作流程称之为类加载器的双亲委派模型.

  双亲委派模型的好处是类随着它的类加载器一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要.我们都说java语言是面向对象的语言,一切皆对象,所有类的父类都是Object类,这是为什么呢?就是因为Object类是顶层启动类加载器加载的(jre\lib下的rt.jar),所有子类在尝试加载Obejct父类的时候都会委托给启动类加载器,保证了所有类的父类都是同一个Object对象!

  另外加载阶段也是开发人员可控性最强的阶段,我们可以自定义加载器加载类,也可以使用JDK提供的加载器来动态加载代码,比较典型的是JDBC创建数据库连接的时候,都要调用Class.forName()方法来加载驱动类,另外我们也可以在代码中调用this.getClass().getClassLoader()方法来加载类,需要注意的是这样获取的类加载器是应用程序类加载器.

验证

  验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全.不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证.

准备

  准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配.
对于该阶段有以下几点需要注意:
  1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中.
  2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值.
  下表列出了Java中所有基本数据类型以及reference类型的默认零值:
file
  另外需要注意的是,对于常量,在该阶段会直接赋值为代码中设定的值.

解析

  解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程.所谓符号引用,可以大致归结为以下三种:
  类和接口的全限定名(即带有包名的Class名,如:org.lxh.test.TestClass)
  字段的名称和描述符(private、static等描述符)
  方法的名称和描述符(private、static等描述符)

  而所谓直接引用,简单理解就是直接指向引用对象,我们知道,主流有两种访问对象地址的方式,直接指针和句柄,因此这里的直接引用既可以是直接指针,也可以是句柄.

初始化

  初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码.在准备阶段,类变量已经被赋过一次各数据类型的默认零值,而在初始化阶段,则是根据程序员通过程序指定的值去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器clinit()方法的过程.这里简单说明下clinit()方法的执行规则:
  1.clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问.

  2.clinit()方法与实例构造器init()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的clinit()方法执行之前,父类的clinit()方法已经执行完毕.因此,在虚拟机中第一个被执行的clinit()方法的类肯定是java.lang.Object。

  3.clinit()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成clinit()方法。

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

  5.虚拟机会保证一个类的clinit()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,直到活动线程执行clinit()方法完毕.如果在一个类的clinit()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的.

  其中对我们最有帮助的也是需要我们记忆的是第一点,明白了这一点,就能清晰的知道变量的加载顺序,再结合类初始化的规则,就能清晰的明白一个类的加载过程,那么java类什么时候需要初始化?

Java类什么时候需要初始化

  Java虚拟机规范中并没有强制要求什么情况下需要开始类加载过程.但是对于初始化阶段,虚拟机规范则严格规定了有且仅有5种情况必须立即对类进行“初始化”(而加载,验证,准备自然需要在此之前开始):
  1.遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化.生成这四条指令单最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候.

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

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

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

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

  那么让我们来看一个实际的例子来理解类加载机制:

class Father{
    public static int a = 10;
    static {
        a= 20;
        System.out.println("触发了father的加载");
    }
}

class Children extends Father{
    public static int b = a;
    static {
        System.out.println("触发了children的加载");
    }
}

public class TestClassLoad {

    public static void main(String[] args) {
        System.out.println(Children.b);
    }
}

  让我们来分析一下这段代码的加载过程:
  1.首先,根据 java类什么时候需要初始化中的规则第4条(当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类),虚拟机会先初始化TestClassLoad类.

  2.main方法中打印Children.b的值,根据 java类什么时候需要初始化中的规则第1条(这里是遇到getstatic字节码时),要触发Children类的初始化.

  3.再根据java类什么时候需要初始化中的规则第3条(当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化),会先触发Father类的初始化.

  4.触发Father类的初始化,根据类加载过程的准备阶段,首先会为静态变量a分配内存并且设置默认值,从上文我们知道默认值为0,再执行到初始化阶段,根据clinit()方法的执行规则1,根据代码顺序会先将a设置为10,再执行static静态代码块中的语句,将a赋值为20,并且打印日志:触发father的加载.

  5.继续执行chidren的加载,此时按照我上述的分析过程,最后得到的结果是会将b的值赋值为20,因此最终的执行结果应该是:

触发了father的加载
触发了children的加载
20

  让我们执行来验证一下:
file
  结果和我们分析的结果一样,说明我们的分析过程是完全正确的.假如我们在main方法中打印Children.a的值会如何?再假如我们把Father类中的静态变量和static代码块的顺序交换一下结果又如何呢?感兴趣的童鞋可以自己分析下.

总结

  阅读完本篇文章后,我想童鞋们应该明白了以下几点:
  1.类加载过程.
  2.多态的底层实现:静态分派和动态分派.
  3.java是如何保证所有类的父类都是同一个Object类的(双亲委派模型).
  4.java类什么时候需要初始化
  其实,童鞋们回忆一下,是否曾经出去找工作笔试的时候,经常会遇到各种父类子类的static赋值,然后问我们最后的执行结果是什么的啊?如果我们没有弄明白类加载机制,就算答对了也是蒙对的,在看完了这篇文章弄明白了类加载机制之后,童鞋们是否有一种拍大腿叫绝的感觉:啊,我终于弄明白类加载机制了,以后笔试再遇到类似的题,妈妈再也不怕我蒙错了!因为经过我们的分析,完全能得出准确的正确答案!

  本文我们深入了解了类加载机制,下一篇文章,让我们来学习GC,敬请期待!

  如果觉得博主写的不错,欢迎关注博主微信公众号,博主会不定期分享技术干货!
file

本文由博客一文多发平台 OpenWrite 发布!

posted @ 2020-03-24 06:04  悟空不败  阅读(442)  评论(0编辑  收藏  举报