JVM好好学三(类加载和类加载器)

JVM好好学三(类加载和类加载器)

大纲

  • 1、类加载子系统

  • 2、类加载的过程

  • 3、类加载器

1、类加载子系统

        类加载子系统负责从文件系统或是网络中加载.class文件,class文件在文件开头有特定的文件标识。 把加载后的class类信息存放于方法区,除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)。

        classLoader只负责class文件的加载,至于是否可以运行,则由Execution Engine决定,如果调用构造器实例化对象,则该对象存放在堆区

2、 类加载的过程

        一般我们写的程序经过编译后成为了.class文件,.class文件中描述了类的各种信息,最终都需要加载到虚拟机之后才能运行和使用。而虚拟机如何加载这些.class文件?.class文件的信息进入到虚拟机后会发生什么变化?

        类使用的7个阶段 类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括, (其中验证、准备、解析3个部分统称为连接(Linking))

  • 加载(Loading)

  • 验证(Verification)

  • 准备(Preparation)

  • 解析(Resolution)

  • 初始化(Initiallization)

  • 使用(Using)

  • 卸载(Unloading)

        加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段不一定:它在某些情况下可以初始化阶段之后在开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)

2.1 加载

        加载是类加载的第一个阶段。有两种时机会触发类加载: 预加载: 虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常用到的,像java.lang.*、java.util.、java.io. 等等,因此随着虚拟机一起加载。要证明这一点很简单,写一个空的main函数,设置虚拟机参数为"-XX:+TraceClassLoading"来获取类加载信息,运行一下:

[Opened E:\developer\JDK8\JDK\jre\lib\rt.jar]
[Loaded java.lang.Object from E:.jar]
[Loaded java.io.Serializable from E:.jar]
[Loaded java.lang.Comparable from E:.jar]
[Loaded java.lang.CharSequence from E:.jar]
[Loaded java.lang.String from E:.jar]
[Loaded java.lang.reflect.AnnotatedElement from E:.jar] ......
  • 运行时加载

        虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类。 那么,加载阶段做了什么,其实加载阶段做了有三件事情:

  • 获取.class文件的二进制流

  • 将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中

  • 在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。一般这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的。

        虚拟机规范对这三点的要求并不具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如第一条,根本没有指明二进制字节流要从哪里来、怎么来,因此单单就这一条,就能变出许多花样来:

  • 从zip包中获取,这就是以后jar、ear、war格式的基础

  • 从网络中获取,典型应用就是Applet

  • 运行时计算生成,典型应用就是动态代理技术

  • 由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件

  • 从数据库中读取,这种场景比较少见

        总而言之,在类加载整个过程中,这部分是对于开发者来说可控性最强的一个阶段。

2.2 链接

        链接包含三个步骤: 分别是 验证Verification , 准备Preparation , 解析Resolution 三个过程

2.2.1 验证Verification

        连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。 验证阶段将做以下几个工作:

-- 文件格式验证
-- 元数据验证
-- 字节码验证 
-- 符号引用验证

2.2.2 准备Preparation

        准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配。有两个地方需要注意:

  • 这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中

  • 这个阶段赋初始值的变量指的是那些不被final修饰的static变量,比如"public static int value = 123",value在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如"public static final int value = 123;"就不一样了,在准备阶段,虚拟机就会给value赋值为123。 下面两段代码,A类将会输出 0,而 B类 将无法通过编译。

public class A {
   static int a;       
   public static void main(String[] args) {
       System.out.println(a);
   }
} 



public class B {
    public static void main(String[] args) {
        int a;
        System.out.println(a);
    }
}

        这是因为局部变量不像类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。因此,即使程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值。但局部变量就不一样了,如果没有给它赋初始值,是不能使用的

2.2.4 解析Resolution

        解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 什么是符号引用? 符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。 这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:

  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符

这么说可能不太好理解,结合实际看一下,写一段很简单的代码:

public class TestMain {
private static int i;
private double d;

public static void print() {

}

private boolean trueOrFalse() {
     return false;
   }
}

用javap把这段代码的.class反编译一下:

Constant pool:
#1 = Class #2 // com/xrq/test6/TestMain
#2 = Utf8 com/xrq/test6/TestMain
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 i
#6 = Utf8 I
#7 = Utf8 d
#8 = Utf8 D
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Methodref #3.#13 // java/lang/Object."<init>":()V
#13 = NameAndType #9:#10 // "<init>":()V
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 Lcom/xrq/test6/TestMain;
#18 = Utf8 print
#19 = Utf8 trueOrFalse
#20 = Utf8 ()Z
#21 = Utf8 SourceFile
#22 = Utf8 TestMain.java

        看到Constant Pool也就是常量池中有22项内容,其中带"Utf8"的就是符号引用。比如#2,它的值是"com/xrq/test6/TestMain",表示的是这个类的全限定名;

        又比如#5为i,#6为I,它们是一对的,表示变量时 Integer(int)类型的,名字叫作i;#6为D、#7为d也是一样,表示一个Double(double)类型的变量,名字为d; #18、#19表示的都是方法的名字。

        总而言之,符号引用和我们上面讲的是一样的,是对于类、变量、方法的描述。符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。

