本文的主要目的是讲解jvm的内存区域划分以及各个垃圾回收器的回收流程。
JVM 内存区域划分
方法区与堆是所有java线程共享的内存区域,只要java应用进程不关闭就一直存在。
虚拟机栈、本地方法栈、程序计数器是每个java线程独有的内存区域,顺着线程创建而创建,随着线程销毁而销毁。
程序计数器
- 可以看做是记录当前线程所执行的字节码(class)的行号指示器,Java虚拟机的多线程是通过轮流切换并分配处理器时间来执行的,因此每个处理器(或者多核处理器的一个内核)在每一刻只会执行一条线程中的指令,因此为了线程切换后能回到正确的执行位置,需要记录当前线程执行的字节码指令的地址。
- 如果执行的是Native方法,则这个计数器为空(Undefined)。
- 该内存区域是唯一一个在Java虚拟机规范中没有规定任何内存溢出错误的区域。
虚拟机栈
- 每个方法执行的同时会创建一个栈帧用于存储局部变量表(储存了编译器可知的各种基本数据类型,如boolean、byte,对象引用)、操作数栈、动态链接、方法出口等信息,每个方法从开始执行到结束的过程,就对应一个栈帧在虚拟机栈入栈到出栈的过程。
- 由于局部变量表需要的空间在编译期间就已经确定好了,因此这个方法需要需要在栈帧中需要分配多大的局部变量空间是完全确定的,在运行期间不会改变。
Java虚拟机规范对这个区域规定了两种异常状况
栈溢出错误(StackOverflowError)异常:线程请求的栈深度大于虚拟机所允许的深度 内存溢出错误(OutOfMemoryError)异常:虚拟机栈扩展时申请不到足够的内存
本地方法栈
- 本地方法栈于虚拟机栈类似,区别是虚拟机栈记录的是java方法,本地方法栈记录的是调用的本地方法,如通过JNI(Java Native Interface)调用本地应用或库。
- 该区域和虚拟机栈一样也定义了两种异常
堆
- 堆是jvm管理的最大一块内存区域,主要用于保存对象的实例,但不是所有的对象实例都会保存在堆(如JIT编译,逃逸分析后发现对象不会逃逸到方法体外,就会进行优化,优化后的对象通过标量替换后被拆解成一个一个成员变量,分配在虚拟机栈上,这里就不需要在栈上分配了)
- 一般垃圾回收器会通过分代思想,将堆分为年轻代和老年代(老年代主要用于保存存活时间比较长的对象),避免GC时扫描整个堆,并根据不同类型的堆区域特点,使用不同的垃圾回收器。
- 从jdk7开始,堆除了保存对象实例,还将之前保存在方法区的字符串常量池以及类静态变量移到了堆(因为方法区收集频率比较低,只会在full GC的时候回收,在大量创建字符串常量的场景下,容易导致方法区空间不足。
- 设置堆大小
-Xmx xxm 设置堆最大值
-Xms xxm 设置堆最小值
-Xmn xxm 设置年轻代大小
方法区
- 方法区在jdk7之前主要保存一些已被虚拟机加载的类信息、字符串常量池、静态变量、即时编译器(JIT编译器)编译后的代码等数据。
- 在jdk7后字符串常量池和静态变量被移到了堆,在jdk8后整个方法区剩下的内容全都移动到元空间(Meta space)中,元空间因为使用的是本地内存,因此不用担心像以前的方法区那样过于担心方法区内存大小限制,本地内存默认只受操作系统的进程内存大小限制。
- 在java8之前,有人会把方法区叫做永久代,实际上永久代只存在于hotSpot,hotSpot将垃圾回收器的分代设计延伸到方法区,这样垃圾回收器就能够像管理堆那样管理方法区。
垃圾回收
如何判断对象已死
引用计数法
给对象添加一个引用计数器,每当有一个地方引用他时,计数器就加1,当引用失效时,计数器就减1;如何时刻计数器为0的对象就是不可能再被使用的,可以被回收。
优点:实现简单
缺点:解决不了对象之间相互循环引用的问题,python和swift虽然也使用了引用计数法,但python额外采用了三色技术去解决循环依赖的问题,swift则要求开发者自己在代码层面手动处理循环依赖的问题。
可达性分析算法
从一些被称为GC ROOT的对象出发,从这些节点开始向下搜索,搜索所走过的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连的话(就是说不可达),就说明该对象是可回收的。 在Java中,可以作为GC Roots的对象包括以下几种
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中引用类型静态变量
- 运行时常量池里面的引用类型常量(String或Class类型)
- JVM内部数据结构的一些引用,如sun.jvm.hotspot.memory.Universe类
- 本地方法栈中JIT(即一般说的Native方法)引用的对象
- 用于同步的监控对象
回收算法
标记-清除
算法分为标记和清除两个阶段:首先标记所有需要回收的对象,然后统一回收。
缺点:会留下大量内存碎片
标记-复制
- 将内存分为多个区域,经过标记后存活的对象全部移动到另一个区域,然后将原来的区域包含的对象清除。
- 现在许多虚拟机都采用这种收集算法来回收年轻代,默认将年轻代内存分为一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1。每次只使用Eden和一块Survivor,当回收时,将存活的对象转移到另一块Survicor空间,然后清除Eden和使用过的Survicor空间,因此年轻代内存只有10%会被“浪费”。
缺点:需要将原来的内存空间划出一部分用于复制迁移
标记-整理
将标记后存活的对象往一端一起移动,然后清除剩下的对象。
缺点:耗费的时间比较久,因此在追求低延迟的场景下一般不使用该算法。
垃圾收集器
- 这9个垃圾处理器,除了G1、Shenandoah、ZGC支持全堆回收,其他回收器都只支持年轻代或老年代。
- 为了减少维护的工作量,从JDK9开始,Serial+Serial Old、ParNew + Serial Old这些组合关系就被JDK移除了。
- CMS之所以会额外使用到Serial Old,是因为CMS使用的是标记-清除算法,会留下许多内存碎片,因此会通过Serial Old整理内存整理。
Serial
JDK1.3之前就出现了,年轻代的收集器,单线程,采用复制算法,回收时会暂停所有用户线程。
ParNew
年轻代收集器,采用复制算法,回收时也是会暂停所有用户线程,与Serial不同的是,回收时会开启多个线程并行回收,默认线程数与CPU数相等。
Parallel Scavenge
- Parraller Scavenge是一个年轻代收集器,采用复制算法,拥有和ParNew一样并行、多线程的特点。
- 与下面说的CMS等收集器关注点不同,其他收集器是尽可能地缩短垃圾收集时用户线程的停顿时间,而Paraller Scavenge收集器的目的是达到一个可控制的吞吐量。停顿时间越短就越适合需要与用户交互的程序,而高吞吐量则可以高效地利用cpu时间,尽快完成程序的运算任务(如大数据场景)。 吞吐量 = 运行用户代码时间/ (运行用户代码时间 + 垃圾收集时间)
Serial Old
Serial的老年代版本,也是采用单线程回收,与年轻代版本的区别是采用的是标记-整理算法。
Parallel Old
Parallel的老年代版本,与年轻代的区别是采用的是标记-整理算法。
CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的老年代收集器,采用标记-清除算法,适合B/S架构的服务端,这类的应用尤其讲究服务的响应速度。
回收过程分为4个步骤,其中初始标记和重新标记这两个步骤仍然需要Stop The World
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记:从第一步堆中标记到的对象开始进行追踪
- 重新标记:修正并发标记期间因用户程序继续执行导致标记产生变动的那一部分对象的标记记录,这个停顿时间会比初始标记长一点,但远比并发标记时间短(新产生的回收对象只能留到下一次GC进行清理)。这次标记也会扫描整个年轻代,因为CMS为了节省记忆集内存,只记录了老年代到年轻代的引用。
- 并发清除:因为清除时不需要移动存活的对象,因此支持与用户线程并发执行。
整个过程耗时最长的并发标记和并发清除过程收集器都是可以与用户线程一起运行的,所以CMS可以看做是并发收集器。 优点:并发收集,低停顿 缺点::因为是通过标记-清除,会留下内存碎片,因此CMS在发现内存占用达到一定比例,或者经过一定次数的GC后,会进行一次内存整理。
为什么其他老年代回收器使用的都是标记-整理算法,CMS反而使用的是标记-清除算法?
标记-整理算法因为需要移动对象,所以会导致用户线程停顿时间比较长,而CMS的设计理念是降低用户线程停顿时间,所以采用了标记-清除算法,虽然会留下内存碎片,但CMS发现内存占用比例过大、没有足够的连续内存分配对象、经过一定次数的Full GC后,会进行内存整理的操作,避免碎片过多。
G1
G1(Garbage First)是JDK9新出的收集器,替代了JDK8的Parallel Old和Parallel Scavenge,成为JDK的默认处理器,提出了基与Region的内存布局形式,Region的类型有Eden、Survivor、Old、Hunongous(存储大对象),并且由于根据Region回收,G1支持用户指定停顿时间,G1会在该期望时间内回收其中一部分Region的内存。 从局部上看,Region与Region之间采用的是复制算法,从全局上看,G1采用的是标记-整理算法(当内存碎片过多不足分配时,会进行full GC操作,进行内存整理)
G1提供了两种GC模式,Young GC和Mixed GC。 * Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。 * Mixed GC:进行young GC,外加根据全局并发标记统计得出收集收益高的若干老年代Regio
因为年轻代会逐渐晋升到老年代,当老年代占用的内存大小达到堆的一定比例(默认45%),G1会开始全局并发标记,让mixGc回收内存。 全局并发标记阶段:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改每个Region的TAMS(Top At Mark Start)指针(因为G1是支持并发的,每个Region会有两个指针用于标记哪些内存区域可以使用)的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:对初始标记标记到的堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB(初始快照)记录下的在并发时有引用变动的对象。
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB(Snapshot At the begining)记录。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
上述阶段中,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的
优点:能够指定最大停顿时间
缺点:记忆集占用了比较多的内存空间,因为每个Region都要记录自己引用了哪些Region、被哪些Region应用,这些内存大约销毁堆内存的10%-20%。
Shenandoah
可以说是G1的升级版,在回收时开启了多个线程回收,其它内容因为相关资料和引用比较少在这里就不详讲了。
ZGC
ZGC是JDK11提出的新垃圾回收器,但是知道JDK17,还没有成为JDK的默认垃圾回收器,ZGC的目标是将用户线程控制在10ms内,停顿时间不会随着堆的大小增加而增加,只会与GC ROOT数量有关。
ZGC的流程主要分为以下4个:
- 并发标记:对初始标记标记到的堆中对象进行可达性分析,与其他收集器不同的是,ZGC的标记是标记在对象指针上的(通过着色指针技术,可参考tech.meituan.com/2020/08/06/…
- 并发预备重分配:扫描决定哪些对象需要清理,并将需要清理的region组成重分配集。
- 并发重分配:将重分配集中存活的对象复制到新的region,并为重分配集中每一个region生成转发表,如果此时有线程访问重分配集中的对象,则将其转发到新的region。
- 并发重映射:更新重分配集中转发表的引用地址,并删除转发表,该阶段会合并到并发标记阶段(通过并发标记扫描全部对象时顺便更新转发表中的对象引用)。
优点:因为消耗时间最长的对象迁移操作支持用户线程并发,所以停顿时间很短 缺点:
- 仅支持64位系统,最大只支持4TB内存
- ZGC是单代垃圾回收器,单代垃圾回收器每次处理的对象更多,更耗费CPU资源
- 无法使用指针压缩
常用命令及配置
使用使用的GC版本
java -XX:+PrintCommandLineFlags -version
jps
查看正在运行的java进程,前面的数字jvm 虚拟进程id(vmid)
jstat
查询jvm的内存统计信息
查询内存使用率:jstat -gcutil [vmid]
- s0: survivor 0占用率
- s1: survivor 1占用率
- e: Eden使用率
- O: 老年代使用率
- M: 元空间使用率
- CCS: 类压缩空间使用率
- YGC: 年轻代gc次数
- YGCT: 年轻代平均耗时
- FGC: 老年代GC次数
9: GCT: GC总的平均耗时
查询内存使用情况:
与jstat -gcutil不同的是,走路显示的是gc大小及使用的量
jinfo
查看java进程的jvm配置信息:jinfo -flags [vmid]
jstat
输出应用线程堆栈: jstack -l [vimd] > stack.text
jmap
显示jvm堆中信息:jmap [option] [vmid] 存储当前堆的快照:jmap -dump:format=b,file=heapdump.phrof [vmid]
设置堆最大、最小大小
设置堆最小大小: -Xms1024M
设置堆最大大小: -Xmx1024M
OOM时自动dump堆内存
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=[path]
参考文章
博客
JIT编译:www.cnblogs.com/hollischuan…
GC优化:tech.meituan.com/2017/12/29/…
G1的一些关键技术:tech.meituan.com/2016/09/23/…
G1回收过程介绍: www.javadoop.com/post/g1
developer.51cto.com/article/677…
书籍
深入理解Java虚拟机(第三版)