转载

JVM调试工具和类结构还有类加载机制


前言

JVM 是 Java Virtual Machine(Java虚拟机)的缩写,它是一种规范,HotSpot VM是其最主流的实现(其他实现),通常我们讨论JVM如果没有特意说明是何种实现,便指的是HotSpot VM。JVM也并非仅支持Java语言,任何可编译为字节码的编程语言能可以运行在JVM上,例如前不久谷歌在 I/O 2017宣布将作为 Android 开发 First-Class 语言的 Kotlin。理解Class文件的组成结构,其如何通过类加载器加载进JVM,类加载器又是以何种机制工作,这有助于我们更进一步的理解JVM。本文将结合作者自己的理解对Class文件与类加载机制做一粗浅的解析,不对之处,望指出,共勉。

Class文件

Class 文件指的是以.class为后缀的文件,它包含可被JVM执行的字节码,通常由JVM平台编程语言源代码文件(例如.java、.kt、.groovy文件等)编译而来,也可通过字节码工具生成(例如ASM),当然如果你想手写字节码我也不会拦你,只要符合规范即可。

Class File Consist

一个Class文件通常由上图中这10个部分组成,本文不再敖述每部分所代表的意思,相信你看完下面这两篇文章应该会对其有一个大致的理解。

类加载机制

  • ClassLoader
    ClassLoader 就是类加载器,它的唯一职责就是将Class文件加载到JVM中,通常开发者并不需要自己创建ClassLoader,但在框架、中间件中自定义ClassLoader 非常常见,Tomcat便极具代表性,通过自定义的Tomcat Classloader体系 实现应用的相互隔离。

在 Java 中默认提供了三个类加载器,分别是BootstarapClassLoaderExtClassLoaderAppClassLoader,它们各自只负载加载规定目录内的Class文件,结构关系及目录见上图。

public class Test {
    public static void main(String[] args) {
        System.out.println(Test.class.getClassLoader());
        System.out.println(Test.class.getClassLoader().getParent());
        System.out.println(Test.class.getClassLoader().getParent().getParent());
       
        /* 输出:
        sun.misc.Launcher$AppClassLoader@3da997a
        sun.misc.Launcher$ExtClassLoader@4921a90
        null*/
    }
}

注:AppClassLoaderExtClassLoader 由 Java 编写并且都是 java.lang.ClassLoader 的子类,而 BootstarapClassLoader 并非由 Java 实现而是由C++ 实现,所以打印结果为null

  • 双亲委派制

    简单的来说,双亲委派制就是当加载一个Class文件时会先交由上层ClassLoader来加载,如果发现已加载则直接返回,如果没有加载则去当前ClassLoader 的classes目录寻找该Class文件,找到则加载,找不到则交由下层ClassLoader来继续加载,如果直到最下层加载器都无法加载(找不到该Class文件)则抛出ClassNotFoundException异常。下面通过解读java.lang.ClassLoaderloadClass(String name, boolean resolve)源码来进一步了解该机制是如何运转的,代码如下所示。

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            // 首先,检查该Class是否已经被加载,如果已加载直接返回。
            Class c = findLoadedClass(name);
            // 没有被加载
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    //是否存在上层加载器,如果存在交由上层加载器加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {//如果不存在继续向上委派给BootstarapClassLoader加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
                   //所有上层加载器都无法加载,由当前加载器进行加载
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
  • 类的加载过程
Class Lifecycle

加载
在加载阶段,JVM需要完成以下3件事情:
  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

注:JVM规范的这3点要求其实并不算具体,因此JVM实现与具体应用的灵活度都是相当大的

相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器区完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。

对于数组类而言,数组类本身不通过类加载器创建,它是由JVM直接创建的。但数组类与类加载器任然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建。

加载阶段完成后,JVM外部的二进制字节流就按照JVM所需的格式存储在方法区之中,方法区中的数据存储格式由JVM实现自行定义,JVM规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot VM而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前JVM的要求,并且不会危害JVM自身的安全。比如验证“魔数”是否为0xCAFEBABE、Class文件编译版本号是否符合当前JVM等。

Java语言本身是相对安全的语言,使用纯粹的Java代码无法做到注入访问数组边界意外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。

但前面已经说过,Class文件并不一定要求用Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生Class文件。在字节码语言层面上,上述Java代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。

