什么是类加载
众所周知,Java代码需要先编译成class字节码,然后被虚拟机解释执行(即使考虑JIT也算)。
不同于C/C++这种编译语言生成的可执行文件,Java字节码只有基于JVM才能真正执行。把字节码从class文件(或其他外部来源)加载到内存,并形成可以被虚拟机直接使用的数据结构,这一过程就是类加载。
我们这里指的类,包括了类、接口和数组类。由于数组类是虚拟机内部生成的,本文主要讨论普通类的加载。无论是哪种形式的类,其加载流程都是一样的。
类如何被加载
虚拟机加载类的过程可以分为三大步骤:加载(Loading)、链接(Linking)、初始化(Initializing)。其中链接又可以细分为验证(Verification)、准备(Preparation)、解析(Resolution)。可以参考下图。
加载(Loading)
加载就是将类的字节流读入,并构造出Class对象的过程。 为了避免与类加载一词的歧义,本节所指的加载阶段使用”加载(Loading)”来表示。在加载(Loading)阶段,虚拟机需要完成三件事情[1]:
- 通过一个类的全限定名来获取定义此类的二进制字节流。(找到byte[])
- 将这个字节流代表的静态存储结构转化为为方法区的运行时数据结构。(把byte[]存入方法区,具体数据结构由虚拟机自行定义)
- 在内存中生成一个代表这个类的 java.lang.Class对象,作为方法区这个类各种数据的访问入口。(再创建一个对应的Class对象。规范中并未提及Class对象存在哪个区域,虽然是个对象,Hotspot把Class对象特殊地放在了方法区)
这一阶段是类加载过程中,开发者可控性最强的,因为这个阶段离不开我们熟悉的ClassLoader。我们以非数组类、非Bootstrap ClassLoader加载的类为例,其加载(Loading)的过程具体如下:
- 在类需要被加载(Loading)的时候,JVM会调用类加载器的loadClass(String)方法,其返回值就是已经加载完成的java.lang.Class对象。注意调用loadClass(String)方法时,默认不会进行链接和初始化。
- Bootstrap ClassLoader是JVM级别的,由C++撰写。Extension ClassLoader、App ClassLoader都是Java类,都继承自URLClassLoader。我们使用到的Classloader也大多继承自URLClassLoader。以下内容请结合JDK源码java.net.URLClassLoader阅读。
- 上面的第一步找到字节流,就是在ClassLoader中实现的。具体在URLClassLoader中,对应着java.net.URLClassLoader#findClass方法。URLClassLoader在构造时需要定义它的加载的URL范围URL[],在findClass时就会从这个范围的URL里查找class文件,读取byte[]。
- 上面的第二步和第三步,则是在ClassLoader类的defineClass本地方法里实现的。JVM规范里定义了,用户自定义ClassLoader加载新类时,必须通过defineClass方法。
- defineClass的实现里会在JVM里触发Deriving a Class的动作:此时会做一些初步的校验,诸如ClassFormatError、UnsupportedClassVersionError之类的错误可能被抛出。也会触发其父类的加载——如果被derive的类的父类还没有加载,那么会转而先去加载(Loading)父类,再来继续derive。
- 还有可以看到的是,默认的ClassLoader.loadClass方法实现里,是遵循双亲委派机制的,即优先调用父加载器的loadClass方法,如果父加载器能成功加载则直接返回。关于双亲委派后面也会具体讲述。
链接(Linking)
链接包括了验证(Verification)和准备(Preparation)一个类和它的父类或成员类型(针对数组类),还有符号引用的解析(Resolution)(解析可选,可在后面单独执行)。
验证(Verification)
验证是为了保证类的字节流格式符合虚拟机的要求,如果验证过程发现它不符合class文件格式的约束,虚拟机会抛出VerifyError。
验证的细节很多,大体会完成四个阶段:文件格式验证、元数据验证、字节码验证和符号引用验证。细节可以参考[1]书中所述。
由于字节码的来源多种多样,字节码可以由多种语言的不同编译器产生,甚至是字节码增强动态产生,因此JVM通常不会信任编译器。之所以验证这一环节会做如此复杂的校验,主要是为了保护自身的正确运行,防止恶意代码,也因此这一步骤会耗费很大性能。
准备(Preparation)
准备阶段就是为静态成员变量分配空间,并设置初始值。赋值和静态代码不会在此时执行。这些静态变量会被分配到方法区中。
除了静态成员变量,准备阶段还会为一些JVM所需的数据结构分配空间,例如method table。Method table记录了一个类和它父类的所有方法和方法的代码指针,相同签名的方法在方法表中的偏移量是一样的,用来实现方法的动态绑定,是JVM实现多态的方式。
解析(Resolution)
解析就是虚拟机将类的常量池中的符号引用替换为直接引用。
在类的字节码中,其他类的成员和方法都是通过一组名字唯一确定的,这就是符号引用。在它们被真正使用之前,还必须被替换为直接引用——一个指向目标的指针或偏移量,以便在执行中能高效地定位和跳转。
符号引用有7种类型,包括类和接口(CONSTANT_Class_info) 、成员变量(CONSTANT_Fieldref_info)、类方法(CONSTANT_Methodref_info)、接口方法(CONSTANT_InterfaceMethodref_info )、MethodHandle(CONSTANT_MethodHandle_info)、MethodType(CONSTANT_MethodType_info)、调用点限定符(CONSTANT_InvokeDynamic_info)。不同类型的解析过程如下:
- 类和接口的解析
- 设代码所处的类为D,需要被解析的符号引用类为C
- 对于非数组类的解析,首先会使用D的ClassLoader来加载C类。
- 对于数组类的解析,则会使用使用D的ClassLoader来加载C数组的元素类型。
- 最后校验D类对C类的访问权限(例如C类的限定是private则会抛出异常)。
- 成员变量的解析
- 作为前提,成员变量所在的类C需要先完成解析
- 如果C实现了接口,则会按照继承关系从下往上递归查找这些接口和他们的父接口,如果存在名称和类型匹配的成员变量,则返回这个成员变量的直接引用。
- 按照继承关系从下往上递归查找C类及其父类,如果存在名称和类型匹配的成员变量,则返回这个成员变量的直接引用。
- 否则查找失败,抛出NoSuchFieldError。如果查找成果但没有访问权限,则抛出IllegalAccessError。
- 类的方法解析
- 作为前提,方法所在的类C需要先完成解析
- 查找C类及其父类,如果存在名称和描述符一致的方法,则完成查找。
- 查找C实现的接口及其父接口,如果存在名称和描述符一致的方法,则完成查找。
- 否则查找失败,抛出NoSuchMethodError。
- 如果查找到的方法是抽象的(接口方法也算抽象),则抛出AbstractMethodError。如果查找到的方法无权限访问,则抛出IllegalAccessError。
- 接口方法解析
- 和类的方法解析基本一样。只需要查找C类和父接口的方法匹配即可。
- MethodType和MethodHandle解析
- invokedynamic解析
- 上面两种都是为了支持动态语言,和类加载关系不大,本文就不深入了。
初始化(Initialization)
类的初始化过程,简单地说就是执行类的<clinit>()方法的过程。
<clinit>()方法是编译器生成的:++编译器会自动收集类中的静态变量的赋值和static块的语句,执行顺序取决于.java源文件内从上到下的顺序,合并产生<clinit>()方法++。
JVM会保证每个子类的<clinit>()方法执行前,父类的<clinit>()已经执行完毕。因此总是父类的clinit先执行。
同时,为了保证初始化过程的线程安全,JVM实现中仔细地做了锁和校验。因此我们最好避免在静态方法块中做非常耗时的操作。
类什么时候加载
笼统地说,一个类在需要被使用的时候才会被加载。
具体来说,对于加载(Loading)和链接,JVM规范只规定了以下约束:
- 一个类必须在链接之前完成加载(Loading)。
- 一个类必须在初始化之前完成链接(解析是可选的)。
上面的约束简单的说就是要求按顺序加载,然后对于初始化这一步的时机,JVM规范是有明确规定的,当且仅当以下6个条件时会做初始化:
- 执行new, getstatic, putstatic, 或invokestatic四种字节码指令时。
- 对类进行某些反射调用时。
- 子类初始化时会先触发父类的初始化。
- 接口的实现类初始化时会先初始化接口的默认方法。(Java 8+)
- 虚拟机启动时会初始化主类(main()方法的类),还有一些特定的类也是虚拟机内部初始化的(如Object, String, Class等)
- 使用MethodHandle返回静态方法的句柄时,对应的类会做初始化。
特别地,对于解析(Resolution)这一步骤,规范中并没有要求在链接时完成解析,而是直接说明可以有两种情况:一种是链接过程中完成全部解析(“eager” or “static” resolution),另一种是在需要使用到该符号引用时再去解析 (“lazy” or “late” resolution)。
上面所说的”使用到”指的是执行以下字节码指令时,符号引用必须被解析:anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, 和 putstatic 。
实际上我们使用的Hotspot采用的是后者,因此即使一个类被完全加载了,也会有可能在之后的某次方法调用中抛出NoSuchMethodError。
最后我们举个例子,表示一下类加载和初始化的时机。首先定义了几个类,类图和代码如下:
1 | /** |
下面是main方法执行时,各个类的加载过程:
1 | public class MainClass { |
类加载的隔离
这一章节主要讨论类加载器。
类加载器介绍
上文已经提到,在类的加载(Loading)过程中,需要通过一个类加载器,它的主要职责就是构造出Class对象,并且构造出的Class对象会有一个引用指向创建它的ClassLoader。
JVM在运行时默认会创建三个ClassLoader:Bootstrap ClassLoader、Extension ClassLoader和App ClassLoader(默认的加载器,也称为SystemClassLoader,因为ClassLoader.getSystemClassLoader()会返回它)。三种ClassLoader能够加载的jar包范围各不相同:Bootstrap负责加载运行时核心Java类/jre/lib/rt.jar;Extension加载/jre/lib/ext/下的jar包;App ClassLoader负责加载系统变量CLASSPATH下的类 。由于JVM具体实现的不同,Bootstrap和Extension有时可以认为是同一个类加载器。当然开发者也可以自己创建类加载器,不同的类加载器主要的区别在于,他们查找类字节流的范围或方式不同。
ClassLoader加载类时,也就是调用loadClass(String)时会优先优先调用父加载器的loadClass方法,因此三者是符合“双亲委派”机制的。之所以默认提供了这种双亲委派的加载结构,是为了提供一种父类加载器优先加载的层次,使更核心更基础的类优先被底层的加载器加载,以防上层对核心的类进行篡改。
有趣的是,“双亲委派”一词翻译自parent delegation,这里的双亲并不是指有两个父加载器,准确地说叫“父委派模型”更好一些。
需要注意,在JVM中,同一个类是可以同时被不同的类加载器加载的,但是JVM会认定他们是两个不同的类型。即类的全名+类加载器才能唯一确定一个类。这种特性可以用来做不同模块类加载的隔离。
加载时,使用哪个类加载器
上面提到过,每一个加载好的Class对象,都会记录加载它的ClassLoader的引用。JVM在加载类的时候,默认使用的就是所在类的类加载器,称作当前类加载器。
例如位于类A的代码:B b = new B();等同于 B b = Class.forName(“B”, false, A.class.getClassLoader()).newInstance();
当然,我们也可以直接指定类加载器加载,可以通过Class.forName(className, true, classLoader)或者ClassLoader.loadClass(String name)来加载一个类。两个方法的区别在于,在默认参数下,Class.forName会完整地做加载链接初始化,而ClassLoader.loadClass只会做加载。
为了解决一些特定问题,有时需要绕过所在类的双亲委派加载方式,直接显式地指定类加载器进行加载。例如,Java核心类库提供的SPI功能,需要加载第三方jar的实现代码,然而SPI相关的接口和类是由Bootstrap Classloader加载的,并不能加载到classpath下的第三方jar包,因此Java提供了一种**线程上下文类加载器(ThreadContextClassLoader)**:每个线程可以绑定一个类加载器,通过Thread.currentThread().getContextClassLoader()Thread.currentThread().setContextClassLoader()来读写。
线程上下文类加载器默认会设置成AppClassLoader。在SPI的例子中,JDK提供了java.util.ServiceLoader类,该类虽然是由BootrapLoader加载,但是定义了load方法,通过获取线程上下文类加载器实现“绕过”双亲委派机制的类加载。
1 | // ServiceLoader.load(Class)源码 |
实现类加载的隔离
维护大型项目的人一定遭受过jar包冲突带来的苦恼,冲突的jar包会带来各种ClassNotFoundException、NoSuchMethodException和一些隐蔽的报错。如下图,相同的类可能出现在不同的jar包中,而它们的版本可能有不同,最终类加载时只会选取其中一个(而且是不确定的、随机的)。常见的解决办法之一就是将不同模块类加载隔离开。
类加载的隔离技术应用广泛,本质上是为了系统不同模块间的解耦,适用于实现系统的模块化/容器化,中间件SDK的隔离等。类加载的隔离基本上都使用了不同ClassLoader,通过重写自定义ClassLoader实现。根据不同需要,他们或多或少的都“破坏”了双亲委派机制。常用的几种实现方案如下:
Tomcat
作为一个web服务器,Tomcat进程可以同时运行多个web应用(Tomcat里叫Context),多个web应用间的类加载隔离,依然是采用多ClassLoader的实现:
具体来说,Bootstrap、Extension和App ClassLoader的作用依然不变,不过由于Tomcat启动的系统变量CLASSPATH只指定了三个jar包:$CATALINA_HOME/bin/bootstrap.jar、tomcat-juli.jar和commons-daemon.jar,因此App ClassLoader只用于加载这三个jar。
Common ClassLoader,其类加载目录由catalina.properties配置,默认为 ${catalina.base}/lib和 ${catalina.home}/lib。创建后Common被设置为线程上下文类加载器,并负责加载Tomcat后续启动流程使用的相关类。在旧版本的Tomcat中Common ClassLoader会分为commonLoader、 sharedLoader、catalinaLoader三个ClassLoader,从Tomcat7开始默认并不会扩展成三个,但可以通过配置的不同形成不同的加载器结构,此处不展开讨论。
Webapp类加载器在每个web应用部署时创建,负责加载每个web应用内使用的类,创建后会被切换为线程上下文类加载器(部署web应用和处理该web应用请求时都会切换)。负责加载/WEB-INF/classes和/WEB-INF/lib/*.jar范围的类。由于每个web应用都使用独立的Webapp类加载器,不同web应用之间的类完全不会受到干扰。
Tomcat的类加载机制是相对最正统,最符合双亲委派思想的了。唯一一点破坏双亲委派的地方在于,WebAppClassLoader默认不直接委托(delegate=false),而是在保障Java核心类(boostrap)的基础上优先加载项目路径提供的类,而不是优先调用父加载器。
OSGi
仅通过双亲委派的加载方式的确可以做到类加载的隔离,但是彻底的隔离无法解决平级模块相互依赖的问题。OSGi可以解决这种问题。
谈到类加载的隔离不得不提到OSGi(Open Service Gateway Initiative),因为它是同类技术中最经典最成熟的。根据官方定义,现在的OSGi技术是指一系列用于定义Java动态化组件系统的标准。核心在于为Java模块化提供环境。它由OSGi联盟从1999年起开始维护,起初正如它字面含义是为了构建开放的网关平台,以支持嵌入式设备之间的交互。随后从R4版本开始进入Java SE/EE领域,形成了Java模块化的规范。如IDE的Eclipse,应用服务器的Websphere、Weblogic等都使用了OSGi技术。常见的基于OSGi标准实现的OSGi框架包括Knopflerfish, Apache Felix以及Equinox。
OSGi中的模块称为Bundle,Bundle是一个带有元数据的jar,包含了class和一些描述。Bundle里面会声明Export-Package和Import-Package,一个Bundle中的class只有在Export-Package中才可以被外界访问,实现了package粒度的可见性控制。也就是说模块之间class的相互依赖和影响,可以精确到package粒度,一个模块只会感知到其他模块export的class,没有export会被完全隐藏起来。
类比一下,这里的Bundle有些类似如今的微服务,Bundle是在JVM层面的,微服务是分布式系统层面的。他们都通过package/服务的注册和发现来实现模块/系统间的解耦。
除了模块的管理,OSGi还定义了Bundle的服务注册发现、生命周期管理、安全机制等功能,如下图。
下面谈一下OSGi的类加载机制,OSGi中各个Bundle相互都可以依赖,处于平级关系,显然双亲委派的层次已经不能满足这种灵活的要求。在OSGi的实现中,通常每个Bundle都有一个单独的ClassLoader,而且这个ClassLoader重写了loadClass方法,此时的加载过程不再遵循双亲委派,而是大体上采取如下流程:
- java核心类依然委派给父加载器
- import的类委派给对应的Bundle的类加载器加载
- 否则使用当前Bundle的类加载器,在当前Bundle的classpath查找
不过,时至今日OSGi的热度一直在降低,对于模块化来说,Java 9已经集成了Jigsaw——一种更轻量级的模块化技术。在其他需要类加载隔离的场景下,也有越来越多替代品。正如J2EE的EJB一样,强大而复杂的规范总是让人望而却步,大部分场景下大家需要的只是一个更轻量的框架。
其他方案
阿里集团和蚂蚁都各自开发了新的类加载隔离容器,阿里内部使用的叫pandora,蚂蚁开源了sofa-ark。至于为什么不使用OSGi,毕玄老师写过一篇文章解释过(传送门),大体上也是因为繁琐的配置和使用方式带来了很多开发负担。
这些轻量级类隔离容器的原理也基本相同,一般涉及到三种类加载器,一种用于加载容器框架本身的类ContainerClassLoader,一种用于加载应用的第三方依赖的PluginClassLoader,一种是应用本身使用的BizClassLoader。插件类似于OSGi的bundle,拥有独立的ClassLoader且需要指定export的类有哪些,export的类最终会被BizClassLoader优先加载。下图为示意图。
此处所指的ClassLoader也已经不再使用双亲委派,没有了实际的父加载器的概念了。其实这些方案就是简化版的OSGi思想:独立的模块使用单独的类加载器,需要外部依赖的类通过导出的方式实现,且导出的类会被优先加载。
参考资料
[1] 周志明. 深入理解Java虚拟机[M]. 机械工业出版社, 2013.(这本书讲的的确很细致很深入)
[2] The Java® Virtual Machine Specification. Java SE 8 Edition
[3] The Java® Language Specification. Java SE 8 Edition
[4] Javadoc : ClassLoader (Java Platform SE 8 )
[5] https://www.artima.com/insidejvm/ed2/lifetypeP.html
[6] http://ifeve.com/classloader/