【深入浅出JVM】类加载机制

635 阅读11分钟

Java代码执行流程

在讨论java类的加载机制前,我们需要了解Java代码的编译执行流程 Java代码执行流程.jpg

类生命周期

类的生命周期包括:加载、链接、初始化、使用和卸载,其中加载、链接、初始化,属于类加载的过程,我们下面仔细讲解。使用是指我们new对象进行使用,卸载指对象被垃圾回收掉了。

类生命周期.jpg

加载

加载指的是把class字节码文件从各个来源通过类加器(classLoader)装入内存中:

  1. 通过一个类的全限定名(包名+类名)来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入

字节码来源: 一般的加载源包括从本地路径下编译生成的class文件,从jar包中的clas文件,从远程网络,以及动态代理时编译的class文件。

链接

验证(Verify)

确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。

  1. 文件格式的验证,文件中是否有不规范的或者附加的其他信息。例如常量中是否有不被支持的常量
  2. 元数据的验证,保证其描述的信息符合Java语言规范的要求,例如是否有父类,是否承了不被允许的finall类等
  3. 字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性
  4. 符号引用的验证,比如校检符号引用中通过全限定名是否能够找到对应的类,校验符号引用中的访问性(priate,pubc等)是否可被当前类访问等

准备(prepare)

为类变量(注息,不是实例变量)分配内存,并且予初值。初值不是代码中具体写的初始化的值,而是Java拟机根不同变量类型的赋认初始值。例如int型初值为0, reference为null等。

解析(resolve)

  1. 将常量池内的符号引用换为直接引用的过程。举个例子来说,现在调用方法 hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。
  2. 在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用皆换为具体的内存地址或偏移量,也就是直接引用。

初始化

这个阶段主要是对类变量初始化,是执行类构造器的过程,《Java虚拟机规范》严格规定了有且只有六种情况必须立即对类进行“初始化”

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    • 使用new关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    • 调用一个类型的静态方法的时候
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载机制

双亲委派模型

类加载机制.jpg

Parent Delegation, 双亲委托机制(这个翻译和socket一样,莫名其妙), 指多个类加载器之间存在父子关系的时候,某个class类具体由哪个加载器进行加载的问题。

具体过程如下:

  1. 当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载。
  2. 如果父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。

双亲委派机制的作用:

避免类重复加载导致冲突,保证Java核心库的安全。

例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

Bootstrap ClassLoader

  1. Bootstrap ClassLoader 无父类加载器,嵌套在JVM内部,java程序无法直接操作这个类,使用C/C++语言实现。
  2. 用于加载Java核心类库,如<JAVA_HOME>/lib目录下的类库,出于安全考虑,启动类只加载包名为:java、javax、sun开头的类。

Extension ClassLoader

  1. 扩展类加载器(Extention Classloader) 父类加载器为 Bootstrap ClassLoader,由Java语言编写。
  2. 扩展类加载器(Extention Classloader)负责加载JVM扩展类,比如从系统属性 java.ext.dirs 目录中加载类库,或者从JDK安装目录 $JAVA_HOME/jre/lib/ext 目录下加载类库。我们就可以将我们自己的包放在以上目录下,就会自动加载进来了。

Application ClassLoader

  1. 应用程序加载器(Application Classloader)也叫系统类加载器,负责加载环境变量classpath或者系统属性java.class.path指定路径下的类库。
  2. 它同时也是程序中默认的类加载器,我们Java程序中的类,都是由它加载完成的。

Custom ClassLoader

我们可以自定义类加载器,满足特殊的类加载需求,如解决类冲突,实现热加载,实现jar包的加密保护。主要由两种实现方式:

  1. 继承java.lang.ClassLoader,重写findClass()方法
  2. 继承URLClassLoader类,重写loadClass方法

反向委派

反向委托.jpg Java 中有一个 SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。

常见的 SPI 有 JDBC、JNDI等,这些 SPI 的接口属于 Java 核心库,一般存在rt.jar包中,如下面的java.sql.Driver,这些类的类加载器是Bootstrap ClassLoader。

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    public Driver() throws SQLException {
    }

    static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException var1) {
            throw new RuntimeException("Can't register driver!");
        }
    }
}

但是 com.mysql.jdbc.Driver 属于业务代码,这个类是无法由 Bootstrap ClassLoader 加载的。此时出现了一种两难的境地:Bootstrap类加载器无法直接加载SPI的实现类,同时由于双亲委派模式的存在,Bootstrap类加载器也无法反向委托AppClassLoader加载器SPI的实现类。

在这种情况下,我们就需要一种特殊的类加载器来加载第三方的类库,而 线程上下文类加载器(双亲委派模型的破坏者)就是很好的选择。

//part1:DriverManager::loadInitialDrivers
  ...
  ServiceLoader <Driver> loadedDrivers = ServiceLoader.load(Driver.class);
  Iterator<Driver> driversIterator = loadedDrivers.iterator();
  ...

  //part2:ServiceLoader::load
  public static <T> ServiceLoader<T> load(Class<T> service) {
      ClassLoader cl = Thread.currentThread().getContextClassLoader();
      return ServiceLoader.load(service, cl);
  } 

java.util.ServiceLoader 主要用于动态加载SPI接口的实现类,他的类加载器是 Bootstrap ClassLoader。

在上述代码中,ClassLoader cl = Thread.currentThread().getContextClassLoader() 将当前的加载器设置为线程上下文加载器,通过Launcher 类(jre 中用于启动入口函数 main 的类)的代码可以发现,对于一个刚启动的应用,他的上下文线程加载器就是 Application ClassLoader。

