3 对象的实例化内存布局与访问定位
3.1 对象实例化
对象创建的方式和步骤开始说起
对象创建方式
- new:最常见的方式、单例类中调用getInstance 的静态类方法,XXXFactory 的静态方法
- Class的 newInstance 方法:在 JDK 9 里面被标记为过时的方法,因为只能调用空参构造器,权限必须是 public
- Constructor的 newInstance(XXX):反射的方式,可以调用空参的,或者带参的构造器,权限没有要求
- 使用clone():不调用任何的构造器,要求当前的类需要实现 Cloneable 接口中的 clone() 方法
- 使用反序列化:序列化一般用于Socket 的网络传输,从文件、网络中获取文件二进制流
- 第三方库Objenesis
创建对象的步骤
- 判断对象对应的类是否加载、连接、初始化
虚拟机遇到一条new指令,首先会去检查这个指令的参数是否能在MetaSpace(元空间)的常量池中定位到一个类的符号引用,并且检查这个类是否已经被加载、解析和初始化(类元信息是否存在)。如果没有,在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 Key 进行查找对应的 .class 文件,如果没有找到文件,则抛出 ClassNotFoundException 异常,如果找到,则进行类加载,并生成对应的 Class 对象。
- 为对象分配内存
首先计算对象占用空间的大小,接着堆中划分一块内存给新的对象。如果实例成员变量时引用变量仅分配引用变量空间即可,即4个字节大小( long 和 double 是8个字节)
- 内存规整:指针碰撞法
如果内存时规整的,那么虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。意思是所有用过的内存在一边,空闲的内存放在另外一边,中间放着一个指针作为分界点的指示器。分配内存仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离而已。如果垃圾收集器选择的是 Serial ,ParNew 这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带 Compact(整理)过程的收集器时,使用指针碰撞。
- 内存不规整:虚拟机表需要维护一个列表,空闲列表分配
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为了 “空闲列表(Free List)”。
- 说明:选择哪种分配方式由 Java 堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 处理并发问题
在分配内存空间的时候,另一个问题就是及时保证new对象的时候保证线程的安全:创建对象时十分频繁的操作,虚拟机需要解决并发问题。虚拟机采用两种方式解决并发问题:
- CAS失败重试、区域加锁:保证指针更新操作的原子性
- TLAB:TLAB 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一个小块内存,称为本地线程分配缓冲区,(TLAB,Thread Local Allocation Buffer)虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数来设定
- 初始化分配到的内存
内存分配结束后,虚拟机将分配的内存空间都初始化零值(不包括对象头),这一步保证的对象的实例字段在java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值(不包括类变量即静态变量)
- 属性的默认初始化
- 显示初始化
- 代码块中的初始化
- 构造器初始化
- 所有属性设置默认值,保证对象实例字段在不赋值可以直接使用
- 设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM 实现。
- 执行init方法进行初始化
在 Java 程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量
因此一般来说(由字节码中跟随 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。
过程
加载类元信息->为对象分配内存->处理并发问题->属性的默认初始化(零值初始化)->设置对象头信息->显示初始化
3.2 对象的内存布局
3.2.1 对象头(Header)
对象头包括两部分,分别时运行时元数据和类型指针
运行时元数据:
- 哈希值
- GC年龄分代
- 锁状态标志
- 线程持有的锁
- 偏向锁ID
- 偏向时间戳
类型指针:
指向类元数据 InstanceKlass ,确定该对象所属的类型。指向的其实是方法区中存放的类元信息
说明: 如果是数组,还需要记录数组的长度
3.2.2 实例数据(Instance Data)
说明
它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
规则
- 相同宽度的字段总是被分配在一起
- 父类中定义的变量会出现在子类之前
如果 CompactFields 参数为 true(默认为 true),子类的窄变量可能插入到父类变量的空隙
3.2.3 对齐填充
3.3 对象的访问定位
JVM 是如何通过栈帧中的对象引用访问到其内部的对象实例呢?
——定位,通过栈上 reference 访问
图示
创建对象的目的就是为了使用它
对象访问的两种方式3.3.1 句柄访问
句柄访问就是说栈的局部变量表中,记录的对象的引用,然后在堆空间中开辟了一块空间,也就是句柄池
优点
reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference 本身不需要被修改3.3.2 直接指针(HotSpot采用)
直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针(对象头中存放的类型指针),指向的是方法区中的对象类型数据4 直接内存(Direct Memory)
直接内存 Direct Memory
不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 直接内存是在 Java 堆外的、直接向系统申请的内存区间。
- 来源于 NIO ,通过存在堆中的 DirectByteBuffer 操作 Native 内存
- 通常,访问直接内存的速度会优于 Java 堆。即读写性能高。
- 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
- Java 的 NIO 库允许 Java 程序使用直接内存,用于数据缓冲区
使用下列代码,直接分配本地内存空间
int BUFFER = 1024 1024 1024; _// 1GB
_ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
非直接缓存区和直接缓存区
原来采用 BIO 的架构,我们需要从用户态切换成内核态
NIO 的方式使用了直接缓存区的概念
存在的问题
- 也可能导致 OutOfMemoryError 异常
- 由于直接内存在 Java 堆外,因此它的大小不会直接受限于 -Xmx 指定的最大堆大小,但是系统内存是有限的,Java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。
- 缺点
- 分配回收成本较高
- 不受 JVM 内存回收管理
- 直接内存大小可以通过 MaxDirectMemorySize 设置
如果不指定,默认与堆的最大值 -Xmx 参数值一致
5 执行引擎
1 执行引擎概述
执行引擎属于 JVM 的下层,里面包括解释器、及时编译器、垃圾回收器
执行引擎是Java 虚拟机核心的组成部分之一。
- “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。
那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎的工作流程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于 PC 寄存器。
- 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
执行引擎通过读取PC寄存器中的指令,在操作数栈用操作
从外观上来看,所有的 Java 虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程。
2 Java代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
- 前面橙色部分是生成字节码文件的过程,和JVM 无关
- 后面蓝色和绿色才是 JVM 需要考虑的过程
Java 代码编译是由 Java 源码编译器来完成,流程图如下所示:
Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示
我们用一个总的图,来说说解释器和编译器
什么是解释器(Interpreter)
当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
什么是 JIT 编译器
JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
为什么 Java 是半编译半解释型语言
JDK 1.0 时代,将 Java 语言定位为“解释执行”还是比较准确的。再后来,Java 也发展出可以直接生成本地代码的编译器。现在 JVM 在执行 Java 代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
翻译成本地代码后,就可以做一个缓存操作,存储在方法区中
3 机器码、指令、汇编语言
机器码
- 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它编写程序,这就是机器语言。
- 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
- 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。
- 机器指令与CPU 紧密相关,所以不同种类的 CPU 所对应的机器指令也就不同。
指令
- 由于机器码是有0 和 1 组成的二进制序列,可读性实在太差,于是人们发明了指令。
- 指令就是把机器码中特定的0 和 1 序列,简化成对应的指令(一般为英文简写,如 mov,inc 等),可读性稍好
- 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
指令集
- 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。如常见的
- x86指令集,对应的是 x86 架构的平台
- ARM指令集,对应的是 ARM 架构的平台
汇编语言
- 由于指令的可读性还是太差,于是人们又发明了汇编语言。
- 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。
- 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
- 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
高级语言
- 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
- 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
高级语言也不是直接翻译成机器指令,而是翻译成汇编语言,如下面说的 C 和
C、C++源程序执行过程
编译过程又可以分成两个阶段:编译和汇编。
- 编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。
字节码字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
- 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
- 字节码典型的应用为:Java bytecode
4 解释器
JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
为什么 Java 源文件不直接翻译成不同平台对应的机器指令 ,而是翻译成字节码文件?满足 Java 程序实现跨平台特性,也可能是因为直接翻译的机器指令代价较大,耗时较长解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- 当一条字节码指令被解释执行完成后,接着再根据PC 寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
解释器分类
在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
- 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
- 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
- 在HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块构成。
- Interpreter模块:实现了解释器的核心功能
- Code模块:用于管理 HotSpot VM 在运行时生成的本地机器指令
- 在HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块构成。
现状
- 由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 Python、Perl、Ruby 等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些 C/C++ 程序员所调侃。
- 为了解决这个问题,JVM 平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
- 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
5 JIT 编译器
Java 代码的执行分类
- 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
- 第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行
- HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在 Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
- 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。
问题来了
有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置 JIT 编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。
JRockit 虚拟机是砍掉了解释器,也就是只采及时编译器。那是因为 JRockit 只部署在服务器上,一般已经有时间让他进行指令编译的过程了,对于响应来说要求不高,等及时编译器的编译完成后,就会提供更好的性能
首先明确: 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。
所以: 尽管 JRockit VM 中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
HotSpot JVM执行方式
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
案例
注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的 1/8 。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的 JVM 均是解释执行,还没有进行热点代码统计和 JIT 动态编译,导致机器启动之后,当前 1/2 发布成功的服务器马上全部宕机,此故障说明了 JIT 的存在。—阿里团队
概念解释Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java 文件转变成 .class 文件的过程;
- 也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。
还可能是指使用静态提前编译器(AOT 编译器,Ahead of Time Compiler)直接把 .java 文件编译成本地机器代码的过程。
前端编译器:Sun 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。
JIT 编译器:HotSpot VM的 C1、C2 编译器。
AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
热点代码及探测技术
当然是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的执行性能。
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT 编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为 OSR(On Stack Replacement)编译。
- 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
- 目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测。
- 采用基于计数器的热点探测,HotSpotVM 将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
- 方法调用计数器用于统计方法的调用次数
- 回边计数器则用于统计循环体执行的循环次数
方法调用计数器
- 这个计数器就用于统计方法被调用的次数,它的默认阀值在Client 模式下是1500次,在 Server 模式下是10000次。超过这个阈值,就会触发 JIT 编译。
- 这个阀值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
热点衰减如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)
- 半衰周期是化学中的概念,比如出土的文物通过查看C60 来获得文物的年龄
- 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
- 另外,可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。
HotSpot VM 可以设置程序执行方法
缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为 Java 虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
- -Xint:完全采用解释器模式执行程序
- -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行
- -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。
HotSpot VM 中 JIT 分类
在 HotSpot VM 中内嵌有两个 JIT 编译器,分别为 Client Compiler和Server Compiler,但大多数情况下我们简称为 C1 编译器和 C2 编译器。开发人员可以通过如下命令显式指定 Java 虚拟机在运行时到底使用哪一种即时编译器,如下所示:
- -client:指定Java 虚拟机运行在 Client 模式下,并使用 C1 编译器;
- C1 编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
-server:指定Java 虚拟机运行在 Server 模式下,并使用 C2 编译器。
- C2 进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。(使用 C++ 实现)
C1 和 C2 编译器不同的优化策略在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联,去虚拟化、元余消除。
- 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:在运行期间把一些不会执行的代码折叠掉
C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在 C2 上有如下几种优化:
- 标量替换:用标量值代替聚合对象的属性值
- 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
- 同步消除:清除同步操作,通常指synchronized
分层编译策略
分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发 C1 编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。
不过在 Java 7 版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由 C1 编译器和 C2 编译器相互协作共同来执行编译任务。
总结一般来讲,JIT编译出来的机器码性能比解释器高
C2编译器启动时长比 C1 慢,系统稳定执行以后,C2 编译器执行速度远快于 C1 编译器
AOT编译器JDK 9引入了 AOT 编译器(静态提前编译器,Ahead of Time Compiler)
- Java 9引入了实验性 AOT 编译工具 jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
- 所谓 AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
.java -> .class -> (使用jaotc) -> .so
- 最大的好处:
- Java虚拟机加载已经预编译成二进制库,可以直接执行。
- 不必等待及时编译器的预热,减少Java 应用给人带来“第一次运行慢” 的不良体验
缺点:
- 破坏了Java “ 一次编译,到处运行”,必须为每个不同的硬件,OS 编译对应的发行包
- 降低了Java 链接过程的动态性,加载的代码在编译器就必须全部已知。
- 还需要继续优化中,最初只支持Linux X64 java base
写到最后自 JDK10 起,HotSpot 又加入了一个全新的及时编译器:Graal 编译器
- 编译效果短短几年时间就追平了C2 编译器,未来可期
目前,带着实验状态标签,需要使用开关参数 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 去激活才能使用
6 字符串常量池
6.1 String的基本特性
String:字符串,使用一对 “” 引起来表示
- String s1 = “mogublog” ; // 字面量的定义方式
- String s2 = new String(“moxi”);
- String声明为 final 的,不可被继承
- String实现了 Serializable 接口:表示字符串是支持序列化的。实现了 Comparable 接口:表示 String 可以比较大小
String在 JDK 8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK 9 时改为 byte[]
6.2 为什么JDK 9改变了结构
String 类的当前实现将字符存储在 char 数组中,每个字符使用两个字节(16位)。从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且,大多数字符串对象只包含拉丁字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部 char 数组中有一半的空间将不会使用。
Motivation
The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Lation-1 character. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.
Description
We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field. The new String class will store characters encoded either as ISO-8859-1/Latin-1 (one byte per character), or as UTF-16(two bytes per character), based upon the contents of the string. The encoding flag will indicate which encoding is used.
我们建议改变字符串的内部表示 Class 从 UTF-16 字符数组到字节数组+一个 encoding-flag 字段。新的 String 类将根据字符串的内容存储编码为 ISO-8859-1/Latin-1(每个字符一个字节)或 UTF-16 (每个字符两个字节)的字符。编码标志将指示使用哪种编码。
结论:String 再也不用 char[] 来存储了,改成了 byte [] 加上编码标记,节约了一些空间
// 之前
_private final char value[];
// 之后
_private final byte[] value
同时基于 String 的数据结构,例如 StringBuffer 和 StringBuilder 也同样做了修改6.3 String的不可变性
String:代表不可变的字符序列。简称:不可变性。
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
- 当调用 String 的 replace() 方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
- 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
代码
public class StringTest1 {
public static void test1() {
// 字面量定义的方式,“abc”存储在字符串常量池中
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2);
s1 = "hello";
System.out.println(s1 == s2);
System.out.println(s1);
System.out.println(s2);
System.out.println("----------------");
}
public static void test2() {
String s1 = "abc";
String s2 = "abc";
// 只要进行了修改,就会重新创建一个对象,这就是不可变性
s2 += "def";
System.out.println(s1);
System.out.println(s2);
System.out.println("----------------");
}
public static void test3() {
String s1 = "abc";
String s2 = s1.replace('a', 'm');
System.out.println(s1);
System.out.println(s2);
}
public static void main(String[] args) {
test1();
test2();
test3();
}
}
运行结果
true
false
hello
abc
----------------
abc
abcdef
----------------
abc
mbc
面试题
public class StringExer {
String str = new String("good");
char[] ch = {'t','e','s','t'};
public void change(String str, char ch []) {
str = "test ok";
ch[0] = 'b';
}
public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);
System.out.println(ex.str);
System.out.println(ex.ch);
}
}
输出结果
good
best
字符串常量池是不会存储相同内容的字符串的
- String 的 String Pool 是一个固定大小的 Hashtable ,默认值大小长度是1009。如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降。
- 使用 -XX:StringTableSize 可设置 StringTable 的长度
- 在 JDK 6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize 设置没有要求
- 在 JDK 7 中,StringTable 的长度默认值是 60013 ,StringTablesize 设置没有要求
在 JDK 8 中,StringTable 可以设置的最小值为 1009
public class TestStringTableSize2 { public static void main(String[] args) throws IOException { String fileName = “words.txt”; generateString(fileName); readString(fileName); } private static void readString(String fileName) throws IOException { Instant start = Instant.now(); BufferedReader bufferedReader = new BufferedReader(new FileReader(new File(fileName))); String data; while ((data = bufferedReader.readLine()) != null) { data.intern(); } Instant end = Instant.now(); System.out.println(“use:” + Duration.between(start, end).toMillis() + “ms”); } public static void generateString(String fileName) throws IOException { FileWriter fileWriter = new FileWriter(fileName); for (int i = 0; i < 100000; i++) { //生成长度为1-10的字符串 int length = (int) (Math.random() 10 + 1); fileWriter.write(generateString(length) + “\n”); } fileWriter.flush(); fileWriter.close(); } private static String generateString(int length) { StringBuffer stringBuffer = new StringBuffer(); //65-90 97-122 字母 for (int i = 0; i < length; i++) { int upperNum = (int) (Math.random() (90 - 65 + 1) + 65); int lowerNum = (int) (Math.random() * (122 - 97 + 1) + 97); int num = Math.random() > 0.5 ? upperNum : lowerNum; stringBuffer.append((char) num); } return stringBuffer.toString(); } } |
---|
-XX:StringTableSize=100009 | use:89ms |
---|---|
-XX:StringTableSize=1009 | use:268ms |
6.4 String的内存分配
- 在 Java语言中有8种基本数据类型和一种比较特殊的类型 String 。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
- 常量池就类似一个Java 系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种。
- 直接使用双引号声明出来的String 对象会直接存储在常量池中。
- 比如:String info = “http://atguigu.com“;
- 如果不是用双引号声明的String 对象,可以使用 String 提供的 intern() 方法。
- 直接使用双引号声明出来的String 对象会直接存储在常量池中。
- Java 6及以前,字符串常量池存放在永久代
- Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆内
- 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
- 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7 中使用 String.intern()。
- Java 8元空间,字符串常量在堆
为什么 StringTable 从永久代调整到堆中
在 JDK 7 中,interned 字符串不再在 Java 堆的永久代中分配,而是在 Java 堆的主要部分(称为年轻代和年老代)中分配,与应用程序创建的其他对象一起分配。此更改将导致驻留在主 Java 堆中的数据更多,驻留在永久生成中的数据更少,因此可能需要调整堆大小。由于这一变化,大多数应用程序在堆使用方面只会看到相对较小的差异,但加载许多类或大量使用字符串的较大应用程序会出现这种差异。intern() 方法会看到更显著的差异。
- 永久代的默认比较小
- 永久代垃圾回收频率低
String 的基本操作
Java 语言规范里要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列(包含同一份码点序列的常量),并且必须是指向同一个 String 类实例。
案例1
System.out.println(“1”); System.out.println(“2”); System.out.println(“3”); System.out.println(“4”); System.out.println(“1”); System.out.println(“2”); System.out.println(“3”); System.out.println(“4”); |
---|
案例二
class Memory{
public static void main(String[] args) {
int i = 1;
Object obj = new Object();
Memory mem = new Memory();
mem.foo(obj);
}
private void foo(Object param) {
String str = param.toString();
System.out.println(str);
}
}
6.5 字符串拼接操作
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的变量
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
如果拼接的结果调用intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
public static void test1() {
String s1 = "a" + "b" + "c"; // 得到 abc的常量池
//这里上下两个语句编译后是一样的,都是在字符串常量池中创建一个“abc"的对象
String s2 = "abc"; // abc存放在常量池,直接将常量池的地址返回
/**
* 最终java编译成.class,再执行.class
*/
System.out.println(s1 == s2); // true,因为存放在字符串常量池
System.out.println(s1.equals(s2)); // true
}
public static void test2() {
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
String s5 = s1 + "hadoop";//原理是StringBuilder,会在堆中创建一个对象
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4); // true
System.out.println(s3 == s5); // false
System.out.println(s3 == s6); // false
System.out.println(s3 == s7); // false
System.out.println(s5 == s6); // false
System.out.println(s5 == s7); // false
System.out.println(s6 == s7); // false
String s8 = s6.intern();
System.out.println(s3 == s8); // true
}
从上述的结果我们可以知道:
如果拼接符号的前后出现了变量,则相当于在堆空间中 new String() ,具体的内容为拼接的结果
而调用 intern() 方法,则会判断字符串常量池中是否存在 “javaEEhadoop” 值,如果存在则返回常量池中的值,否者就在常量池中创建
底层原理
拼接操作的底层其实使用了 StringBuilder
s1 + s2 的执行细节StringBuilder s = new StringBuilder();
- s.append(s1);
- s.append(s2);
- s.toString(); -> 类似于new String(“ab”);//这里的toString()只会在堆中创建一个String对象,并不会在字符串常量池中放入“ab”这个字符串,所以并不是完全等同于new String(”ab’)
在 JDK 5 之后,使用的是 StringBuilder,在 JDK 5 之前使用的是 StringBuffer
String | StringBuffer | StringBuilder |
---|---|---|
String 的值是不可变的,这就导致每次对 String 的操作都会生成新的 String 对象,不仅效率低下,而且浪费大量优先的内存空间 | StringBuffer 是可变类,和线程安全的字符串操作类,任何对它指向的字符串的操作都不会产生新的对象。每个 StringBuffer 对象都有一定的缓冲区容量,当字符串大小没有超过容量时,不会分配新的容量,当字符串大小超过容量时,会自动增加容量 | 可变类,速度更快 |
不可变 | 可变 | 可变 |
线程安全 | 线程不安全 | |
多线程操作字符串 | 单线程操作字符串 |
注意,我们左右两边如果是变量的话,就是需要 new StringBuilder 进行拼接,但是如果使用的是 final 修饰,则是从常量池中获取。所以说拼接符号左右两边都是字符串常量或常量引用 则仍然使用编译器优化。也就是说被 final 修饰的变量,将会变成常量,类和方法将不能被继承
在开发中,能够使用 final 的时候,建议使用上
public static void test4() {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
}
运行结果
true
拼接操作和 append 性能对比
public static void method1(int highLevel) {
String src = "";
for (int i = 0; i < highLevel; i++) {
src += "a"; // 每次循环都会创建一个StringBuilder对象
}
}
public static void method2(int highLevel) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
sb.append("a");
}
}
方法1耗费的时间:4005ms,方法2消耗时间:7ms
结论:
通过StringBuilder 的 append() 方式添加字符串的效率,要远远高于 String 的字符串拼接方法
好处
- StringBuilder的 append 的方式,自始至终只创建一个 StringBuilder 的对象
- 对于字符串拼接的方式,还需要创建很多StringBuilder 对象和调用 toString 时候创建的String 对象
- 内存中由于创建了较多的StringBuilder 和 String 对象,内存占用过大,如果进行 GC 那么将会耗费更多的时间
改进的空间
- 我们使用的是StringBuilder 的空参构造器,默认的字符串容量是16,然后将原来的字符串拷贝到新的字符串中,我们也可以默认初始化更大的长度,减少扩容的次数
- 因此在实际开发中,我们能够确定,前前后后需要添加的字符串不高于某个限定值,那么建议使用构造器创建一个阈值的长度
6.6 intern() 的使用
intern() 是一个 native 方法,调用的是底层 C 的方法
字符串池最初是空的,由 String 类私有地维护。在调用 intern() 方法时,如果池中已经包含了由 equals(object) 方法确定的与该字符串对象相等的字符串,则返回池中的字符串。否则,该字符串对象将被添加到池中,并返回对该字符串对象的引用。
如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern() 方法:intern() 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
比如:
String myInfo = new string(“I love atguigu”).intern();
也就是说,如果在任意字符串上调用 String.intern() 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是 true
(”a”+”b”+”c”).intern()==”abc”
通俗点讲,Interned String 就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)
/*
如何保证变量s指向的是字符串常量池中的数据呢?
有两种方式:
方式一: String s = “shkstart”;//字面量定义的方式
方式二: 调用intern()
String s = new String(“shkstart”).intern();
String s = new StringBuilder(“shkstart”).toString().intern();
*/public class StringIntern {
public static void main(String[] args) {
String s = new String("1");
s.intern();//调用此方法之前,字符串常量池中已经存在了"1",没有将intern的值返回赋值
String s2 = "1";
System.out.println(s == s2);//jdk6:false jdk7/8:false
String s3 = new String("1") + new String("1");
//s3变量记录的地址为:new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!因为在运行期才会有这个11
s3.intern();
//在字符串常量池中生成"11"。如何理解:jdk6:创建了一个新的对象"11",也就有新的地址。
//jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址
String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
}
}
在JDK1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。
而JDK1.7的intern()不会再复制实例,只是在常量池中记录首次出现的实例的引用,因此intern()返回的引用和StringBuilder创建的那个字符串的实例是同一个。对str2比较返回false是因为”java”这个字符串在执行StringBuilder.toString()之前就已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true。
来自 [https://www.cnblogs.com/wangshen31/p/10404353.html](https://www.cnblogs.com/wangshen31/p/10404353.html)
结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用 intern() 方法能够节省内存空间。
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern() 方法,就会很明显降低内存的大小。
6.7 面试题
new String("ab")会创建几个对象
/**
* new String("ab") 会创建几个对象? 看字节码就知道是2个对象
*
*/
public class StringNewTest {
public static void main(String[] args) {
String str = new String("ab");
}
}
我们转换成字节码来查看
0 new #2
3 dup
4 ldc #3
6 invokespecial #4
9 astore_1
10 return
这里面就是两个对象
- 一个对象是:new 关键字在堆空间中创建
另一个对象:字符串常量池中的对象
new String("a") + new String("b") 会创建几个对象
/**
* new String("ab") 会创建几个对象?
*
*/
public class StringNewTest {
public static void main(String[] args) {
String str = new String("a") + new String("b");
}
}
字节码文件为
0 new #2
3 dup
4 invokespecial #3>
7 new #4
10 dup
11 ldc #5
13 invokespecial #6>
16 invokevirtual #7
19 new #4
22 dup
23 ldc #8
25 invokespecial #6>
28 invokevirtual #7
31 invokevirtual #9
34 astore_1
35 return
我们创建了6个对象- 对象2:new String(“a”)
- 对象3:常量池的 a
- 对象4:new String(“b”)
- 对象5:常量池的 b
- 对象6:toString 中会创建一个 new String(“ab”)
- 调用 toString 方法,不会在常量池中生成 ab
6.8 StringTable的垃圾回收
/**
* String的垃圾回收
* -Xms15m -Xmx15m -XX:+PrintStringTableStatistics-XX:+PrintGCDetails
*/
public class StringGCTest {
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}
6.9 G1 中的 String 去重操作
http://openjdk.java.net/jeps/192
注意这里说的重复,指的是在堆中的数据,而不是常量池中的,因为常量池中的本身就不会重复
描述
- 背景:对许多Java 应用(有大的也有小的)做的测试得出以下结果:
- 堆存活数据集合里面 String 对象占了25%
- 堆存活数据集合里面重复的String 对象有13.5%
- String对象的平均长度是45
- 许多大规模的 Java 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java 堆中存活的数据集合差不多25%是 String 对象。更进一步,这里面差不多一半 String 对象是重复的,重复的意思是说:string1.equals(string2) = true。堆上存在重复的 String对象必然是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动持续对重复的 String对象进行去重,这样就能避免浪费内存。
实现
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String 对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String 对象。
- 使用一个Hashtable 来记录所有的被 String 对象使用的不重复的 char 数组。当去重的时候,会查这个 Hashtable,来看堆上是否已经存在一个一模一样的 char 数组。
- 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到 Hashtable,这样以后的时候就可以共享这个数组了。
命令行选项
- UsestringDeduplication(bool):开启 String 去重,默认是不开启的,需要手动开启
- PrintStringDeduplicationStatistics(bool):打印详细的去重统计信息
StringDeduplicationAgeThreshold(uintx):达到这个年龄的 String 对象被认为是去重的候选对象、
7.垃圾回收
7.1 概述
为什么需要GC(清理内存)
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
- 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM 将整理出的内存分配给新的对象。
- 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常进行。而经常造成 STW 的GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。
java垃圾回收机制
优点
- 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
- 没有垃圾回收器,Java也会和 C++ 一样,各种悬垂指针,野指针,泄露问题让你头疼不已。
- 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
- Oracle官网关于垃圾回收的介绍
担忧
- 对于 Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。
- 此时,了解JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
GC 主要关注的区域
GC 主要关注于方法区和堆中的垃圾收集
垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收
- 其中,Java堆是垃圾收集器的工作重点
从次数上讲:
在堆里存放着几乎所有的Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
- 那么在JVM 中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
判断对象存活一般有两种方式:引用计数算法(目前jvm中基本不使用)和可达性分析算法。
7.2.1.1 引用计数法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
- 对于一个对象A,只要有任何一个对象引用了 A,则 A 的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象 A 的引用计数器的值为0,即表示对象 A不可能再被使用,可进行回收。
- 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
- 缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java 的垃圾回收器中没有使用这类算法。
循环引用
当 p 的指针断开的时候,内部的引用形成一个循环,这就是循环引用,从而造成内存泄漏
public class RefCountGC {
// 这个成员属性的唯一作用就是占用一点内存
private byte[] bigSize = new byte[510241024];
// 引用
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();
obj1.reference = obj2;
obj2.reference = obj1;
obj1 = null;
obj2 = null;
// 显式的执行垃圾收集行为
//这里发生GC,obj1和obj2能否被回收?
System.gc();
}
}
运行结果
[GC (System.gc()) [PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K), 0.0061980 secs] [Times: user=0.00 sys=0.00, real=0.36 secs]
[Full GC (System.gc()) [PSYoungGen: 808K->0K(76288K)] [ParOldGen: 8K->672K(175104K)] 816K->672K(251392K), [Metaspace: 3479K->3479K(1056768K)], 0.0045983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 76288K, used 655K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
eden space 65536K, 1% used [0x000000076b500000,0x000000076b5a3ee8,0x000000076f500000)
from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
to space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
ParOldGen total 175104K, used 672K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1ea8070,0x00000006cc900000)
Metaspace used 3486K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 385K, capacity 388K, committed 512K, reserved 1048576K
我们能够看到,上述进行了 GC 收集的行为,将上述的新生代中的两个对象都进行回收了
PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K)
如果使用引用计数算法,那么这两个对象将会无法回收。而现在两个对象被回收了,说明 Java 使用的不是引用计数算法来进行标记的。
小结
- 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
- 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
- Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
Python如何解决循环引用?
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
- 相较于引用计数算法,这里的可达性分析就是 Java、C# 选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)
思路
- 所谓”GC ROOT”根集合就是一组必须活跃的应用
- 基本思路:
- 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
这里需要注意的是,在可达性分析算法中,每次标记的是直接或间接与GC Roots连接的对象,标记完成后,遍历整个内存空间,将没有被标记的对象删除。
JAVA中哪些对象可以称之为GC Roots
- 虚拟机栈中引用的对象:各个线程被调用的方法中使用到的参数,局部变量等等
- 本地方法栈内JNI(通常说本地方法)引用的对象方法区中类静态属性引用的对象:Java 类的引用类型静态变量
- 方法区中常量引用的对象:字符串常量池(String Table)里的引用
- 所有被同步锁synchronized持有的对象
- java虚拟机栈内部的引用:基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
总结
总结就是一句话,堆空间外的一些结构,比如虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为GC Roots进行可达性分析。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。比如:分代收集和局部回收(Partial GC)。
如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,而不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GC Roots 集合中去考虑,才能保证可达性分析的准确性。
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但是自己又不存放在堆内存中,那它就是一个GC Roots对象。
注意
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是导致 GC 进行时必须“Stop The World”的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
7.2.2 对象的finalization机制
Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize() 方法。
finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
注意
永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用。
理由包括下面三点:
- 在finalize() 时可能会导致对象复活。
- finalize()方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize() 方法将没有执行机会。
- 因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收
- 一个糟糕的finalize() 会严重影响 GC 的性能。
从功能上来说, finalize() 方法与 C++ 中的析构函数比较相似,但是 Java 采用的是基于垃圾回收器的自动内存管理机制,所以 finalize() 方法在本质上不同于 C++ 中的析构函数。
由于 finalize() 方法的存在,虚拟机中的对象一般处于三种可能的状态。
生存还是死亡?
如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize() 中复活。
- 不可触及的:对象的finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize()只会被调用一次。
以上3种状态中,是由于 finalize() 方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
具体过程
判定一个对象 objA 是否可回收,至少要经历两次标记过程:
- 如果对象=objA 到 GC Roots 没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行finalize() 方法
- 如果对象objA 没有重写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
- 如果对象objA 重写了 finalize() 方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的Finalizer 线程触发其 finalize() 方法执行。
finalize()方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下, finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize() 方法只会被调用一次。
7.2.3 清除阶段
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的内存空间分配给新对象。目前jvm中常见的有三种垃圾手机算法
标记-清除算法(Mark-Sweep)
复制算法(Copying)
标记-压缩算法(Mark-Compact)7.2.3.1 标记-清除算法
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在1960年提出并并应用于 Lisp 语言。
执行过程
当堆中的有效内存空间(Available Memory)被耗尽的时候,就会停止整个程序(也被称为Stop The World),然后进行两项工作,第一项则是标记,第二项则是清除标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。
- 标记的是引用的对象,不是垃圾!!
- 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收
什么是清除?
这里所谓的清除并不是真正的置空,而是把需要清除的对象地址保存在空闲的地址列表中,下次有新对象需要加载时,判断垃圾的位置空间内存是否足够,如果够,就存放覆盖的原有的地址
关于空闲列表是在为对象分配内存的时候
- 如果内存规整:采用指针碰撞的方式进行内存分配
- 如果内存不规整:虚拟机需要维护一个列表,空闲列表分配
缺点
- 标记清除算法的效率不算高(并不是最垃圾)
- 在进行 GC的时候,需要停止整个应用程序,导致用户体验较差
这种方式清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表
7.2.3.2 复制算法
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于1963年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了Lisp 语言的一个实现版本中。
核心思想
将活着内存空间分为两块,每次只是用其中的一块,在垃圾回收的时候将正在使用的内存空间中存活的对象复制到另一块内存空间中,之后再清除原先内存空间中的所有对象,然后交换角色,最后完成垃圾回收
在新生代的Service0,Service1区中就使用到了这种算法
优点没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点
- 此算法的缺点也是很明显的,就是需要两倍的内存空间。
- 对于 G1这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小(region后面会说到)
注意
如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不是很多,或者说必须非常低才可以(老年代中会有大量存活对象,效率很低),特别适合垃圾对象很多,存活对象很少的场景,例如:Young 区的 Survivor0 和 Survivor1 区;
应用场景
在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
7.2.3.3 标记-压缩(整理)算法
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。
执行过程
- 第一阶段和标记清除算法一致,从根节点开始标记所有被引用对象
- 第二阶段将所有存活对象压缩到内存的一端,按顺序存放,之后,清除边界之外的所有空间。
标清和标整的区别
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
指针碰撞(Bump the Pointer)
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)
标整的优缺点
优点
- 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点
- 从效率上来说,标记-整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
- 移动过程中,需要全程暂停用户应用程序。即:STW
小结
效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
标记清除 | 标记整理 | 复制 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
7.2.3.4 分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的
在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代(Young Gen)
年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 HotSpot 中的两个 Survivor 的设计得到缓解。
- 老年代(Tenured Gen)
老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
- Mark阶段的开销与存活对象的数量成正比。
- Sweep阶段的开销与所管理区域的大小成正相关。
- Compact 阶段的开销与存活对象的数据成正比。
以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代
7.2.3.5 增量收集算法
概述
上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态。在 Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。
基本思想
如果一次性将所有垃圾进行回收,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序交替执行,每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
7.2.3.6 分区算法
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。*写在最后:
注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。
8 垃圾回收常见的相关概念
8.1 System.gc()的理解
- 在默认情況下,通过System.gc()或者Runtime. getRuntime().gc()的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
- 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用(仅仅是提醒垃圾回收,会不会回收不一定)。
- JVM实现者可以通过System.gc()调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。
8.2 内存溢出与内存泄漏
2.1 内存溢出
- 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。内存泄漏可能会导致内存溢出。
- 由于GC一直在发展,所有一般情況下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况。
- 大多数情況下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存,供应用程序继续使用。
- javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
- 首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。原因有二:
- Java虚拟机的堆内存设置不够。
- 首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。原因有二:
比如:可能存在内存泄漏问题也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xmg、Xmx来调整。
- **代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)**
对于老版本的Oracle JDK、因为永久代的大小是有限的、并且对永久代垃圾回收(如常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:”java.lang. OutOfMemoryError: Permgen space”。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:”java.lang.OutOfMemoryError: Metaspace”。直接内存不足,也会导致OOM
- 这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
- 例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
- 在java.nio.BIts.reservememory()方法中,我们能清楚的看到, System.gc()会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发的
也称作”存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
- 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的”内存泄漏”
- 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutofmemoryError异常,导致程序崩溃。
- 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
- 举例:
- 单例模式
单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
- 一些提供close的资源未关闭导致内存泄漏
数据库连接(dataSourse. getConnection(),网络连接(socket)和io连接必须手close,否则是不能被回收的。
8.3 Stop The World
1.Stop-the-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW
- 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
- 分析工作必须在一个能确保一致性的快照中进行
- 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
- 被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。
- STW事件和采用哪款GC无关,所有的GC都有这个事件
- 哪怕是G1也不能完全避免Stop-the-world情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
- STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
- 开发中不要用System.gc();会导致Stop-the-world的发生
8.4 安全点与安全区域
5.1 安全点
- 程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为”安全点( Safepoint)”。
- Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂通常会根据”是否具有让程序长时间执行的特征”为标准。比如:选择些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。
- 如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
- 抢先式中断:(目前没有虚拟机采用了)
首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
- 主动式中断:
设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
5.2 安全区域
- Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC Safepoint。但是,程序”不执行”的时候呢?例如线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,”走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
- 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的 Safepoint
- 实际执行时
- 强引用(Strong Reference)
- 软引用(Soft Reference)
- 弱引用(Weak Reference)
- 虚引用(Phantom Reference)
这4种引用强度依次逐渐减弱
除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们
Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用
- 【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?(这几种引用在堆中都是可达的)
- 强引用(Strong Reference):最传统的”引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似”Object obj= new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用(Soft Reference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常
- 弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
- 虚引用(Phantom Reference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
5.1 强引用-不回收
- 在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。
- 当在Java语言中使用new操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。
- 强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
- 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策珞。
- 相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之ー。
- 强引用例子:
局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是StringBuffer实例的强引用
5.2 软引用-内存不足即回收
- 软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收(一次回收,不触及的对象),如果这次回收还没有足够的内存,オ会抛出内存溢出异常。
- 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
- 垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)
- 类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。
- 在JDK1.2版之后提供了java.lang.ref. Softreference类来实现软引用
- 代码测试
当内存足够,不会回收软引用的可达对象;当内存不够,会回收软引用的可达对象
5.3 弱引用-发现即回收
- 弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
- 弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
- 软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
- 弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检査是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收
- 在JDK1.2版之后提供了java.lang.ref. WeakReference类来实现软引用
- 代码测试
| 你开发中使用过WeakHashMap吗?
WeakHashMap的Entry方法继承了WeakReference
6.4 虚引用-对象回收跟踪
- 也称为”幽灵引用”或者”幻影引用”,是所有引用类型中最弱的
- 一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收
- 它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null。
- 为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
- 虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
- 由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。
- 在JDK1.2版之后提供了Phantomre ference类来实现虚引用
- 虚引用的代码测试
/**
* 虚引用的测试
*/
public class PhantomReferenceTest {
public static PhantomReferenceTest obj;//当前类对象的声明
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;//引用队列
public static class CheckRefQueue extends Thread {
@Override
public void run() {
while (true) {
if (phantomQueue != null) {
PhantomReference<PhantomReferenceTest> objt = null;
try {
objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
} catch (InterruptedException e) {
e.printStackTrace();
}
if (objt != null) {
System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
}
}
}
}
}
@Override
protected void finalize() throws Throwable { //finalize()方法只能被调用一次!
super.finalize();
System.out.println("调用当前类的finalize()方法");
obj = this;
}
public static void main(String[] args) {
Thread t = new CheckRefQueue();
t.setDaemon(true);//设置为守护线程:当程序中没有非守护线程时,守护线程也就执行结束。
t.start();
phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
obj = new PhantomReferenceTest();
//构造了 PhantomReferenceTest 对象的虚引用,并指定了引用队列
PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<PhantomReferenceTest>(obj, phantomQueue);
try {
//不可获取虚引用中的对象
System.out.println(phantomRef.get());
//将强引用去除
obj = null;
//第一次进行GC,由于对象可复活,GC无法回收该对象
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
System.out.println("第 2 次 gc");
obj = null;
System.gc(); //一旦将obj对象回收,就会将此虚引用存放到引用队列中。
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
6.5 终结器引用
- 它用以实现对象的finalize()方法,也可以称为终结器引用
- 无需手动编码,其内部配合引用队列使用
- 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象
9 垃圾回收器
1. GC分类与性能指标
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK的版本处于高速迭代过程中,因此Java发展至今已经产生了众多的GC版本。从不同角度分析垃圾收集器,可以将GC分为不同的类型。
1.1 GC分类
- 按线程数(垃圾回收的线程)分可以分为串行垃圾回收器和并行垃圾回收器。
- 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中。
- 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
- 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了”stop-the- World”机制。
- 按照工作模式分,可以分为并发式垃圾回收器和独占式拉圾回收器。
- 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
- 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
- 按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
- 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片
| 再分配对象空间使用:指针碰撞
- 非压缩式的垃圾回收器不进行这步操作。
| 再分配对象空间使用:空闲列表
- 按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器
1.2 性能指标
1.2.1 评估GC的性能指标
- 性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例
| 总运行时间:程序的运行时间+内存回收的时间
- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 收集频率:相对于应用程序的执行,收集操作发生的频率。
- 内存占用:Java堆区所占的内存大小。
- 快速:一个对象从诞生到被回收所经历的时间。
- 指标概述
- 上面标红的三者共同构成一个”不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
- 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果
- 简单来说,主要抓住两点:吞吐量、暂停时间
1.2.2 吞吐量
- 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。
| 比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
- 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
- 吞吐量优先,意味着在单位时间内,STW的总时间最短
1.2.3 暂停时间
- “暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态
| 例如,GC期间16毫秒的暂停时间意味着在这16毫秒期间内没有应用程序线程是活动的。
- 暂停时间优先,意味着尽可能让单次STW的时间最短,但总时间不会最短
1.2.4 吞吐量 VS 暂停时间
- 高吞吐量较好,因为这会让应用程序的最终用户感觉只有应用程序线程在做”生产性”工作。直觉上,吞吐量越高程序运行越快
- 低暂停时间(低延迟)较好,因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的20毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。
- 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
- 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。
- 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
- 在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
- 现在标准:在最大吞吐量优先的情况下,降低停顿时间
2. 不同的垃圾回收器
垃圾收集机制是Java的招牌能力,极大地提高了开发效率。这当然也是面试的热点。那么,Java常见的垃圾收集器有哪些?2.1 垃圾收集器发展史
有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection,对应的产品我们称为Garbage Collector。
- 1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。 ParNew垃圾收集器是Serial收集器的多线程版本
- 2002年2月26日, Parallel GC和Concurrent Mark Sweep(CMS) GC跟随JDK1.4.2一起发布,Parallel GC在JDK6之后成为Hotspot默认GC。
- 2012年,在JDK1.7u4版本中,G1可用。
- 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS
- 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
- 2018年9月,JDK11发布。引入Epsilon垃圾回收器,又被称为”No-Op(无操作)回收器。同时,引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)
- 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC(Experimental)。
- 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
- 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macos和Windows上的应用
2.2 7款经典的垃圾收集器
- 线程分类
- 串行回收器: Serial、 Serial Old
- 并行回收器: ParNew、 Parallel Scavenge、Parallel Old
- 并发回收器:CMS、G1
- 分代
- 新生代收集器: Serial、 ParNew、Parallel Scavenge
- 老年代收集器: Serial Old、Parallel old、CMS
- 整堆收集器:G1
2.3 垃圾收集器的组合关系
- 两个收集器间有连线,表明它们可以搭配使用:Serial/ Seral Old、 Serial/CMS、 ParNew/ Serial Old、 ParNew/CMS、Parallel Scavenge/Serial Old, Parallel Scavenge/Parallel Old, G1:
- 其中Serial Old作为CMS出现”Concurrent Mode Failure”失败的后备预案。
- (红色虚线)由于维护和兼容性测试的成本,在JDK8时将Serial+CNS、ParNew+ Serial Old这两个组合声明为废弃(JEP173),并在JDK9中完全取消这些组合的支持,即:移除。
- (绿色虚线)JDK14中:弃用Parallel Scavenge和Serial Old GC组合
- (青色虚线)JDK14中:删除CMS垃圾回收器
2.4 不同垃圾回收器概述
- 为什么要有很多收集器,一个不够吗?
因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
- 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器
2.5 查看默认的垃圾收集器
- XX:+ PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
- 使用命令行指令: jinfo-flag相关垃圾回收器参数进程ID
3. 回收器
3.1 Serial回收器:串行回收
3.1.1 概述
- Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。
- Serial收集器作为Hotspot中Client模式下的默认新生代垃圾收集器。
- Serial收集器采用复制算法、串行回收和”stop-the-World”机制的方式执行内存回收。
- 除了年轻代之外, Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。 Serial Old收集器同样也采用了串行回收和”Stop the World”机制,只不过内存回收算法使用的是标记-压缩算法
- Seriall Old是运行在Client模式下默认的老年代的垃圾回收器
- Seriall Old在 Server模式下主要有两个用途:①与新生代的Parallel Scavenge配合使用 ②作为老年代CMS收集器的后备垃圾收集方案
- 这个收集器是一个单线程的收集器,但它的”单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)
3.1.2 优势
- 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说, Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
| 运行在C1ient模式下的虚拟机是个不错的选择。
- 在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生使用串行回收器是可以接受的。
3.1.3 如何使用
- 在 Hotspot虚拟机中,使用-X:+ UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。
| 等价于新生代用Serial GC,且老年代用Serial Old GC
3.1.4 总结
这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpuオ可以用。现在都不是单核的了对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Javaweb应用程序中是不会采用串行垃圾收集器的
3.2 ParNew回收器:并行回收
3.2.1 概述
- 如果说Serial GC是年轻代中的单线程垃圾收集器,那么 ParNew收集器则是Serial收集器的多线程版本。
| Par是Parallel的缩写,New:只能处理的是新生代
- ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。 ParNew收集器在年轻代中同样也是采用复制算法、”Stop-the- World”机制
- ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
- 对于新生代,回收次数频繁,使用并行方式高效。
- 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)
3.2.2 由于 ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器回收效率在任何场景下都会比Sernal收集器更高效?
- ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
- 但是在单个CPU的环境下, ParNew收集器不比 Seral收集器更高效。虽然 Seral收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销
- 因为除Serial外,目前只有 Parnew GC能与CMS收集器配合工作
3.2.3 参数配置
- 在程序中,开发人员可以通过选项”-XX:+UseParNewGC手动指定使用ParNewl收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
- XX:Parallel GC Threads限制线程数量,默认开启和CPU数据相同的线程数
3.3 Parallel回收器:吞吐量优先
3.3.1 概述
- Hotspot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和”stop-the-World”机制。
- 那么Parallel收集器的出现是否多此一举?
- 和ParNew收集器不同, Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
- 自适应调节策略(JVM运行过程,根据运行情况进行性能监控,动态调整内存分配情况,达到最优策略)也是Parallel Scavenge与ParNew一个重要区别。
- 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
- Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。
- Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和”stop-the-world”机制
- 在程序吞吐量优先的应用场景中, Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。
- 在Java8中,默认是此垃圾收集器。
3.3.2 参数配置:
- -XX:+ UseParallelGC手动指定年轻代使用Parallel并行收集器执行内存回收任务。
- -XX:+ UseParallelOldGC手动指定使用并行回收收集器。
- 分别适用于新生代和老年代。默认jdk8是开启的。
- 上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
- -XX:ParallelGCThreads设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
- 在默认情況下,当CPU数量小于8个,ParallelGCThreads的值等于CPU数量。
- 当CPU数量大于8个,ParallelGCThreads的值等于3+[5* CPU_CountJ/8]。
- -XX: MaxGCPauseMilllis设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。
- 为了尽可能地把停顿时间控制在 Maxgcpausemnlls以内,收集器在工作时会调整Java堆大小或者其他一些参数。
- 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
- 该参数使用需谨慎。(可能会把堆空间调小,GC频率增高)
- -XX: GCTimeRatio。垃圾收集时间占总时间的比例(=1/(N+1))用于衡量吞吐量的大小。
- 取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1%。
- 与前一个-XX: MaxGCPauseMlllis参数有一定矛盾性。暂停时间越长, Radio参数就容易超过设定的比例。
- -XX:+UseAdaptiveSizePolicy设置Parallel Scavenge收集器具有自适应调节策略
| 在堆空间Eden:S0:S1=8:1:1也提到过自适应,默认是开启的
- 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
- 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量( GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。
3.4 CMS回收器:低延
3.4.1 概述
- 在JDK1.5时期, Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS( Concurrent-Mark- Sweep)收集器,这款收集器是Hotspot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
- CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
- 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
- CMS收集器就非常符合这类应用的需求
- CMS的垃圾收集算法采用标记-清除算法,并且也会”Stop-the-world”
- 不幸的是,CMS作为老年代的收集器,却无法与JDK1.4.6中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个
- 在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC
3.4.2 工作原理
CMS整个过程比之前的收集器要复杂,整个过程分为4个性要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
- 初始标记( Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为”Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
- 并发标记(Concurrent-Mark)阶段:从 GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
- 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交又运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除(Concurrent- Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的(会存在内存碎片问题)
3.4.3 原理分析
老年代的垃圾回收器中只有CMS采用的是标记-清除算法。
- 尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行”Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要”stop-the-World”,只是尽可能地缩短暂停时间。
- 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
- 另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
- CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片,那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。
- 有人会觉得既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。 Mark Compact更适合”Stop-the-World”这种场景下使用
3.4.4 优缺点
- CMS的优点:
- 并发收集
- 低延迟
- CMS的弊端:
- 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
- CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
- CMS收集器无法处理浮动垃圾()。可能出现”Concurrent Mode Failure”失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间
3.4.5 CMS收集器可以设置的参数。
- -XX:+UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收任务
- 开启该参数后会自动将-XX:+ UseParNewGC打开。即: ParNew(Young区用)+CMS(Old区用)+ Serial Old的组合。
- -XX:CMSlnitiatingOccupanyFraction设置堆内存使用率的阀值,一旦达到该阈值,便开始进行回收。
- JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行次CMS回收。JDK6及以上版本默认值为92%(68%回收频率高)
- 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数。
- -XX:+ UseCMSCompactAtFullCollection用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
- -XX: CMSFullGCsBeforeCompaction设置在执行多少次Full GC后对内存空间进行压缩整理。
- -XX: ParallelCMSThreads设置CMS的线程数量
- CMS默认启动的线程数是(Parallel GCnhreads+3)/4,
ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
3.4.6 小结:
- Hotspot有这么多的垃圾回收器,那么如果有人问Parallel GC、 Concurrent Mark Sweep、 Serial GC这三个GC有什么不同呢?
请记住以下口令:
如果你想要最小化地使用内存和并行开销,请选Serial GC;
如果你想要最大化应用程序的吞吐量,请选Parallel GC;
如果你想要最小化GC的中断或停顿时间,请选CMS GC。
- 新特性说明
- JDK9新特性:CMS被标记为Deprecate了(JEP291)
- 如果对JDK9及以上版本的 Hotspot.虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。
- JDK14新特性:删除CMS垃圾回收器(JEP363)
- 移除了CMS垃圾收集器,如果在JDK14中使用-XX:+UseConcMarkSweepGC的话,JVM不会报错,只是给出一个Warning息,但是不会exit。JVM会自动回退以默认GC方式启动
- JDK9新特性:CMS被标记为Deprecate了(JEP291)
3.5 G1回收器:区域化分代式
3.5.1 概述
- 既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First(G1)GC?
- 原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage- First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。
- 与此同时,为了适应现在不断扩大的内存和不断増加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
- 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起”全功能收集器”的重任与期望。
- 为什么名字叫做 Garbage First(1)呢?
- 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region来表示Eden、幸存者0区,幸存者1区,老年代等。
- G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
- 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先( Garbage First)。
- G1(Garbage- First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
- 在JDK1.7版本正式启用,移除了Experimental的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old组合。被Oracle官方称为”全功能的垃圾收集器”。
- 与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾回收器,需要使用-XX:UseG1GC来启用。
3.5.2 优缺点
3.5.2.1 G1回收器的特点(优势)
与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:
- 并行与并发
- 并行性:G1在回收期间,可以有多个GCc线程同时工作,有效利用多核计算能力。此时用户线程STW
- 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
- 分代收集
- 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的(因为Region),也不再坚持固定大小和固定数量。
- 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
- 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;
- 空间整合
- CMS:”标记-清除”算法、内存碎片、若干次GC后进行一次碎片整理
- G1将内存划分为一个个的Region。内存的回收是以Region作为基本单位的。Region内部是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,从一个Region复制到另外一个Region,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
- 可预测的停顿时间模型(即:软实时soft real-time)
- 这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
- 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
- G1跟踪各个 Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
- 相比于CMS GC,G1未必能做到CMS在最好情況下的延时停顿,但是最差情况要好很多。
- 这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
3.5.2.2 缺点
- 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
- 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
3.5.3 G1回收器的参数设置
- -XX:+UseG1GC手动指定使用G1收集器执行内存回收任务(JDK9以后默认,就不用专门设置)。
- -XX:G1HeapRegionSize设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
- -XX: MaxGCPauseMillis设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
- -XX:ParallelGCThread设置STW工作线程数的值。最多设置为8
- -XX: ConcGCThreads设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
- -XX: InitiatingHeapOccupancyPercent设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。
3.5.4 G1回收器的常见操作步骤
- G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成
- 第一步:开启G1垃圾收集器
- 第二步:设置堆的最大内存
- 第三步:设置最大的停顿时间
- G1中提供了三种垃圾回收模式: Young GC、 Mixed GC和Full GC,在不同的条件下被触发
3.5.5 G1回收器的适用场景
- 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
- 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
- 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的 Region的増量式清理来保证每次GC停顿时间不会过长)。
- 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
- 超过50%的Java堆被活动数据占用;
- 对象分配频率或年代提升频率变化很大;
- GC停顿时间过长(长于0.5至1秒)。
- Hotspot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
3.5.6 Region:化整为零
- 使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
- 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续
- 一个Region有可能属于Eden, Survivor或者Old/ Tenured内存区域。但是Region只可能属于一个角色图中的E表示该Region属于Eden内存区域,S表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示末使用的内存空间。
- G1垃圾收集器还増加了一种新的内存区域,叫做Humongous(大对象)内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个Region,就放到H。
- 设置H的原因
- 对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
- Region
3.5.7 回收过程
G1GC的垃圾回收过程主要包括如下三个环节
- 年轻代GC(Young GC)
- 老年代并发标记过程(Concurrent Marking)
- 混合回收( Mixed GC)
- 如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。——后备方案
| 顺时针, young gc -> young gc + concurrent mark -> Mixed GC顺序,进行垃圾回收
3.5.7.1 回收过程说明
- 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年经代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到 Survivor区间或者老年区间,也有可能是两个区间都会涉及。
- 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
- 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
- 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
3.5.8 回收细
3.5.8.1 相关概念
- Remembered Set
- 一个对象被不同区域引用的问题个Region不可能是孤立的,一个 Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?在我们前面讨论哪些对象是GC Roots的时候。存在一种对象在新生代,但是老年代中会不会有对象引用这个新生代对象的时候,这个新生代对象是不是也可以作为GC Roots呢?是不是还要扫描老年代中对象来判断时候存在这个引用呢。
- 在其他的分代收集器,也存在这样的问题(而G1更突出)
- 回收新生代也不得不同时扫描老年代?
- 这样的话会降低 Minor GC的效率
- 解决办法
- 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
- 每个Region都有一个对应的Remembered Set;
- 每次 Reference-类型数据写操作时,都会产生一个Write Barrier(写屏障)暂时中断操作;
- 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)
- 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中
- 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set:就可以保证不进行全局扫描,也不会有遗漏。
3.5.8.2 G1回收过程一:年轻代GC
- 概述
- JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
- 年轻代垃圾回收只会回收Eden区和Survivor区。
- YGC时,首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
- 然后开始如下回收过程:
- 第一阶段,扫描根
- 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
- 第二阶段,更新RSet。
- 处理 dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
- 第一阶段,扫描根
- 第三阶段,处理RSet。
- 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
- 第四阶段,复制对象。
- 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到 Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阅值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果 Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
- 第五阶段,处理引用。
- 处理Soft,Weak, Phantom,Final, ONI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
G1回收器在垃圾清理后都会处理一下引用问题。
- 对于应用零序的引用值语句object field= object,JVM会在之前和之后执行特殊的操作在dirty card queue中入队一个保存了对引用信息的card,在年经代回收的时候,G1会对Dirty Eard Queues中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系
- 那为什么不在引用赋值语句处直接更新RSet呢?这是为了性的需要,RSet的理需要程同步,开销会很大,使用队列性会好很多
3.5.8.3 G1回收过程二:并发标记过程
- 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
- 根区域扫描( Root Region Scanning):G1 GC扫描 Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在 young GC.之前完成。
- 并发标记( Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
- 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法: snapshot-at-the- beginning(SATB)。
- 独占清理(cleanup,STW):计算各个区域的存活对象和GCc回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集
- 并发清理阶段:识别并清理完全空闲的区域。
这个阶段会触发一次年轻代GC,并且会回收一些内存满的老年代Region,另外一些没有满的老年代在混合回收中分批次回收
3.5.8.4 G1回收过程三:混合回收
- 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
- 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段, Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程青参考上面的年轻代回收过程
- 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
- 混合回收并不一定要进行8次。有一个阈值-XX:G1 Heapwastepercent,默认值为16%,意思是允许整个堆内存中有16%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于16%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少
3.5.8.5 可选过程 Full GC
- G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The- World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
- 要避免FullGC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过増大内存解决。
- 导致G1Full GC的原因可能有两个
- Evacuation的时候没有足够的to- space来存放晋升的对象:
- 并发处理过程完成之前空间耗尽
3.5.8.6 G1回收过程:补充
从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。
3.5.9 G1回收器优化建议
- 年轻代大小
- 避免使用-Xmn或-XX: Newratio等相关选项显式设置年轻代大小
- 固定年轻代的大小会覆盖暂停时间目标
- 暂停时间目标不要太过严苛
- G1 GC的吞吐量目标是9%的应用程序时间和10%的垃圾回收时间
- 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。
3.6 垃圾回收器总结
- 7种经典垃圾回收器总结
截止JDK1.8,一共有7款不同的垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。
- 怎么选择垃圾回收器
| Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让WM的性能有一个很大的提升。
- 优先调整堆的大小让WM自适应完成。
- 如果内存小于100,使用申行收集器
- 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
- 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
- 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
| 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1
- 最后需要明确一个观点:
- 没有最好的收集器,更没有万能的收集
- 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
- 对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。这里较通用、基础性的部分如下
- 垃圾收集的算法有哪些?
- 如何判断一个对象是否可以回收?
- 垃圾收集器工作的基本流程。
- 另外,大家需要多关注垃圾回收器这一章的各种常用的参数。
4. GC日志分析
4.1 日志分析
- 通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。
- 内存分配与垃圾回收的参数列表-XX:+PrintGC:输出GC日志。类似-verbose: gc
- -XX: +PrintGCDetails输出GC的详细日志
中括号中的数据是年轻代回收前占用大小和回收后占用大小,小括号是年轻代大小
中括号外是堆(年轻代和老年代)回收前占用大小和回收后占用带线啊哦,小括号内是堆总大小
- -XX:+ PrintGCTimeStamps输出GC的时间戳(以基准时间的形式)
- -XX:+ PrintGCDateStamps输出GC的时间戳(以日期的形式,如2013-0504m21:53:59.234+0800)
- -XX: +PrintHeapAtGC:在进行GC的前后打印出堆的信息
- -Xloggc:./logs/ge.log日志文件的输出路径
4.2 日志补充说明
- “[GC”和”[FullGC”说明了这次垃圾收集的停顿类型,如果有”Full”则说明GC发生了”stop-the-World”;
- 使用Serial收集器在新生代的名字是 Default New Generation,因此显示的是”[De fnew;
- 使用 Parnew收集器在新生代的名字会变成”[ParNew”,意思是”Parallel New Generation
- 使用Parallel Scavenge收集器在新生代的名字是”[Psyounggen;
- 老年代的收集和新生代道理一样,名字也是收集器决定的;
- 使用G1收集器的话,会显示为”garbage- first heap”
- Allocation Failure
- 表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
- Psyounggen:5986K->696K(8704K)J5986K->704K(9216K)
- 中括号内:GC回收前年轻代占用大小,回收后大小,(年轻代总大小)
- 括号外:GC回收前年轻代和老年代占用大小,回收后大小,(年轻代和老年代总大小)
- user代表用户态回收耗时,sys内核态回收耗时,real实际耗时。由于多核的原因,时间总和可能会超过real时间
4.3 堆空间日志数据解读
![image.png](https://cdn.nlark.com/yuque/0/2020/png/1552354/1592669194136-91141a63-cd4c-495a-9bc0-2404f74a449d.png#align=left&display=inline&height=297&margin=%5Bobject%20Object%5D&name=image.png&originHeight=794&originWidth=1567&size=1310591&status=done&style=none&width=586#align=left&display=inline&height=794&margin=%5Bobject%20Object%5D&originHeight=794&originWidth=1567&status=done&style=none&width=1567)<br /> ![image.png](https://cdn.nlark.com/yuque/0/2020/png/1552354/1592669404276-8485689d-2adb-43fa-adea-38fdfbaae33d.png#align=left&display=inline&height=177&margin=%5Bobject%20Object%5D&name=image.png&originHeight=672&originWidth=2210&size=967480&status=done&style=none&width=582#align=left&display=inline&height=672&margin=%5Bobject%20Object%5D&originHeight=672&originWidth=2210&status=done&style=none&width=2210)<br /> ![image.png](https://cdn.nlark.com/yuque/0/2020/png/1552354/1592669441413-7dd40750-32e4-4a79-8af6-a030836961a2.png#align=left&display=inline&height=143&margin=%5Bobject%20Object%5D&name=image.png&originHeight=625&originWidth=2493&size=852797&status=done&style=none&width=572#align=left&display=inline&height=625&margin=%5Bobject%20Object%5D&originHeight=625&originWidth=2493&status=done&style=none&width=2493)<br /> ![image.png](https://cdn.nlark.com/yuque/0/2020/png/1552354/1592669683885-69fffd43-e4fe-475f-aa75-b3c2ed2cd86b.png#align=left&display=inline&height=235&margin=%5Bobject%20Object%5D&name=image.png&originHeight=644&originWidth=1525&size=402910&status=done&style=none&width=556#align=left&display=inline&height=644&margin=%5Bobject%20Object%5D&originHeight=644&originWidth=1525&status=done&style=none&width=1525)<br /> ![image.png](https://cdn.nlark.com/yuque/0/2020/png/1552354/1592669704905-c9803e66-9501-4e03-98ae-b8482cfec1ec.png#align=left&display=inline&height=315&margin=%5Bobject%20Object%5D&name=image.png&originHeight=646&originWidth=1193&size=292187&status=done&style=none&width=581#align=left&display=inline&height=646&margin=%5Bobject%20Object%5D&originHeight=646&originWidth=1193&status=done&style=none&width=1193)<br /> ![image.png](https://cdn.nlark.com/yuque/0/2020/png/1552354/1592670500750-de4e34bc-bbf4-43ee-9ef7-3048ce9e64f7.png#align=left&display=inline&height=645&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1290&originWidth=1201&size=1606070&status=done&style=none&width=600.5#align=left&display=inline&height=1290&margin=%5Bobject%20Object%5D&originHeight=1290&originWidth=1201&status=done&style=none&width=1201)
4.4 日志分析工具
- 如果想把GC日志存到文件的话,是下面这个参数:
- 可以用一些工具去分析这些gc日志:
| 常用的工具有:GCViewer、 GCEasy、 GCHisto、 GCLogViewer、Hpjmeter、 garbagecat等。
5. 垃圾回收器的新发展
5.1 概述
- GC仍然处于飞速发展之中,目前的默认选项G1 GC在不断的进行改进,很多我们原来认为的缺点,例如串行的Full GC、 Card Table扫描的低效等,都已经被大幅改进,例如,JDK10以后,Full GC已经是并行运行,在很多场景下,其表现还略优于Parallel GC的并行Full GC实现。
- 即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在Serverless等新的应用场景下, Serial GC找到了新的舞台。
- 比较不幸的是CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除
5.2 Shenandoah GC
- 我们还看到了引入了两个新的收集器:ZGC(JDK11出现)和Shenandoah(Open JDK12)。主打特点:低停顿时间
- Open JDK12的Shenandoah GC:低停顿时间的GC(实验性)
- Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由Oracle公司团队领导开发的Hotspot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK:没有区别的 Oracle公司仍拒绝在OracleJDK12中支持 Shenandoah
- Shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研究项目Pauseless GC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDK
- Red Hat研发Shenandoah团队对外宣称, Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为260MB还是2606B,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。
- 这是Redhat七在2016年发表的论文数据,测试内容是使用E8对200GB的维基百科数据进行索引。从结果看:
- 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
- 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。
- 总结:
- Shenandoah GC的弱项:高运行负担下的吞吐量下降
- Shenandoah GC的强项:低延迟时间。
- Shenandoah GC的工作过程大致分为九个阶段,这里就不再赘述。在之前Java12新特性视频里有过介绍。
5.3 ZGC
- ZGC与Shenandoah目标高度相似,在尽可能对存吐量影响不大的前提下,实现在任意堆内存大小下部可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
- 《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。
- ZGC的工作过程可以分为4个阶段:并发标记-并发预备重分配-并发重分配并发重映射等。
- ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的
- 在ZGC的强项停顿时间测试上,它亳不留情的将Parallel、G1拉开了两个数量级的差距。无论平均停顿、95%停顿、99%停顿、99.9%停顿,还是最大停顿时间,ZGC都能毫不费劲控制在10毫秒以内。
- 测试数据
- JDK14的新特性
- ZGC应用在macos上
- ZGC应用在Windows上
- JDK14之前,ZGC仅Linux才支持。
- 尽管许多使用ZGC的用户都使用类エinux的环境,但在 Windows和 macos上,人们也需要ZGC进行开发部署和测试。许多桌面应用也可以从ZGC中受益。因此,ZGC特性被移植到了Windows和macos上。
- 现在mac或Windows上也能使用ZGC了,示例如下:
5.4 Ali GC
- AliGC是阿里巴巴JVM团队基于G1算法开发,面向大堆应用场景,指定场景下的对比:
**
15岁觉得游泳难,放弃游泳,到18岁遇到一个你喜欢的人约你去游泳,你只好说“我不会耶”。18岁觉得英文难,放弃英文,28岁出现一个很棒但要会英文的工作,你只好说“我不会耶”。人生前期越嫌麻烦,越懒得学,后来就越可能错过让你动心的人和事,错过新风景。
感谢尚硅谷,感谢宋红康老师,感谢赶路的自己