概述
我们常说的JDK (Java Development Kit)包含了 Java语言、Java虚拟机和Java API类库这三部分,是Java程序开发的最小环境。而JRE (Java Runtime Environment)包含了 Java API中的Java SE API子集和Java虚拟机这两部分,是Java程序运行的标准环境。那么可以看出Java虚拟机的重要性,它是整个Java平台的基石,是Java语言编译代码的运行平台。你可以把Java虚拟机看作一个抽象的计算机,它有各种指令集和各种运行时数据区域。 虽然叫Java虚拟机,但其实在它之上运行的语言可不仅仅是Java,还包括Kotlin、Groovy、Scala、Jython等。因此对于Android开发来说,不管你开发用的是Java还是Kotlin,你都需要去理解Java虚拟机。
Java虚拟机家族
有些读者可能认为Java虚拟机就是“一个”虚拟机而已,它还有家族?或者认为Java 虚拟机指的就是Oracle的HotSpot虚拟机,这里来简单介绍Java虚拟机家族,自从1996 年Sun公司发布的JDK1.0中包含的Sun Classic VM到今天,出现和消亡了很多种虚拟机, 我们这里只简单介绍目前存活的相对主流Java虚拟机。
1.HotSpot VM
Oracle JDK和OpenJDK中自带的虚拟机,是最主流的和使用范围最广的Java虚拟机。 介绍Java虚拟机的技术文章,如果不做特殊说明,大部分都是介绍HotSpot VM的。HotSpot VM并非是Sun公司开发的,而是由Longview Technologies这家小公司设计的,它在1997 年被Sun公司收购,Sun公司又在2009年被Oracle收购。
2.J9 VM
J9VM是IBM开发的虚拟机,目前是其主力发展的Java虚拟机。J9 VM的市场定位和 HotSpot VM接近,它是一款设计上从服务器端到桌面应用再到嵌入式都考虑到的多用途虚 拟机,目前J9 VM的性能水平大致与HotSpot VM是一个档次的。
3.Zing VM
以Oracle的HotSpot VM为基础,改进了许多影响延迟的细节。最大的3个卖点如下:
- 低延迟,“无暂停”的C4 GC, GC带来的暂停可以控制在10ms以下的级别,支持的Java堆大小可以达到1TB。
- 启动后快速预热功能。
- 可管理性:零开销、可在生产环境全时开启、整合在JVM内的监控工具Zing Vision.
需要注意的是,Android中的Dalvik和ART虚拟机并不属于Java虚拟机,因此这里没有列出它们,关于Dalvik和ART虚拟机将在第11章进行介绍。
Java虚拟机执行流程
当我们执行一个Java程序时,它的执行流程如图10-1所示。
从图10-1中可以发现Java虚拟机执行流程分为两大部分,分别是编译时环境和运行时 环境,当一个Java文件经过Java编译器编译后会生成Class文件,这个Class文件会由Java 虚拟机来进行处理。Java虚拟机与Java语言没有什么必然的联系,它只与特定的二进制文 件:Class文件有关。因此无论任何语言只要能编译成Class文件,就可以被Java虚拟机识 别并执行,如图10-2所示。
图10-2语言与Java虚拟机
Java内存模型
Java堆和方法区是多个线程共享的数据区域。多个线程可以操作堆和方法区中的同一个数据。Java的线程间通过共享内存进行通信
Java内存模型
Java内存模型的英文名称为Java Memory Model(JMM),其并不想JVM内存结构一样真实存在,而是一个抽象的概念。通过JSR-133 Java Memory Model and Thread Specification中的描述,我们知道JMM和线程有关,它描述了一组规范或规则,一个线程对共享变量的写入时对另一个线程是可见的。
Java多线程对共享内存进行操作的时候,会存在一些如可见性、原子性和顺序性的问题,JMM是围绕着多线程通信及相关的一些特性而建立的模型。而JMM定义了一些语法集,而这些语法集映射到Java语言的volatile、synchronized等关键字
Java虚拟机结构
这里所讲的体系结构,指的是Java虚拟机的抽象行为,而不是具体的比如HotSpot VM 的实现。按照Java虚拟机规范,抽象的Java虚拟机如图10-3所示。
图10-3 Java虚拟机结构
从图10-3可以看出Java虚拟机结构包括运行时数据区域、执行引擎、本地库接口和本地方法库,其中类加载子系统并不属于Java虚拟机的内部结构。图10-3中标出了线程共享和线程私有的区域,比如方法区和Java堆就是所有线程共享的数据区域。下面针对图10-3 来介绍Android开发需要掌握的Class文件格式和运行时数据区域。
1. Class文件格式
Java文件被编译后生成了 Class文件,这种二进制格式文件不依赖于特定的硬件和操作系统。每一个Class文件中都对应着唯一的类或者接口的定义信息,但是类或者接口并不一定定义在文件中,比如类和接口可以通过类加载器来直接生成。10.1.2节中我们知道无论任何语言只要能编译成Class文件,就可以被Java虚拟机识别并执行,可见Class文件的重要性,了解它对于我们学习那些基于Java虚拟机的语言会有很大帮助。下面我们来学习Class文件格式,如下所示:
ClassFile {
u4 magic;//魔数,固定值为OxCAFEBABE,用来判断当前文件是不是能被Java虚拟机处理的Class文件
u2 minor_version; //副版本号
u2 major_version; //主版本号
u2 constant_pool_count; //常量池计数器
cp_info constant_pool [constant_pool_count-l]; //常量池
u2 access_flags; //类和接口层次的访问标志
u2 this_class; //类索引
u2 superclass; //父类索引
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_info attributes[attributes_count]; //属性表
}
可以看到ClassFile具有很强的描述能力,包含了很多关键的信息,其中u4、u2表示 “基本数据类型”,class文件的基本数据类型如下所示。
- u1 : 1字节,无符号类型
- u2: 2字节,无符号类型。
- u4: 4字节,无符号类型。
- u8: 8字节,无符号类型。
2.类的生命周期
一个Java文件被加载到Java虚拟机内存中到从内存中卸载的过程被称为类的生命周期。类的生命周期包括的阶段分别是:加载、链接、初始化、使用和卸载,其中链接包括 了三个阶段:验证、准备和解析,因此类的生命周期包括了 7个阶段。广义上来说类的加载包括了类的生命周期的5个阶段,分别是加载、链接(验证、准备和解析)、初始化。如 图10-4所示。
接下来大概介绍类的加载各个阶段所做的工作,如下所示。
1.加载:查找并加载Class文件。
2.链接:包括验证、准备和解析。
- 验证:确保被导入类型的正确性。
- 准备:为类的静态字段分配字段,并用默认值初始化这些字段。
- 解析:虚拟机将常量池内的符号引用替换为直接引用。
图10-4类的生命周期
3.初始化:将类变量初始化为正确初始值。
根据《深入理解Java虚拟机》的描述,加载阶段(不是类的加载)主要做了3件事情:
- 根据特定名称査找类或接口类型的二进制字节流。
- 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数 据的访问入口。
其中第一件事情就是由Java虚拟机外部的类加载子系统来完成的,下面我们来学习类加载子系统。
3.类加载子系统
类加载子系统通过多种类加载器来查找和加载Class文件到Java虚拟机中,Java虚拟机有两种类加载器:系统加载器和自定义加载器。其中系统加载器包括以下三种。
1. Bootstrap ClassLoader (引导类加载器)
用C/C++代码实现的加载器,用于加载指定的JDK的核心类库,比如java.lang.、Java.uti.等这些系统类。它用来加载以下目录中的类库:
- $JAVA_HOME/jre/lib 目录。
- -Xbootclasspath参数指定的目录。
Java虚拟机的启动就是通过引导类加载器创建一个初始类来完成的。由于类加载器是使用平台相关的底层C/C++语言实现的,所以该加载器不能被Java代码访问到,但是我们 可以查询某个类是否被引导类加载器加载过。
2.Extensions ClassLoader (拓展类加载器)
用于加载Java的拓展类,提供除了系统类之外的额外功能。它用来加载以下目录中的 类库:
- 加载$JAVA_HOME/jre/lib/ext 目录。
- 系统属性java.ext.dir所指定的目录。
3.Application ClassLoader (应用程序类加载器)
又称作System ClassLoader(系统类加载器),这是因为这个类加载器可以通过 ClassLoader的getSystemClassLoader方法获取到。它用来加载以下目录中的类库:
- 当前应用程序Classpath目录。
- 系统属性java.class.path指定的目录。
除了系统加载器还有自定义加载器,它是通过继承java.lang.ClassLoader类的方式来实 现自己的类加载器的。关于类加载器这里只是简单介绍,在12章会进行详细介绍。
4.运行时数据区域
很多人将Java的内存分为堆内存(Heap)和栈内存(Stack),这种分法不够准确,Java 的内存区域划分实际上远比这要复杂。Java虚拟机在执行Java程序的过程中会把它所管理 的内存划分为不同的数据区域,根据《Java虚拟机规范(Java SE7版)》的规定,这些数据区域分别为程序计数器、Java虚拟机栈、本地方法栈、Java堆和方法区,下面一一对它们 进行介绍。
1.程序计数器
为了保证程序能够连续地执行下去,处理器必须具有某些手段来确定下一条指令的地 址,而程序计数器正是起到这种作用。程序计数器(Program Counter Register)也叫作PC 寄存器,是一块较小的内存空间。在虚拟机概念模型中,字节码解释器工作时就是通过改变程序计数器来选取下一条需要执行的字节码指令的,Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,在一个确定的时刻只有一个处理器执行一条线 程中的指令,为了在线程切换后能恢复到正确的执行位置,每个线程都会有一个独立的程序计数器,因此,程序计数器是线程私有的。如果线程执行的方法不是Native方法,则程 序计数器保存正在执行的字节码指令地址,如果是Native方法则程序计数器的值为空 (Undefined).程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况 的数据区域。
2.Java虚拟机栈
每一条Java虚拟机线程都有一个线程私有的Java虚拟机栈(Java Virtual Machine Stacks)。它的生命周期与线程相同,与线程是同时创建的。Java虚拟机栈存储线程中Java 方法调用的状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java虚拟机 栈包含了多个栈帧,一个栈帧用来存储局部变量表、操作数栈、动态链接、方法出口等信息。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java虚拟机栈中, 在该方法执行完成后,这个栈帧就从Java虚拟机栈中弹出。我们平常所说的栈内存(Stack) 指的就是Java虚拟机栈。Java虚拟机规范中定义了两种异常情况。
- 如果线程请求分配的栈容量超过Java虚拟机所允许的最大容量,Java虚拟机会抛出 StackOverflowError0
- 如果Java虚拟机栈可以动态扩展(大部分Java虚拟机都可以动态扩展),但是扩展 时无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的 Java虚拟机栈,则会抛出OutOfMemoryError异常。
3.本地方法栈
Java虚拟机实现可能要用到C Stacks来支持Native语言,这个C Stacks就是本地方法栈(Native Method Stack)。它与Java虚拟机栈类似,只不过本地方法栈是用来支持Native 方法的。如果Java虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无须支持本 地方法栈。在Java虚拟机规范中对本地方法栈的语言和数据结构等没有强制规定,因此具 体的Java虚拟机可以自由实现它,比如HotSpot VM将本地方法栈和Java虚拟机栈合二为一。 与Java虚拟机栈类似,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
4.Java 堆
Java堆(Java Heap)是被所有线程共享的运行时内存区域。Java堆用来存放对象实例, 几乎所有的对象实例都在这里分配内存。Java堆存储的对象被垃圾收集器管理,这些受管 理的对象无法显式地销毁。从内存回收的角度来分,Java堆可以粗略地分为新生代和老年 代,从内存分配的角度Java堆中可能划分出多个线程私有的分配缓冲区。不管如何划分, Java堆存储的内容是不变的,进行划分是为了能更快地回收或者分配内存。Java堆的容量 可以是固定的,也可以动态扩展。Java堆所使用的内存在物理上不需要连续,逻辑上连续 即可。Java虚拟机规范中定义了一种异常情况:如果在堆中没有足够的内存来完成实例分 配,并且堆也无法进行扩展时,则会抛出OutOfMemoryError异常。
5.方法区
方法区(Method Area)是被所有线程共享的运行时内存区域,用来存储已经被Java 虚拟机加载的类的结构信息,包括运行时常量池、字段和方法信息、静态变量等数据。方 法区是Java堆的逻辑组成部分,它一样在物理上不需要连续,并且可以选择在方法区中不 实现垃圾收集。方法区并不等同于永久代,只是因为HotSpot VM使用永久代来实现方法区,对于其他的Java虚拟机,比如J9和JRockit等,并不存在永久代概念。
在Java虚拟机规范中定义了一种异常情况:如果方法区的内存空间不满足内存分配需 求时,Java虚拟机会抛OutOfMemoryError异常。
6.运行时常量池
运行时常量池(Runtime Constant Pool)并不是运行时数据区域的其中一份子,而是方法区的一部分。在10.2.1节中我们得知,Class文件不仅包含类的版本、接口、字段和方法等信息,还包含常量池,它用来存放编译时期生成的字面量和符号引用,这些内容会在类 加载后存放在方法区的运行时常量池中。运行时常量池可以理解为是类或接口的常量池的 运行时表现形式。
在Java虚拟机规范中定义了一种异常情况:当创建类或接口时,如果构造运行时常量池所需的内存超过了方法区所能提供的最大值,Java虚拟机会抛出OutOfMemoryError异常。
对象的创建
对象的创建是我们经常要做的事,通常是通过new指令来完成一个对象的创建的,当 虚拟机接收到一个new指令时,它会做如下的操作。
1.判断对象对应的类是否加载、链接和初始化
虚拟机接收到一条new指令时,首先会去检查这个指定的参数是否能在常量池中定位 到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初 始化过。
2.为对象分配内存
类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式。
- 指针碰撞:如果Java堆的内存是规整的,即所有用过的内存放在一边,而空闲的内 在放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象 大小相等的距离,这样便完成分配内存工作。
- 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录哪 些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象, 并在分配后更新列表记录。
Java堆的内存是否规整根据所采用的垃圾收集器是否带有压缩整理功能有关。
3.处理并发安全问题
创建对象是一个非常频繁的操作,所以需要解决并发的问题,有两种方式:
- 对分配内存空间的动作进行同步处理,比如在虚拟机采用CAS算法并配上失败重试的方式保证更新操作的原子性。
- 每个线程在Java堆中预先分配一小块内存,这块内存称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),线程需要分配内存时,就在对应线程的TLAB ± 分配内存,当线程中的TLAB用完并且被分配到了新的TLAB时,这时候才需要同 步锁定。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。
4.初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。
5.设置对象的对象头
将对象的所属类、对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头 中。关于对象头,10.4节中会进行介绍。
6.执行init方法进行初始化
执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建了出来。
对象的堆内存布局
对象创建完毕,并且已经在Java堆中分配了内存,那么对象在堆内存是如何进行布局 的呢?以HotSpot虚拟机为例,对象在堆内存的布局分为三个区域,分别是对象头(Header). 实例数据(Instance Data),对齐填充(Padding)。下面分别来对这三个区域进行简单介绍。
- 对象头:对象头包括两部分信息,分别是Mark World和元数据指针,Mark World 用于存储对象运行时的数据,比如HashCode,锁状态标志、GC分代年龄、线程持 有的锁等。而元数据指针用于指向方法区中的目标类的元数据,通过元数据可以确 定对象的具体类型,具体是如何实现的请看10.5节。
- 实例数据:用于存储对象中的各种类型的字段信息(包括从父类继承来的)。
- 对齐填充:对齐填充不一定存在,起到了占位符的作用,没有特别的含义。
Mark Word在HotSpot中的实现类为markOop.hpp, markOop被设计成一个非固定的数据结构,这是为了在极小的空间中存储尽量多的数据,32位虚拟机的markOop格式如下所示:
其中部分数据类型解释如下。
- hash:对象的哈希码。
- age:对象的分代年龄。
- biased Jock:偏向锁标识位。
- lock:锁状态标识位。
- JavaThread*:持有偏向锁的线程ID。
- epoch:偏向时间戳。
对象的堆内存布局如图10-5所示。
图10-5对象的堆内存布局
oop-klass 模型
oop-klass模型是用来描述Java对象实例的一种模型,它分为两个部分,OOP (Ordinary Object Pointer)指的是普通对象指针,用来表示对象的实例信息。klass用来描述元数据。
在HotSpot中就采用了 oop-klass模型,oop实际是一个家族,Java虚拟机内部会定义很多oop类型,如下所示:
hotspot/src/share/vm/oops/oopsHierarchy.hpp
typedef class markOopDesc* markOop; //oop 标记对象
typedef class oopDesc* oop; //oop家族的顶级父类
typedef class instanceOopDesc* instanceOop; //表示 Java 类实例
typedef class arrayOopDesc* arrayOop;//数组对象
typedef class objArrayOopDesc * objArrayOop; //引用类型数组对象
typedef class typeArrayOopDesc* typeArrayOop; //基本类型数组对象
其中 oopDesc 是所有 oop 的顶级父类,arrayOopDesc 是 objArrayOopDesc 和 typeArrayOopDesc 的父类。instanceOopDesc 和 arrayOopDesc 都可以用来描述对象头。
在 oopsHierarchy.hpp 中还定义了 klass 家族:
hotspot/src/share/vm/oops/oopsHierarchy.hpp
class Klass; //klass 家族的父类
class InstanceKlass; //描述Java类的数据结构
class InstanceMirrorKlass; //java.lang.Class 实例
class InstanceClassLoaderKlass; //特殊的InstanceKlass,不添加任何字段
class InstanceRefKlass; //描述 java.lang.ref.Reference 的子类
class ArrayKlass; //描述Java数组信息
class ObjArrayKLass;//描述Java中引用类型数组的数据结构
class TypeArrayKlass; //描述Java中基本类型数组的数据结构
其中Klass是klass家族的父类(不是顶级父类),ArrayKlass是ObjArrayKlass和 TypeArrayKlass的父类,可以发现oop家族的成员和klass家族的成员有着对应的关系,比如 instanceOopDesc 对应 InstanceKlass, objArrayOopDesc 对应 ObjArrayKlass。这里我们拿 instanceOopDesc和InstanceKlass的对应关系来举例,其他的对应关系也是类似的。 instanceOopDesc的定义如下所示:
hotspot/src/share/vm/oops/instanceOop.hpp
class instanceOopDesc : public oopDesc {
public:
static int header_size() {
return sizeof(instanceOopDesc)/HeapWordSize;
}
static int base_offset_in_bytes() {
return (UseCompressedOops && UseCompressedClassPointers) ?klass_gap_offset_in_bytes():sizeof(instanceOopDesc);
}
static bool contains_field_offset(int offset, int nonstatic_field_size) {
int base_in_bytes = base_offset_in_bytes();
return (offset >= base_in_bytes &&(offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
}
};
可以看出 instanceOopDesc 继承了oopDesc:
openjdk/hotspot/src/share/vm/oops/oop.hpp
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
// Fast access to barrier set. Must be initialized.
static BarrierSet* _bs;
}
oopDesc中包含两个数据成员:mark 和_metadatao其中markOop类型的mark对象指的是前面讲到的Mark World。metadata是一个共用体,其中klass是普通指针,_compressed_klass 是压缩类指针,它们就是10.4节讲到的元数据指针,这两个指针根据对应关系都会指向 instanceKlass, instanceKlass可以用来描述元数据,我们接着往下看,instanceKlass的代码如下所示:
openjdk/hotspot/src/share/vm/oops/instanceKlass.hpp
class InstanceKlass: public Klass (
enum ClassState (
allocated,
loaded,
linked,
being_initialized,
fully_initialized,
initialization_error
};
}
instanceKlass继承自klass,枚举ClassState用来标识对象的加载进度,klass中定义的部分字段如下所示:
hotspot/src/share/vm/oops/klass.hpp
jint _layout_helper; //对象布局的综合描述符
Symbol* _name;//类名
oop _java_mirror; // 类的镜像类
Klass* _super; // 父类
Klass* _subklass; // 第一个子类
Klass* _next_sibling; //下一个兄弟节点
jint _modifier_flags //修饰符标识
AccessFlags _access_flags; //访问权限标识
可以看到klass描述了元数据,具体来说就是Java类在Java虚拟机中对等的C++类型 描述,这样继承自klass的instanceKlass同样可以用来描述元数据。了解了 opp-klass模型, 我们就可以分析Java虚拟机是如何通过栈帧中的对象引用找到对应的对象实例的,如图 10-6所示。
从图10-6中可以看出,Java虚拟机通过栈帧中的对象引用找到Java堆中的 instanceOopDesc,这样就可以访问到Java对象的实例信息,当需要访问对象的具体类型等信息时,可以通过instanceOopDesc中的元数据指针来找到方法区中对应的instanceKlass.
图10-6确定对象具体类型
垃圾标记算法
垃圾收集器(Garbage Collection),通常被称作GCO提到GC,很多人认为它是伴随 Java而出现的,其实GC出现的时间要比Java早太多了,它是1960年诞生于MIT的Lisp。 GC主要做了两个工作,一个是内存的划分和分配,另一个是对垃圾进行回收。关于内存的 划分和分配,目前Java虚拟机内存的划分是依赖于GC设计的,比如现在GC都是采用了 分代收集算法来回收垃圾的,Java堆作为GC主要管理的区域,被细分为新生代和老年代, 再细致一点新生代又可以划分为Eden空间、From Survivor空间、To Survivor空间等,这 样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。 关于对垃圾进行回收,被引用的对象是存活的对象,而不被引用的对象是死亡的对象(也 就是垃圾),GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。 在对垃圾进行回收前,GC要先标记出垃圾,那么如何标记呢?目前有两种垃圾标记算法, 分别是引用计数算法和根搜索算法,这两个算法都和引用有些关联,因此讲垃圾标记算法 前,我们先回顾一下引用的知识点。
1.Java中的引用
在JDK1.2之后,Java将引用分为强引用、软引用、弱引用和虚引用。
1.强引用
当我们新建一个对象时就创建了一个具有强引用的对象,如果一个对象具有强引用, 垃圾收集器就绝不会回收它。Java虚拟机宁愿抛出OutOfMemoryError异常,使程序异常终 止,也不会回收具有强引用的对象来解决内存不足的问题。
2.软引用
如果一个对象只具有软引用,当内存不够时,会回收这些对象的内存,回收后如果还 是没有足够的内存,就会抛出OutOfMemoryError异常。Java提供了 SoftReference类来实 现软引用。
3.弱引用
弱引用比起软引用具有更短的生命周期,垃圾收集器一旦发现了只具有弱引用的对象, 不管当前内存是否足够,都会回收它的内存。Java提供了 WeakReference类来实现弱引用。
4.虚引用
虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何引用一样,在任何时候都可能被垃圾收集器回收。一个只具有虚引用的对象,被垃圾收集器 回收时会收到一个系统通知,这也是虚引用的主要作用。Java提供了 PhantomReference类 来实现虚引用。
2.引用计数算法
引用计数算法的基本思想就是每个对象都有一个引用计数器,当对象在某处被引用的 时候,它的引用计数器就加1,引用失效时就减1。当引用计数器中的值变为0,则该对象 就不能被使用,变成了垃圾。
目前主流的Java虚拟机没有选择引用计数算法来为垃圾标记,主要原因是引用计数算 法没有解决对象之间相互循环引用的问题。
举个例子,在下面代码的注释1和注释2处,dl和d2相互引用,除此之外这两个对象无任何其他引用,实际上这两个对象已经死亡,应该作为垃圾被回收,但是由于这两个对象互相引用,引用计数就不会为0,如果Java虚拟机采用了引用计数算法,垃圾收集器 就无法回收它们。
class _2MB_Data {
public Object instance = null;
private byte [] data = new byte [2 * 1024 * 1024];//用来占内存,测试垃圾回收
}
public class ReferenceGC {
public static void main(String[] args) {
_2MB_Data dl = new _2MB_Data();
_2MB_Data d2 = new _2MB_Data();
dl.instance = d2;//1
d2.instance = dl;//2
dl = null;
d2 = null;
System.gc ();
}
}
如果你使用Android Studio,就在Edit Configurations中的VM options加入如下语句来输出详细的GC日志:
-XX:+PrintGCDetails
运行程序,GC 日志为:
GC (System.gc()) [PSYoungGen: 8028K->832K (76288K) ] 8028K->840K(251392K), 0.0078334 secs Full GC (System.gc ()) [PSYoungGen: 832K->0K(76288K) ] [ParOldGen: 8K->603K (175104K)] 840K->603K(251392K), [Metaspace: 3015K->3015K(1056768K) ], 0.0045844 secs Heap
PSYoungGen total 76288K, used 1966K [0x000000076af80000, 0x0000000770480000, 0x00000007c0000000)
eden space 65536K, 3% used [0x000000076af80000,0x000000076bl6bac0,0x000000076 ef80000)
from space 10752K, 0% used [0x000000076ef80000,0x000000076ef80000,0x000000076 fa0000)
to space 10752K, 0% used [0x000000076fa00000r 0x000000076fa00000,0x0000000770 480000)
ParOldGen total 175104K, used 603K [0x00000006c0e00000, 0x00000006cb900000, 0x000000076af8000Q)
object space 175104K, 0% used [0x00000006c0e00000z 0x00000006c0e96dl0,0x00000006 cb900000)
Metaspace used 3046K, capacity 4496K, committed 4864K, reserved 1056768K class space used 334K, capacity 388K, committed 512K, reserved 1048576K
査看此GC日志前我们先来简单了解一下各参数的含义,[GC (System.gc())和[Full GC (System.gc())说明了这次垃圾收集的停顿类型,而不是来区分新生代GC和老年代GC的。 [Full GC (System.gcO)说明这次GC发生了 STW, STW也就是Stop the World机制,意思是 说在执行垃圾收集算法时,只有GC线程在运行,其他的线程则会全部暂停,等待GC线 程执行完毕后才能再次运行。
PSYoungGen代表新生代,ParOldGen代表老年代,Metaspace代表元空间(JDK 8中 用来替代永久代PermGen).
我们来看日志的[GC (System.gc()),内存变化为:8028K-840K(251392K), 8028K代表回收前的内存大小,840K代表回收后的内存大小,251392K代表内存总大小。因此可以 得知内存回收大小为(8028-840) KB。这就说明JDK 8的HotSpot虚拟机并没有引用计数 算法来标记内存,它对上述代码中的两个死亡对象的引用进行了回收。
3.根搜索算法
这个算法的基本思想就是选定一些对象作为GC Roots,并组成根对象集合,然后以这 些GC Roots的对象作为起始点,向下搜索,如果目标对象到GC Roots是连接着的,我们 则称该目标对象是可达的,如果目标对象不可达则说明目标对象是可以被回收的对象,如 图10-7所示。
从图10-7可以看出,Obj5、Obj6和Obj7都是不可达的对象,其中0bj5和Obj6虽然互相引用,但是因为它们到GC Roots是不可达的,所以它们仍旧被判定为可回收的对象, 这样根搜索算法就解决了引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。在Java中,可以作为GC Roots的对象主要有以下几种:
- Java栈中引用的对象。
- 本地方法栈中JNI引用的对象。
- 方法区中运行时常量池引用的对象。
- 方法区中静态属性引用的对象。
- 运行中的线程。
- 由引导类加载器加载的对象。
- GC控制的对象。
还有一个问题是被标记为不可达的对象会立即被垃圾收集器回收吗?要回答这个问题 我们首先要了解Java对象在虚拟机中的生命周期。
Java对象在虚拟机中的生命周期
在Java对象被类加载器加载到虚拟机中后,Java对象在Java虚拟机中有7个阶段。
1.创建阶段(Created)
创建阶段的具体步骤为:
- 为对象分配存储空间。
- 构造对象。
- 从超类到子类对static成员进行初始化。
- 递归调用超类的构造方法。
- 调用子类的构造方法。
2.应用阶段(In Use)
当对象被创建,并分配给变量赋值时,状态就切换到了应用阶段。这一阶段的对象至 少要具有一个强引用,或者显式地使用软引用、弱引用或者虚引用。
3.不可见阶段(Invisible)
在程序中找不到对象的任何强引用,比如程序的执行已经超出了该对象的作用域。在 不可见阶段,对象仍可能被特殊的强引用GC Roots持有着,比如对象被本地方法栈中JNI 引用或被运行中的线程引用等。
4.不可达阶段(Unreachable)
在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达。
5.收集阶段(Collected)
垃圾收集器已经发现对象不可达,并且垃圾收集器已经准备好要对该对象的内存空间 重新进行分配,这个时候如果该对象重写了 finalize方法,则会调用该方法。
6.终结阶段(Finalized)
在对象执行完finalize方法后仍然处于不可达状态时,或者对象没有重写finalize方法, 则该对象进入终结阶段,并等待垃圾收集器回收该对象空间。
7.对象空间重新分配阶段(Deallocated)
当垃圾收集器对对象的内存空间进行回收或者再分配时,这个对象就会彻底消失。
好了,我们已经了解了 Java对象在虚拟机中的生命周期,再来回想10.6.3节说的问题: 被标记为不可达的对象会立即被垃圾收集器回收吗?很显然是不会的,被标记为不可达的对 象会进入收集阶段,这时会执行该对象重写的finalize方法,如果没有重写finalize方法或者 finalize方法中没有重新与一个可达的对象进行关联才会进入终结阶段,并最终被回收。
8.垃圾收集算法
在10.6节中我们学习了垃圾标记算法,垃圾被标记后,GC就会对垃圾进行收集,垃 圾收集有很多种算法,这一节就来介绍常用的垃圾收集算法的思想。
1.标记一清除算法
标记一清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为 两个阶段。
- 标记阶段:标记出可以回收的对象。
- 清除阶段:回收被标记的对象所占用的空间。
标记一清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基 础上进行改进的。标记一清除算法的执行过程如图10-8所示。
标记一清除算法主要有两个缺点,一个是标记和清除的效率都不高,另一个从图10-8 就可以看出来,就是容易产生大量不连续的内存碎片,碎片太多可能会导致后续没有足够 的连续内存分配给较大的对象,从而提前触发新的一次垃圾收集动作。
2.复制算法
为了解决标记一清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两 个相等的区域,每次只使用其中一个区域。在垃圾收集时,遍历当前使用的区域,把存活 对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。复制算法 的执行过程如图10・9所示。
这种算法每次都对整个半区进行内存回收,不需要考虑内存碎片的问题,代价就是使 用内存为原来的一半。复制算法的效率与存活对象的数目多少有很大的关系,如果存活对 象很少,复制算法的效率就会很高。由于绝大多数对象的生命周期很短,并且这些生命周 期很短的对象都存于新生代中,所以复制算法被广泛应用于新生代中,关于新生代中复制 算法的应用,会在后面的分代收集算法中详细介绍。
图10-9复制算法的执行过程
3.标记一压缩算法
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的 对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记一清除算法可以应用 在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标 记一压缩(Mark-Compact)算法,与标记一清除算法不同的是,在标记可回收的对象后将所 有存活的对象压缩到内存的一端,使它们紧凑地列在一起,然后对边界以外的内存进行回 收,回收后,已用和未用的内存都各自一边,标记一压缩算法的执行过程如图10-10所示。
图10-10标记一压缩算法的执行过程
标记一压缩算法解决了标记一清除算法效率低和容易产生大量内存碎片的问题,它被 广泛应用于老年代中。
4.分代收集算法
分代收集算法会结合不同的收集算法来处理不同的空间,因此在学习分代收集算法之 前我们首先要了解Java堆区的空间划分。Java堆区的空间划分在Java虚拟机中,各种对象 的生命周期会有着较大的差别,大部分对象生命周期很短暂,少部分对象生命周期很长, 有的甚至与应用程序以及Java虚拟机的运行周期一样长。因此,应该对不同生命周期的对 象采取不同的收集策略,根据生命周期长短将它们分别放到不同的区域,并在不同的区域 采用不同的收集算法,这就是分代的概念。现在主流的Java虚拟机的垃圾收集器都采用分 代收集算法(Generational Collection)e Java堆区基于分代的概念,分为新生代(Young Generation)和老年代(Tenured Generation),其中新生代再细分为 Eden 空间、From Survivor 空间和To Survivor空间。因为Eden空间中的大多数对象生命周期很短,所以新生代的空间 划分并不是均分的,HotSpot虚拟机默认Eden空间和两个Survivor空间的所占的比例为8:1。
分代收集
根据Java堆区的空间划分,垃圾收集的类型分为两种,它们分别如下。
- Minor Collection:新生代垃圾收集。
- Full Collection:对老年代进行收集,又可以称作 Major Collection, Full Collection 通 常情况下会伴随至少一次的Minor Collection,它的收集频率较低,耗时较长。
当执行一次Minor Collection时,Eden空间的存活对象会被复制到To Survivor空间, 并且之前经过一次Minor Collection并在From Survivor空间存活的仍年轻的对象也会复制 到To Survivor空间。有两种情况Eden空间和From Survivor空间存活的对象不会复制到To Survivor空间,而是晋升到老年代。一种是存活的对象的分代年龄超过 -XX:MaxTenuringThreshold (用于控制对象经历多少次Minor GC才晋升到老年代)所指定 的阈值。另一种是To Survivor空间容量达到阈值。当所有存活的对象被复制到To Survivor 空间,或者晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收 对象,如图10・11所示。
图10-11复制算法在新生代中的应用
这个时候GC执行Minor Collection, Eden空间和From Survivor空间都会被清空,新 生代中存活的对象都存放在To Survivor空间。接下来将From Survivor空间和To Survivor 空间互换位置,也就是此前的From Survivor空间成为了现在的To Survivor空间,每次 Survivor空间互换都要保证To Survivor空间是空的,这就是复制算法在新生代中的应用。 在老年代则会采用标记一压缩算法或者标记一清除算法。
本章小结
Java虚拟机是一个很庞大的知识体系,本章也只是介绍了 Java虚拟机知识中的非常少 的一部分,包括Java虚拟机结构、oop.klass模型、垃圾标记算法和垃圾收集算法等。对于 Android开发者来说,了解这些知识点对普通的Android开发工作完全够用,如果想要更深 入地了解Java虚拟机则需要阅读专业介绍Java虚拟机的书籍。