public Launcher() {
 Launcher.ExtClassLoader var1;
 try {
     var1 = Launcher.ExtClassLoader.getExtClassLoader();
 } catch (IOException var10) {
     throw new InternalError("Could not create extension class loader", var10);
 }
 
 try {
     this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
 } catch (IOException var9) {
     throw new InternalError("Could not create application class loader", var9);
 }
 Thread.currentThread().setContextClassLoader(this.loader);
 ...
 }

ClassLoader的使用场景

ClassLoader可以用于依赖冲突,热加载和加密保护

依赖冲突

一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识,假如不同中间件引分别引入一个依赖 JAR,但是JAR的版本都不同,分别为JAR1.0,JAR2.0和JAR3.0,根据maven依赖处理的机制,引用路径最短的依赖会真正作为应用最终的依赖,其他版本的依赖会被忽略,我们可以使用不同的类加载器进行 JAR 的加载,就能同时引入不同版本的依赖.

热加载

java项目的启动少则几十秒,多则几分钟,如此慢的启动速度极大地影响了程序开发的效率,我们可以通过classloader完成对变更内容的加载,进行快速地调试。下面我实现一个简单的Java热加载。

自定义类加载器

public class MyClassLoader extends ClassLoader{
    /** 要加载的 Java 类的 classpath 路径 */
    private String classpath;

    public MyClassLoader(String classpath) {
        // 指定父加载器
        super(ClassLoader.getSystemClassLoader());
        this.classpath = classpath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] data = this.readClass(name);
        return this.defineClass(name, data, 0, data.length);
    }

    private byte[] readClass(String name) {
        try {
            name = name.replace(".", "/");
            FileInputStream inputStream = new FileInputStream(new File(classpath + name + ".class"));
            // 定义字节数组输出流
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int b = 0;
            while ((b = inputStream.read()) != -1) {
                baos.write(b);
            }
            inputStream.close();
            return baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

定义需要进行热加载的类

package com.classloader.demo;

/**
 * 实现这个接口的子类,需要热加载
 */
public interface DoSomethingService {
    public void doSomething();
}
package com.classloader.demo;

import java.util.Date;

public class DoSomethingServiceImpl implements DoSomethingService {
    @Override
    public void doSomething() {
        System.out.println(new Date() + ": Java类热加载测试");
    }
}

封装热加载类的相关信息

package com.classloader.demo;

public class HotLoadObjectInfo {

    private ClassLoader classLoader;

    // class文件最后一次修改时间
    private long loadTime;

    /** 需要被热加载的类 */
    private DoSomethingService doSomethingService;

......
这里是字段的getter和setter
......

}

类的热加载(核心)

package com.classloader.demo;

import java.io.File;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;

public class HotLoad {
    // <类名,最后一次额修改时间>
    private static final Map<String, HotLoadObjectInfo> loadTimeMap = new HashMap<>();

    // 热加载类的类路径
    public static final String CLASS_PATH = "/Users/chenrunkai/Desktop/MyComputer/Program/dubbo_test/dubbo-api/target/classes/";

    /** 实现热加载的类的全名称(包名+类名 ) */
    public static final String TARGET = "com.classloader.demo.DoSomethingServiceImpl";

    public static DoSomethingService getHotLoadObject(String className){
        File loadFile = new File(CLASS_PATH + className.replaceAll("\\.", "/") + ".class");
        // 获取最后一次修改时间
        long lastModified = loadFile.lastModified();
        // loadTimeMap 不包含 ClassName 为 key 的信息,证明这个类没有被加载,要加载到 JVM
        // 加载类的时间戳变化了,我们同样要重新加载这个类到 JVM。
        if (loadTimeMap.get(className) == null || loadTimeMap.get(className).getLoadTime() != lastModified) {
            // 这里才是重点
            MyClassLoader myClassLoader = new MyClassLoader(className);
            Class loadClass = null;
            // 加载
            try {
                loadClass = myClassLoader.loadClass(className);
                DoSomethingService doSomethingService = newInstance(loadClass);

                HotLoadObjectInfo hotLoadObjectInfo = new HotLoadObjectInfo();
                hotLoadObjectInfo.setClassLoader(myClassLoader);
                hotLoadObjectInfo.setLoadTime(lastModified);
                hotLoadObjectInfo.setDoSomethingService(doSomethingService);

                loadTimeMap.put(className,hotLoadObjectInfo);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
        }
        return loadTimeMap.get(className).getDoSomethingService();
    }

    // 以反射方式创建对象
    private static DoSomethingService newInstance(Class loadClass) {
        try {
            return (DoSomethingService)loadClass.getConstructor(new Class[] {}).newInstance(new Object[] {});
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
        return null;
    }


}

测试

package com.classloader.demo;

public class TestMain {
    public static void main(String[] args) {
        while (true){
            DoSomethingService doSomethingService = HotLoad.getHotLoadObject(HotLoad.TARGET);
            doSomethingService.doSomething();
        }
    }
}

我们使用idea进行断点调试

image.png

修改源码并重新编译

image.png

继续执行,从输出中我们可以知道,class文件已经被替换并加载

image.png

加密保护

基于java开发编译产生的jar包是由.class字节码组成,由于字节码的文件格式是有明确规范的。因此对于字节码进行反编译,就很容易知道其源码实现了。如果不希望被别人窥探源码,那就需要对jar包进行加密。

image.png

jar包加密的本质,还是对字节码文件进行操作。但是JVM虚拟机加载class的规范是统一的,因此我们在最终加载class文件的时候,还是需要满足其class文件的格式规范,否则虚拟机是不能正常加载的。因此我们可以在打包的时候对class进行正向的加密操作,然后,在加载class文件之前通过自定义classloader先进行反向的解密操作,然后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现解密方法的classloader才能正常加载。

参考

Java 类加载器(ClassLoader)的实际使用场景有哪些?

jvm类加载器,类加载机制详解,看这一篇就够了