走进JVM
JVM概述
JVM(Java Virtual Machine)是一种用于计算设备的规范,它是一个虚构出来的是通过在实际的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码)就可以在多种平台上不加修改地运行。
Java虚拟机本质上就是一个程序
,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。
JVM的位置
JVM体系结构
程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间计数器互不影响,独立存储。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(native)方法,这个计数器值则应为空(Undefined)
Java虚拟机栈(Java Virtual Machine Stack)
Java虚拟机栈也是线程私有的,它的生命周期与线程相同。
Java虚拟机栈描述的是Java方法执行的线程内存模型:每个方法
被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。
两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展无法申请到足够的内存会抛出OutOfMemoryError异常
本地方法栈(Native Method Stacks)
本地方法栈与虚拟机栈的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是虚拟机使用到的本地(Native)方法服务
本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError
Java堆(Java Heap)
Java堆是虚拟机所管理的内存中最大的一块
Java堆被所有线程共享,在虚拟机启动时创建
此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存
如果在Java堆中没有内存完成实例分配,并且堆也无法扩展时,Java虚拟机将会抛出OutOfMemoryError
方法区(Method Area)
方法堆用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
HotSpot永久代的发展
- JDK8以前,HotSpot虚拟机设计团队选择把收集器扩展到方法区,或者说使用永久代实现方法区
- JDK6,放弃永久代,逐步改为采用本地内存来实现方法区的计划
- JDK7,把原来放在永久代的字符串常量池、静态变量等移至Java堆中
- JDK8,完全废弃了永久代的概念,在本地内存中实现的元空间来代替,把JDK中永久代还剩余的内容(主要是类型信息)全部移到元空间
如过方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError
运行时常量池(Runtime Pool)
运行时常量池是方法区的一部分
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
当常量池无法再申请到内存时会抛出OutOfMemoryError
对象
对象的创建
对象所需内存的大小在类加载完成后便可完全确定
对象的内存分配方式
- 指针碰撞:假设Java中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存则被放在另一边,中间放着一个指针作为分界点的指示器
- 空闲列表:如果Java中的内存不是规整的,已经被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。
使用Serial、ParNew等压缩整理过程的收集器时,系统采用指针碰撞算法,简单又高效;而当使用CMS这种基于清除算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存
新对象产生后,对象的构造方法,即Class文件中的<init>()方法没有执行,所有字段都默认为零值。执行了<init>()方法,按照程序员的意愿对对象进行初始化。
对象的内存布局
-
对象头
- 存储对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特
- 类型指针,即对象指向它的类型元数据指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
如果对象是数组,必须在对象头有一块用于记录数组长度的数据
实例数据
对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来对齐填充
仅仅起着占位符的作用,对象大小都必须是8字节的整数倍
对象的访问定位
通过栈上的reference数据来操作堆上的具体对象
- 使用句柄:Java堆中将可能划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄地址包含了对象实例数据与类型数据各自具体的地址信息
- 直接指针:Java堆中对象的内存布局就必须考虑如何设置访问类型的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销
使用句柄来访问最大的好处就是reference中存储的是稳定句柄地址,在对象移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要需改。使用直接指针来访问最大的好处就是速度更快,节省了一次指针定位的时间开销
垃圾收集器与内存分配策略
判断对象是否存活
垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些“存活”,哪些“死去”(即不可能再被任何途径使用的对象)
-
引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效时,计数器值减一;任何时刻计数器值为0的对象就是不可能再被使用的
优:原理简单,判定效率高
缺:占用额外内存空间进行计数,有很多额外情况需要考虑,必须要配合大量额外处理才能保证正确地工作,例如对象之间相互循环引用问题public class ReferenceCountingGC { public Object instance = null; public static void testGC() { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; } }
对象objA和objB都有instance,相互赋值之外,这两个对象没有任何的引用,实际上着两个对象已不可能再被访问,但是因为它们互相引用着对方,导致它们的计数器都不为零,引用计数算法也就无法再回收它们
-
可达性分析算法
该算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”。如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明这个对象是不可能再被使用的
固定可作为GC Roots的对象包括以下几种:
。在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如当前正在运行的方法所使用到的参数、局部变量、临时变量等
。在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
。在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
。在本地方法栈中JNI(即通常所说的Native方法)引用的对象
。Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器
。所有被同步锁(synchronized关键字)持有的对象
。反映Java虚拟机内部情况的JMXBean、JVMTI中注册时间的回调、本地代码缓存等
引用
强引用
指在程序代码之中普遍存在的引用赋值,即类似Object obj = new Object()
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象软引用
软引用是用来描述一些还有用,但是非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常弱引用
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象虚引用
虚引用也成为“幽灵引用”或者“幻影引用”,它是最弱的一种关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
垃圾收集算法
垃圾收集分类
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为,有些资料上Major GC为整堆收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这样的行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
-
标记 - 复制算法
- 半区复制
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
缺点:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制开销;可用内存缩小为原来的一半,空间浪费多
优点:对于多数对象都是存活的,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可,这样实现简单,运行高效
- 半区复制
-
Appel式回收
把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只是用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间HotSpot虚拟机默认Eden:Survivor = 8:1,即每次新生代中的可用内存空间为整个新生代的90%(Eden80%+Survivor10%),而只有一个Survivor空间,即10%的新生代时被“浪费”的
-
标记 - 清除算法
“标记”:标记出所有需要回收的对象或者标记存活的对象
“回收”:统一回收掉所有被标记的对象或者统一回收所有未被标记的对象
缺点:
①执行效率不稳定,如果Java堆中包含大量的对象,而且其中大部分需要被回收,这时必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率都随对象的数量增长而降低;
②内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大内存对象时无法找到足够的内存而不得不提前触发另一次垃圾收集动作
-
标记 - 整理算法
该算法标记过程与“标记 - 清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存,它与“标记 - 清除”算法的本质差异在于前者是一种移动式的回收算法,而后者是非移动式的
stop the world
:像在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这种停顿被称为“stop the world”(标记 - 清除算法也需要停顿,但是停顿时间相对而言要短)移动对象则内存回收时会更复杂,不移动则在内存分配时更复杂(不移动会存在许多空间碎片,需要通过“分区空闲链表”等解决内存分配问题),有一种解决方案就是让虚拟机大多数时间使用标记 - 清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记 - 整理算法收集一次。
并发的可达性分析
将对象分为三种颜色
白色:对象尚未被垃圾收集器访问过
黑色:对象已被垃圾收集器访问过,且这个对象的所有引用已经扫描过
灰色:对象已被垃圾收集器访问过,但至少还有一个该对象的引用未被扫描
并发消失问题
并发消失问题的两个条件同时满足:
① 赋值器插入了一条或多条从黑色对象到白色对象的新引用
② 赋值器删除了全部从灰色对象到该白色对象的新引用
解决并发扫描时的对象消失问题
- 增量更新:破坏条件①,当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,再扫描一次,即黑色对象一旦插入指向白色对象的引用,就会变成灰色对象
- 原始快照:破环条件②,当灰色对象要删除指向白色对象的引用时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,即无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图照进行搜索
经典垃圾收集器
两个收集器之间存在连线,则它们可以搭配使用;收集器所处的区域表示它是属于新生代收集器还是老年代收集器
-
Serial 收集器
单线程工作的收集器,不仅仅说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直至它收集结束
它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器
优点:
① 简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的;
②对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行场景:客户端模式下的虚拟机 (用户桌面的应用场景以及部分微服务应用中,分配给虚拟机的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代,垃圾收集的时间最多只有一百毫秒)
-
ParNew 收集器
实质上是Serial收集器的多线程并行版本
它是不少运行在服务端模式下的HotSpot虚拟机,是JDK7之前的遗留系统中的首选新生代收集器(原因:除了Serial收集器外,目前只有它能与CMS收集器配合工作)
-
Parallel Scavenge 收集器
新生代收集器,基于标记 - 复制算法实现的收集器,也是能够并行收集的收集器
Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,又成为“吞吐量优先收集器”
-
Serial Old 收集器
是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记 - 整理算法供客户端模式下的HotSpot虚拟机使用
如果在服务器端模式下:①在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用;②作为CMS收集器发生失败时的后备预案,在并发收集Concurrent Mode Failure时使用
-
Parallel Old 收集器
是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记 - 整理算法搭配Parallel Scavenge收集器,适用于注重吞吐量或者处理器资源较为稀缺的场合
-
CMS 收集器(Concurrent Mark Sweep)
获取最短回收停顿时间为目标的收集器,基于标记 - 清除算法实现,又称“并发低停顿收集器”适合互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短
过程:
①初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要Stop The World
②并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长
③重新标记(CMS remark):修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,比初始标记耗时长一些,需要Stop The World
④并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的以经死亡的对象在整个过程中,耗时最长的并发标记和并发清除阶段,垃圾收集器都可以和用户线程一起工作,并发执行
优点:并发收集、低停顿
缺点:
①CMS收集器对资源处理非常敏感:并发阶段,会占用一部分线程导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程是(处理器核心数量+3)/4,处理器核心数越少,影响越大
②需要预留一部分空间供并发收集时运行:CMS收集器无法处理“浮动垃圾”(在标记结束后,用户线程继续运行而产生的垃圾,无法在本次垃圾收集清除),而且由于用户线程还在进行,需要给用户线程预留内存空间
③收集结束会有大量内存碎片产生,内存碎片合并整理过程无法并发
-
Garbage First 收集器
面向局部收集的设计思路和基于Region的内存布局形式,使用Region划分内存空间,具有优先级的区域回收方式,延迟可控的情况下获得尽可能高的吞吐量主要面向服务端应用的垃圾收集器
停顿时间模型:能够支持指定在一个长度为M毫秒的时间片段内,消耗再垃圾收集上的时间大概率不超过N毫秒这样的目标
Mixed GC模式:G1以前的收集器的垃圾收集目标范围要么是整个新生代,要么是真个老年代,再要么就是真个堆,而G1面向堆内存任何部分来组成回收集进行回收,衡量标准由它属于哪个分代变为哪块内存中存放的垃圾数量最多,收益最大
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。G1虽然仍是遵循分代收集理论设计,但不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域,每个区域都可以根据需要地扮演Eden、Survivor、Old。收集器能够对扮演不同角色的Region采用不同策略去处理
Humongous Region:专门用来存储大对象,G1认为只要大小超过了一个Region容量一半的对象即为大对象,超过整个Region容量的大对象,将会存放在N个连续的Humongous Region中
G1仍然保留新生代老年代的概念,但不在是固定的了,它们都是一系列区域(不需要连续)的动态集合。
G1收集器之所以能简历可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元
,即每次收集都是Region大小的整数倍
处理思路:
① G1收集器跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及祸首所需时间的经验值
② 在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(-XX:MaxGCPauseMillis,默认值200)优先处理回收价值收益最大的那些Region
Region里面存在的跨Region引用对象如何解决
每个Region都维护有自己的记忆集,这些记忆集记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。(“双向卡表”:我指向谁以及谁指向我)。由于Region数量比传统的收集器分代数量明显要多,因此G1收集器要比其他的传统收集器有者更高的内存负担,G1至少耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作
并发标记阶段如何保证收集线程与用户线程互不干扰地运行
CMS收集器采用增量更新算法实现,而G1收集器采用原始快照实现
,G1为每一个Region设计了两个名为TAMS(Top at Mark Start),并发回收时新分配的对象地址必须要在这两个指针位置以上,这些对象是被隐式标记过的,默认它们是存活的,不纳入回收范围。如果内存回收速度赶不上内存分配速度,G1收集器也要被迫冻结用户线程,导致Stop The World
如何建立可靠的停顿预测模型
G1收集器的停顿预测模型是以衰减均值为理论基础实现的,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。Region的统计状态越新越能决定其回收的价值。G1收集器会记录每个Region的回收耗时、记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息,然后通过这些信息预测现在开始回收的话,由哪些Region组成会收集才可以在不超过期望停顿时间的约束下获得最高利益
运作步骤:
1、初始标记:仅仅只是标记以下GC Roots能直接关联到的对象,并且修改ATMS指针的值,需要停顿线程,但耗时很短,且结用进行Minor GC的时候同步完成,并没有额外的停顿
2、并发标记:从GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,耗时较长,可与用户程序并发执行。扫描完成后,还要重新处理原始快照(SATB)记录下的在并发时有引用变动的对象
3、最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
4、筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户所期望的停顿时间来制定回收计划,把决定回收的多个Region构成的会收集的存活对象复制到空的Region中,再将旧的Region全部清理掉。这里设计存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成
在小内存应用上CMS的表现大概率仍然要优于G1,在大内存应用上G1则大多能发挥其优势。这个优劣势的Java堆容量平衡的通常在6GB至8GB之间
低延迟垃圾收集器
衡量垃圾收集器的三项最重要指标:内存占用、吞吐量和延迟
随着计算机硬件的发展、性能的提升,内存占用和吞吐量的影响逐渐减低,但内存的增大使得延迟负面效果增大,因此延迟的重要性日益凸显
-
Shenandoah 收集器
目标是实现一种能在热河堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的垃圾收集器,意味着不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作Shenandoah与G1有者相似的堆内存布局以及许多阶段的处理思想高度一致,但至少与G1有三个明显的不同
① 支持并发的整理算法,可以与用户线程并发
② (目前)是默认不适用分代收集的
③ 摒弃了在G1中耗费大量内存和计算机资源去维护的记忆集,改用名为“连接矩阵”的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率Region N有对象指向Region M,则在N行M列中做标记
三个重要的并发阶段
- 并发标记:与G1一样
- 并发回收:是Shenandoah与之前其他收集器的核心差异,Shenandoah通过读屏障和“Brooks Pinters”的转发指针来解决并发回收问题
- 并发引用更新:真正开始进行引用更新操作,不再需要沿对象图搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可
蓝色:用户线程用来分配对象内存的Region
黄色:被选入回收集的Region
绿色:存活对象
灰色:空闲Region转发指针:旧对象上转发指针的引用位置指向新对象
-
ZGC 收集器(Z Garbage Collector)
与Shenandoah的目标相似,都希望在尽可能吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记 - 整理算法的,以低延迟为首要目标的垃圾收集器
ZGC的Region(Page、ZPage)具有动态性——动态创建和消耗,以及动态的区域大小
在x64硬件平台下,ZGC具有大、中、小三种Region容量- 小型:容量固定为2MB,用于放置小于256KB的小对象
- 中型:容量固定为32MB,用于放置大于等于256KB但小于4MB对象
- 大型:容量不固定,可以动态变化,但必须是2MB的整数倍,用于放置4MB或以上的大对象,每个大型 Region只会放一个大对象,在ZGC中不会被重分配,因为复制一个大对象代价高昂
染色指针是一种直接将少量额外的信息存储在指针上的技术
- 并发标记:ZGC的并发标记是在指针上而不是在对象上进行,标记阶段会更新染色指针中的Marked0、Marked1标志位
- 并发预备重分配:根据特定查询条件统计出本次收集过程要清理哪些Region,组成重分配集。ZGC每次回收都会扫描所有的Region,省去记忆集的维护成本。ZGC重分配集只是决定了里面的存活对象会被重新复制到其他Region,里面的Region会被释放,而并不能说回收只针对重分配集里面的Region
- 并发重分配:把重分配集中的存活对象复制到新的Region上,并为重分配集上的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。指针的“自愈”能力:如果用户线程并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,同时修正更新该引用的值,使其指向新对象。跟Brooks的转发指针比,只有第一次访问旧对象会陷入转发,只慢一次。重分配集中某个Region的存活对象都复制完毕即可释放,但转发表不能释放。
- 并发重映射:修正真个堆中指向重分配集中旧对象的所有引用,这个不是必要的,即使不做,旧引用也是可以“自愈”的,清理旧引用的目的是为了不变慢,还有结束后可以释放转发表。合并到下一次垃圾收集循环中的并发标记阶段完成,节省一次遍历对象图开销
其他收集器
-
Epsilon 收集器
不能够进行垃圾收集为“卖点”的垃圾收集器“自动内存管理子系统”:一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理布局、对象的分配、与解释器协作、与编译器协作、与监控子系统协作等职责。堆的管理和对象的分配是一个最小功能的垃圾收集器必须实现的内容
近年来大型系统从传统单体用用向微服务化、无服务化方向发展的趋势越发明显,如果应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon是很恰当的选择
选择合适的收集器
应该如何选择一款适合自己应用的收集器
- 应用程序的关注点是什么?
- 吞吐量:数据分析、科学计算类的任务,目标是尽快能算出结果
- 延迟:SLA应用,那么停顿时间直接影响服务质量,严重的甚至会导致事务超时
- 内存占用:客户端应用或者嵌入式应用,垃圾收集的内存占用则是不可忽视的
- 运行应用的基础措施如何?
- 硬件规格
- 系统架构x86-32/64、SPARC还是ARM/ Aarch64
- 处理器的数量多少
- 分配内存的大小
- 操作系统是Linux、Solaris还是Windows
- 使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?
垃圾收集日志
JDK9后,日志框架统一为
-Xlog[:[selector][:[output][:[decorators][:output-options]
selector由标签(Tag)和日志级别(level)组成
标签:某个功能模块的名字,“gc"只是HotSpot众多功能日志的其中一项
-
日记级别:从低到高,Trace,Debug,Info(默认),Warning,Error,Off
decorator要求每行日志输出都附加上额外的内容
- time:当前日期和时间
- uptime:虚拟机启动到现在经过的时间,以秒为单位(默认)
- timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出
- uptimemillis:虚拟机启动到现在经过的毫秒数
- timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出
- uptimenanos:虚拟机启动到现在经过的纳秒数
- pid:进程ID
- tid:线程ID
- level:日志级别(默认)
- tags:日志输出的标签集(默认)
JDK9统一日志框架前、后如何获得垃圾收集器的相关信息
- 查看GC基本信息
前:-XX:+PrintGC
后:-Xlog:gc - 查看GC详细信息,如果把日志级别调至Debug或者Trace,还将获得更多细节信息
前:-XX:+PrintGCDetails
后:-Xlog:gc* - 查看GC前后的堆、方法区可用容量变化
前:-XX:+PrintHeapAtGC
后:-Xlog:gc+heap=debug - 查看GC过程中用户线程并发时间以及停顿时间
前:-XX:+PrintGCApplicationStoppedTime
后:-Xlog:safepoint - 查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息
前:-XX:+PrintAdaptiveSizePolicy
后:-Xlog:gc+ergo*=trace - 查看熬过收集后剩余对象的年龄分布信息
前:-XX:PrintTenuringDistribution
后:-Xlog:gc+age=trace
JDK9前后日志参数变化表
JDK9前日志参数 | JDK9后配置形式 |
---|---|
G1PrintHeapRegions | Xlog:gc+region=trace |
G1PrintRegionLivenessInfo | Xlog:gc+liveness=trace |
G1SummarizeConcMark | Xlog:gc+marking=trace |
G1SummarizeRSstStats | Xlog:gc+remset*=trace |
GCLogFileSize,NumverOfGCLogFiles,UseGCLogFile Rotation | Xlog:gc*:file=<file>::filecount=<count>,filesize=<filesize in kb> |
PrintAdaptiveSizePolicy | Xlog:gc+ergo*=trace |
PrintClassHistogramAfterFullGC | Xlog:classhisto*=trace |
PrintClassHistogramBeforeFullGC | Xlog:classhisto*=trace |
PrintGCApplicationConcurrentTime | Xlog:safepoint |
PrintGCApplicationStoppedTime | Xlog:safepoint |
PrintGCDateStamps | 使用time修饰器 |
PrintGCTaskTimeStamps | Xlog:gc+task=trace |
PrintGCTimeStamps | 使用uptime修饰器 |
PrintHeapAtGC | Xlog:gc+heap=debug |
PrintHeapAtGCExtended | Xlog:gc+heap=trace |
PrintJNIGCStalls | Xlog:gc+jni=debug |
PrintOldPLAB | Xlog:gc+plab=trace |
PrintParallelOldGCPhaseTimes | Xlog:gc+phases=trace |
PrintPLAB | Xlog:gc+plab=trace |
PrintPromotionFailure | Xlog:gc+promotion=debug |
PrintReferenceGC | Xlog:gc+ref=debug |
PrintStringDeduplicationStatistics | Xlog:gc+stringdedup |
PrintTaskqueue | Xlog:gc+task+stats=trace |
PrintTenuringDistribution | Xlog:gc+age=trace |
PrintTerminationStats | Xlog:gc+task+stats=debug |
PrintTLAB | Xlog:gc+tlab=trace |
TraceAdaptiveGCBoundary | Xlog:heap+ergo=debug |
TraceDynamicGCThreads | Xlog:gc+task=trace |
TraceMetadataHumongousAllocation | Xlog:gc+metaspace+alloc=debug |
G1TraceConcRefinement | Xlog:gc+refine=debug |
G1TraceEagerReclaimHumongousObjects | Xlog:gc+humongous=debug |
G1TraceStringSymbolTableScrubbing | Xlog:gc+stringtable=trace |
垃圾收集器参数总结
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收 |
UseParNewGC | 打开此开关后,使用Parnew+Serial Old的收集器组合进行内存回收,在JDK9后不支持 |
UseConcMarkSweepGC | 打开此开关后,使用ParNew+CMS+Serial Old的收集器组合进行内存回收。Serial收集器将作为CMS收集器出现“Concurrent Mode Failure”失败后的后备收集器使用 |
UseParallelGC | JDK9之前虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel收集器组合进行内存回收 |
UseParallelOldGC | 打开此开关后,使用Parallel Scavenge+Parallel Old的收集器组合进行内存回收 |
SurvivorRatio | 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接分配到老年代 |
MaxTenuringThreshold | 晋升到老年代的对象年龄,每个对象坚持过一次Minor GC之后,年龄就增加1,当超过这个参数时就进入老年代 |
UseAdaptiveSizePolicy | 动态调整Java堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况 |
ParallelGCThreads | 设置并行GC时进行回收的线程数 |
GCTimeRatio | GC时间占总时间的比率,默认为99,即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效 |
MaxGCPauseMillis | 设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效 |
CMSInitiatingOccupancyFraction | 设置CMS收集器在老年代空间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS收集器时生效 |
UseCMSCompactAtFullCollection | 设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用CMS收集器时生效,这个参数在JDK9开始废弃 |
CMSFullGCsBeforeCompaction | 设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用CMS收集器时生效,这个参数在JDK9开始废弃 |
UseG1GC | 使用G1收集器,这个是JDK9之后的Server模式默认值 |
G1HeapRegionSize=n | 设置Region大小,并非终值 |
MaxGCPauseMillis | 设置G1收集过程目标时间,默认值是200ms,不是硬件条件 |
G1NewSizePercent | 新生代最小值,默认是5% |
G1MaxNewSizePercent | 新生代最大值,默认值是60% |
ParallelGCThreads | 用户线程冻结期间并执行的收集器线程数 |
ConcGCThreads=n | 并发标记、并发整理的执行线程数,对不同的收集器,根据其能够并发的阶段,有不同的含义 |
InitiatingHeapOccupancyPercent | 设置触发标记周期的Java堆占用阈值。默认值是45%。这里的Java堆占比指的是non_young_capacity_bytes,包括old+humongous |
UseShenandoahGC | 使用Shenandoah收集器。这个选项在OracleJDK中不被支持,只能在OpenJDK 12或者某些支持Shenandoah的Backport发行版使用。目前仍然要配合-XX:+UnlockExperimentalVMOptions使用 |
ShenandoahGCHeuristics | Shenandoah何时启动一次GC过程,其可选择有adaptive,static,compact,passive,aggressive |
UseZGC | 使用ZGC收集器,目前仍要配合-XX:UnlockExperimentalVMOptions使用 |
UseNUMA | 启用NUMA内存分配支持,目前只有Parallel和ZGC支持,以后G1收集器可能也会支持该选项 |
内存分配与回收策略
Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存
-
对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC添加VM参数
输入参数后,Apply-->OK
package chapter3; /** * VM args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 *Xms20M -Xmx20M -Xmn10M:限制了Java堆大小为20M,不可扩展,分配10M给新生代,所以剩下10M 老年代 *-XX:SurvivorRatio=8:Eden:Survivor = 8:1 *产生垃圾收集原因:allocation4需要4M,但Eden已经占用6M,所以发生Minor GC */ public class MinorGC { public static void main(String[] args) { testAllocation(); } private static final int _1MB = 1024*1024; public static void testAllocation(){ byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation4 = new byte[4 * _1MB]; //出现一次Minor GC } }
-
大对象直接进入老年代
大对象是指需要大量连续内存空间的Java对象,最典型的大对象便是很长的字符串或者元素数量很庞大的数组Java虚拟机要避免大对象的原因是
在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置它们,当复制对象时,大对象以为着高额的内存复制开销使用
+XX:PretenureSizeThreshold
指定大于该设置的对象直接进入老年代,避免在Eden和Survivor区之间来回复制,产生大量的内存复制操作+XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效
package chapter3; /** * VM args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 - XX:PretenureSizeThreshold=3145728 -XX:+UseSerialGC * 将PretenureSizeThreshold设置为3M,超过这个值的对象直接进入老年代 * 老年代使用了40%,也就是4MB */ public class PretenureSizeThreshold { private static final int _1MB = 1024*1024; public static void main(String[] args) { testPretenureSizeThreshold(); } public static void testPretenureSizeThreshold(){ byte[] allocation; allocation = new byte[4*_1MB]; //直接分配在老年代 } }
-
长期存活的对象将进入老年代
虚拟机为每一个对象定义了一个对象年龄(Age)计数器,存储在对象头中
每熬过一次Minor GC,对象age增1,当年龄加到一定程度(默认值15),就会被晋升到老年代
对象晋升到老年代的阈值,可以通过参数
-XX:MaxTenuringThreshold
设置package chapter3; /** * VM args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 - XX:MaxTenuringThreshold=1 -XX:+UseSerialGC * */ public class TenuringThreshold { private static int _1MB = 1024*1024; public static void main(String[] args) { testTenuringThreshold(); } public static void testTenuringThreshold(){ byte[] allocation1,allocation2,allocation3; allocation1 = new byte[_1MB / 4]; //什么时候进入老年代决定于-XX:MaxTenuringThreshold设置 allocation2 = new byte[4 * _1MB]; allocation3 = new byte[4 * _1MB]; allocation3 = null; allocation3 = new byte[4 * _1MB]; } }
-
动态对象年龄判定
如果在Survivor空间中低于或等于某年龄是所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到设定的年龄package chapter3; /** * VM args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution -XX:+UseSerialGC *allocation1、allocation2直接进入了老年代,并没有等到15岁 */ public class TenuringThreshold2 { private static int _1MB = 1024*1024; public static void main(String[] args) { testTenuringThreshold2(); } @SuppressWarnings("unused") public static void testTenuringThreshold2(){ byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[_1MB / 4]; //allocation1+allocation2大于survivor空间一半 allocation2 = new byte[_1MB / 4]; allocation3 = new byte[4 * _1MB]; allocation4 = new byte[4 * _1MB]; allocation4 = null; allocation4 = new byte[4 * _1MB]; } }
-
空间分配担保
在发生Minor GC之前,虚拟机先检查老年代最大可用连续空间是否大于新生代所有对象的总空间,如果条件成立,则这一次Minor GC确保是安全的。如果不成立,则查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败;如果允许,继续坚持老年代最大可用连续空间是否大于历次晋升到老年代的平均大小,如果大于,将尝试进行一次Minor GC,尽管是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那就会进行一次Full GC虽然担保失败还会Full GC且比直接Full GC耗时,但通常情况下还是会把开关打开,避免Full GC频繁
虽然源码还定义了-XX:HandlePromotionFailure参数,但是在实际虚拟机中已经不会再使用它。JDK 6后,只要老年代的连续空间大小大于新生代对象总大小或历次晋升的平均大小就进行Minor GC,否则进行Full GC
笔记来源于《深入理解Java虚拟机》周志明 著