1. 写在前面
实际项目中,编写Java程序时一直都停留在应用阶段,至于代码编译后是什么样子,类文件如何加载,如何执行,JVM内存分配与垃圾回收等内容,一直都是一知半解,总想着找机会读一下《深入理解Java虚拟机》这本书。近段时间,总算是有了些许空余时间,怀着略显饥渴的心情,在两周时间里断断续续的读完了这本书。然而才过了这么几天,对书中讲解的知识点又忘记的差不多了(吐血ing…)。也许读书就是这样吧,读第一遍只能记住书的大概,只有在实际项目中遇到问题回头翻阅这本书时去读第二遍以至第三遍,才能完完全吸收书中的知识点。今天,借此机会,简单总结一下书中知识点,方便以后回顾,也算是为第一遍阅读《深入理解Java虚拟机》画下一个句号。
2. JVM内存分布
上图为JVM运行时内存分布,主要虚拟机栈、本地方法栈、程序计数器、JVM堆和方法区。其中堆和方法区是Thread共享的,虚拟机栈、本地方法栈和程序计数器是Thread独享的。
2.1 程序计数器
程序计数器:每个Thread都有程序计数器,用来保存代码的执行位置。
2.2 虚拟机栈
虚拟机栈中分布有栈帧,每个方法对应一个栈帧,方法的运行伴随着栈帧的入栈和出栈操作。栈帧中保存有局部变量表、操作数栈、返回地址、动态连接等信息。局部变量表在代码编译成类文件后会指定需要多个slot,局部变量表的大小就确定了,方法的参数、外部引用、this引用等都保存在局部变量表中。
代码运行时需要进行入栈和出栈操作,这些都需要使用操作数栈来实现。
2.3 本地方法栈
JVM中Thread非java代码的运行不在虚拟机栈中运行,而是在本地方法栈中运行。
2.4 堆
堆是JVM中进行垃圾收集的主站场,JVM中new对象时都需要在堆中申请相应的内存,垃圾回收机制定期对堆中内存进行收集,为了能够更好的管理堆中内存,将内存分为了新生代、老生代,并发展了各种垃圾收集器,以满足不同需要。这其中涉及各种回收算法和垃圾收集器等内容,我们将在下节详细讲解。
2.5 方法区
JVM启动后,当类被第一次使用时JVM会将该类的各种信息以及编译完成的代码放入方法区中,即类加载。在堆中新建类对象时,需要在方法区中获取该类的各种信息。方法区的内存常常被称为永久代,但实际上垃圾收集机制并不是不收集方法区的内存,仅仅是频率较低,且收集较为困难。
3. 垃圾收集机制
垃圾回收机制实际上主要解决了三个问题:
- 哪些属于需要回收的垃圾?
- 如何回收垃圾?
- 何时回收垃圾?
3.1 可达性分析算法
判断一个对象是否需要回收,之前提出过引用计数算法,即计数每个对象的被引用次数,但该算法存在缺陷,但A和B对象相互引用时,A和B则永远不会被回收,因此这种引用计数算法是不可行的。
可达性分析算法是目前使用的垃圾标定算法,该算法通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链“Reference Chain”,当一个对象到GC Roots没有任何引用链相连时,即从GC Roots到这个对象不可达,则证明该对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种: - 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即Native方法)
PS:这里的引用,与C语言中引用非常类似。可达性分析算法要求在对堆中对象进行可达性分析时,需要暂停整个进程即Stop The World,这往往会导致运行效率低下。后期的垃圾收集器针对该问题提出了多种改进,产生了多种垃圾收集器。3.2 垃圾收集算法
- 标记-清理算法:该算法对标记为可收集的内存进行回收,优点是速度快,缺点是容易产生大量内存碎片,会出现无较大连续内存可用的情况。
- 复制算法:该算法首先将堆内存分为两部分如A和B,A中有数据,B为空白内存,垃圾收集时将A中存活对象复制到B中并清空A,下一轮收集则将B中存活对象复制到A中并清空B。该算法优点为效率较高且会有连续可用内存,缺点是需要将内存一分为二,造成内存资源浪费。后期将该算法做了改进,用于堆中新生代内存的收集算法。主要改进为,结合新生代内存每次存活对象较少的特点,将新生代内存划分为Eden、Survivor1和Survivor2三部分Eden较大,两个Survivor较小,每次收集将Eden与其中一个Survivor(如Survivor1)中存活对象放入空白的另一个Survivor(如Survivor2),并清空Eden与Survivor1,新建对象时只从Eden中分配内存。这样的话即可对复制算法扬长避短,收集效率较高。同时,当新生代存活对象较大,Survivor存放不下时,会触发老年代内存担保机制,即将新生代存活的对象放入老年代中。
- 标记-整理算法:该算法首先使用标记-清理算法,然后将存活对象移向内存一侧,并记录内存的偏移,分配新内存时从偏移处开始分配新的内存。该算法适合存活对象较多的情况,主要用于对老年代内存的收集。需要注意的是老年代没有担保机制。
- 分代收集算法:该算法主要是根据数据的不同特点,将堆内存分为新生代和老年代,新生代内存的特点是频繁新建与回收,每次存活的对象较少,适合改进后的复制算法,老年代内存的特点是对象的存活时间较长,不会频繁新建与回收,因此适合标记-整理算法或标记清理算法。同时,当新生代内存存活的对象较多,Survivor放不下时,会触发内存担保机制,将新生代内存存活对象放入老年代内存。老年代内存中的数据是从新生代转换而来。若老年年内存依然不够,则会触发对老年代内存的垃圾收集,若依然不够,则会导致OutofMemory异常。
3.3 垃圾收集时间点
- 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- 老年代GC(Full GC):指发生在老年代的GC。
- Minor GC触发条件:当Eden区满时,触发Minor GC。
- Full GC触发条件:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小3.4 垃圾收集器介绍
- Serial收集器:单线程垃圾收集器,进行垃圾收集时,必须暂停其他所有的工作线程。
- ParNew收集器:Serial收集器的多线程版本
- Parallel Scavenge收集器与ParNew相似,但可以达到一个可控制的吞吐量(CPU运行用户代码时间/CPU总消耗时间)
- Serial Old收集器:Serial收集器的老年代版本,使用“标记-整理算法”
- Parallel Old收集器:Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理算法”
- CMS收集器:一种以获取最短回收停顿时间为目标的收集器,使用“标记——清除算法”。整个收集过程分为4个步骤:(1)初始标记;(2)并发标记;(3)重新标记;(4)并发清除
- G1收集器是当今收集器技术发展的最前沿成果之一,特点有:并行与并发、分代收集、空间整合、可预测的停顿等。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年的的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
4. 类文件结构
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件包含了Java虚拟机指令集和符号表以及若个其他辅组信息。Class文件格式采用一种类似C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本数据类型,以u1,u2,u4,u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。Class中以_info结尾代表一张表。4.1 魔数
每个Class文件的头4个字节称为魔数(Magic Number),它唯一作用就是用来确定文件是否能被虚拟机接受,值为“CAFEBABE”4.2 版本号
接下来的4个字节存储着Class文件的版本号,第五第六个字节为次版本号(Minor Version),第七第八为主版本号(Major Version)。版本号主要用于版本控制,高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。4.3 常量池
常量池是一个表类型的数据项,相当于Class文件的资源仓库,与Class文件其他项目关联最多,占用Class空间最大的数据项之一,且是第一个出现的表类型数据项目。4.4 访问标志
常量池之后就是由两个字节代表的访问标志(access flags)这些标志用于识别一些类或者接口层次的访问信息,包括这个Class是类还是接口;是否定义为public;是否定义为abstract类型;是否被final修饰。4.5 类索引、父类索引与接口索引集合
访问标志位之后就是u2类型的类索引,父类索引和接口索引集合。Class文件由这三项数据确定这个类的继承关系。这三项数据(u2类型的索引值)各指向类型为CONSTANT_Class_info的类描述符常量。4.6 字段表集合
字段表用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。字段表中字段的各种描述信息(作用域比如public,private,是否被final,static修饰,是否可序列化等)均使用标志位表示,名称则引用常量池中的常量来描述。4.7 方法表集合
在方法表中,方法的描述和字段的描述基本一致,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。
方法中的代码经过编译器编译成字节码指令后存放在方法属性表集合中一个名为“Code”的属性里面。
如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息。4.8 属性表集合
Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
为了能正确解析Class文件,在Java SE 7中预定义了21项属性,虚拟机在运行时会忽略他不认识的属性。5. JVM类加载机制
如上图所示,类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。5.1 加载
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
5.2 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 - 文件格式验证:
(1) 是否以魔数0xCAFEBABE开头;
(2) 主、次版本号是否在当前虚拟机处理范围之内;
(3) 常量池的常量中是否有不被支持的常量类型(检查常量tag标志);
(4) 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
(5) CONSTARTN_Utf8_info型的常量中是否有不符合UTF8编码的数据;
(6) Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。 - 元数据验证:
(1) 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);
(2) 这个类的父类是否继承了不允许被继承的类(被final修饰的类);
(3) 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
(4) 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。 - 字节码验证
(1) 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时去按long类型来加载入本地变量表中;
(2) 保证跳转指令不会跳转到方法体以外的字节码指令上;
(3) 保证方法体中的类型转换是有效的。 - 符号引用验证
(1) 符号引用中通过字符串描述的全限定名是否能找到对应的类;
(2) 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法或字段;
(3) 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。5.3 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。需要注意的是,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。另外,这里所说的初始值“通常情况”下是数据类型的零值,而不是程序赋值的初始值。5.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。直接引用是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。这个过程中涉及对类或接口的解析、对字段的解析、对类方法的解析、对接口方法的解析。5.5 初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。6. JVM字节码执行
上图为运行时栈帧结构,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址和一些额外的附加信息。每个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。6.1 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。6.2 操作数栈
操作数栈也常称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
当一个方法刚开始执行的时候,这个方法的操作数栈时空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。6.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。6.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用这,取决遇到何种方法返回指令,这种方式称为正常完成出口。
另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用了athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口,一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。