“与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。”
类加载机制
类加载时机
以前初学的时候,以为在Web程序启动时,静态代码块中的内容就会执行。其实这是没有理解类是何时进行初始化的。
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的动态绑定。Java虚拟机规范中没有强制规定类何时初始化,但是对于初始化阶段,则严格规定以下情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。(而一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。)
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。”
这五种场景称为“主动引用”,而引用类的方法不会触发初始化的则称为“被动引用”。被动引用具体有以下三种例子:
- 通过子类引用父类的静态字段,不会导致子类初始化。(对于静态字段,只有直接定义这个字段的类才会初始化,因此只会触发父类的初始化。)
- 通过数组定义来引用类,不会触发此类的初始化。
- 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
类加载过程
类装载主要涉及三个步骤:加载、连接(验证、准备、解析)、初始化
加载 (Class-Loading)
- 通过类的全限定名取得类的二进制流
- 将字节流所代表的静态存储结构转为方法区数据结构
- 在Java堆中生成对应的java.lang.Class对象
连接 - 验证
其目标是保证Class文件字节流的格式是正确的,符合虚拟机的要求,主要涉及:
文件格式的验证
- 是否以0xCAFEBABE开头
- 版本号是否合理
元数据验证:对字节码描述的信息进行语义分析,以保证符合 Java 语言规范的要求
- 该类是否有父类(除了 Object,所有类都应当有父类)
- 该类是否继承了不允许被继承的类(被 final 修饰的类)
- 非抽象类是否实现了所有该实现的抽象方法
字节码验证 (很复杂):对类的方法体进行校验分析,以保证方法执行时不会危害虚拟机安全
- 运行检查
- 栈数据类型和操作码数据参数吻合
- 保证跳转指令不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换是有效的
符号引用验证:发生在将符号引用转化为直接引用的时候,动作发生在连接第三阶段 - 解析的过程中。像
java.lang.NoSuchFieldError
,java.lang.NoSuchMethodError
都是发生在此阶段。- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 访问的方法或字段是否存在且有足够的权限
连接 - 准备
分配内存,并为类变量设置初始值,变量所使用的内存都在方法区中进行分配。
内存分配仅为类变量 (static 修饰),而不包括实例变量,实例变量将会在对象示例化时随着对象一起分配在 Java 堆中。如
public static int v=1;
在准备阶段中,v会被设置为0。在初始化的<clinit>
中才会被设置为1。对于static final类型,在准备阶段就会被赋上正确的值
public static final int v=1;
连接 - 解析
将符号引用转为直接引用的过程。
符号引用:符号可以是任何形式的字面量,引用的目标不一定已经加载到内存中。
直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
初始化
到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
执行类构造器
<clinit>
static变量 赋值语句
static{} 语句
子类的
<clinit>
调用前保证父类的<clinit>
被调用<clinit>
是线程安全的。虚拟机会保证一个类的<clinit>
方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>
方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。
类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。“相等”包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
什么是类装载器ClassLoader
- ClassLoader是一个抽象类;
- ClassLoader的实例将读入Java字节码将类装载到JVM中;
- ClassLoader可以定制,满足不同的字节码流获取方式;
- ClassLoader负责类装载过程中的加载阶段。
ClassLoader的重要方法
1 | // 载入并返回一个Class |
ClassLoader分类
BootStrap ClassLoader (启动ClassLoader)
负责将存放在
<JAVA_HOME>\lib
目录中的,或者被-Xbootclasspath
参数所指定的路径中的类库加载到虚拟机内存中。Extension ClassLoader (扩展ClassLoader)
负责加载
<JAVA_HOME>\lib\ext
目录中的,或者被java.ext.dirs
系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。App ClassLoader (应用ClassLoader/系统ClassLoader)
负责加载用户类路径
ClassPath
上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。Custom ClassLoader(自定义ClassLoader)
Custom ClassLoader的简单示例
从特定路径找到特定类的class文件,获取其字节数组后调用defineClass
方法来定义类。
1 | public class CustomClassLoader extends ClassLoader { |
双亲委派模型
上图中的类加载器之间的层次关系即为类加载器的双亲委派模型:自底向上检查类是否被加载,自顶向下尝试加载类。
类加载器不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
好处是:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。Object无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,各种类加载器环境中都是同一个类。若没有使用双亲委派模型,用户自己编写了一个Object类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
以下代码就是 ClassLoader.loadClass方法,可以看到双亲委派的具体实现。
1 | protected Class<?> loadClass(String name, boolean resolve) |
破坏双亲委派模型
First
该模型虽然解决了各个类加载器基础类的统一问题,但如果基础类要调用用户的代码则无法实现。如SPI的定义在rt.jar(即BootStrap ClassLoader)中,而实现类在AppClassLoader中。
解决方式为线程上下文类加载器(Thread Context ClassLoader),基本思想是,在顶层ClassLoader中,传入底层ClassLoader的实例。这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()
方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。读取即为Thread.currentThread().getContextClassLoader()
。
Second
双亲委派模式是默认的模式,但不是必须这么做,破坏双亲委派模型也可以自定义实现ClassLoader:
- Tomcat 的 WebappClassLoader 就会先加载自己的Class,找不到再委托parent;
- OSGi 的 ClassLoader形成网状结构,根据需要自由加载Class。
loadClass和forName之间的区别
ClassLoader.loadClass(name)
调用重载方法loadClass(name, false),第二个参数为 resolve,其含义就是是否进行解析(连接的解析阶段),若是 true,会调用 resolveClass,开始连接指定的类。所以 loadClass 不会连接类。
而Class.forName
内部调用 forname0 方法,其第二个布尔值参数代表是否初始化这个类, 而调用时使用的是 true,即会初始化这个类。
所以结论是,Class.forName 得到的是 class 是已经初始化的;而 ClassLoader.loadClass 得到的 Class 是还没有进行连接的。可以自定义一个类,类中加一个静态代码块进行确认是否进行了初始化。
loadClass 可以在 Spring IOC 中看到身影,这么做的原因是和 Spring 的 lazy loading 有关。为了加快类加载速度,大量使用延时加载技术,loadClass不需要执行类中的初始化代码且不需要连接,类的初始化工作留到实际使用到这个类的时候才去进行。
Tomcat 类加载器结构
主流Java服务器都实现了自定义的类加载器,因为一个健全的Web服务器需要解决以下几个问题:
- 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。
- 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
- 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
- 支持JSP应用的Web服务器,大多数都需要支持HotSwap功能。
因为上述问题的存在,所以Web服务器一般都会划分出好几个类路径。以Tomcat为例:
/common目录:类库可被Tomcat和所有的Web应用程序共同使用。
/server目录:类库可被Tomcat使用,对所有的Web应用程序都不可见。
/shared目录:类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
/WebApp/WEB-INF目录:类库仅仅可以被此Web应用程序使用,对Tomcat和其他Web应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器。CommonClassLoader
、CatalinaClassLoader
、SharedClassLoader
和WebappClassLoader
则是Tomcat自己定义的类加载器,它们分别加载/common/*
、/server/*
、/shared/*
和/WebApp/WEB-INF/*
中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
思维导图
Reference
周志明. 深入理解Java虚拟机:JVM高级特性与最佳实践