什么是直接引用?

        直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机示例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在在内存中了。 解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。那这个阶段都做了哪些工作呢?大体可以分为:

  • 类或接口的解析

  • 类方法解析

  • 接口方法解析

  • 字段解析

2.2.4 初始化

        类的初始化阶段是类加载过程的最后一个步骤, 之前介绍的几个类加载的动作里, 除了在加载阶 段用户应用程序可以通过自定义类加载器的方式局部参与外, 其余动作都完全由Java虚拟机来主导控 制。

        直到初始化阶段, Java虚拟机才真正开始执行类中编写的Java程序代码, 将主导权移交给应用程序。 初始化阶段就是执行类构造器()方法的过程。 ()并不是程序员在Java代码中直接编写 的方法, 它是Javac编译器的自动生成物,()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的 语句合并产生的, 编译器收集的顺序是由语句在源文件中出现的顺序决定的。

        静态语句块中只能访问 到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访 问, 代码如下所示:

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

        ()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法) 不同, 它不需要显 式地调用父类构造器, Java虚拟机会保证在子类的()方法执行前, 父类的()方法已经执行 完毕。 因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。由于父类的()方法先执行, 也就意味着父类中定义的静态语句块要优先于子类的变量赋值 操作, 如下代码所示, 字段B的值将会是2而不是1。方法执行顺序

public class TestClinit02 {
   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);
   }
}

        ()方法对于类或接口来说并不是必需的, 如果一个类中没有静态语句块, 也没有对变量的 赋值操作, 那么编译器可以不为这个类生成()方法。 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 ()方法。 但接口与类不同的是, 执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 此外, 接口的实现类在初始化时也 一样不会执行接口的()方法。 Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步, 如果多个线程同 时去初始化一个类, 那么只会有其中一个线程去执行这个类的()方法, 其他线程都需要阻塞等 待, 直到活动线程执行完毕()方法。 如果在一个类的()方法中有耗时很长的操作, 那就 可能造成多个进程阻塞, 在实际应用中这种阻塞往往是很隐蔽的

3、类加载器

        类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

  • (1)类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步

  • (2)每个 Java 类都有一个引用指向加载它的 ClassLoader。

  • (3)数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的

        简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象

3.1 类加载器的加载规则

        JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次

public abstract class ClassLoader {
  ...
  private final ClassLoader parent;
  // 由这个类加载器加载的类。
  private final Vector<Class<?>> classes = new Vector<>();
  // 由VM调用,用此类加载器记录每个已加载类。
  void addClass(Class<?> c) {
        classes.addElement(c);
   }
  ...
}

3.2 类加载器的类型

JVM 中内置了三个重要的 ClassLoader:

  • (1)BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar 、resources.jar 、charsets.jar等 jar 包和类))以及被 -Xbootclasspath参数指定的路径下的所有类。

  • ExtensionClassLoader(扩展类加载器) :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

  • AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

        除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

        每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类是通过 BootstrapClassLoader 加载的。( 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。)

3.3 双亲委派模型

        类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。根据官网介绍

ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,
ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 
虚拟机中被称为 "bootstrap class loader"的内置类加载器本身没有父类加载器,
但是可以作为 ClassLoader 实例的父类加载器
  • ClassLoader 类使用委托模型来搜索类和资源

  • 双亲委派模型要求除了顶层的bootstrap classloader外,其余的类加载器都应有自己的父类加载器

  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器

类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码

public abstract class ClassLoader {
  ...
  // 组合
  private final ClassLoader parent;
  protected ClassLoader(ClassLoader parent) {
       this(checkCreateClassLoader(), parent);
  }
  ...
}

组合优于继承,多用组合少用继承

3.4 双亲委派模型的执行流程

        双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        //首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //当父类的加载器不为空,则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    //当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                //当父类加载器无法加载时,则调用findClass方法来加载该类
                //用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                //用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            //对类进行link操作
            resolveClass(c);
        }
        return c;
    }
}

执行流程如下:

  • 先去检查该类是否已经被加载过,已经加载的类会被直接返回,否则进行步骤2

  • 将加载的任务交给父类加载器,如果父类加载器为null,则交给bootstrap classloader, 这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。

  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

        JVM 判定两个 Java 类是否相同的具体规则 :JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。

  • 双亲委派模型的好处

  • (1) 避免重复加载:由于类加载器会在其加载的类中缓存已经加载的类,所以使用双亲委派模型可以避免重复加载同一个类,从而节省内存空间。

  • (2) 防止类库被篡改:由于父类加载器可以优先加载类库,因此双亲委派模型可以避免用户篡改Java核心类库等安全问题。

  • (3) 保证类的一致性:如果父类加载器已经加载了一个类,子类加载器再次尝试加载该类时,由于双亲委派模型的存在,子类加载器将不会再次加载该类,而是直接使用父类加载器所加载的类,从而保证类的一致性。

  • (4) 支持类的版本管理:由于Java类库升级时,只需要升级父类加载器中的类库即可,从而避免类库版本不一致的问题。

end
  • 作者:旭仔(联系作者)
  • 发表时间:2024-03-22 21:25
  • 版权声明:自由转载-非商用-非衍生-保持署名
  • 转载声明:如果是转载栈主转载的文章,请附上原文链接
  • 公众号转载:请在文末添加作者公众号二维码(公众号二维码见右边,欢迎关注)
  • 评论