深入理解java虚拟机

1 篇文章 0 订阅

第一部分 走进Java

1.1 概述

1. 略,大致内容就是吹Java,不过用的java基本上都觉得java是世界上最好的语言吧!

1.2 java技术体系

1. 从广义上将,Kotlin、Clojure、JRuby、Groovy等运行于Java虚拟机上的编程语言及其相关的程序都属于java技术体系中的一员。如果仅从传统意义上来看,JCP官方所定义的java技术体系包括了一下几个组成部分:
	1. java程序设计语言
	2. 各种硬件平台上的Java虚拟机实现
	3. .Class文件格式
	4. .Java类库API
	5. 来自商业机构和开源社区的第三方java类库
2. 我们把java程序设计语言、Java虚拟机、Java类库这三个部分称为jdk(java development kit),jdk是用于支持Java程序开发的最小环境。
3. 把Java类库api中的Java se api子集和Java虚拟机这两部分统称为jre(java runtime environment),jre是支持Java程序运行的标准环境。
4. 如果按照技术所服务的领域来划分的话,Java技术体系可以分为一下四条主要的产品线:
	1. java card:支持java小程序(Applets)运行在小内存设备上的平台。
	2. java me
	3. java se
	4. java ee

1.3 java发展史

1.4 java虚拟机家族

1.5 展望Java技术的未来

1.6 实战:自己编译jdk

1.7 本章小结

第二部分 自动内存管理

第2章 java内存区域与内存溢出异常

1. Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
2. 对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们即是拥有最高权力的“皇帝”,又是从事最基础工作的劳动人民--即拥有每一个对象的所有权,又担负着每个一对象生命从开始到终结的维护责任。

2.2 运行时数据区域

1. Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。Java虚拟机所管理的内存将会包括以下几个运行时数据区域
	1. 由所有线程共享的数据区:方法区(Method Area)、堆(Heap)、执行引擎、本地库接口
	2. 线程隔离的数据区:虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(Program Counter Register)、本地方法库。
2.2.1 程序计数器
1. 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
2. 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互补影响,独立存储,我们称这类内存区域为“线程私有”的内存。
3. **如果线程正在执行的是Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《java虚拟机规范》中没有规定任何OutMemoryError情况的区域。**
2.2.2 Java虚拟机栈
1. 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
2. 经常有人把Java内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统地C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是“堆”和“栈”两块。其中,“堆”在稍后会专门讲述,而“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。
3. 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
4. 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。
5. 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
2.2.3 本地方法栈
1. 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
2. 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据库结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-spot虚拟机)直接就把本地方法栈和虚拟机合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
2.2.4 Java堆
1. 对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里"几乎"所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:"所有的对象实例以及数组都应当在堆上分配",用几乎是因为Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
2. Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作"GC堆(Garbage Collected Heap)"。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的。
3. 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer TLAB),以提升对象分配时的效率。将Java堆细分的目的只是为了更好的回收内存,或者更快地分配内存。
4. 根据《Java虚拟机规范》地规定,Java堆可以处于物理上不连续地内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效地考虑,很可能会要求连续地内存空间。
5. Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实力分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
2.2.5 方法区
2.2.6 运行时常量池
1. 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
2. Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储那种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储到运行时常量池中。
3. 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用的比较多的便是String类的intern()方法。
4. 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
2.2.7 直接内存
1. 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。
2. 在JDK1.4中新加入了NIO(new Input/OutPut)类,**引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,**它可以使用Nactive函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Nactive堆中来回复制数据。
3. 显然,本机直接内存的分配不会受Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存区设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统的限制),从而导致动态扩展时出现OutOfMemoryError异常。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建
1. Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(这里讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是一个怎样一个过程呢?
2. 当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,以后会讨论这部分细节。
3. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存的绝对规整的,所有被使用过的内存都被放在一块,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞"(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为"空闲列表"(Free List)。选择那种分配方式由Java是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩的垃圾收集器时,系统采用的是分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时理论上就只能采用较为复杂的空闲列表来分配内存。
4. 除如何划分可用空间外,还有另一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B有同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:一种是对分配内存空间的动作进行同步处理-实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer TLAB),那个线程要分配内存,就在那个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定。
5. 内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
6. 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Head)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
7. 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始-构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息还没有按照预定的意图构造好。一般来说(由字节码流中new指令后是否跟随invokespecial指令所决定,Java编辑器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其它方式产生的则不一定如此),new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
2.3.2 对象的内存布局
1. 在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为**三个部分**:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
2. HotSpot虚拟机对象的对象头部分包括两类信息。
	1. 第一类是存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和63个比特,官方称它为"Mark Word"。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是**与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Work被设计成一个有着动态定义的数据结构,**以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSport虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其它状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下。
		1. 存储内容-标志位-状态
		2. 对象哈希码、对象分代年龄-01-未锁定
		3. 指向锁记录的指针-00-轻量级锁定
		4. 指向重量级锁的指针-10-膨胀(重量级锁定)
		5. 空、不需要记录信息-11-GC标记
		6. 偏向线程ID、偏向时间戳、对象分代年龄-01-可偏向
	2. 对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。此外,如果对象是一个Java数组,那在对象头中还必须有一块记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息判断数组的大小
