浅谈类加载器与类加载案例解析

一、示意图

 

  注:Car car1 = new Car; 其中car1作为引用类型变量,保存在Java栈中,而对象本身保存在堆中。类加载器只负责将Car.class文件加载到内存中,此后JVM将根据这个数据文件封装对应的数据结构(即类对象),虽然类对象也是对象,但是HotSpot虚拟机将其放在了方法区中。

二、类加载器——双亲委托机制和沙箱安全

  类加载器分为四类(主要为三类):启动类加载器、扩展类加载器、应用类加载器和自定义类加载器,本文不对自定义类加载器做介绍。

  你有没有想过:为什么你没有定义过String类,你却能拿来用呢?原因就是当虚拟机启动时会去本地jre下面的lib目录下的rt.jar中加载该类,此类由官方提供。rt.jar是Java最重要的jar包之一,所以很有必要了解其目录结构:

  

  下面以案例的形式逐个介绍类加载器和类加载机制:

  引导:

  案例1:

public class ClassLoaderTest {
    public static void main(String[] args) {
        String string = new String();
        System.out.println(string.getClass().getClassLoader());
    }
}

  输出:

null

  输出类加载器为null的原因是启动类加载器由c++实现,并不继承ClassLoader抽象类。

  案例2:

  如果是开发人员自己定义的类,则是由应用类加载器实现的:

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoaderTest classLoaderTest = new ClassLoaderTest();
        System.out.println(classLoaderTest.getClass().getClassLoader());
    }
}

  输出:

sun.misc.Launcher$AppClassLoader@18b4aac2

  sun.misc.Launcher也是rt.jar包中sun/misc目录下的Launcher.class。

  由上面两个案例我们了解到,启动类加载器和应用类加载器负责加载对应的类。而随着Java功能的强大,扩展出来的类也越来越多,在lib/ext目录下的class文件以及包名以javax开头的class文件则是由扩展类加载器加载的。就好像地球之所以能孕育生命,就必须有阳光、空气、水等资源,而这些初始的事务类比Java中最原始的类,他们只有启动类加载器加载的,而之后孕育出生命类比扩展类加载器加载扩展类,最后人类创造了很多事务则是由应用类加载器加载的。

  1、类加载器之间的关系:

  案例3:

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoaderTest classLoaderTest = new ClassLoaderTest();
        System.out.println(classLoaderTest.getClass().getClassLoader().getParent().getParent());
        System.out.println(classLoaderTest.getClass().getClassLoader().getParent());
        System.out.println(classLoaderTest.getClass().getClassLoader());
    }
}

  输出:

null
sun.misc.Launcher$ExtClassLoader@1540e19d
sun.misc.Launcher$AppClassLoader@18b4aac2

  可以看出应用类加载器的父亲为扩展类加载器,而扩展类加载器的父亲为启动类加载器。其实三大类加载器之间的关系并不像是父子关系,更像是一种包含关系。

  如果在加一行:System.out.println(classLoaderTest.getClass().getClassLoader().getParent().getParent().getParent());就会报空指针异常了,因为启动类加载器是继承链最顶端的那个类加载器。

  结构图:

   

  2、双亲委托机制——沙箱安全

  案例4:

package java.lang;

public class String {
    public static void main(String[] args) {
        System.out.println("String Test");
    }
}

  在这里,我刻意建立了一个java.lang包并定义一个String类,运行时出现错误:

错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
   public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application

  提示说我的String类中没有main方法,可是在我定义的String类中是有main方法的。之所以报错,原因就是当加载一个自定义的类时,应用类加载器将加载任务上抛给扩展类加载器,而扩展类加载器将任务上抛给启动类加载器,启动类尝试根据包名类名加载此类,于是在rt.jar包中找到了String.class便不再继续往下抛,但是rt.jar包中的String类没有main方法,就报错了。

  当加载一个自定义的类时,应用类加载器并不会立刻加载此类,而是交给扩展类加载器,扩展类加载器也不会立刻加载,而是直接交给了启动类加载器,如果启动类加载器没有在rt.jar中找到,再将加载任务下抛给扩展类加载器,扩展类加载器如果找到,那么就不会继续下抛,如果扩展类加载器没有找到,那么就下抛给应用类加载器,一般用户定义的类都是由应用类加载器加载的,如果应用类加载器没有找到,就会抛出classNotFoundException。Java通过这样的方式保证了用户自定义的类不会污染jre自带的基本类,保证了程序的健壮性和安全性。


 

