目录
垃圾回收
堆空间的基本结构
内存分配和回收原则
分代收集机制
Minor GC 流程
空间分配担保
老年代
大对象直接进入老年代
长期存活的对象将进入老年代
GC的区域
对象存活判定算法
引用计数法
可达性分析算法
finalize()
字符串常量判活
类判活
垃圾回收算法
标记清除算法
标记复制算法
标记整理算法
垃圾收集器
Serial(串行)收集器
ParNew收集器
Parallel Scavenge收集器
Serial Old 收集器
Parallel Old 收集器
CMS 收集器
G1 收集器
其他引用类型
强引用
软引用
弱引用
虚引用
参考文章
垃圾回收
当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
堆空间的基本结构
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
-
新生代内存(Young Generation):新生代主要存储新创建的对象。当对象被创建时,它们首先会被分配到新生代。
-
老生代(Old Generation):老年代主要存储长时间存活的对象,老年代的垃圾回收频率相对较低,因此其大小通常比新生代大得多。
-
永久代(Permanent Generation):然方法区和永久代都用于存储类的元数据信息,但它们之间有一些区别。方法区主要存储加载到JVM中的类的信息,而永久代则存储类的元数据信息,这些信息是类加载时所需要的。永久代的存在使得Java具有了动态加载类和实现类的反射机制等功能。
他们三个在堆中的占比为8:1:1,JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。
内存分配和回收原则
分代收集机制
不同的分代内存回收机制也存在一些不同之处,在HotSpot虚拟机中,新生代被划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1,老年代的GC频率相对较低,永久代一般存放类信息等(其实就是方法区的实现)如图所示:
那么它是如何运作的呢?
首先,所有新创建的对象,在一开始都会进入到新生代的Eden区(如果是大对象会被直接丢进老年代),在进行新生代区域的垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用对象:
接着,在一次垃圾回收之后,Eden区域没有被回收的对象,会进入到Survivor区。在一开始From和To都是空的,而GC之后,所有Eden区域存活的对象都会直接被放入到From区,最后From和To会发生一次交换,也就是说目前存放我们对象的From区,变为To区,而To区变为From区:
接着就是下一次垃圾回收了,操作与上面是一样的,不过这时由于我们To区域中已经存在对象了,所以,在Eden区的存活对象复制到From区之后,所有To区域中的对象会进行年龄判定(每经历一轮GC年龄+1
,如果对象的年龄大于默认值为15
,那么会直接进入到老年代,否则移动到From区)
最后像上面一样交换To区和From区,之后不断重复以上步骤。
垃圾回收的分类可以分为Minor GC,Major GC, Full GC
-
Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。
-
触发条件:新生代的Eden区容量已满时。
-
-
Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。
-
Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。
-
触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间
-
触发条件2:Minor GC后存活的对象超过了老年代剩余空间
-
触发条件3:永久代内存不足(JDK8之前)
-
触发条件4:手动调用
System.gc()
方法
-
Minor GC 流程
空间分配担保
Survivor区无法容纳的对象直接送到老年代,让老年代进行分配担保(当然老年代也得装得下才行)在现实生活中,贷款会指定担保人,就是当借款人还不起钱的时候由担保人来还钱。
老年代
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。
-
G1 垃圾回收器会根据
-XX:G1HeapRegionSize
参数设置的堆区域大小和-XX:G1MixedGCLiveThresholdPercent
参数设置的阈值,来决定哪些对象会直接进入老年代。 -
Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(
XX:ThresholdTolerance
是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。
长期存活的对象将进入老年代
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
GC的区域
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
-
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
-
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
-
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
对象存活判定算法
我们要进行垃圾回收算法,首先我们需要识别哪些对象是不需要了,可以进行垃圾回收。下面介绍一下几种垃圾回收算法。
引用计数法
给对象中添加一个引用计数器:
-
每当有一个地方引用它,计数器就加 1;
-
当引用失效,计数器就减 1;
-
任何时候计数器为 0 的对象就是不可能再被使用的。
优点:方法实现简单,效率高
缺点:无法解决循环依赖,两个循环依赖则一直无法释放内存
可达性分析算法
目前比较主流的编程语言(包括Java),一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。
首先每个对象的引用都有机会成为树的根节点(GC Roots),可以被选定作为根节点条件如下:
-
位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量)同样也包括本地方法栈中JNI引用的对象。
-
类的静态成员变量引用的对象。
-
方法区中,常量池里面引用的对象,比如我们之前提到的
String
类型对象。 -
被添加了锁的对象(比如synchronized关键字)
-
虚拟机内部需要用到的对象。
finalize()
虽然在可达性算法中判定为不可达对象,但是要想真正的释放对象还需要一个阶段——finalize()
方法
当对象没有覆盖 finalize
方法,或 finalize
方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行,直接进行销毁。
流程图:
字符串常量判活
前面通过算法可以判定对象是否活跃,那么怎么判断堆中的字符串常量池的对象是否存活呢?
如何判断一个常量是废弃常量?
假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池了。
类判活
堆中的对象和字符串常量池的垃圾可以回收,现在看方法区的类怎么判断是否应该垃圾回收。
如何判断一个类是无用的类?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”:
-
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
-
加载该类的
ClassLoader
已经被回收。 -
该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
垃圾回收算法
垃圾回收算法中对堆中的每一个对象都依次判断是否需要回收,这样的效率其实是很低的,我们可以对堆中的对象进行分代管理——使用我们上面说的分代收集机制。
说了垃圾回收的分代进行收集,接下来介绍具体的收集过程
标记清除算法
最古老的垃圾回收算法,分为两步 :标记需要回收的对象 ——> 进行回收
虽然简单,但是缺点也是很明显的:
-
堆中如果对象很多,那么标记的时间将会很长,也会存在大量的标记
-
堆中需要回收的对象都不是连续的,可能都是四处分散的,释放以后空间不连续,会存在许多的空隙,导致空间使用率降低
标记复制算法
标记复制算法,实际上就是将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。
优点:解决了上一种垃圾回收算法的回收后空间间隙的问题
缺点:空间的浪费,每次都有一半的空间未利用,可能频繁的进行垃圾回收
这种算法就非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收,而我们之前所说的新生代Survivor区其实就是这个思路,包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的。
标记整理算法
刚刚的标记复制法很适合新生代,但是老年代的空间的垃圾回收的频率都是很低的,所以就会造成长期的一半空间浪费,所以这是不适合老年代的
那么我们在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。
优点:空间的充分利用,也没有标记复制算法复杂
缺点:修改对象的位置的时候,需要程序进行暂停
垃圾收集器
聊完了对象存活判定和垃圾回收算法,接着我们就要看看具体有哪些垃圾回收器的实现了。我们可以自由地为新生代和老年代选择更适合它们的收集器。
Serial(串行)收集器
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
新生代:标记复制
老年代:标记整理
优点:简单高效
缺点:造成停顿
ParNew收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
新生代:标记-复制算法
老年代:标记整理算法
Parallel Scavenge收集器
Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,老年代使用标记整理算法
JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old
Serial Old 收集器
Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
-
初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
-
并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
-
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
-
并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
优点:并发收集,停顿时间短
缺点:CPU资源敏感,无法处理浮动垃圾,垃圾回收算法——标记清除
会有很多的空间间隙
浮动垃圾:并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。这些垃圾有个专业名词:浮动垃圾。
G1 收集器
此垃圾收集器也是一款划时代的垃圾收集器,在JDK7的时候正式走上历史舞台,并且在JDK9时,取代了JDK8默认的 Parallel Scavenge + Parallel Old 的回收方案。
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
在Java中,高吞吐量指的是系统能够快速处理大量请求的能力,也就是处理更多的请求数量。高吞吐量通常是一个高效的应用程序的标志。
G1垃圾收集器将堆内存分成2048个大小相同的独立region
块,每个region
块的大小根据堆的实际空间定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的Region
大小相同,且在JVM的整个生命周期内不会发生改变。
那么分出这些Region
有什么意义呢?每一个Region
都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。此外,G1收集器还存在一个Humongous区域,它专门用于存放大对象(一般认为大小超过了Region容量一半的对象为大对象)这样,新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的。
它的回收过程:
-
初始标记(暂停用户线程):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
-
并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
-
最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
-
筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
其他引用类型
在Java中,如果一个变量的类型是一个对象类型,那么它里面存放的就是对象的引用,如果一个变量的类是是基础类型(int, double等),那么它里面存放的值就是基本类型的值。
强引用
Java中通过new
出来的变量都是强引用变量,都是存放在堆中,也是垃圾回收的重点对象。但是如果不满足我们前面的对象存货判定算法满足的对象也是无法进行回收的。但是其他类型的引用都是有可能不判定也是会被回收。
软引用
如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
弱引用
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
虚引用
虚引用相当于没有引用,随时都有可能会被回收。(作用就是当作一个通知,当引用被回收时候的通知)
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
参考文章
青空霞光——jvm垃圾回收
JavaGuide-Jvm垃圾回收