这是Java基础篇(JVM)的第二篇文章,紧接着上一篇字节码详解,这篇我们来详解Java的类加载机制,也就是如何把字节码代表的类信息加载进入内存中。

我们知道,不管是根据类新建对象,还是直接使用类变量/方法,都需要在类信息已经加载进入内存的前提下。在Java虚拟机规范中,类加载过程也就是类的生命周期包括7个部分:加载、验证、准备、解析、初始化、使用、卸载。不过我们先不写这几个阶段,先讲讲类加载器的知识,然后再来看具体的类加载过程。

1. 类加载器

关于类加载器,我主要关注两个方面,一是类加载器的作用,二是类加载器的双亲委托机制。

首先说第一个,类加载器在Java体系中有两个作用:

(1)在类生命周期的加载阶段,通过一个类的全限定名来获取此类的二进制字节流。在JVM规范中,没有强制规定类加载器为虚拟机的一部分,也就是说,类加载过程是可以放到JVM外部去实现的。说通俗一点,就是我们可以根据规范自己去实现加载器,如HotSpot实现中,启动类加载器是C++写的,是虚拟机的一部分,但其它类加载器都是Java写的,继承自java.lang.ClassLoader类。

这样规定有两个好处,一是二进制字节流的来源可以不限于Class文件,可从zip包获取(jar、war)、从网络获取(Applet)、运行时计算生成(动态代理)、从其它文件生成(JSP编译得到)等;第二个是我们可以自己实现类加载器,如OSGi就充分利用了类加载器的灵活实现(反双亲委托)、Tomcat等服务器也有自己的类加载器体系。

(2)在类的整个生命周期内,用来判定两个类是否相等。只有当类的全限定相等,且由同一个类加载器加载时,才认为两个类完全相等。这会影响到equals()方法、isAssignableFrom()方法、isInstance()方法(instanceOf)的执行结果。

这里我要问两个问题,一是普通类是和类加载器类相关联还是和它的实例相关联?二是它们是如何关联起来的?

第一个问题,应该是和类加载器的实例相关联。从需求出发,我们需要有多个不同的类加载器来加载类,这时候就不能使用静态的方法,而应用实例来加载;而且从结果来推过程:看ClassLoader等的源码,loadClass()方法都不是static的,所以应该是和类加载器的实例相关联。

第二个问题,我们看到ClassLoader类中维护了一个HashSet,这个集合中存储的是以该加载器作为初始加载器的类的全限定名,这称为类加载器的命名空间,这样,类和类加载器就联系起来了。

对Java程序员来说,类加载器的体系结构如图:

注意这里的父类/子类加载器并非继承关系,而是组合的关系:在ClassLoader类中,定义了一个变量parent。在使用类加载器时,会首先给这个变量赋值,如AppClassLoader类加载器,首先会将这个parent赋值为ExtClassLoader类型的变量。

类加载器真正的继承关系是之前提到的:启动类加载器是JVM的一部分,其它类加载器都继承自ClassLoader抽象类。

各个类加载器的作用是:

(1)启动类加载器:加载放在\lib中的、JVM能够识别的类库。Java程序不能直接引用启动类加载器。

(2)引导类加载器:加载放在\lib\ext中的所有类库,开发者可以直接使用扩展类加载器。

(3)应用类加载器:加载用户类路径(ClassPath)下的指定的类库,开发者可以直接使用自定义类加载器,通常我们自己编写的类都是由这个类加载器加载。

比较特殊的是自定义类加载器,通常来说有了上面的类加载器体系就够用了,但对于一些特殊的场合,还需要编写自定义加载器,比较常见的有我自己总结有两个:

1. 在双亲委托机制下,实现特殊的需要。如为了安全考虑,需要先将字节码加密,类加载器加载时需要先解密;或者需要从非标准的来源如网络获取二进制字节码进行加载等;

2. 破坏双亲委托机制,以实现诸如热部署等功能。

编写自定义类加载器的方法是:继承ClassLoader抽象类,并重写其findClass()方法,如何重写findClass()方法见下文。

再来看看第二个,类加载器的双亲委托机制:

类的加载采用双亲委托的机制,即:先由本加载器的父类加载器尝试加载,只有当父类加载器不能完成加载动作时,才由本类加载器进行加载(如果父类加载器为null,则由启动类加载器尝试加载)。默认是应用类加载器,逐级往上委托。

另外,类加载器还是全盘委托的,也就是说,与本类相关的(引用或继承等)类都以本类为初始加载器,并通过双亲委托机制确定其最终的加载器。

采用双亲委托机制的好处是,“Java类随着它的类加载器一起具备了一种带有优先级的层次关系”,可以保证一些基本的Java不会被破坏。如Object、String等。因为标志一个类除了类本身,还有加载它的类加载器。

