JVM的内存分配与垃圾回收策略

自动内存管理机制主要解决了两个问题:
给对象分配内存以及回收分配给对象的内存。

>>垃圾回收的区域

前面的笔记中整理过虚拟机运行数据区,再看一下这个区域:

JVM的内存分配与垃圾回收策略

注意在这个Runtime Data Area中:

程序计数器、Java栈、本地方法栈3个区域随线程而生,随线程而灭;
每一个栈帧中分配多少内存基本上在类结构确定下来的时候就已知,
因此这几个区域的内存分配和回收都具有确定性,不需过多考虑回收问题,方法结束或者线程结束时,内存自然就随之回收了。

Java堆和方法区Method Area则不一样,
一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序处于运行期间才知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

>>判断对象是否需要回收的策略

(1)引用计数算法
定义:引用计数算法(Reference Counting):给对象添加一个引用计数器,每当一个地方引用它时,计数器值就+1;当引用失效时,计数器值就-1;任何时刻计数器为0的对象就是不可能被再使用的;
优点:实现简单,判定效率高;微软的COM技术、Python中都使用了Reference Couting算法进行内存管理;
缺点:由于其很难解决对象之间相互循环引用的问题,主流Java虚拟机里面都没有选用Refrence Couting算法来管理内存;
(2)可达性分析算法
定义:可达性分析(Reachability Analysis)判断对象存活的基本思路:通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(即GC Roots到这个对象不可达)时,则证明此对象是不可用的;
JVM的内存分配与垃圾回收策略
Java语言中,可作为GC Roots对象包括:
虚拟机栈(栈帧中的本地变量表)中引用的对象;
方法区中类静态属性引用的对象;
方法区中产量引用的对象;
本地方法栈中JNI(即一般的Native方法)引用的对象

>>垃圾回收算法

(1)标记-清除算法(Mak-Sweep)

JVM的内存分配与垃圾回收策略
定义:MS算法分标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
两点不足:
效率问题,标记和清除两个过程的效率都不高;
空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多后导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发一次垃圾收集动作;

(2)复制算法(Coping)

定义:Coping算法将可用内存按容量划分为大小相等的两块,每次使用其中一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用的内存清理掉。

JVM的内存分配与垃圾回收策略
优点:每次对整个半区进行回收,内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效;
不足:提高效率的代价是将内存缩小到原来的一半;
现代商业虚拟机都采用这种收集算法来回收新生代,但新生代中的对象一般98%是朝生夕死,无需按照1:1比例来划分内存空间,而是将内存分为1块较大的Eden(伊甸园)空间和2块较小的Survivor(幸存者)空间,每次使用Eden和其中1块Survivor。
回收时,将Eden和Survivor中还存活的对象一次性复制到另外一个Survivor空间中,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot VM默认Eden和Survivor的比例是8:1:1,即只浪费10%的内存。
98%的对象可回收只是一般场景下的数据,无法保证每次回收都只有不多于10%的对象存活,所以当Survivor空间不足时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion),让对象进入老年代。

(3)标记-整理算法(Mark-Compact)

出场背景:复制算法在对象存活率较高时复制操作较多,效率会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以对应被使用内存中的所有对象都100%存活的极端情况,所以老年代一般不直接选用这种算法。

JVM的内存分配与垃圾回收策略
定义:根据老年代的特点,提出标记-整理(Mark-Compact)算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理调用端边界以外的内存。

(4)分代收集算法

当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)算法,根据对象存活周期的不同将内存分为几块。
一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法

新生代每次垃圾回收时都发现有大批对象死去,只有少量对象存活,故采用复制算法,以少量对象复制的成本即可完成收集;
老年代中因为对象存活率高、没有额外空间对其进行分配担保,必须采用标记-清理或标记-整理算法来进行回收。

>>主要内存分配策略

(1)对象优先在Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC
(2)大对象直接进入老年代:所谓的大对象就是指需要大量连续内存空间的JAVA对象,最典型的大对象就是那种很长的字符串和数组。经常产生大对象容易导致额外的GC操作,JVM中提供了一个-XX:PretenureSizeThreshold参数(这个参数只对Serial和ParNew这两个新生代垃圾收集器有效),
令大于这个参数的对象直接在老年代中分配,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝。
(3)长期存活的对象将进入老年代
我们知道,JVM产生一个对象的时候,首先将其放在新生代的Eden区中,并且随着young gc的产生,大部分的对象都被回收了,那么“熬过”这次GC的对象呢?
JVM给了每个对象一个“年龄计数器”,所谓的年龄计数器就是指,这个对象熬过第一次GC,并且进入了Survivor区中,那么就将这个对象的年龄设为1,之后,每熬过一次GC,年龄+1,当这个值到达一个阀值(默认15,可通过-XX:MaxTenuringThreshold来设置)时,这个对象就会被移到老年代中。