3. 接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX: FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX: CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
4. 对象的第三个部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
2.3.3 对象的访问定位
1. 创建对象自然是为了后续使用该对象,我们的java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
	1. 如果使用句柄访问的话,java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
	2. 如果使用直接指针访问的话,Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
2. 这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对像被移动时只会改变句柄中实例数据指针,而reference本身不需要被修改。
3. 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

2.4 实战:OutOfMemoryError异常

1. 在《java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其它几个运行时区域都有可能发生OutOfMemoryError异常的可能,本节将通过若干实例来验证异常实际发生的代码场景,并且将初步介绍若干最基本的与自动内存管理子系统相关的HotSopt虚拟机参数。
2. 本节实战的目的有两个:1. 通过代码验证《java虚拟机规范》中描述的各个运行时区域存储的内容;2. 希望读者在工作中遇到实际的内存溢出异常时,能根据异常的提示信息迅速得知是哪个区域的内存溢出,知道怎样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。
2.4.1 java堆溢出
1. java堆用于存储对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后会产生内存溢出的异常。
2. java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出情况。出现Java堆内存溢出时,异常堆栈信息"java.lang.OutOfMemoryError"会跟随进一步提示"java heap space"。
3. 要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
2.4.2 虚拟机栈和本地方法栈溢出
1. 由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《java虚拟机规范》中描述了两种异常:
	1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
	2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryEoor异常。
2. 《java虚拟机规范》明确允许java虚拟机实现自行选择是是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。
2.4.3 方法区和运行时常量池溢出
2.4.4 本机直接内存溢出
1. 直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与java堆最大值(由-Xmx指定)一致。
1. 由于运行时常量池是方法区的一部分。

2.5 本章小结

1. 到此为止,我们明白了虚拟机里面的内存是如何划分的,那部分区域、什么样的代码和操作可能导致内存溢出异常。虽然java有垃圾收集机制,但内存溢出离我们并不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,下一章将详细讲解java垃圾收集机制为了避免出现内存溢出都做了那些努力。

第3章 垃圾收集器与内存分配策略

3.1 概述

1. 说起垃圾收集(Garbage Collection),有不少人把这项技术当作JAVA语言的伴生产物,事实上,垃圾收集的历史远远比JAVA久远。
2. 上一章介绍了JAVA内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈和出栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
3. 而JAVA堆和方法区这两个区域则有着很显著的不确定性,只有在处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的真是这部分内存该如何管理,本文后续讨论的内存分配与回收也仅仅特指这一部分内存。

3.2 对象已死?

3.2.1 引用计数算法

要看到代码清单3-1的GC日志,需要给JVM加上-XX:+PrintGCDetails
1. 很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能在被使用的。但是在JAVA领域,至少在主流的JAVA虚拟机里面都没有选用引用计数器算法来管理内存,主要原因是,这个看似简单得算法有很多例外的情况要考虑,必须要配合大量额外处理才能保证正确的工作,譬如单纯的引用计数器就很难解决对象之间互相循环引用(成环)的问题。