JVM如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是JVM对自身保护的一项重要工作。

准备
准备阶段将为静态变量申请内存,并赋予初始值(基本类型为其默认值,引用类型为null),假设有如下代码:

public static int value = 123;

在该阶段value的值将根据其类型int初始化为 0。而将 value 赋值为123的动作在初始化阶段才会执行(调用<clinit()>方法,执行putstatic指令)。

解析
解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。

初始化
在初始化阶段会调用类的初始化方法<clinit()>为静态变量赋予实际的值(例如将value赋值为123)、执行静态代码块。在 JVM 规范中没有强制约束加载的时机,不过对于初始化JVM规范严格规定了有且只有5种情况必须立即对类进行初始化:

  1. 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的Java代码场景是:使用new关键字实例化对象时、读取或设置一个类的静态字段(static)时(被static修饰又被final修饰的,已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法时。
  2. 使用Java.lang.refect包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。
  4. 当JVM启动时,需要指定一个主类(即包含public static void main(String[] args) 方法的类),JVM会对该主类触发初始化。
  5. 当使用JDK1.5支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

如果你觉得上的面描述太过臃肿,下面是我总结出的一个脑图(查看源文件),供你参考。

  • 实现一个自定义ClassLoader

在实际开发过程中,可能会需要创建应用自己的类加载器。典型的场景包括实现特定的Java字节代码查找方式、对字节代码进行加密/解密以及实现同名 Java 类的隔离等。创建自己的类加载器并不是一件复杂的事情,只需继承java.lang.ClassLoader类并覆写相应的方法即可。 java.lang.ClassLoader中提供的方法有不少,下面介绍几个创建类加载器时需要考虑的:

  • defineClass():这个方法用来完成从Java字节码数组到java.lang.Class对象的转换,由本地方法实现,通常不会去覆写该方法。
  • findLoadedClass():这个方法用来根据名称查找已经加载过的Class。一个类加载器不会重复加载同一名称的Class。
  • findClass():这个方法用来根据名称查找并加载Class。
  • loadClass():这个方法用来根据名称加载Class,并且实现了双亲委派制。
  • resolveClass():这个方法用来连接一个Class。

通常我们实现一个自定义ClassLoader 只需继承 java.lang.ClassLoader并覆写protected Class<?> findClass(String name)即可,如果你要打破双亲委派制则需要同时覆写protected Class<?> loadClass(String name, boolean resolve),下面将展示如何实现一个自定义ClassLoader。

首先创建一个测试类并编译为.class文件以供稍后测试使用,本文将E:\classes目录作为类加载器的加载目录,所以需要将编译后的.class文件复制到该目录。

public class Test {

    public void say (){
        System.out.println("Hello");
    }
}

实现自定义ClassLoader

public class CustomClassLoader extends ClassLoader {
    private final String classesDir;

    public CustomClassLoader(String classesDir) {
        this.classesDir = classesDir;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = name;
        if (fileName.indexOf('.') != -1) {
            fileName = fileName.replaceAll("\\.", "\\\\");
        }
        fileName = fileName + ".class";
        try {
            try (FileInputStream in = new FileInputStream(classesDir + fileName)) {
                try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                    byte[] buffer = new byte[1024];
                    int len = 0;
                    while ((len = in.read(buffer)) != -1) {
                        out.write(buffer,0,len);
                    }
                    byte[] data = out.toByteArray();
                    return defineClass(name, data, 0, data.length);
                }
            }
        } catch (IOException e) {
            throw new ClassNotFoundException(name);
        }
    }

    public static void main(String[] args) throws ReflectiveOperationException{
        //1. 将Test.java 编译为Test.class 后复制到 E:\classes 下,当然也可以选择其他目录作为加载目录。
        //2. 加载
        ClassLoader classLoader = new CustomClassLoader("E:\\classes\\");
        Class<?> clazz = classLoader.loadClass("Test");//如果你的Test在一个包内,需要加上包名,如x.y.z.Test
        //3. 通过反射调用say()方法
        Object instance = clazz.newInstance();
        Method method = clazz.getMethod("say", null);
        method.invoke(instance);//Hello
    }
}

链接:https://www.jianshu.com/p/5f3278916b38
来源:简书
正文到此结束
Loading...