【原创】JVM 的类加载机制?盘它!_JAVA

程序员的成长之路

互联网/程序员/技术/资料共享 

阅读本文大概需要 12 分钟。

我们编写的代码存储在java文件中,java源代码通过编译生成Java虚拟机可识别的字节码,存储在Class文件中。运行java程序时需要将Class文件中的信息加载到Java虚拟机中,而这个过程就是类加载的过程。

【原创】JVM 的类加载机制?盘它!_JAVA_02

如上图所示,假设写一个类A存储为A.java,通过javac A.java编译生成A.class,A.class中存储了各种描述A类的信息。

然后运行程序,执行java A.class,这时java虚拟机会先将A.class中信息转换成Java虚拟机所需的存储格式,然后存放在方法区中。

之后,Java虚拟机会再创建一个java.lang.Class对象实例,这个实例将作为访问方法区中A类信息的入口。使用A中方法时,要先创建一个实例new A(),Java虚拟机基于类的描述信息在Java堆中创建一个A的实例。

那何时会触发类的加载呢?

Java虚拟机规范中并未明确指出,但对类的初始化时机做了明确说明。

我们先了解一下类的加载流程:

【原创】JVM 的类加载机制?盘它!_JAVA_03

JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化。

根据这个流程,初始化触发时类加载的第一个阶段---加载阶段肯定已经完成了,那我们可以这样推论,类初始化的触发时机定会触发整个类加载过程。

1.加载

加载(也称“装载”)是类加载过程中的一个阶段,这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。