3.2.2 可达性分析算法
  1. 当前主流的商用程序语言都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为"GC Roots"的根对象作为起始节点集,从这些结点开始,根据引用关系向下搜索,搜索过程走过的路径称为"引用链"(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象不可能再被使用的。
    1. 在JAVA技术体系里面,固定可作为GC Roots的对象包括以下几种:
      1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
      2. 在方法区中类静态属性引用的对象。
      3. 在本地方法栈中JNI引用的对象。
      4. JAVA虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象。
      5. 所有被同步锁持有的对象。
      6. 反映JAVA虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
    2. 除了这些固定的GC Roots集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其它对象"临时性"的加入,共同构成完整的GC Roots集合。
3.2.3 再谈引用
1. 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象是否存活都和"引用"离不开关系。在JDK1.2版之前,JAVA里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有"被引用"或者"未被引用"两种状态。
2. 在JDK1.2版之后,JAVA对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
	1. 强引用是最传统的"引用"的定义,是指在程序代码之中普遍存在的引用赋值。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
	2. 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了SoftReference类来实现软引用。
	3. 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。
	4. 虚引用也称为"幽灵引用"或"幻影引用",他是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后提供了PhantomReference类实现虚引用。
3.2.4 生存还是死亡?
1. 即使在可达性分析算法中判断为不可达的对象,也不是"非死不可"的,这时候他们暂时还处于"缓刑"阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为"没有必要执行"。
2. 如果这个对象被判定为有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后的一条由虚拟机自动建立的、低调度优先级的Finalize线程去执行他们的finalize()方法。这所说的"执行"是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收子系统的奔溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将会对F-Queue的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。
3. 任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,他的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

3.3 垃圾收集算法

1. 垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,在本节中只重点介绍分代收集理论和几种算法思想及其发展过程。
2. 从如何判定对象消亡的角度出发,垃圾收集算法可以划分为"引用计数式垃圾收集"(Reference Counting GC)和"追踪式垃圾收集"(TracingGC)两大类,这两类也常被称作"直接垃圾收集"和"间接垃圾收集"。由于引用计数式垃圾收集算法在本书讨论到的主流JAVA虚拟机中均未涉及,所以我们暂不把它作为正文主要内容来讲解,本节介绍的所有算法均属于追踪式垃圾收集的范畴。
3.3.1 分代收集理论
1. 当前商业虚拟机的垃圾收集器,大多数都遵循了"分代收集"(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,他建立在两个分代假说之上:
	1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
	2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
2. 这两个分代假说共同奠定了多款垃圾收集器的一致的设计原则:收集器应该将JAVA堆划分出不同的区域,任何将回收对象依据其年龄分配到不同的区域之中存储。
3. 在JAVA堆划分出不同的区域后,垃圾收集器才可以每次都只回收其中某一个或某些部分的区域,也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法-因而发展出了"标记-复制算法","标记-清除算法","标记-整理算法"。
4. 把分代收集理论具体放到现在的商业JAVA虚拟机里,设计者一般至少会把JAVA堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。这样的划分显而易见的存在一个问题,对象不是孤立的,对象之间会存在跨代引用。为了解决这个问题,需要对分代收集理论添加第三条经验法则。
	3. 跨代引用假说(Intergenerational Reference Hypotthesis):跨代引用相对于同代引用来说仅占极少数。
5. 这其实可以根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时灭亡的。
6. 根据这条假说,我们就不应该再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为"记忆集",Remembered Set),这个结构会把老年代划分为若干小块,标识出老年代的那一块内存会存在跨代引用。此后当发生Minor GC时,只会包含了跨代引用的小块内存里的对象才会被加入到GC Root进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
注意:我们刚刚已经提到了"Minor GC",为避免产生混淆,在这里统一定义:
1. 部分收集(Partial GC):指目标不是完整收集整个JAVA堆的垃圾收集,其中又分为:
	1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾回收。
	2. 老年代收集(Major GC/Old GC):指指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。读者需按上下文区分"Major GC"到底是指老年代收集还是整堆收集。
	3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
2. 整堆收集(Full GC):收集整个JAVA堆和方法区的垃圾收集。
3.3.2 标记-清除算法
1. 最早出现也是最基础的垃圾收集算法是"标记-清除算法",算法分为两个"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来。标记过程就是对象是否属于垃圾的判定过程。
2. 之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。他的主要缺点有两个:
	1. 执行效率不稳定,标记和清除两个过程的效率都随着对象数量增长而降低
	2. 内存空间的碎片化问题:标记、清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3.3 标记-复制算法
1. 标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,提出了一种称为"半区复制"(Semispace Copying)的垃圾收集算法,它将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法有一个优点:对于多数对象都是可回收的情况,算法需要复制的都是少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可;但这种算法有两个缺点:
	1. 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。
	2. 这种复制回收算法的代价是将可用内存缩小为原来的一半,空间浪费未必太多了一点。
2. 现在商用JAVA虚拟机大多都优先采用了这种收集算法去收集新生代。有一种"Apple式回收",Apple回收的具体做法就是将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存时只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90%,只有一个Survivor空间,即10%的新生代空间会被浪费,然后人都没有办法保证每次回收都只有不多于10%的对象存活,因此Apple式回收还一个充当罕见情况的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多都是老年代)进行分配担保(Handle Promotion)。
3.3.4 标记-整理算法
1. 标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都是100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
2. 正对老年代对象的存亡特征,提出了另一种有针对性的"标记-整理"(Mark-Compact)算法,其中的标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界外的内容。
3. 标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
	1. 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更让使用者不得不小心翼翼地权衡其弊端了。
	2. 但如果跟标记-清除算法那样完全不考虑移动和整理存货对象的话,弥散与堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
	3. 基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更为复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动会更加划算。此语境中的吞吐量的实质是赋值器(Mutator)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多。这部分的耗时增加,吞吐量仍然是下降的。