>>垃圾收集器

不同的厂商实现由不同,不一而足。
Serial收集器
ParNew收集器(Parallel New)
Parallel Scavenge收集器
Serial Old收集器
Parallel Old收集器

>>知乎问题 Java 等语言的 GC 为什么不实时释放内存?

下面是RednaxelaFX的回答:

1.最基本的纯引用计数方式的自动内存管理可以做到实时释放死对象,但却无法处理存在循环引用的对象图的释放。

这个问题一定程度上可以通过引入弱引用的概念来解决,但通用的能处理带循环引用对象图的引用计数都是有别的管理方式备份的(通常是某种tracing GC,例如mark-sweep;也有名为“trial-deletion”的循环检测方法,但这个通常比tracing性能更差所以用得较少),例如CPython使用以引用计数为主、mark-sweep为辅的方式,Adobe Flash的ActionScript VM 2(AVM2)也是以延迟引用计数(DRC)为主、增量/保守式mark-sweep为辅。反之,像C++的std::shared_ptr就是纯引用计数,无法靠自己处理带循环引用的对象图,而必须靠程序员自己小心使用,在必要的地方用std::weak_ptr来破除循环;CPython在2.0之前也使用纯引用计数,无法处理循环引用,只能等着泄漏内存。既然通用的引用计数还得用tracing GC来备份,实现这样的自动内存管理等于得实现两份,想偷懒的话还不如一开始就只实现某种tracing GC,例如mark-sweep。

2.最基本的纯引用计数方式对引用计数器的操作非常频繁,这里有额外开销,至于是否严重到成问题就看具体应用的可忍受程度。在内存充裕的前提下,基本的tracing GC比基本的引用计数方式的性能更好(特别是从throughput角度看),不需要做冗余的计数器更新。同时,在多线程环境下引用计数器可能成为线程间共享的数据,需要做同步保护(这里把原子更新算同步保护的一种),这也是个额外开销的来源;因为tracing GC不需要维护引用计数器所以也就没有这种同步的开销。引用计数的这些性能缺点可以通过一些高级变种来缓解,例如前面提到AVM2的延迟引用计数,只记录堆上对象之间的引用计数而不记录栈上(主要是表达式临时值)对对象的引用计数,以此减少对计数器的更新次数来提高性能。详情可参考文档:MMgc | MDN。这些引用计数的高级变种通常意味着一定程度的延迟释放,跟楼主想实时释放的初衷就不符了。另一方面,虽然最基本的tracing GC会有较长的延迟,但它们也有高级变种,可以并行、并发、增量式执行,降低延迟;也有办法实现thread-local GC来应对像是“请求-响应”式的Web应用批量释放一个线程临时分配的对象的需求。

3.如果选用tracing GC来实现自动内存管理,它是不显式维护对象的引用计数的,也就没有“引用计数到0”的概念。所以基于tracing GC的JVM或其它语言的运行时环境自然不会“引用计数到0就释放对象”。

4.引用计数方式其实也有经典的卡顿情况。例子之一就是一个对象个数很多、引用链很长的对象图假如只是被一个引用而留活,那么那个引用一死就会引发大量对象扎堆释放(但却不是“批量释放”,开销不同),这一样会引起卡顿。单纯讨论最坏情况的话其实引用计数也有这样糟糕的一面。纯人工的malloc()/free()或new/delete可以让程序员人肉找出生命周期相同的对象,然后利用诸如arena之类的方式为它们分配内存,就可以它们死的时候真正批量释放掉它们,这样就很高效;但纯引用计数却不是这么回事。使用引用计数会否遇到这种卡顿全看你的程序里对象图的引用关系是怎样的。

主要整理自 周志明·《深入理解Java虚拟机》一书

上一篇:干货,记一次Metaspace导致频繁fgc的问题排查过程


下一篇:jdk8 permgen OOM再见迎来metaspace