注意这里不一定非得要从一个 Class 文件获取,这里既可以从 ZIP 包中读取(比如从 jar包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其他文件生成(比如将 JSP 文件转换成对应的 Class 类)。

画外音:加载阶段主要步骤是什么?

1、通过“类全名”来获取定义此类的二进制字节流

2、将字节流所代表的静态存储结构转换为方法区(1.8开始是元空间)的运行时数据结构

3、在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口

加载阶段是通过类加载(ClassLoader)来完成的,而类加载器也可以由用户自定义完成,因此,开发人员可以通过定义类加载器去控制字节流的获取方式。

加载之后,二进制文件会被读入到虚拟机所需的格式存储在方法区中,方法区中存储格式由虚拟机自行定义,然后在java堆中实例化一个java.lang.Class类对象,通过这个对象就可以访问方法区中的数据。

2.验证

这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

画外音:验证阶段包括哪几个检验过程?

主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

1、文件格式验证

验证class文件格式规范。

2、元数据验证

就是对字节码描述的信息进行语义分析,保证描述的信息符合java语言规范。验证点可能包括(这个类是否有父类(除Object)、这个类是否继承了不允许被继承的类(final修饰的)、如果这个类的父类是抽象类,是否实现了父类或接口中要求实现的方法)。

3、字节码验证

进行数据流和控制流分析,这个阶段对类的方法体进行校验,保证被校验的方法在运行时不会做出危害虚拟机的行为。

4、符号引用验证

符号引用中通过字符串描述的权限定名是否能找到对应的类、符号引用类中的类,字段和方法的访问性(private protected public default)是否能被当前类访问。

3.准备

准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。

注意这里所说的初始值概览,比如一个类变量定义为:

public static int port = 8080;

实际上变量 port 在准备阶段过后的初始值为 0 而不是 8080,将 port 赋值为 8080 的 putstatic 指令是程序被编译后,存放于类构造器 <client> 方法之中。

画外音:putstatic指令是什么?

Java中,静态属性和静态方法都是属于类的,类的诸多实例共享同一个静态属性和静态方法。

操作实例属性的JVM指令为:

getfield —— 从对象中获取字段

putfield —— 设置对象中字段的值

操作实例方法的JVM指令为:

invokespecial —— 根据编译时类型来调用实例方法

invokevirtual —— 运行时按照对象的类来调用实例方法

至于静态属性和静态方法,对应的指令为:

getstatic —— 从类中获取静态字段

putstatic —— 设置类中静态字段的值

invokestatic —— 调用类(静态)方法

画外音:类构造器<client>方法是什么?

<client>()方法,是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。

先简单了解下,后面在深入了解。

但是注意如果声明为:

public static final int port = 8080

在编译阶段会为 port 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 port 赋值为 8080。

话外音:ConstantValue 属性是什么?

ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被 static 修饰的变量才可以使用这项属性。

非 static 类型的变量的赋值实在实例构造器方法中进行的;static 类型变量赋值分两种,在类构造器其中赋值,或使用 ConstantValue 属性赋值。

4.解析

解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。

主要有以下四种:

  1. 类或接口的解析

  2. 字段解析

  3. 类方法解析

  4. 接口方法解析

符号引用就是 class 文件中的:

1. CONSTANT_Class_info

2.CONSTANT_Field_info

3.CONSTANT_Method_info

等类型的常量。

符号引用

符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在虚拟机规范的 Class 文件格式中。

直接引用

直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

5.初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其他操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。

类构造器 <client>

初始化阶段是执行类构造器 <client> 方法的过程。

<client> 方法是由编译器自动收集类中的类变量的赋值操作和静态语句合成而成的。

虚拟机会保证子 <client> 方法执行之前,父类的 <client> 方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成 <client>() 方法。

话外音:也就是说,在类的初始化阶段,只会初始化与类相关的静态赋值语句和静态语句,也就是有static关键字修饰的信息,而没有static修饰的赋值语句和执行语句在实例化对象的时候才会运行。

如果一个类被直接引用,就会触发类的初始化。在 Java 中,直接引用的情况有:

  1. 通过 new 关键字实例化对象、读取或设置类的静态变量、调用类的静态方法。

  2. 通过反射方法执行以上三种行为。

  3. 初始化子类的时候,会触发父类的初始化。

  4. 作为程序入口直接运行时(也就是直接调用 main 方法)

除了以上四种情况,其他使用类的方式叫做被动引用,而被动引用不会触发类的初始化。

注意以下几种情况不会执行类初始化:

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

  2. 定义对象数组,不会触发该类的初始化。

  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。

  4. 通过类名获取 Class 对象,不会触发类的初始化。

  5. 通过 Classs.forName 加载执行类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其他这个参数是告诉虚拟机,是否要对类进行初始化。

  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

来看一个类加载顺序的 Demo

public class JvmTest {     public static JvmTest jt = new JvmTest();     public static int a;    public static int b = 0;     static {        a++;        b++;    }     public JvmTest() {        a++;        b++;    }     public static void main(String[] args) {        /**         * 准备阶段:为 jt、a、b 分配内存并赋初始值 jt=null、a=0、b=0         * 解析阶段:将 jt 指向内存中的地址         * 初始化:jt 代码位置在最前面,这时候 a=1、b=1         *          a 没有默认值,不执行,a还是1,b 有默认值,b赋值为0         *          静态块过后,a=2、b=1         */        System.out.println(a);  // 输出 2        System.out.println(b);  // 输出 1    }}

话外音:接口的初始化是怎么样的?

接口中不能使用静态代码块,但接口也需要通过 <clinit>() 方法为接口中定义的静态成员变量显式初始化。

接口与类不同,接口的 <clinit>() 方法不需要先执行父类的 <clinit>() 方法,只有当父接口中定义的变量被使用时,父接口才会初始化。

6.使用

类的使用包括主动引用和被动引用,主动引用在初始化的那里已经说过了,这里再贴一下。

主动引用

  • 通过 new 关键字实例化对象、读取或设置类的静态变量、调用类的静态方法。

  • 通过反射方法执行以上三种行为。

  • 初始化子类的时候,会触发父类的初始化。

  • 作为程序入口直接运行时(也就是直接调用 main 方法)

被动引用

  • 引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。

  • 定义类数组,不会引起类的初始化。

  • 引用类的常量,不会引起类的初始化。

7.卸载

关于类的卸载,在类使用完之后,如果满足下面的情况,类就会被卸载:

  • 该类所有的实例都已经被收回,也就是 Java 堆中不存在该类的任何实例。

  • 加载该类的 ClassLoader 已经被回收。

  • 该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

如果以上三个条件全部满足,jvm 就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java 类的整个生命周期就结束了。

话外音:总结下,对象基本上都是在jvm的堆区中创建,在创建对象之前,会触发类加载(加载、连接、初始化),当类初始化完成后,根据类信息在堆区中实例化类对象,初始化非静态变量、非静态代码以及默认构造方法,当对象使用完之后会在合适的时候被jvm垃圾收集器回收。

对象的生命周期只是类的生命周期中使用阶段的主动引用的一种情况(即实例化类对象)。

而类的整个生命周期则要比对象的生命周期长的多。

8.类加载器

虚拟机设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,JVM 提供了 3 种类加载器:

【原创】JVM 的类加载机制?盘它!_JAVA_04

1.启动类加载器(Bootstrap ClassLoader)

负责加载 JAVA_HOME\lib 目录中的,或通过 -Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。

2.扩展类加载器(Extension ClassLoader)

负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库。

3.应用程序类加载器(Application ClassLoader)

负责加载用户路径(classpath)上的类库。JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader 实现自定义的类加载器。

9.双亲委派模型

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送给到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它加载路径下没有找到所需加载的 Class),子加载器才会尝试自己去加载。

采用双亲委派的一个好处是比如加载器位于 rt.jar 包中的类 java.lang.Object,不管是那个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这就保证了使用不同的类加器最终得到的都是同一个 Object 对象。

【原创】JVM 的类加载机制?盘它!_JAVA_05

10. OSGI(动态模型系统)

OSGi(Open Service Gateway Initiative),是面向 Java 的动态模型系统,是 Java 动态化模块化系统的一系列规范。

架构图如下:

【原创】JVM 的类加载机制?盘它!_JAVA_06

1.动态改变构造

OSGi 服务平台提供在多种网络设备上无需重启的动态改变构造的功能。

为了最小化耦合度和促使这些耦合度可管理,OSGi 技术提供一种面向服务的架构,它能使这些组件动态地发现对方。

2.模块化编程与热插拔

OSGi 旨在为实现 Java 程序的模块化编程提供基础条件,基于 OSGi 的程序很可能可以实现模块化级的热插拔功能,当程序升级更新时,可以只停用、重新安装然后启动程序的其中一部分,这对企业级程序开发来说是非常具有有活力的特性。

OSGi 描绘了一个很美好的模块化开发目标,而且定义了实现这个目标的所需服务与架构,同时也有成熟的框架进行实现支持。

但并非所有的应用都适合采用 OSGi 作为基础架构,它在提供强大功能同时,也引入了额外的复杂度,因为它不遵守了类加载器的双亲委派模型。

话外音:OSGi 模块化框架是很早就出现的一个插件化框架,最早 Eclipse 用它出名,但这些年也没有大热。它的具体功能是什么?

以一个简单开的开发场景作说明:假设我们使用SSM框架来开发我们的Web项目,我们做产品设计和开发的时候都是分模块的,我们分模块的目的就是实现模块之间的“解耦”,更进一步的目的是方便对一个项目的控制和管理。我们对一个项目进行模块化分解之后,我们就可以把不同模块交给不同的开发人员来完成开发,然后项目经理把大家完成的模块集中在一起,然后拼装成一个最终的产品。一般我们开发都是这样的基本情况。

在开发过程中,模块之间还要彼此保持联系,比如A模块要从B模块拿到一些数据,而B模块可能要调用C模块中的一些方法(除了公共底层的工具类之外)。

最后,我们要把最终的项目部署到tomcat或者jBoss的服务器中。那么我们启动服务器的时候,能不能关闭项目的某个模块或功能呢?很明显是做不到的,一旦服务器启动,所有模块就要一起启动,都要占用服务器资源,所以关闭不了模块,假设能强制拿掉,就会影响其它的功能。

以上就是我们传统模块式开发的一些局限性。

软件开发一直在追求一个境界,就是模块之间的真正“解耦”、“分离”,这样我们在软件的管理和开发上面就会更加的灵活,甚至包括给客户部署项目的时候都可以做到更加的灵活可控。但是我们以前使用SSM等架构模式进行产品开发的时候我们是达不到这种要求的。

所以OSGI的技术规范应运而生。现在的OSGI技术就可以满足我们之前所说的境界:在不同的模块中做到彻底的分离,而不是逻辑意义上的分离,是物理上的分离,也就是说在运行部署之后都可以在不停止服务器的时候直接把某些模块拿下来,其他模块的功能也不受影响。

现在主流的一些应用服务器,Oracle的weblogic服务器,IBM的WebSphere,JBoss,还有Sun公司的glassfish服务器,都对OSGI提供了强大的支持,都是在OSGI的技术基础上实现的。

简单点说,OSGI 被设计专门用来开发可分解为功能模块的复杂的 Java 应用。OSGI提供以下优势:

1.可以动态地安装、卸载、启动、停止不同的应用模块,而不需要重启容器。

2.在同一时刻可以跑多个同一个应用模块的实例。

3.OSGI在SOA领域提供成熟的解决方案,包括嵌入式,移动设备和客户端应用等。

不管做什么,只要坚持下去就会不一样!

<END>

【原创】JVM 的类加载机制?盘它!_JAVA_07