这里我有个问题,我看到ClassLoader中的loadClass()等方法都不是static的,也就是说是类加载器类的实例进行的加载操作,那么对于我们一个普通程序而言,并没有显式地去新建一个类加载器类的对象,这个对象是虚拟机启动时就自动建好的吗?如果是,那加载ClassPath下的类的类加载器实例是同一个吗?

这个问题我找了很多地方都没有明白回答,我谈谈自己的理解:我们知道命名空间的规则是:同一个命名空间中类的全限定名不能重复;不同命名空间中的类不能相互访问。因为存的是以它作为初始类加载器的类,由全盘委托机制可得到,与之相关的类它都可以访问。以此看来,虚拟机启动时是为每个层次都新建了一个类加载器对象,如果没有显式地自己新建类加载器对象,那么所有的类都是由这几个默认的加载器实例加载。由同一层级类加载器实例加载的类,也就都在同一命名空间,可以相互访问。

前面提到了破坏双亲委托机制,这里再简要地说说这个点。破坏双亲委托机制通常有两种场合:

一是基础类需要回调用户的代码,这时由于基础类是由更上层的类加载器加载的(如启动类加载器),它不能加载用户代码中的类,如果还按照双亲委托,则这些类永远无法加载。如JNDI、JDBC等都是这种场合。这时候的解决方案是,通过引入“线程上下文类加载器”来加载用户代码中的类,这个线程上下文类加载器不是双亲委托机制体系下的类加载器,自然就不受双亲委托机制的约束了。

二是在要求程序动态性的场合,如需要代码热替换、模块热部署等。这时候类加载机制就不再是双亲委托机制中的树状结构,二是复杂的网状结构。这属于模块化这部分的知识,具体不是很清楚,可以先放一放以后再了解。


最后,关于类加载器,有个问题我一直没有搞明白,那就是类加载器到底是如何加载表示类信息的二进制字节流的?前面说到,我们自定义一个类加载器,Java规范推荐我们重写findClass()方法(而不是重写loadClass()方法,以避免破坏双亲委托机制),那么我们该如何重写findClass()方法呢?

我想,如果可以搞清楚扩展类加载器或者应用类加载器的findClass()方法,上面的疑问应该就可以搞清楚了。下面我们就通过AppClassLoader的源码,来分析分析应用类加载器的findClass()方法[1]。

首先来看AppClassLoader的继承结构:

可以看到,URLClassLoader继承了ClassLoader抽象类,AppClassLoader是sun.misc.Launcher的静态内部类,它继承了URLClassLoader类。

下面是AppClassLoader的loadClass()方法:


public synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { int i = name.lastIndexOf('.'); if (i != -1) { SecurityManager sm = System.getSecurityManager(); if (sm != null) { sm.checkPackageAccess(name.substring(0, i)); } } return (super.loadClass(name, resolve)); }

我们看到最后一行是调用super的loadClass()方法,由于它的直接父类URLClassLoader()没有重写loadClass()方法,最终这里是调用ClassLoader的loadClass()方法,仍然遵循双亲委托原则。下面是ClassLoader的loadClass方法:


protected synchronized 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 {   // 否则,让JVM启动类加载器加载 c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { // 父类(和启动类)加载器无法加载,则使用本类加载器加载
c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }

对应用类加载器来说,加载类时还是调用它的loadClass()方法,紧接着调用ClassLoader的loadClass()方法,在该方法中,调用了findClass()方法。这个方法在ClassLoader类中没有给出具体实现,其具体实现在URLClassLoader中:


protected Class<?> findClass(final String name) throws ClassNotFoundException { try { return (Class) AccessController.doPrivileged(new PrivilegedExceptionAction() { public Object run() throws ClassNotFoundException { String path = name.replace('.', '/').concat(".class"); Resource res = ucp.getResource(path, false); if (res != null) { try { return defineClass(name, res); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } else { throw new ClassNotFoundException(name); } } }, acc); } catch (java.security.PrivilegedActionException pae) { throw (ClassNotFoundException) pae.getException(); } }

可以看到,findClass()方法的核心代码在defineClass()处,它是URLClassLoader中的方法。关于这个方法,官方的描述是:使用从特定源获取的字节码来构造一个Class对象(返回的Class对象在使用前必须先解析)。defineClass()的源码较长,这里选取其中比较核心的一段:

        java.nio.ByteBuffer bb = res.getByteBuffer();
if (bb != null) {
// Use (direct) ByteBuffer:
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
return defineClass(name, bb, cs);
} else {
byte[] b = res.getBytes();
// must read certificates AFTER reading bytes.
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
return defineClass(name, b, 0, b.length, cs);
}

我们看到,这里使用了NIO的 (direct) ByteBuffer类来缓冲特定源的字节码,最终调用了ClassLoader类中的defineClass()方法。

本文暂时就分析到这个层次,因为目的是回答先前提出的两个问题,现在我们可以给出一个较为合适的答案:

问:1. 类加载器是如何加载二进制字节码的?

答:使用NIO的ByteBuffer类来缓冲并读入,接着调用defineClass()方法,只要字节码符合规范,这个方法就能够在内存中构造Class对象,并返回对其的引用。

问:2. 编写自定义类加载器时,如何重写findClass()方法?

答:首先,要考虑具体的需求,其次,常见的步骤是先用IO或者NIO读入字节码文件,再调用defineClass()方法。

2. 类加载过程

大致讲完了类加载器我关注的几点,现在正式来写类加载的过程。前面说到,在Java虚拟机规范中,类加载过程也就是类的生命周期包括7个部分:加载、验证、准备、解析、初始化、使用、卸载:

各个过程的作用简要介绍如下:

(1)加载。加载过程用到的就是我们前面讨论了那么长的类加载器,这个过程的主要目的是通过一个类的全限定名来获取这个类的二进制字节流,并将这个字节流代表的静态存储结构转化成方法区中运行时的数据结构,最后,在内存中生成这个类的Class对象。

加载阶段的结果是,方法区中存储了该类的信息,内存中也生成了相应的Class对象。

需要注意的是,在HotSpot虚拟机实现中,Class对象在方法区中,而不是在堆中。另外,数组类本身不由类加载器创建,而是由虚拟机直接创建,但是数组类的元素类型是类加载器创建的。最后,加载阶段可能并未完成,后面的连接阶段就已经开始。

(2)验证。Java语言本身相对安全,但是由于字节码文件来源不确定,所以必须验证其安全性,以免危害整个系统。

验证阶段主要的工作是:文件格式验证、元数据验证、字节码验证以及符号引用验证。

只有经过文件格式验证阶段的验证,字节流才会进入内存的方法区进行存储,而后面三个验证阶段都是基于方法区的存储结构进行。

元数据验证阶段是为了保证类信息符合Java语言规范。

字节码验证是为了确定程序语义合法、符合逻辑,最为复杂。

符号引用验证发生在虚拟机将符号引用转化为直接引用的时候(解析阶段),是进行对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

(3)准备。正式为类变量分配内存并赋予初值。这里有两个点需要注意,一是这个阶段只为类变量赋初值,二是这里的初值是程序默认的初值(null或0或false)。

(4)解析。将常量池中的符号引用转换为直接引用。符号是指Class文件中的各种常量,符号引用仅仅使用相应的符号来表示要引用的目标,并不要求所引用的目标都在内存当中。直接引用则不同,直接引用和内存布局相关,直接引用的对象一定是被加载到内存当中的。

(5)初始化。Java虚拟机规范没有明确规定字节码“加载”的时机,但却明确规定了初始化的时机,触发初始化则肯定先触发“加载”操作。

触发初始化的时机有5个:

  1. 遇到new关键字、读取或设置类的static变量、调用一个类的static方法时;
  2. 反射调用时;
  3. 初始化一个类,发现其父类未被初始化,则初始化其父类;
  4. 虚拟机启动时,包含main方法的类会先初始化;
  5. 动态语言支持(略)。

(6)最后详细说说卸载。类什么时候被卸载呢?当类对应的Class对象不再被引用时,类会被卸载,类在方法区中的数据也会被删除。问题就变成Class对象什么时候被卸载了。我们知道,Class对象始终会被其类加载器引用,那么也就是说,如果类是被启动类加载器、引导类加载器以及应用类加载器加载的,那么它始终不会被卸载。

嗯,这篇就先写到这里。其实很早就写完了,中间隔了一个月的时间去做毕设写论文,下周答完辩就算是硕士毕业了。

[1] 如果我们单是下载了Sun的JDK,那么是看不到AppClassLoader的源码的。这里需要去下载OpenJDK的源码,通过这个开源的项目,我们可以看到更多关于Java的源码,甚至还有JVM的源码。https://download.java.net/openjdk/jdk6

[2] 与传统的IO不同,NIO使用了临时存储区来缓冲数据,它基于块。ByteBuffer是NIO里用得最多的Buffer,它包含两个实现方式:HeapByteBuffer是基于Java堆的实现,而(direct)ByteBuffer则使用了sun.misc.Unsafe的API进行了堆外的实现。

Java基础篇(JVM)——类加载机制的更多相关文章

  1. Java基础篇(JVM)——字节码详解

    这是Java基础篇(JVM)的第一篇文章,本来想先说说Java类加载机制的,后来想想,JVM的作用是加载编译器编译好的字节码,并解释成机器码,那么首先应该了解字节码,然后再谈加载字节码的类加载机制似乎 ...

  2. Java基础篇——JVM之GC原理(干货满满)

    原创不易,如需转载,请注明出处https://www.cnblogs.com/baixianlong/p/10697554.html ,多多支持哈! 一.什么是GC? GC是垃圾收集的意思,内存处理是 ...

  3. JVM类加载机制详解(二)类加载器与双亲委派模型

    在上一篇JVM类加载机制详解(一)JVM类加载过程中说到,类加载机制的第一个阶段加载做的工作有: 1.通过一个类的全限定名(包名与类名)来获取定义此类的二进制字节流(Class文件).而获取的方式,可 ...

  4. JVM基础系列第7讲:JVM 类加载机制

    当 Java 虚拟机将 Java 源码编译为字节码之后,虚拟机便可以将字节码读取进内存,从而进行解析.运行等整个过程,这个过程我们叫:Java 虚拟机的类加载机制.JVM 虚拟机执行 class 字节 ...

  5. Java虚拟机(四):JVM类加载机制

    1.什么是类的加载 类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构 ...

  6. Java虚拟机(五):JVM 类加载机制

    一.JVM 类加载机制 JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程. 1. 加载: 加载是类加载过程中的第一个阶段,这个阶段会在内存中生成一个代表 ...

  7. 理解JVM——类加载机制

    我们在编写Java程序之后,会通过编译器得到一个class文件,这个class文件是如何与JVM进行配合的呢?类中的信息是如何变成JVM可以使用的Java类型呢?这些都是类加载机制做到的. 虚拟机把描 ...

  8. 深入理解JVM虚拟机6:深入理解JVM类加载机制

    深入理解JVM类加载机制 简述:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验.转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制. 下面我们具体 ...

  9. 一夜搞懂 | JVM 类加载机制

    前言 本文已经收录到我的Github个人博客,欢迎大佬们光临寒舍: 我的GIthub博客 学习导图 一.为什么要学习类加载机制? 今天想跟大家唠嗑唠嗑Java的类加载机制,这是Java的一个很重要的创 ...

随机推荐

  1. 前端必读:Vue响应式系统大PK(下)

    转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具.解决方案和服务,赋能开发者. 原文参考:https://www.sitepoint.com/vue-3-reactivity-system ...

  2. Centos7 安装 htop

    此安装方法是目前位置我了解到的最简介.最快速的安装方法.本人亲验:   系统版本: CentOS Linux release 7.3.1611 (Core)   安装步骤: yum -y instal ...

  3. du -cs /var/lib/BackupPC/pc/10.1.60.211/目录名

    # du -cs /var/lib/BackupPC/pc/10.1.60.211/7870236 /var/lib/BackupPC/pc/10.1.60.211/7870236 总用量  

  4. JDK版本升级

    背景:本来安装了一个1.6版本的JDK,因为版本过低需要升级成1.8 安装过程很简单一路next,主要是遇到几个问题需要备注一下解决方法. Error opening registry key'sof ...

  5. Node.js入门(含NVM、NPM、NVM的安装)-(转载)

    Node.js的介绍 引擎 引擎的特性: JS的内核即引擎.因为引擎有以下特性: (1)转化的作用: 汽油柴油等等->动能 模板+数据--->页面 js引擎:js 代码--->机器码 ...

  6. Python基础之PyCharm快捷键大全

    Pycharm中打开Help->Keymap Reference可查看默认快捷键帮助文档 一.编辑(Editing) Ctrl + Space 基本的代码完成(类.方法.属性) Ctrl + A ...

  7. Linux中级之ansible配置(playbook)

    一.playbooks 如果用模块形式一般有幂等性,如果用shell或者command没有幂等性 playbooks相当于是shell脚本,可以把要执行的任务写到文件当中,一次执行,方便调用 task ...

  8. MyBatis 全局配置文件详解(七)

    MyBatis 配置文件作用 MyBatis配置文件包含影响 MyBatis 框架正常使用的功能设置和属性信息.它的作用好比手机里的设置图标,点击这个图标就可以帮助我们查看手机的属性信息和设置功能.其 ...

  9. MyBatisPlus详细总结记录

    本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com 小 Hub 领读: 一篇写得非常详细的文章,增删改查,各种插件,让你测底熟悉 mybatis plus. 作者:yo ...

  10. 使用mybatis逆向工程Example类,(或者)or条件查询(Day_47)

    使用Example类,or条件查询 SetmealExample setmealExample=new SetmealExample(); setmealExample.or().andNameLik ...