Java垃圾回收
垃圾回收区域及划分
在介绍Java垃圾回收之前,我们需要了解Java的垃圾主要存在于哪个区域。JVM内存运行时区域划分如下图所示:
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
? Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入退出而进栈出栈,在类结构确定下来时就已知每个栈帧中的分配内存。而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,而在java8中,方法区存放于元空间中,元空间与堆共享物理内存,因此,Java堆和方法区是垃圾收集器管理的主要区域。
? 从垃圾回收的角度,由于JVM垃圾收集器基本都采用分代垃圾收集理论,所以 Java 堆还可以细分为如下几个区域(以HotSpot虚拟机默认情况为例):
? 其中,Eden 区、From Survivor0(“From”) 区、To Survivor1(“To”) 区都属于新生代,Old Memory 区属于老年代。
? 大部分情况,对象都会首先在 Eden 区域分配;在一次新生代垃圾回收后,如果对象还存活,则会进入 To 区,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(超过了 survivor 区的一半时,取这个值和 MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值),就会晋升到老年代中。经过这次 GC 后,Eden 区和From区已经被清空。这个时候,From和To会交换他们的角色,保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程。在这个过程中,有可能当次Minor GC后,Survivor 的”From”区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。
? 针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
Java堆内存常见分配策略
判断对象死亡
? 堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。判断一个对象是否存活有引用计数、可达性分析这两种算法,两种算法各有优缺点。 Java和Go都使用可达性分析算法,一些动态脚本语言(如:ActionScript)一般使用引用计数算法。
引用计数法
? 引用计数法给每个对象的对象头添加一个引用计数器,每当其他地方引用一次该对象,计数器就加1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
? 这个方法实现简单,效率高,但是主流的Java虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。即如下代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
public class ReferenceCountingGc { Object instance = null; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB; //objB引用计数器为1 objB.instance = objA; //objA引用计数器为1 objA = null; objB = null; }}
? 目前Python语言使用的是引用计数法,它采用了“标记-清除”算法,解决容器对象可能产生的循环引用问题,关于详细原理可以参考Python垃圾回收机制详解
可达性分析算法
? 这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。算法优点是能准确标识所有的无用对象,包括相互循环引用的对象;缺点是算法的实现相比引用计数法复杂。比如如下图所示Root1和Root2都为“GC Roots” ,白色节点为应被垃圾回收的
? 关于Java查看可达性分析、内存泄露的工具,强烈推荐“Memory Analyzer Tool”,可以查看内存分布、对象间依赖、对象状态。
? 在Java中,可以作为**“GC Roots”**的对象有很多,比如:
不可达的对象并非“非死不可”
? 即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
判断一个运行时常量池中的常量是废弃常量
JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
? 假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。
如何判断一个方法区的类是无用的类
? 类需要同时满足下面 3 个条件才能算是 “无用的类”,虚拟机可以对无用类进行回收。
垃圾收集算法
? 当确定了哪些对象可以回收后,就要需要考虑如何对这些对象进行回收,目前垃圾回收算法主要有以下几种。
标记清除算法
? 该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
适用场合:存活对象较多的情况、适用于年老代(即旧生代)
? 缺点:
标记复制算法
? 为了解决效率问题,出现了“标记-复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。使用复制算法,回收过程中就不会出现内存碎片,也提高了内存分配和释放的效率
? 适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)
? 缺点:需要一块儿空的内存空间,整理阶段,由于移动了可用对象,需要去更新引用。
标记整理算法
? 对于对象存活率较高的场景,复制算法要进行较多复制操作,使得效率会变低,这种场景更适合标记-整理算法,与标记-清理一样,标记整理算法先标记出对象的存活状态,但在清理时,是先把所有存活对象往一端移动,然后直接清掉边界以外的内存。
? 适用场合:对象存活率较高(即老年代)
? 缺点:整理阶段,由于移动了可用对象,需要去更新引用。
分代收集算法
? 当前Java虚拟机的垃圾收集采用分代收集算法,一般根据对象存活周期的不同将内存分为新生代和老年代。在新生代中,每次收集都会有大量对象死去,可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高,而且没有额外的空间对它进行分配担保,所以我们选择“标记-清除”或“标记-整理”算法进行垃圾收集。
垃圾收集器
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
垃圾收集器 |
特点 |
算法 |
适用场景 |
优点 |
缺点 |
Serial |
最基本、历史最悠久的单线程垃圾收集器。 |
新生代采用标记-复制算法,老年代采用标记-整理算法。 |
运行在 Client 模式下的虚拟机 |
简单、高效 |
垃圾回收时必须暂停其他所有的工作线程 |
ParNew |
Serial 收集器的多线程版本 |
新生代采用标记-复制算法,老年代采用标记-整理算法 |
运行在 Server 模式下的虚拟机 |
并行,效率高 |
|
Parallel Scavenge |
使用标记-复制算法的多线程收集器,关注吞吐量 |
新生代采用标记-复制算法,老年代采用标记-整理算法。 |
JDK1.8 默认收集器在注重吞吐量及CPU资源的场合 |
吞吐量高 |
|
Serial Old |
Serial 收集器的老年代版本 |
标记-整理算法 |
在 JDK<1.5与 Parallel Scavenge 收集器搭配使用作为CMS收集器的后备方案 |
简单、高效 |
垃圾回收时必须暂停其他所有的工作线程 |
Parallel Old |
Parallel Scavenge 收集器的老年代 |
标记-整理算法 |
在注重吞吐量及CPU资源的场合 |
吞吐量高 |
|
CMS |
多线程的垃圾收集器(用户线程和垃圾回收线程可以同时进行) |
标记-清除算法 |
希望系统停顿时间最短,注重服务的响应速度的场景 |
并发收集、低停顿 |
对 CPU 资源敏感,无法处理浮动垃圾,产生垃圾碎片 |
G1 |
一款面向服务器的垃圾收集器,并行并发,空间整合,可预测的停顿时间 |
标记-复制算法 |
服务端应用、针对具有大内存多处理器的机器 |
停顿时间可控、基本无空间碎片 |
可能存在空间浪费、程序运行时的额外执行负载高 |
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
Golang垃圾回收
? 从Go v1.12版本开始,Go使用了非分代的、并发的、基于三色标记清除的垃圾回收器。相关标记清除算法可以参考和C/C++一样,Go是一种静态类型的编译型语言。因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC),调度和并发之类的语言功能。首先让我们看一下Go内部的内存管理是什么样子的。
Golang内存管理^[6]^
这里先简单介绍一下 Golang 运行调度。在 Golang 里面有三个基本的概念:G, M, P。
G: Goroutine 执行的上下文环境。
M: 操作系统线程。
P: Processer。进程调度的关键,调度器,也可以认为约等于 CPU。
一个 Goroutine 的运行需要 G + P + M 三部分结合起来。
图源:Golang—内存管理(内存分配)
TCMalloc
? Go将内存划分和分组为页(Page),这和Java的内存结构完全不同,没有分代内存,这样的原因是Go的内存分配器采用了TCMalloc的设计思想:
Page
? 与TCMalloc中的Page相同,x64下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。
Span
? 与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。
mcache
? mcache是提供给P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<= 32Kb)。尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。所有类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个锁G。因此,这更有效。mcache从mcentral需要时请求新的span。
mcentral
? mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。每个mcentral包含两个mspanList:
mheap
? mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,也是垃圾回收的重点区域,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。
栈
? 这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事。
内存分配
? Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。
? 核心思想:把内存分为多级管理,降低锁的粒度(只是去mcentral和mheap会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。
内存回收
声明:本站部分文章内容及图片转载于互联 、内容不代表本站观点,如有内容涉及侵权,请您立即联系本站处理,非常感谢!