声明
- 上一篇写了[日更-2019.4.29、30] 关于JVM、Dalvik、ART的基础分析 ,本篇就单独对JVM进行一下分析。
0 写在前面
在使用Java这门语言时最先接触的一般是JDK和JRE这两个概念。
- JDK (Java Development Kit) 包含了Java语言、Java虚拟机和JavaAPI类库这三部分,是Java程序开发的最小环境。
- JRE (Java Runtime Environment)包含了Java API中的Java SE API子集和Java虚拟机这两部分, 是Java程序运行的标准环境。
最关键的还是Java虚拟机(JVM),Java之所以一处编译到处运行的关键就是因为它的存在,虽然名字叫JVM,但在其上运行的语言还有Kotlin、Jython等。
1 JVM的基本知识
1.1 主流JVM
JVM在Java这门语言创生的时候就伴随其成长了,目前Java的版本到了Java 10,Oracle JDK 和Open JDK使用的虚拟机是最主流的虚拟机HotSpot VM。
1.2 JVM执行流程
Java 虚拟机执行流程分为两大部分:
-
编译时环境:当一个Java文件经过Java编译器编译后会生成Class文件,这个Class文件会由 Java 虚拟机来进行处理;
-
运行时环境:JVM与Java语言没有什么必然的联系,它只与特定的二进制文件: Class文件有关。因此无论任何语言只要能编译成 Class 文件,就可以被 Java 虚拟机识别并执行;
2 JVM的结构
先上一张图,Java虚拟机结构包括运行时数据区域、执行引擎、本地库接口和本地方法库,其中类加载子系统并不属于Java虚拟机的内部结构。
2.1 Class文件格式
Java 文件被编译后生成了Class 文件,它不依赖于特定的硬件和操作系统。每一个Class 文件中都对应着唯一的类或者接口的定义信息, 但是类或者接口并不一定定义在文件中,比如类和接口可以通过类加载器来直接生成。无论任何语言只要能编译成Class文件,就可以被Java虚拟机识别并执行,可见Class文件的重要性, 那么Class文件的格式如下所示:
Class File {
u4 magic ; //魔数,固定值为OxCAFEBABE ,用来判断当前文件是不是能被Java 虚拟机处理的Class 文件
u2 minor_version ; //副版本号
u2 major_version ; //主版本号
u2 constant_pool_count ; //常量池计数器
cp info_constant_pool[constant pool count- 1]; //常量池
u2 access_flags ; //类和接口层次的访问标志
u2 this_class ; //类索引
u2 super_class ; //父类索引
u2 interfaces_count ; //接口计数器
u2 interfaces[interfaces_count] ; //接口表
u2 fields_count ; //字段计数器
field_info_fields[fields count] ; //字段表
u2 methods_count ; //方法计数器
method info_methods[methods_count] ; //方法表
u2 attributes_count; //属性计数器
attribute_infoattributes[attributes_count] ; //属性表
其中,
ul: 1 字节,无符号类型。
u2: 2 字节,无符号类型。
u4: 4 字节,无符号类型。
u8: 8 字节,无符号类型。
2.2 类的生命周期
一个Java文件被加载到Java虚拟机内存中到从内存中卸载的过程被称为类的生命周期。类的生命周期包括的阶段分别是:加载、链接、初始化、使用、卸载,其中链接包括了三个阶段:验证、准备、解析,因此类的生命周期包括了7 个阶段。
- 加载:查找并加载Class 文件(不是类的加载),主要做了3 件事情:
-
根据特定名称查找类或接口类型的二进制字节流(由Java 虚拟机外部的类加载子系统来完成)。
-
将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构。
-
在内存中生成一个代表这个类的java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
- 链接:包括验证、准备和解析。
- 验证:确保被导进来类型的正确性。
- 准备:为类的静态字段分配字段,并用默认值初始化这些字段。
- 解析:虚拟机将常量地内的符号引用替换为直接引用。
-
初始化:将类变量初始化为正确初始值。
-
使用。
-
卸载。
2.3 类加载子系统
类加载子系统通过多种类加载器来查找和加载C lass 文件到Java 虚拟机中, Java 虚拟机有两种类加载器:系统加载器和自定义加载器。其中系统加载器包括以下三种。
- Bootstrap Classloader (引导类加载器)
用C++代码实现的加载器,用于加载指定的JDK的核心类库,比如java.lang.java.uti.等这些系统类。它用来加载以下目录中的类库:
- $JAVA_HOME/jre/lib 目录。
- -Xbootclasspath 参数指定的目录。
Java 虚拟机的启动就是通过引导类加载器创建一个初始类来完成的。由于类加载器是使用平台相关的底层C++代码实现的,所以该加载器不能被Java代码访问,但是我们可以查询某个类是否被引导类加载器加载过。
- Extensions Classloader (拓展类加载器)
用于加载Java的拓展类,提供除了系统类之外的额外功能。它用来加载以下目录中的类库:
- 加载$JAVA_HOME/jre/lib/ext目录。
- 系统属性java.ext.dir 所指定的目录。
- Application Classloader (应用程序类加载器)
又称作System ClassLoader (系统类加载器),这是因为这个类加载器可以通过ClassLoader 的getSystemClassLoader 方怯获取到。它用来加载以下目录中的类库:
- 当前应用程序Classpath 目录。
- 系统属’性java.class.path 指定的目录。
除了系统加载器还有自定义加载器, 它是通过继承java.lang.ClassLoader类的方式来实现自己的类加载器。
2.4 运行时数据区域
Java 虚拟机在执行Java 程序的过程中会把它所管理的内存划分为不同的数据区域,根据《Java 虚拟机规范( Java SE7 版)》的规定,这些数据区域分别为程序计数器、Java 虚拟机枝、本地方住枝、Java 堆和方住区。
- 程序计数器
程序计数器(Program Counter Register)也叫作PC寄存器,是一块较小的内存空间。在虚拟机概念模型中,字节码解释器工作时就是通过改变程序计数器来选取下一条需要执行的字节码指令的,Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,在一个确定的时刻只有一个处理器执行一条线程中的指令,为了在线程切换后能恢复到正确的执行位置,每个线程都会有一个独立的程序计数器,因此,程序计数器是线程私有的。如果线程执行的方也不是Native方法,则程序计数器保存正在执行的字节码指令地址,如果是Native方法则程序计数器的值为空(Undefined )。程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError 情况的数据区域。
- Java 虚拟机枝
每一条Java 虚拟机线程都有一个线程私有的Java 虚拟机栈(Java Virtual Machine Stacks)。它的生命周期与线程相同,与线程是同时创建的。Java虚拟机栈存储线程中Java方法调用的状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java虚拟机栈包含了多个栈帧,一个栈帧用来存储局部变量表、操作数栈、动态链接、方怯出口等信息。当线程调用一个Java 方法时,虚拟机压入一个新的栈帧到该线程的Java 虚拟机栈中,在该方法执行完成后,这个栈帧就从Java 虚拟机栈中弹出。我们平常所说的栈内存(Stack)指的就是Java虚拟机栈。Java虚拟机规范中定义了两种异常情况。
- 如果线程请求分配的枝容量超过Java 虚拟机所允许的最大容量, Java 虚拟机会抛出StackOverflow Error。
- 如果Java 虚拟机技可以动态扩展(大部分Java 虚拟机都可以动态扩展),但是扩展时无怯申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的Java 虚拟机枝,则会抛出OutOfMemoryError异常。
- 本地方法栈
Java 虚拟机实现可能要用到C Stacks来支持Native语言,这个C Stacks就是本地方法栈(Native Method Stack)。它与Java 虚拟机栈类似,只不过本地方法栈是用来支持Native方法的。如果Java虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无须支持本地方法栈。在Java虚拟机规范中对本地方法栈的语言和数据结构等没有强制规定,因此具体的Java虚拟机可以自由实现它,比如HotSpotVM将本地方也栈和Java 虚拟机栈合二为一。与Java 虚拟机栈类似,本地方法栈也会抛出StackOverflowError 和OutOfMemoryError异常。
- Java堆
Java 堆(Java Heap)是被所有线程共享的运行时内存区域。Java 堆用来存放对象实例,几乎所有的对象实例都在这里分配内存。Java 堆存储的对象被垃圾收集器管理,这些受管理的对象无法显式地销毁。从内存回收的角度来分,Java堆可以粗略地分为新生代和老年代,从内存分配的角度Java堆中可能划分出多个线程私有的分配缓冲区。不管如何划分,Java 堆存储的内容是不变的,进行划分是为了能更快地回收或者分配内存。Java 堆的容量可以是固定的或动态扩展的。Java 堆所使用的内存在物理上不需要连续,逻辑上连续即可。Java 虚拟机规范中定义了一种异常情况:如果在堆中没有足够的内存来完成实例分配,并且堆也无怯进行扩展时,将会抛出OutOfMemoryError异常。
- 方法区
方法区(Method Area)是被所有线程共享的运行时内存区域,用来存储已经被Java虚拟机加载的类的结构信息,包括运行时常量池、字段和方法信息、静态变量等数据。方法区是Java 堆的逻辑组成部分,它一样在物理上不需要连续,并且可以选择在方法区中不实现垃圾收集。在Java 虚拟机规范中定义了一种异常情况:如果方法区的内存空间不满足内存分配需求时,Java 虚拟机会抛出OutOfMemoryError异常。
- 运行时常量池
运行时常量地(Runtime Constant Pool)并不是运行时数据区域的其中一份子,而是方法区的一部分。在10.2.1节中我们得知, Class 文件不仅包含类的版本、接口、字段和方法等信息,还包含常量池,它用来存放编译时期生成的字面量和符号引用,这些内容会在类加载后存放在方法区的运行时常量池中。运行时常量地可以理解为是类或接口的常量池的运行时表现形式。在Java 虚拟机规范中定义了一种异常情况: 当创建类或接口时,如果构造运行时常量地所需的内存超过了方怯区所能提供的最大值,Java虚拟机会抛出OutOfMemoryError异常。
3 创建对象
编程时通常通过new来完成一个对象的创建,当虚拟机接收到一个new指令时,它会做如下的操作:
- 判断对象对应的类是否加载、链接和初始化
虚拟机接收到一条new指令时,首先会去检查这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初始化过。
- 为对象分配内存
类加载完成后,接着会在Java堆中划分-块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
- 指针碰撞:如果Java 堆的内存是规整的,即所有用过的内存放在一边,而空闲的内在放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
- 空闲列表:如果Java 堆的内存不是规整的,则需要由虚拟机维护一个列表来记录哪些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
Java 堆的内存是否规整根据所采用的垃圾收集器是否带有压缩整理功能有关。
- 处理并发安全问题
创建对象是一个非常频繁的操作,所以需要解决并发的问题,有两种方式:
- 对分配内存空间的动作进行同步处理,比如在虚拟机采用CAS 算怯井配上失败重试
的方式保证更新操作的原子性。 - 每个线程在Java 堆中预先分配一小块内存,这块内存称为本地线程分配缓冲( Thread Local Allocation Buffer, TLAB ),线程需要分配内存时,就在对应线程的TLAB 上分配内存,当线程中的TLAB 用完并且被分配到了新的TLAB 时,这时候才需要同步锁定。通过-XX:+/-UserTLAB 参数来设定虚拟机是否使用TLAB 。
- 初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。
- 设置对象的对象头
将对象的所属类、对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。
- 执行init方法进行初始化
执行init方怯,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建了出来。
4 垃圾标记算法
垃圾收集器( Garbage Collection ),通常被称作GC。主要做了两个工作, 一个是内存的划分和分配,另一个是对垃圾进行回收。关于内存的划分和分配,目前Java虚拟机内存的划分是依赖于GC设计的, 比如现在GC都是采用了分代收集算法来回收垃圾,Java堆作为GC主要管理的区域,被细分为新生代和老年代,再细致一点新生代又可以划分为Eden 空间、From Survivor 空间、To Survivor 空间等,这样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。
关于对垃圾进行回收,被引用的对象是存活的对象,而不被引用的对象是死亡的对象(也就是垃圾),GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。目前有两种垃圾标记算法,分别是引用计数算法和根搜索算法,这两个算法都和引用有些关联。
4.1 Java 中的引用
在JDK l . 2 之后, Java 将引用分为强引用、软引用、弱引用和虚引用。
- 强引用
当新建一个对象时就创建了一个具有强引用的对象,如果一个对象具有强引用,垃圾收集器就绝不会回收它。Java 虚拟机宁愿抛出OutOfMemoryError 异常,使程序异常终止,也不会回收具有强引用的对象来解决内存不足的问题。
- 软引用
如果一个对象只具有软引用,当内存不够时,会回收这些对象的内存,回收后如果还是没有足够的内存,就会抛出OutOfMemoryError 异常。Java 提供了So武Reference 类来实现软引用。
- 弱引用
弱引用比起软引用具有更短的生命周期,垃圾收集器一旦发现了只具有弱引用的对象,不管当前内存是否足够,都会回收它的内存。Java 提供了WeakReference 类来实现弱引用。
- 虚引用
虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何引用一样,在任何时候都可能被垃圾收集器回收。一个只具有虚引用的对象,被垃圾收集器回收时会收到一个系统通知,这也是虚引用的主要作用。Java 提供了PhantomReference类来实现虚引用。
4.2 引用计数算法
引用计数算怯的基本思想就是每个对象都有一个引用计数器,当对象在某处被引用的时候,它的引用计数器就加1 ,引用失效时就减1 。当引用计数器中的值变为0 ,则该对象就不能被使用,变成了垃圾。
目前主流的Java 虚拟机没有选择引用计数算法来为垃圾标记,主要原因是引用计数算法没有解决对象之间相互循环引用的问题。
如果对象dl和d2相互引用,除此之外这两个对象无任何其他引用,实际上这两个对象已经死亡,应该作为垃圾被回收,但是由于这两个对象互相引用,引用计数就不会为0 ,如果Java 虚拟机采用了引用计数算法,垃圾收集器就不能回收它们。
4.3 根搜索算法
这个算法的基本思想就是选定一些对象作为GC Roots,并组成根对象集合,然后以这些GC Roots的对象作为起始点,向下搜索,如果目标对象到GC Roots 是连接着的,我们则称该目标对象是可达的,如果目标对象不可达就说明目标对象是可以被回收的对象。
从图中可以看出,Obj5、Obj6和Obj7都是不可达的对象,其中Obj5和Obj6虽然互相引用,但是因为它们到GC Roots是不可达的,所以它们仍旧被判定为可回收的对象,
这样根搜索算怯就解决了引用计数算法无诠解决的问题:已经死亡的对象因为相互引用而不能被回收。在Java 中,可以作为GC Roots 的对象主要有以下几种:
- Java 栈中引用的对象;
- 本地方法栈中JNI引用的对象;
- 方法区中运行时常量池引用的对象;
- 方法区中静态属性引用的对象;
- 运行中的线程;
- 由引导类加载器加载的对象;
- GC控制的对象;
还有一个问题是【问题1】被标记为不可达的对象会立即被垃圾收集器回收吗?要回答这个问题我们首先要了解Java对象在虚拟机中的生命周期。
5 Java 对象在虚拟机中的生命周期
在Java对象被类加载器加载到虚拟机中后, Java 对象在Java 虚拟机中有7 个阶段。
- 创建阶段(Created)
创建阶段的具体步骤为:
为对象分配存储空间–>构造对象–>从超类到子类对static成员进行初始化–>递归i周用超类的构造方怯–>调用子类的构造方法
- 应用阶段( In Use)
当对象被创建,并分配给变量赋值时,状态就切换到了应用阶段。这一阶段的对象至少要具有一个强引用,或者显式地使用软引用、弱引用或者虚引用。
- 不可见阶段( Invisible)
在程序中找不到对象的任何强引用,比如程序的执行已经超出了该对象的作用域。在不可见阶段,对象仍可能被特殊的强引用GC Roots 持有着,比如对象被本地方陆战中JNI引用或被运行中的线程引用等。
- 不可达阶段( Unreachable)
在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达。
- 收集阶段( Collected)
垃圾收集器已经发现对象不可达,并且垃圾收集器已经准备好要对该对象的内存空间重新进行分配,这个时候如果该对象重写了finalize 方怯,贝lj 会调用该方法。
- 终结阶段( Finalized)
在对象执行完finalize 方怯后仍然处于不可达状态时,或者对象没有重写finalize 方毡,则该对象进入终结阶段,并等待垃圾收集器回收该对象空间。
- 对象空间重新分配阶段( Deallocated)
当垃圾收集器对对象的内存空间进行回收或者再分配时,这个对象就会彻底消失。
再来回想4.3节的【问题1】:被标记为不可达的对象会立即被垃圾收集器回收吗?很显然是不会的,被标记为不可达的对象会进入收集阶段,这时会执行该对象重写的finalize 方法,如果没有重写finalize 方法或者finalize方法中没有重新与一个可达的对象进行关联才会进入终结阶段,并最终被回收。
6 垃圾收集算法
6.1 标记一清除算法(已弃用)
标记一清除算总( Mark-Sweep )是一种常见的基础垃圾收集算怯,它将垃圾收集分为两个阶段。
- 标记阶段:标记出可以回收的对象。
- 清除阶段:回收被标记的对象所占用的空间。
标记一清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。标记一清除算告主要有两个缺点:
- 标记和清除的效率都不高;
- 容易产生大量不连续的内存碎片,碎片太多可能会导致后续没有足够的连续内存分配给较大的对象,从而提前触发新的一次垃圾收集动作。
6.2 复制算法(已弃用)
为了解决标记一清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。在垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。复制算法的执行过程如下图所示。
这种算发每次都对整个半区进行内存回收,不需要考虑内存碎片的问题,代价就是使用内存为原来的一半。复制算法的效率与存活对象的数目多少有很大的关系,如果存活对象很少,复制算陆的效率就会很高。由于绝大多数对象的生命周期很短,并且这些生命周期很短的对象都存于新生代中,所以复制算法被广泛应用于新生代中,关于新生代中复制算法的应用,会在后面的分代收集算法中详细介绍。
6.3 标记-压缩算法(已弃用)
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记一清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记一压缩(Mark-Compact)算法,与标记一清除算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使它们紧凑地排列在一起,然后对边界以外的内存进行回收,回收后,已用和未用的内存都各自一边,标记一压缩算法的执行过程如图下所示。
标记一压缩算法解决了标记一清除算法效率低和容易产生大量内存碎片的问题,它被广泛应用于老年代中。
6.4 分代收集算法(目前真正使用的)
分代收集算法会结合不同的收集算法来处理不同的空间,因此在学习分代收集算法之前我们首先要了解Java 堆区的空间划分。Java 堆区的空间划分在Java 虚拟机中,各种对象的生命周期会有着较大的差别,大部分对象生命周期很短暂,少部分对象生命周期很长,有的甚至与应用程序以及Java 虚拟机的运行周期一样长。因此,应该对不同生命周期的对象采取不同的收集策略,根据生命周期长短将它们分别放到不同的区域,并在不同的区域采用不同的收集算法,这就是分代的概念。现在主流的Java虚拟机的垃圾收集器都采用分代收集算法( Generational Collection)。
Java堆区基于分代的概念:
- 新生代(Young Generation);
- 老年代(Tenured Generation);
其中新生代再细分为Eden空间、From Survivor空间和To Survivor空间。因为Eden空间中的大多数对象生命周期很短,所以新生代的空间划分并不是均分的,Hotspot虚拟机默认Eden空间和两个Survivor空间的所占的比例为8: 1。
根据Java 堆区的空间划分,垃圾收集的类型分为两种,它们分别如下:
- Minor Collection :新生代垃圾收集。
- Full Collection :对老年代进行收集,又可以称作Mirror Collection, Full Collection 通常情况下会伴随至少一次的Minor Collection ,它的收集频率较低,耗时较长。
当执行一次Minor Collection时,Eden 空间的存活对象会被复制到To Survivor空间,并且之前经过一次Minor Collection并在From Survivor 空间存活的仍年轻的对象也会复制到To Survivor 空间。
有两种情况Eden空间和From Survivor空间存活的对象不会复制到ToSurvivor 空间,而是晋升到老年代:
- 一种是存活的对象的分代年龄超过去X:MaxTenuringThreshold (用于控制对象经历多少次MinorGC 才晋升到老年代)所指定的闹值;
- 另一种是To Survivor 空间容量达到|词值。当所有存活的对象被复制到To Survivor空间,或者晋升到老年代,也就意味着Eden 空间和From Survivor 空间剩下的都是可回收对象,如下所示:
这个时候GC执行Minor Collection, Eden 空间和From Survivor 空间都会被清空,新生代中存活的对象都存放在To Survivor 空间。接下来将From Survivor 空间和To Survivor空间互换位置,也就是此前的From Survivor 空间成为了现在的To Survivor 空间,每次Survivor 空间互换都要保证To Survivor 空间是空的,这就是复制算住在新生代中的应用。在老年代则会采用标记一压缩算陆或者标记-清除算法。