前言
Java 语言是一门面向对象的语言(Java 类型一切皆对象,基本类型除外),我们使用 Java 语言进行程序编写,都是以类的形式进行组织。各种各样的职能类与工具类配以恰当的业务逻辑构成了一个个功能丰富多彩的应用。
可以认为,使用 Java 语言编写的程序最主要的就是各种类的加载使用。因此,了解 Java 类的加载使用过程,会让我们可以更加高效编写出正确,高效的代码。
因此,本篇博文主要对 Java 类加载及其对象创建的整个过程做一个相对完整的讲解。
Java 类加载及其对象创建过程
熟悉 Java 的人都知道,Java 语言是一门基于 JVM 的平台无关的编程语言。使用 Java 编写的语言最终会经过编译器(即 javac)的编译后,生成 Class 字节码文件。最终由 JVM 将该 Class 文件加载进内存中,创建出一个对应的 Class 对象;然后由该 Class 对象,就可以实现实例的创建。
因此,类的加载与对象创建具体涉及到如下三个过程:
- Java 源码编译生成 Class 文件
- JVM 加载 Class 文件到内存,生成 Class 对象
- 创建类实例
下面依次对上述 3 个过程进行分析
Java 源码编译生成 Class 文件
先简单介绍下 Class 文件:Class 文件是一组以 8 bit(即 1 字节)为基础单位的二进制流,其内的各个数据项都具备一定的描述信息(具体信息参考 Java 虚拟机字节码规范),排列紧凑且无多余内容。
Class 文件存储了 Java 语言定义的类的全部信息,包括类名,类属性和类方法····其具备强大的信息描述能力,而 Class 文件内部结构其实只有两种数据类型:无符号数 和 表。
-
无符号数:属于字节码的基本数据类型。以
u1
,u2
,u4
,u8
分别代表 1个字节,2个字节,4个字节和8个字节的无符号数。无符号数可以用来描述数字,索引引用,数量值或者按照 UTF-8 编码构成的字符串值。 -
表:有多个无符号数或者其他表作为数据项构成的复合数据类型。一个类拥有多种结构,比如继承信息,字段,方法等等,这些结构在 Class 文件中都以表的形式进行存储(不同的结构对应不同的表,每个表都有自己定义的格式)。所有表都习惯地以 "_info" 结尾。整个 Class 文件本质上就是一张表。其格式如下所示:
类型 | 名称 | 数量 |
---|---|---|
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | constant_pool | constant_pool_count-1 |
u2 | access_flag | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
从上述 Class 文件格式表中可以看到,Class 文件其实就是依次存储了以下内容(对 cp_info,field_info,method_info 和 attribute_info 这些表的具体格式就不展开讲解了):
-
魔数(magic):用于验证是否是 Class 文件,其固定值为:0xCAFEBABE
-
次版本号(minor_version):Class 文件次版本号
-
主版本号(major_version):Class 文件主版本号
-
常量池(constant_pool):常量池主要存放两大类常量:字面量(Literal) 和 符号引用(Symbolic References)。
字面量 比较接近于 Java 语言层面的常量概念,如字符串,声明为final
的常量值等等。
符号引用 主要包含三类常量:类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。 -
访问标志(access_flag):用于识别类或接口层次的访问信息,包括:该 Class 是类还是接口;是否定义为
public
类型;是否定义为abstract
类型;如果是类的话,是否被声明为final
等。 -
类索引(this_class):用于确定类的全限定名
-
父类索引(super_class):用于确定父类的全限定名
-
接口索引集合(interfaces):用于描述这个类实现的接口集合
-
字段表(fields):用于描述接口或者类中声明的变量。字符包括类级变量(即
static
变量)和实例级变量 -
方法表(methods):用于描述接口或者类中声明的方法。方法中的 Java 代码(方法块)经过编译器翻译成字节码后,存放在方法属性表集合中的一个名为
Code
的属性里面 -
属性表(attributes):在 Class 文件中,字段表,方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息
Java 虚拟机不予任何语言相关联,包括 Java,它只与 Class 文件有联系。当 Java 源码被编译成 Class 文件后,Java 源码定义的类信息就被完整地存储到 Class 文件中了。
到此,Java 源码经由编译器就会编译成 Class 字节码文件了。
此时,就可以进入到下一步:JVM 加载 Class 文件到内存
JVM 加载 Class 文件到内存,生成 Class 对象
当我们 new
一个对象或者调用了类的静态字段/静态方法时,就会触发类的加载。
在类加载之前,JVM 进程肯定是要先启动,后续才能进行类的加载过程。
JVM 启动时,会把其管理执行 Java 程序的内存划分为若干个不同的数据区域,称为 虚拟机运行时数据区域 或 JVM 内存结构。具体的区域如下:
-
程序计数器:一块较小的内存,指向当前线程运行的字节码指令地址。
-
虚拟机栈:描述线程执行 Java 方法的内存模型:每个方法在执行时,会创建一个 栈帧 压入到线程虚拟机栈中,栈帧 用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个方法的调用和执行完成都对应着一个栈帧的入栈和出栈过程。Java 虚拟机的执行引擎是基于栈结构的,此处的栈指的就是该虚拟机栈。
-
本地方法栈:本地方法栈 与 虚拟机栈 的作用相似,区别在于 虚拟机栈 用于执行 Java 方法,而 本地方法栈 用于执行 Native 方法。当执行 Native 方法时,程序计数器 的值为空(Undefined)。
-
Java 堆:用于存放几乎所有的对象实例和数组实例。该区域是 JVM 所管理的内存中最大的一块,也是垃圾收集器主要进行的区域。
-
方法区:用于存储已被虚拟机加载的类信息,常量(
final
),静态变量(static
)和即时编译器编译后的代码等数据。该区域的垃圾回收主要针对的是常量池中的废弃常量和无用的类(该区域的垃圾回收效率较低,因此甚至可以不对该区域进行垃圾回收处理)。在 HotSpot 虚拟机中,该区域也称为 永久代。
JVM 内存结构图如下所示:
运行时数据区当 JVM 内存区域分配完成后,就可以进行类的加载。
类的加载过程主要涉及 3 个阶段操作:加载阶段,连接阶段 和 初始化阶段。
其中,连接阶段 又可以分为 3 个过程:验证,准备 和 解析。
如下图所示:
下面依次对上述类加载过程进行简单讲解:
-
加载:在加载阶段,虚拟机主要完成以下 3 件事情:
1)通过类的全限定名找到该类的字节码文件流。这部分功能有 类加载器 进行加载,对于 Java 开发人员来说,Java 虚拟机类加载器可分为 4 种类型:启动类加载器(Bootstrap ClassLoader),扩展类加载器(Extension ClassLoader),应用程序类加载器(Application ClassLoader) 和 自定义类加载器(User ClassLoader)。其中,应用程序类加载器 也被称为 系统类加载器,如果程序没有自定义类加载器,那么一般情况下使用的就是 应用程序类加载器。要判断一个类(对象)是否为同一个类,必须满足两个条件:由同一个类加载器加载 与 同一个类文件(即字节码相同)。因此,为了防止 Java 程序出现类混乱,Java 虚拟机采用的默认类加载机制为:双亲委派模型,简而言之,双亲委派模型 就是说将类加载请求先交由父类加载器进行加载,父类加载器无法加载时,才由子类加载器进行加载。这样就保证了 Java 程序中类对象的一致性。
2) 将该类字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
3)在内存中(没有具体规定一定在堆中分配,对于 HotSpot 虚拟机而言,Class 对象在方法区中分配)生成一个代表该类的java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。 -
验证:验证是连接阶段的第一步,该过程主要是为了确保 Class 文件的字节流正确且安全。其主要会完成下面 4 个阶段的检验动作:
1)文件格式验证:主要验证字节流是否符合 Class 文件格式规范,并且能被当前版本的虚拟机处理。该阶段验证通过后,就会将字节流转化为方法区中的对应类的存储结构(该操作其实就是 加载 阶段要完成的第 2 件事,因此,加载 阶段 和 连接 阶段启动有先后顺序,但存在交叉执行),后续 3 个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。
2)元数据验证:主要验证 Class 文件字节流是否符合 Java 语言规范。
3)字节码验证:主要是对类的方法体进行校验分析,确保方法在运行时不会做出危害虚拟机安全的事件。
4)符号引用验证:该阶段其实是发生在链接的第三阶段--解析 阶段发生的,主要就是对方法区常量池符号引用的校验。 -
准备:准备阶段是为类变量(即
static
变量)分配内存并进行默认初始化(赋予默认零值)过程。 -
解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程(即将符号引用替换为内存中已存在的对象)。
-
初始化:类初始化阶段是类加载的最后一步,该阶段主要做的就是按出现顺序依次执行类字段的定义初始化和构造初始化(静态代码块)。
到此,方法区中就已经存在一个完备的 Class 对象了。
现在,我们就可以 new
出一个实例对象了。
创建类实例
当我们在代码用 new
一个对象时,这个操作反映到 Class 文件上其实就是一个 new
指令,该指令后面会跟随一个类的全限定名。JVM 通过在方法区运行时常量池中,通过该类的全限定名就可以找到对应的类信息,然后就可以在堆中分配一块内存,用于创建该类实例变量,然后依次执行实例的默认初始化,定义初始化和构造初始化,这样,类实例对象就成功创建完成了。
类实例对象创建完后,就可以使用了。当使用结束时,JVM 就会在垃圾回收器启动时,对其进行存活判断,看是否要回收该对象。
因此,接下来就是垃圾回收过程。
垃圾回收
一个对象要想让垃圾收集器进行回收,则首先要判断该对象是否是一个 “无用对象”,即没有其他实例引用该对象。
判断对象是否 “无用” 的方法一般有两种:引用计数法 和 可达性分析:
-
引用计数法:通过给对象添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器就减1;任何时候,当计数器为0时,表明该对象不可能在被使用,则可进行回收。
引用计数 的优点是实现简单,判定效率也很高。但存在一个问题:很难解决对象之间循环引用问题,如下图所示:
假设 A 是一个持久对象(不会被回收),其引用了 B;而 B 引用了 C,C引用了 D,D 由引用了 B。因此,各自对象的引用计数器如上图红色数字所示。假设此时 A 断开了 B 的引用,如下图所示:
无效引用此时,B,C,D 本来应当算是无效对象,但由于他们循环引用,导致各自的计数器不为0,因此无法回收。
-
可达性分析:该算法通过一系列的 GC Roots 的对象作为起点,从这些节点开始向下搜索,处于 GC Roots 引用链上的对象即为存活对象,不可回收;无法达到 GC Roots 的对象即为不可达对象,可被回收。
上图中以 A 作为 GC Roots,则第二幅图因为 B,C,D 均没有引用链可以到达 A,因此,B,C,D 为不可达对象,可被回收。
Java 使用的判定算法为:可达性分析法
上面只是对单个对象的状态进行分析,而我们知道,当 GC 时,处理的对象是一大块内存的所有对象,不同的内存中,对象的状态(声明周期)不同,因此,这里就涉及到了垃圾收集算法。
常用的垃圾收集算法有如下 4 种:
-
标记-清除算法:见名知意,该算法包含两个阶段:标记 和 清除。标记过程主要就是可达性分析过程,首先找到内存中所有不可达对象位置,然后进行清除。
标记-清除算法的好处是简单直接,缺点是效率不高(标记和清除两个阶段的效率都不高),并且会产生大量不连续的内存碎片。内存碎片过多可能会导致无法分配大对象而导致的频繁的触发垃圾收集动作。 -
复制算法:该算法将内存分配为大小相等两块,每次只使用其中一块进行分配对象。当该块内存用完时,将该块内存上面还存活的对象复制到另一块内存上,然后清除该块内存,如此往复操作。
复制算法 的优点是不存在内存碎片问题,缺点是内存利用率不高,每次只能使用一般的内存空间。
实际使用中, 无需将内存对半分割,可以自定义更恰当的比例进行分割。比如,对于 HotSpot 虚拟机,其将年轻代划分为 8:1:1 的一个较大的 Eden 空间和两个较小的 Survivor 空间。每次分配对象时,使用 Eden 空间和其中一块 Survivor 空间,当回收时,将 Eden 空间和其中那块 Survivor 空间的存活对象复制到另一块 Survivor 空间中,最后清理掉 Eden 空间和那块 Survivor 空间。当复制对象时,Survivor 空间如果不够用,则多余的对象会通过分配担保机制直接进入老年代。
复制算法 的缺点是当对象存活率较高时,就要进行较多的复制操作,效率会变低。 -
标记-整理算法:该算法标记过程与 标记-清除 算法标记过程一致,但标记后,不进行清除,而是将存活对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-整理算法 的有点是不会产生内存碎片。 -
分代收集算法:当前商业虚拟机的垃圾收集都采用 分代收集。该算法只是根据对象存活周期的不同将内存划分为几块,每块采用不同的垃圾收集算法。
比如,对于 Java 堆来说,一般将其分为 新生代 和 老年代。新生代 对象的特点是朝生夕灭,每次垃圾收集时都有大批对象死去,因此适合采用 复制算法,每次 GC 时,只需付出很少的复制操作即可完成垃圾回收;老年代 因为对象的存活率高,且没有额外空间对它进行分配担保,因此必须使用 标记-清理 或 标记-整理 算法来进行回收。
到此,对于 Java 类从源码到 JVM 进程的加载及其实例创建使用过程涉及到的一些相关内容,就已分析完毕。
参考
- 《深入理解 Java 虚拟机》
网友评论