• 0

  • 点赞

  • 收藏

【读书笔记】深入理解Java虚拟机之类加载过程

4天前

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。从概念上讲这些变量所使用的内存都应当在方法区中进行分配,但是注意,方法区本身是一个逻辑上的区域。JDK7及以前,永久代实现方法区,JDK8及以后,类变量则会随着class对象一起存放在Java堆中。

这时候进行内存分配的仅包含类变量,而不包含实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。假设一个类变量定义为:

public static int value = 123;
复制代码

那么变量value在准备阶段以后的初始值为0而不是123,因为此时尚未执行任何Java方法,value赋值为123的动作要到类初始化阶段才会被执行。

以下是各数据类型的零值:

image-20210220105216224

但是,如果类字段的字段属性存在ConstantValue属性,那么在准备阶段就会初始化为指定的值。比如:

public static final int value = 123;
复制代码

编译时Javac将会为value生成ConstantValue属性,在准备阶段会将value赋值为123。

解析

解析阶段就是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7个类符号引用进行。

  1. 类和接口的解析

    假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,整个过程包含一下3个步骤:

    1.1 如果C不是一个数组类型,那么虚拟机将会把N的全限定名传递给D的类加载器去加载这个类C。这个加载过程可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口,一旦这个过程出现异常,那么解析过程就失败。

    1.2 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似"[Ljava/lang/Integer"的形式。如果元素类型为基本类型则是"java.lang.Integer"。

    1.3 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要完成符号引用验证,确定D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegaleAccessError异常。

  2. 字段解析

    要解析一个未被解析过的字段符号引用,首先会对字段所属的类或接口的符号引用进行解析。如果成功则后续进行字段搜索:

    2.1 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段 ,则返回这个字段的直接引用,查找结束。

    2.2 如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

    2.3 如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜素其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

    2.4 否则查找失败,抛出java.lang.NoSuchFieldError异常。

    如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备字段的访问权限,将会抛出java.lang.IllegalAccessError异常。

    如果有一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或父类的多个接口中出现,Javac编译器就可能直接拒绝其编译为class文件。如果注释了Sub类中的"public static int A = 4;",接口与父类同时存在字段A,那么编译器将提示"The field Sub.A is ambiguous",并拒绝编译这段代码。

    public class FieldResolution{
    	interface Interface0{
    		int A = 0;
    	}
      interface Interface1 extends Interface0{
        int A =1;
      }
      interface Interface2{
        int A = 2;
      }
      static class Parent implements Interface1{
        public static int A = 3;
      }
      static class Sub extends Parent implements Interface2{
        //public static int A = 4;
      }
      public static void main(String[] args){
        System.out.println(Sub.A);
      }
    }
       
    复制代码
  3. 方法解析

    方法解析的第一个步骤和字段解析一样,也是需要先解析出方法所属的类或接口的符号引用。如果解析成功,那么我们依然用C表示这个类。

    ​ 3.1 由于class文件格式中类的方法和接口的方法 符号引用的常量类型定义是分开的,如果在类的方法中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChanggeError异常。

    ​ 3.2 如果通过第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

    ​ 3.3 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

    ​ 3.4 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,查找结束,抛出 java.lang.AbstractMethodError异常。

    ​ 3.5 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError.

  4. 接口方法解析

    也是先解析方法所属类或接口的符号引用,如果解析成功,依然用C表示这个接口。

    4.1 如果发现索引C是个类而不是接口,则抛出异常

    4.2 否则,在接口C中查找与目标匹配的方法,如有则返回这个方法的直接引用,查找结束。

    4.3 否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,如有匹配的方法则返回此直接引用,查找结束。

    4.4 由于Java接口允许多重继承,如果C的不同父接口存在多个与之匹配的方法,那将从这多个方法中返回其中一个并结束查找。

    4.5 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

初始化

类的初始化阶段是类加载过程的最后一个步骤,在这个阶段Java虚拟机才真正开始执行类中编写的Java程序代码。

在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序编码制定的计划去初始化类变量和其他资源:初始化阶段就是执行类构造器 <clinit> ()方法的过程。 <clinit>() 是Javac编译器的自动生成物。

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

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

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

由于父类的<clinit>()方法先执行,也就意味着父类的静态语句块要优先于子类的变量赋值操作。比如以下代码字段B的值将会是2而不是1:

static class Parent{
	public static int A = 1;
	static{
		A = 2;
	}
}

static class Sub extends Parent{
  public static int B = A;
}

public static void main(String[] args){
  System.out.println(Sub.B);
}
复制代码

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

类加载器

“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码成为“类加载器”。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。也就是说:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。

双亲委派模型

启动类加载器 Bootstrap Class Loader:负责加载存放在 <JAVA_HOME>/lib 目录。启动类加载器无法被java程序直接引用。

扩展类加载器 Extension Class Loader:负责加载<JAVA_HOME>/lib/ext 目录,或者被java.ext.dirs系统变量所指定的路径所有类库。开发者可以直接在程序中使用扩展类加载器加载class文件。

应用程序类加载器 Application Class Loader:负责加载用户类路径classPath上的所有类库。如果应用程序中没有自定义类加载器,一般情况下这就程序默认的类加载器。

image-20210220165054155

双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

这种加载模式的好处是Java中的类随着它的类加载器一起具备了一种带优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载环境中都能保证是同一个类。

双亲委派模型的实现:

protected synchronzied 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{
        c = findBootstrapClassOrNull(name);
      }
    }catch(ClassNotFoundException e){
      //如果父类加载器抛出ClassNotFoundException
      //说明父类无法完成加载请求
    }
    if(c == null){
     //在父类加载器无法加载时
     //再调用本身的findclass方法进行加载
      c = findClass(name);
    }
  }
  if(resolve){
    resolveClass(c);
  }
  return c;
}
复制代码

这段代码的逻辑:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假设父类加载器加载失败,才调用自己的的findClass()方法尝试加载。

Tomcat 的类加载器

Tomcat 的自定义类加载器 WebAppClassLoader 打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。具体实现就是重写 ClassLoader 的两个方法:findClass 和 loadClass。

以下是findClass的代码实现:

public Class<?> findClass(String name) throws ClassNotFoundException {
    ...
    
    Class<?> clazz = null;
    try {
            //1. 先在Web应用目录下查找类 
            clazz = findClassInternal(name);
    }  catch (RuntimeException e) {
           throw e;
       }
    
    if (clazz == null) {
    try {
            //2. 如果在本地目录没有找到,交给父加载器去查找
            clazz = super.findClass(name);
    }  catch (RuntimeException e) {
           throw e;
       }
    
    //3. 如果父类也没找到,抛出ClassNotFoundException
    if (clazz == null) {
        throw new ClassNotFoundException(name);
     }

    return clazz;
}
复制代码

在 findClass 方法里,主要有三个步骤:

1,先在 Web 应用本地目录下查找要加载的类。

2,如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器 AppClassLoader。

3,如果父加载器也没找到这个类,抛出 ClassNotFound 异常。

loadClass 方法:

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

    synchronized (getClassLoadingLock(name)) {
 
        Class<?> clazz = null;

        //1. 先在本地cache查找该类是否已经加载过
        clazz = findLoadedClass0(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }

        //2. 从系统类加载器的cache中查找是否加载过
        clazz = findLoadedClass(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }

        // 3. 尝试用ExtClassLoader类加载器类加载,为什么?
        ClassLoader javaseLoader = getJavaseClassLoader();
        try {
            clazz = javaseLoader.loadClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }

        // 4. 尝试在本地目录搜索class并加载
        try {
            clazz = findClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }

        // 5. 尝试用系统类加载器(也就是AppClassLoader)来加载
            try {
                clazz = Class.forName(name, false, parent);
                if (clazz != null) {
                    if (resolve)
                        resolveClass(clazz);
                    return clazz;
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }
       }
    
    //6. 上述过程都加载失败,抛出异常
    throw new ClassNotFoundException(name);
}
复制代码

loadClass 方法稍微复杂一点,主要有六个步骤:

  1. 先在本地 Cache 查找该类是否已经加载过,也就是说 Tomcat 的类加载器是否已经加载过这个类。
  2. 如果 Tomcat 类加载器没有加载过这个类,再看看系统类加载器是否加载过。
  3. 如果都没有,就让 ExtClassLoader 去加载,这一步比较关键,目的防止 Web 应用自己的类覆盖 JRE 的核心类。因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里自定义了一个叫 Object 的类,如果先加载这个 Object 类,就会覆盖 JRE 里面的那个 Object 类,这就是为什么 Tomcat 的类加载器会优先尝试用 ExtClassLoader 去加载,因为 ExtClassLoader 会委托给 BootstrapClassLoader 去加载,BootstrapClassLoader 发现自己已经加载了 Object 类,直接返回给 Tomcat 的类加载器,这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。
  4. 如果 ExtClassLoader 加载器加载失败,也就是说 JRE 核心类中没有这类,那么就在本地 Web 应用目录下查找并加载。
  5. 如果本地目录下没有这个类,说明不是 Web 应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web 应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。
  6. 如果上述加载过程全部失败,抛出 ClassNotFound 异常。

从上面的过程我们可以看到,Tomcat 的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载器,而是先在本地目录下加载,为了避免本地目录下的类覆盖 JRE 的核心类,先尝试用 JVM 扩展类加载器 ExtClassLoader 去加载。那为什么不先用系统类加载器 AppClassLoader 去加载?很显然,如果是这样的话,那就变成双亲委托机制了,这就是 Tomcat 类加载器的巧妙之处。

Tomcat 类加载器的层次结构

Tomcat 类加载器的层次结构

我们知道,Tomcat 作为 Servlet 容器,它负责加载我们的 Servlet 类,此外它还负责加载 Servlet 所依赖的 JAR 包。并且 Tomcat 本身也是一个 Java 程序,因此它需要加载自己的类和依赖的 JAR 包。首先让我们思考这一下这几个问题:

  1. 假如我们在 Tomcat 中运行了两个 Web 应用程序,两个 Web 应用中有同名的 Servlet,但是功能不同,Tomcat 需要同时加载和管理这两个同名的 Servlet 类,保证它们不会冲突,因此 Web 应用之间的类需要隔离。
  2. 假如两个 Web 应用都依赖同一个第三方的 JAR 包,比如 Spring,那 Spring 的 JAR 包被加载到内存后,Tomcat 要保证这两个 Web 应用能够共享,也就是说 Spring 的 JAR 包只被加载一次,否则随着依赖的第三方 JAR 包增多,JVM 的内存会膨胀。
  3. 跟 JVM 一样,我们需要隔离 Tomcat 本身的类和 Web 应用的类。

首先看第一个问题,假如我们使用JVM默认AppClassLoader来加载web应用,AppClassLoader只能加载一个Servlet类,在加载第二个同名Servlet类时,AppClassLoader只会返回第一个Servlet类的Class实例,这是因为在AppClassLoader看来,同名的Servlet只能加载一次。

因此Tomcat的解决方案是自定义一个类加载器WebAppClassLoader,并且给每个Web应用创建一个类加载器实例。我们知道Context容器组件对应一个web应用,因此每个Context容器负责创建和维护一个WebAppClassLoader实例。背后的原理是,不同的加载器实例加载的类被认为是不同的类。这就相当于Java虚拟机内部创建了一个个相互隔离的Java类空间,每一个Web应用都有自己的类空间,web应用之间通过各自的类加载器互相隔离。

SharedClassLoader

再看第二个问题,本质需求是两个web应用之间怎么共享库类,并且不能重复加载相同的类。在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把共享的类放到父加载器的加载路径下不就行了吗,应用程序也是通过这种方式共享JRE的核心类。因此Tomcat的设计者又加了一个类加载器SharedClassLoader,作为WebAppClassLoader 的父加载器,专门来加载Web应用之间共享的类。

CatalinaClassLoader

看第三个问题,如何隔离Tomcat本身的类和web应用的类,我们知道要共享可以通过父子关系,要隔离就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的,它们可能拥有同一个父加载器,但是两个兄弟类加载器加载的类是隔离的。基于此Tomcat又设计一个类加载器CatalinaClassLoader,专门加载Tomcat自身的类。

CommonClassLoader

还有一个问题,Tomcat和各Web应用之间需要共享一些类该怎么办?老办法,再增加一个CommonClassLoader,作为CatalinaClassLoader 和SharedClassLoader 的父加载器,CommonClassLoader能加载的类都可以被CatalinaClassLoader 和 SharedClassLoader 使用。

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

java

0

相关文章推荐

未登录头像

暂无评论