浅析JVM(一)Class文件&类加载机制

前言

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
    }
}

查看该部分源码

参考


查看《浅析JVM》系列文章目录

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容