三、类加载与初始化

 与很多在编译期进行类加载、类连接的语言不同,Java的类型加载(classloading)、连接与初始化过程都是在程序运行期间完成的,这样的类加载机制提供了更高的灵活性

 1、虚拟机结束生命周期

    类加载器用于将类将在到内存中,Java虚拟机本身是一个进程,作为一个进程,那就有中断或者结束的可能,通常在如下几种情况下,Java虚拟机将结束生命周期:

    1.   执行了System.exit()方法
    2.   程序正常执行完毕
    3.   程序在执行过程中遇到了异常或错误而导致虚拟机中断
    4.   由于操作系统出现错误而导致Java虚拟机进程终止

 2、类加载过程 

    A、类加载:最常见的类加载就是将已经存在的class文件从磁盘中加载到内存中来。

    B、类连接:

      1、验证:确保被加载类的正确性。可能有人会疑问,class文件都是编译器编译出来的怎么会出错呢?正常情况下是没有错误的,但是class文件是可以被恶意修改的。

      2、准备:为类的静态变量分配内存,并且将其初始化为默认值,比如整形默认值为0

      class Test(){

        public static int a = 1;

      }

      也就是说,在此时,系统为a分配了内存,并且赋值为0

      3、解析:把类中的符号引用转换为直接引用

       通俗的讲:符号引用就是一个类中的方法,通过变量引用了另外一个类。而直接引用则将符号引用通过指针的方式转换为实际的内存指向。

    C、初始化:

      为类中的静态成员变量赋予正确的初始化值。在这个过程中,a值会变成1。

    D、类的使用和卸载


 

 3、类的加载、连接、初始化详解

    引言:

      Java程序对类的使用方式可分为两种:主动使用、被动使用。

      所有的Java虚拟机实现都必须在某个类或接口被Java程序“首次主动使用”时才初始化他们。

  ①、主动使用(七种)----务必背下来

    (1)创建类的实例

  (2)访问某个类或接口的静态变量(getstatic),或者对该静态变量赋值(putstatic

  (3)调用类的静态方法(invokestatic

  (4)反射(如Class.forName(com.test.Test)),获取到类的class对象

  (5)初始化一个类的子类

  (6)Java虚拟机启动时被标明为启动类的类(含有main方法的类)

  (7)JDK1.7开始提供的动态语言支持:java.lang.invoke.MethodHandla实例的解析结果REF_getStaticREF_putStaticREF_invokeStatic句柄对应的类没有初始化,则初始化。

除了以上七种情况,其他使用java类的方式都被看做是类的被动使用,都不会导致类的初始化。虽然不会初始化此类,但并不代表着虚拟机不会去加载连接这个类,这是不同的概念,也许会加载连接,也许不会。

②、类加载、初始化实例讲解【重点】

    类的加载是指将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象用来封装类在方法区内的数据结构,JVM规范中并未说Class对象位于哪里,HotSpot虚拟机将其放在了方法区中,这与我们的认为的对象存放在堆上有所差异。方法区是一种规范,在8版本以前叫永久代,而在8版本后叫元空间(类对象可以被视为实例对象的元数据,因此存放元数据的内存被称为元空间是比较合理的)。

    值得一提的是:当一个类含有静态属性或方法,那么这些静态的成员会作为类的Class对象的组成部分被存放在方法区,这是为什么能够使用类直接调用静态成员的根本依据。而当类创建了实例对象,类中的普通方法会作为实例对象的组成部分一起被存放在堆空间中,且每个实例对象都有一份,因此占用了很大的内存空间,所以堆空间是垃圾回收器的主要工作区域。因为普通方法是实例对象的组成部分,所以普通方法不能使用类直接调用而必须使用实例对象调用。但你一定干过使用实例对象调用静态方法,没有错但会有一条黄线,其实这相当于:对象.getClass().静态方法。这里的介绍并不严谨,仅仅为了帮助理解,关于方法区和堆的介绍后面的章节中会逐一介绍。

加载.class文件的方式:

①、从本地系统中直接加载

②、通过网络下载.class文件

③、从zipjar等归档文件中加载.class文件,这也是为什么Java的第三方库都是以jar包的方式来提供。

④、从专有数据库中提取.class文件

    ⑤、将Java源文件动态的编译为.class文件。这种方式很有用处:在动态代理中,这个类就是在运行期生成出来的,编译期不存在。在web开发中,jsp文件会被会被转换成一个servlet,而servlet是一个java类,因此会被编译成.class文件,然后被虚拟机加载进来。

   下面通过一些案例进一步详细说明类加载的细节:

  案例1:

public class MyTest1{
    public static void main(String[] args){
        System.out.println(MyChild1.str);
    }
}
class MyParent1{
    public static String str = “hello world”;
    static{
        System.out.println(“MyParent1 static block”);
  }
}
class MyChild1 extends MyParent1{
    static{
        System.out.println(“MyChild1 static block”);
    }
} 
MyParent1 static block
hello world

  案例2:

public class MyTest1{
    public static void main(String[] args){
        System.out.println(MyChild1.str2);
    }
}
class MyParent1{
    public static String str = “hello world”;
    static{
        System.out.println(“MyParent1 static block”);
    }
}
class MyChild1 extends MyParent1{
    public static String str2 = “welcome”;
    static{
        System.out.println(“MyChild1 static block”);
    }
}
MyParent1 static block
MyChild1 static block
welcome

为什么案例1没有输出MyChild1 static block?它不是静态代码块吗?难道类没有加载吗?还是类没有被初始化?

原因在于在案例1中,str是在父类中定义的,子类虽然继承父类,但没有直接定义str字段。而在虚拟机初始化类时会遵守:

只有直接定义了静态字段的类,才会被初始化。如果用子类调用父类的静态方法或属性,则视为是父类的主动使用,而不是对子类的主动使用。

父类的初始化要先于子类。

在案例1main方法中虽然使用了子类的名字,但是调用的str字段是父类直接定义的,因此视为是对父类的主动使用,在案例2使用的str2字段是在子类中直接定义的,因此视为是对子类的主动使用。主动使用的类会被初始化,而初始化一个类A的子类B,也是对该类A的主动使用,因此在案例2,子类和父类都会被初始化。

现在我们知道,在案例1中,父类被初始化,子类没有被初始化,那子类有没有被加载呢?可以用虚拟机参数-XX:+TraceClassLoading来追踪类的加载信息并打印出来。与之对应的还有一个-XX:+TraceClassUnLoading来追踪类的卸载信息。由输出信息可以看出子类被加载了。

 

  


 

   案例3:

public class MyTest2 {
    public static void main(String[] args) {
        System.out.println(MyParent2.str1);
    }
}
class MyParent2{
    public static String str1 = "Hello world";
    static{
        System.out.println("MyParent static block");
    }
}
MyParent static block
Hello World

  案例4:

public class MyTest2 {
    public static void main(String[] args) {
        System.out.println(MyParent2.str1);
    }
}
class MyParent2{
    public static final String str1 = "Hello world";
    static{
        System.out.println("MyParent static block");
    }
}
Hello World

  刚才不是说使用一个静态变量,那么此变量的直接定义类会被初始化吗?这里怎么使用了str1str1是由MyParent2直接定义的,却没有初始化MyParent2类呢?

  关键就在这个final常量:

    编译期可以确定值时,常量会在编译阶段会存入到调用这个常量的方法的所在类的常量池中。

    本质上,调用类并没有直接引用到定义常量的类,因此并不会触发定义常量的类的初始化。

    注意:这里指的是将常量存放到了MyTest2 的常量池中,之后MyTest2MyParent2就没有任何关系了,甚至我们可以将MyParent2class文件文件删除。

  

  神奇吧!!!看看反编译的结果:证明了在MyTest2中存在字符串常量Hello world。

  

 

   这里稍微介绍一下助记符,上面中的 ldc 就不再赘述,如果MyParent2中再定义一个public static final  short s = 7;再次反编译MyTest2.

  

  在此不再详细演示,如果s值超过127,那么助记符就会变成:

  sipush:表示将一个短整型常量值(-32768~32767)推送至栈顶。

  iconst_x:表示将int类型x推送至栈顶,x范围(-1~5),JVM认为0~5这几个数比较常见,所以单独处理成了iconst_m1~iconst_5七个助记符。如果x超过5或小于-1,那么助记符就会变成bipush  x其实这些助记符,就放在jdk下的rt.jar包里面。在学习JVM时,建议翻翻大神周志明的《深入理解Java虚拟机》,关于助记符的介绍,书的后面也有详细介绍。


 

   案例5:

public class MyTest3 {
    public static void main(String[] args) {
        System.out.println(MyParent3.str);
    }
}
class MyParent3{

    //随机生成一个字符串,也就是说编译期无法传给调用方法所在类的常量池了。
    public static final String str = UUID.randomUUID().toString();
    static {
        System.out.println("MyParent3 static code");
    }
}
MyParent3 static code
4422e2e5-f144-4954-b79e-015a452c4f39

  这是怎么肥四???不是说常量在编译期就会存入调用它的方法的所属类的常量池中,所以运行期本质上是调用方在自己的常量池调用自己的数据,没有调用声明方的属性,所以不会初始化声明变量的类吗?怎么这里又初始化了呢?难道编译期没把值传过去吗?

  对,就是没传过去,这个常量的值是在运行期确定的。

  当一个常量的值并非编译期确定的,那么其值就不会在编译期被放到调用类的常量池中,

  这时在程序运行时,会导致主动使用这个常量所在的类,显然会导致这个类被初始化。

  具体请看:https://i.cnblogs.com/EditPosts.aspx?opt=1 的第四段。


 

  案例6:

public class MyTest4 {
    public static void main(String[] args) {
        MyParent4 myParent4 = new MyParent4();
     MyParent4 myParent5
= new MyParent4(); } } class MyParent4{ static { System.out.println("MyParent4 static block"); } }
MyParent4 static block

  这题不难,因为创建一个类的对象属于对这个类的主动使用,那么势必会导致这个类的初始化,并且只会初始化一次

 


  案例7:

   

  对于数组实例来说,其类型是由JVM在运行期动态生成的,表示为[Lcom.qlu.JVM.MyParent4动态生成的类型其父类型就是Object,对于数组类型来说,JavaDoc经常将构成数组的元素称为Componet,实际上就是将数组降低一个维度后的类型,另外,Java中不存在多维数组。

   反编译结果:

  

  如果是一个基本类型的数组,那么助记符将是newarray


 

  ③、接口的初始化和加载

  我们知道在接口中的属性默认是public static final修饰的,这与类中的属性不同。

  案例1:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}
interface MyParent5{
    int a =5;
}
interface MyChild5 extends MyParent5{
    int b = 0;
}

 

  由于在接口中的属性默认是由public static final修饰的,也就是说它是一个常量,与案例34 相同,如果接口中的属性在定义是明确赋值,即编译期可以确定值时,JVM会将此常量值存放入调用此常量的方法所在接口的常量池。但是如果此常量是在运行期确定值的,那么会导致声明此常量的接口被初始化。

 


 

   案例2:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}
interface MyParent5{
    public static Thread thread = new Thread (){
        {
            System.out.println("MyParent5 invoke");
        }
    };
}
interface MyChild5 extends MyParent5{
    public  static int b = 111;
}

配置虚拟机参数-XX:+TraceClassLoading,输出:可以看出与类加载不同,当接口不被初始化时,也不会被加载。

作为接口的属性b,其默认修饰符就是public static final。当启动类调用此变量时,如果此变量在编译期确定值,那么虚拟机会拷贝一份到调用方的常量池中。如果编译期无法确定值,例如下面这个案例:


  案例3:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}