3.4 HotSpot的算法细节实现

1. 3.2、3.3节从理论原理上介绍了常见的对象存活判定算法和垃圾收集算法,java虚拟机实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。
3.4.1 根节点枚举
1. 我们以可达性分析算法中从GC Roots集合找引用链这个操作作为虚拟机高效的第一个例子。固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在JAVA应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。
2. 迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的"Stop The World"的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行-这里的"一致性"的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因。
3. 由于目前主流的JAVA虚拟机使用的都是准确式垃圾收集,所有当用户线程停顿下来后,其实并不需要一个不漏地检查完所有执行上下文和全局地引用位置,虚拟机应当是有办法直接得到那些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一但类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
3.4.2 安全点
1. 在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实地问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
2. 实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在"特定的位置"记录了这些信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够停顿。因此,安全点的选定既不能太少以至于让收集器等待时间太长,也不能太过于频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上都以"是否具有让程序长时间执行的特征"为标准进行选定的。因为每条指令执行的时间都非常短暂,程序不太可能因为指令流太长这样的原因而长时间执行,"长时间执行"的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
3. 对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
4. 而主动式中断的思想是当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程中会不停的主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在JAVA堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
5. 由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。
3.4.3 安全区域
1. 使用安全点的设计似乎已经完美解决了如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点,但是,程序"不执行"的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机中的请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配到处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
2. 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生改变,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被拉伸了的安全点。
3. 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则他就必须一直等待,直到收到可以离开安全区域的信号为止。
3.4.4 记忆集与卡表
1. 讲解分代收集理论时,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,都会面临相同的问题。
2. 记忆集是一种用于记录从非收集区指向收集区域的指针集合的抽象数据结构。
3. 记忆集的可供选择的记录精度:
	1. 字节精度:每个记录精确到一个机器字长(就是处理器的寻址位数),改字包含跨代指针。
	2. 对象精度:每个记录精确到一个对象,该对象里有字段包含跨代指针。
	3. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
4. 其中,第三种"卡精度"所指的是用一种称为"卡表"(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。
3.4.5 写屏障
1. 我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把他们变脏等。
2. 卡表元素何时变脏的答案是很明确的-有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在类型字段赋值的那一刻。但问题是如何变脏,及如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但是在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
3. 在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障覆盖范围内。在赋值前的部分的写屏障叫做写前屏障(Pre_write Barrier),在赋值后的则叫做写后屏障(Post_write Barrier)。
4. 卡表在高并发场景下还面临着"伪共享"(False Sharing)问题。伪共享问题是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行为单位(Cache Line)存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
5. 一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表标记元素未被标记过时才将其标记为变脏。
3.4.6 并发的可达性分析
1. 当且仅当以下两个条件同时满足时,会产生"对象消失"的问题:
	1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用
	2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
2. 因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

第4章 虚拟机性能监控、故障处理工具

第5章 调优案例分析与实战

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值