java类加载机制

2018-07-30  本文已影响0人  kindol

总体上,可以分为如下几个阶段:

classLoader.jpg

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段,当中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也成为动态绑定)。而且,这5个阶段是按顺序开始,但未必按顺序进行或完成,可能交叉

静态绑定和动态绑定

阶段一:加载

此阶段jvm做3件事情:

此阶段是可控性最强的阶段,开发人员可以使用自定义类加载器完成加载。

类加载器

类加载器用于实现类的加载动作,但其作用远不仅限于加载阶段;而对于数组类的加载过程,数组类本身不通过类加载器创建,是由jvm直接创建的,但数组类和类加载器仍有很密切的关系,因为数组类的元素类型最终要靠类加载器创建,数组类创建过程即类似递归,不断地去维,最后得到引用类型A,数组将在加载该组件类型的类加载器的类名称空间上被标识

对于任意一个类,都需要由它的类加载器和类本身一同确定其在就 Java 虚拟机中的唯一性,也即,即使源于同一个Class文件,但加载器不同,两个类也必不相等(此处的相等包括类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用 instanceof 关键字对对象所属关系的判定结果)。

java类加载器分为3种:

当然了,以上虽说会在指定目录下加载,但如果将自定义类放在JDK\jre\lib下,出于安全考虑,启动类加载器也不会去加载此类;这也说明,如果要使用自定义类加载器,不要将自定义类型的字节码放置到系统路径或者扩展路径中,否则会被系统类加载器或扩展类加载器抢先加载

同时,我们可以自定义类加载器。因为jvm自带的类加载器只懂得从本地文件加载标准的class文件,若编写自己的类加载器,便可以做到以下几点:

双亲委派机制

类加载器的层次关系如下图:

image

这种层次关系称为类加载器的双亲委派模型,看着像继承,但其实是通过组合来复用父加载器中的代码

工作流程:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类

public Class<?> loadClass(String name) throws ClassNotFoundException {
  return loadClass(name, false);
}

protected synchronized Class<?> loadClass(String name, boolean resolve)  throws ClassNotFoundException {
   // 首先判断该类型是否已经被加载
   Class c = findLoadedClass(name);
   if (c == null) {
     //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
       try {
         if (parent != null) {
         //如果存在父类加载器,就委派给父类加载器加载
             c = parent.loadClass(name, false);
           } else {
        //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
             c = findBootstrapClass0(name);
           }
       }catch (ClassNotFoundException e) {
         // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
         c = findClass(name);
       }
   }
   if (resolve) {
       resolveClass(c);
   }
   return c;
}

一般情况下,推荐覆盖findClass(),而不是loadClass()

好处:

Java 类随着它的类加载器(其实就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证 Java 程序的稳定运作很重要。以java.lang.Object为例,最终委派给启动类加载器加载,以此保证了 Object 类在程序中的各种类加载器中都是同一个类。

java程序动态拓展方式:
说到这,自然需要总结一下,运行时动态扩展java应用程序有如下两个途径:

阶段二:验证

验证的目的是为了确保 Class 文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。一般都有以下四种验证:(第一种是基于字节流检验, 后三种是是基于方法区的存储结构检验)

阶段三:准备

正式为类变量分配内存并设置类变量初始值,当然,这些内存都将在**方法区中分配,需要注意的是:

举个例子:一个变量定义如下

public static int value = 3;

则此阶段变量初始化为0,因为此时并未触发任何初始化操作,把 value 赋值为 3 的 putstatic 指令是在程序编译后,存放于类构造器 ()方法之中的

需要注意的是:
对于同时被 static 和 final 修饰的常量必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值

如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。比如修改以上例子:

public static final int value = 3;

准备阶段即value值为3,可以理解为static final 常量在编译期就将其结果放入了调用它的类的常量池中

阶段四:解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。

此阶段可能发生在初始化前,也可能发生在之后(静态绑定与动态绑定)。对于同一个符号引用进行解析请求是常见的事,因而虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行(除了invokedynamic指令以外)。

包括四种解析:类或接口、字段、类方法、接口方法

阶段五:初始化

在准备阶段,变量已经赋过一次系统要求的初始值;而在初始化阶段,则执行类构造器<clinit>()方法

<clinit>():

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的编译器收集的顺序是由语句在源文件中出现的顺序所决定的静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值(如i=0),但是不能访问(如print(i))。举个例子:

public class Test
{
    static
    {
        i=0;
//      System.out.println(i);
    }
    static int i=1;

    public static void main(String args[])
    {
        System.out.println(i);
    }
}
//输出结果为1,因为按照源代码限制性静态块

<clinit>()方法与实例构造器<init>()方法不同,它不需要显示地调用父类构造器,虚拟机会保证父类的<clinit>()方法先于子类的;父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

当然,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产<clinit>()方法。

而对于接口的<clinit>(),

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

一个类的<clinit>()在多线程环境中需要加锁同步,jvm保证只有一个线程执行这个类的<clinit>(),其他线程都需要阻塞等待,这也可能造成一个问题,若此方法耗时严重,则可能造成多个线程阻塞

类有且只在以下五种情况被初始化:

在一个类加载器中,类只能初始化一次

类初始化的步骤:

  1. 若类还未加载与链接,先进行加载和链接
  2. 若类存在直接父类且类还未初始化,则初始化直接父类(不适用于接口)
  3. 若类中存在初始化语句(如static变量和static块),那就依次执行这些初始化语句

举几个典例:

1. 通过数组定义来引用类,不会触发此类的初始化
2. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
public class ConstClass
{
    static
    {
        System.out.println("ConstClass init!");
    }
    public static  final String HELLOWORLD = "hello world";
}aaa
public class NotInitialization
{
    public static void main(String[] args)
    {
        System.out.println(ConstClass.HELLOWORLD);
    }
}
//输出"aaa"

几个问题

  1. 由不同的类加载器加载的指定类型还是相同的类型吗

    在Java中,一个类用完全匹配类名(包名+类名)作为标识,但在jvm中一个类用其全名和一个ClassLoader的实例作为唯一标识不同类加载器加载的类将被置于不同的命名空间

  2. 使用Class.forName(String name)触发的是哪个类加载器进行类加载行为

    forName(String name)内部调用了forName0(classString, true, ClassLoader.getCallerClassLoader()),而ClassLoader.getCallerClassLoader()先调用

    static ClassLoader getCallerClassLoader() {
        // 获取调用类(caller)的类型
        Class caller = Reflection.getCallerClass(3);
        // This can be null if the VM is  requesting it
        if (caller == null) {
           return null;
        }
        // 调用java.lang.Class中本地方法获取加载该调用类(caller)的ClassLoader
        return caller.getClassLoader0();
     }
     //java.lang.Class.java
     //虚拟机本地实现,获取当前类的类加载器,前面介绍的Class的getClassLoader()也使用此方法
     native ClassLoader getClassLoader0();
    

    也就是说,forName的classLoader其实就是调用类的classLoader,不一定为系统类加载器

  3. 编写自定义类加载器时,若没有设定父加载器,那么父加载器是

    未指定父类加载器的情况下,默认采用系统类加载器自定义的类加载器直接或者间接继承自java.lang.ClassLoader抽象类,对应的无参默认构造函数实现如下:

    protected ClassLoader() {
      SecurityManager security = System.getSecurityManager();
      if (security != null) {
        security.checkCreateClassLoader();
        }
      this.parent = getSystemClassLoader();
      initialized = true;
    }
    

    而当中的getSystemClassLoader()获得的即为AppClassLoader

  4. 在编写自定义类加载器时,如果将父类加载器强制设置为null,那么会有什么影响?

    JVM规范中规定如果用户自定义的类加载器将父类加载器强制设置为null,那么会自动将启动类加载器设置为当前用户自定义类加载器的父类加载器(这个问题前面已经分析过了)

  5. JVM规范中规定如果用户自定义的类加载器将父类加载器强制设置为null,那么会自动将启动类加载器设置为当前用户自定义类加载器的父类加载器(这个问题前面已经分析过了)。同时,我们可以得出如下结论:

即使用户自定义类加载器不指定父类加载器,那么,同样可以加载到JDK/JRE/lib下的类,但此时就不能够加载JDK/JRE/lib/ext目录下的类了(loadClass()默认的委派逻辑)

  1. 如何在运行时判断系统类加载器能加载哪些路径下的类?

    • 获取到系统类加载器(系统类加载器和扩展类加载器本身都派生自URLClassLoader),调用URLClassLoader中的getURLs()方法
    • 直接通过获取系统属性java.class.path 来查看当前类路径上的条目信息 , System.getProperty("java.class.path")
  2. 一道有意思的题目

    https://blog.csdn.net/u013256816/article/details/50837863

    重点在于静态初始化的过程只有一次开始,因而在初始化对象内部的静态变量的时候,如果第一个为实例,那么会认为已经开始了静态初始化过程,不会再去执行一次开始

参考:
http://wiki.jikexueyuan.com/project/java-vm/class-loading-mechanism.html
《深入理解java虚拟机》

上一篇下一篇

猜你喜欢

热点阅读