interface MyParent5{
    public static Thread thread = new Thread (){
        {
            System.out.println("MyParent5 invoke");
        }
    };
}
interface MyChild5 extends MyParent5{
    public  static int b = new Random().nextInt(5);
}

  如果调用的常量值在运行期确定,那么就无法在编译期备份至调用方的常量池,也就是说运行期将初始化定义变量的接口。而由输出结果可以看出,MyParent5接口并没有初始化。由此可知接口并不会因为子接口的初始化而初始化。那么接口是否会因为其实现类的初始化而初始化呢?


 

   案例4:

public class MyTest5 {
    public static void main(String[] args) {
        System.out.println(MyChild5.b);
    }
}
interface MyParent5{
    public static Thread thread = new Thread (){
        {
            System.out.println("MyParent5 invoke");
        }
    };
}
class MyChild5 implements MyParent5{
    public  static int b = new Random().nextInt(5);
}

由此可知接口并不会因其子接口或实现类的初始化而初始化。


 

总结:在判断一个类或者接口是否会被初始化时,一定要以是否主动使用了该类为依据,此外还需要注意static,final等修饰符。最后主要子类的初始化必然导致父类的初始化,而且父类初始化先于子类,但是子接口的初始化或者实现类的初始化并不会导致父接口的初始化。

posted @ 2019-10-12 18:14  菜菜菜鸡  阅读(696)  评论(0编辑  收藏  举报