JVM垃圾回收算法

为什么需要GC

1.手动内存管理
什么是手动内存管理?如果要存储共享数据, 必须显式地进行 内存分配(allocate)和内存释放(free)。如果忘记释放, 则对应的那块内存不能再次使用,即内存泄漏。如果程序很长,或者结构比较复杂, 很可能就会忘记释放内存。因此,业界迫切希望有一种更好的办法,来自动回收不再使用的内存,完全消除可能的人为错误。这种自动机制被称为垃圾收集。

2.引用计数
针对每个对象,只需要记录被引用的次数,当被引用次数变为0时就可以安全回收。如果有循环引用的情况,会导致对象无法回收。需要专门对循环引用的对象做检查来进行回收,比如专门几个线程干这个事情(Python、PHP等语言使用的是引用计数)。

3.引用追踪(reference tracing),JVM 使用的各种垃圾收集算法都是基于引用追踪的算法。解决了引用计数的循环引用问题。

常用的垃圾收集方法

1.如何查找所有的对象?如何做标记?
从根元素遍历可达对象,在本地内存中做记录。
2.什么是对象的可达性?
JVM定义了垃圾收集根元素,包括局部变量(Local variables)、活动线程(Active threads)、静态域(Static fields)、JNI 引用(JNI references)等。
3.什么是标记清除算法?
JVM使用标记清除算法来跟踪所有的可达对象,确保所有的不可达对象占用的内存都能被重新分配。
标记:从根元素遍历所有的可达对象,在本地内存中记录。
清除:保证不可达对象占用的内存能被重新分配。

优势:标记清除算法的优势是不会因为循环引用导致内存泄漏。
缺点:垃圾收集过程需要停止所有线程(STW),否则对象引用关系一直变化,无法对对象的引用关系进行统计。

4.碎片整理
执行清除会产生内存碎片,导致两个问题:1.写操作越来越耗时,寻找一块足够大的内存变得困难;2.创建新对象时,如果碎片化严重没有空闲片段能放得下新对象,会产生内存分配错误。
因此JVM要做的不仅仅是标记和清除,还需要做内存整理。

GC移动某个对象,会修改栈中和堆中所有指向该对象的引用
移动/拷贝/提升/压缩一般来说是一个 STW 的过程,所以修改对象引用是一个安全的行为。但要更新所有的引用,可能会影响应用程序的性能。

5.分代假设
执行垃圾收集需要停止整个应用。对象越多则垃圾收集消耗的时间越长。如果只处理部分内存区域就比较快。程序中大部分可回收内存可以分为两类:1.大部分对象很快不再使用,生命周期短;2.还有一部分不会立即无用,也不会持续太久。

弱代假设:根据对象不同的特点,把对象进行分类。VM被分为年轻代和老年代。
弱代假设优点:可以根据对象不同的特点,采取不同的算法提高GC性能。
弱代假设缺点:不同分代的对象会互相引用,在收集某个分代时就会成为事实上的GC Root

分代 GC 算法专门针对“要么死得快”、“否则活得长”这类特征的对象来进行优化。

内存池的划分

(1)新生代(Eden Space)
用于分配新创建的对象。通常会有多个线程同时创建多个对象,所以新生代划分了多个线程本地分配缓冲区,大部分对象直接由JVM在对应线程的TLAB中分配,避免和其他线程同步操作。

内存分配:TLAB没空间,则在共享Eden区分配,共享Eden区没空间,则进行年轻代GC,GC后Eden区仍然不够,则分配到老年代。
垃圾回收:Eden区进行垃圾收集,会从root过一遍,标记可达对象为存活。

跨代引用垃圾收集问题:对象可能有跨代引用,进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的东西是完全有可能被其他年代所引用的为了找出该区域中的存活对象,不得不在固定的GC Roots之外再额外遍历整个老年代中所有的东西来确保可达性分析结果的正确性。JVM将堆内存进行了分代,对象间可能存在跨代引用,那么每次进行GC的时候都需要进行全堆扫描判断是否有引用吗?答案并不是,JVM通过卡表的的技术来解决这个问题。

跨代引用可达性分析解决办法:如果老年代对象引用了新生代的对象,那么,需要跟踪从老年代到新生代的所有引用,从而避免每次YGC时扫描整个老年代,减少开销。对于HotSpot JVM,使用了卡标记(Card Marking)技术来解决老年代到新生代的引用问题。具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记并加快对GC Roots的扫描。

记忆集:记忆集位于新生代中,是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。用以避免把整个老年代加进GC Roots扫描范围。记忆集的作用是维护了类似一种映射表的关系,避免了全局扫描,本质是用空间换时间。当发生YGC时,只要把记忆集加进来一起扫描,就能知道新生代对象被老年代引用的情况,而不必扫描整个老年代!记忆集是一种逻辑上的概念,并没有规定具体的实现,类似方法区。
卡表:是一个字节数组。JVM将堆内存划分为一系列大小相等的卡页,卡表中的每个元素对应着一个卡页。如果卡页中有对象存在跨代指针,对应卡表的元素为1,否则为0。进行可达性分析只需要找卡表中为1的元素,将对应卡页中的元素加入GCRoot。CMS收集器在老年代中有一块区域用来记录指向新生代的引用。这是一种 point-out,在进行 Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
写屏障:卡表的数据需要维护。在其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏的时间点原则上应该发生在引用字段赋值的那一刻。如何在对象赋值的那一刻去更新卡表呢?JVM是通过写屏障来维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作做的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作。应用写屏障后,虚拟机就会对所有的赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表的操作,无论更新的是否为老年代对新生代的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低的多的。
写屏障更新的伪共享问题:CPU的缓存是以缓存行为单位的,如果线程1和线程2共享一个缓存行,线程1更新了变量1,缓存行失效,线程2读取变量2(也就是即使是操作的不同的变量)则需要从主存读取,这个就是缓存行失效的性能问题。解决办法是在更新卡表将某个元素变脏(就是把对应元素改为1)之前先做检查,如果已经是脏的就不在更新。

标记完成后,所有的存活对象被复制到存活区(Survivor spaces)。整个 Eden 区就可以被认为是空的,然后就能用来分配新对象。为什么是复制而不是移动

问题:年轻代为什么是复制而不是移动
年轻代的对象生命周期短,内存分配频繁,如果采用标记清除算法,容易产生大量内存碎片,所以采用复制算法;年轻代生命周期算,需要复制的对象少,所以使用复制算法。
2.存活区(Survivor Spaces)
Eden 区的旁边是两个存活区(Survivor Spaces),称为 from 空间和 to 空间。需要着重强调的的是,任意时刻总有一个存活区是空的(empty)。垃圾回收时Eden区和其中一个存活区中的存活对象会被复制到另一个存活区。存活的对象会在两个存活区之间复制多次,直到某些对象的存活时间达到一定的阀值分代理论假设,存活超过一定时间的对象很可能会继续存活更长时间
这类“年老”的对象因此被提升(promoted)到老年代。提升的时候,存活区的对象不再是复制到另一个存活区,而是迁移到老年代,并在老年代一直驻留,直到变为不可达对象。
为了确定一个对象是否“足够老”,可以被提升(Promotion)到老年代,GC 模块跟踪记录每个存活区对象存活的次数。每次分代 GC 完成后,存活对象的年龄就会增长。当年龄超过提升阈值(tenuring threshold),就会被提升到老年代区域。
如果存活区空间不够存放年轻代中的存活对象,提升(Promotion)也可能更早地进行
3.老年代(Old Gen)
预期老年代中的对象大部分是存活的,所以不再使用标记和复制(Mark and Copy)算法。而是采用移动对象的方式来实现最小化内存碎片。也就是标记整理算法。步骤如下:

  • 通过标志位(marked bit),标记所有通过 GC roots 可达的对象;
  • 删除所有不可达对象;
  • 整理老年代空间中的内容,方法是将所有的存活对象复制,从老年代空间开始的地方依次存放
    通过上面的描述可知,老年代 GC 必须明确地进行整理,以避免内存碎片过多。

什么是标记复制算法和标记整理算法?
复制算法将内存划分为两个等大小的区域,每次只使用其中一个区域,当该区域满了以后,就将存活的对象复制到另一个区域,并清空原区域。标记整理法则先标记出所有存活对象,随后将这些对象向内存的一端移动,然后清理掉边界以外的内存

4.永久代(Perm Gen)
Java 8 之前有一个特殊的空间,用来存储元数据(metadata)的地方,比如 class 信息等。也保存有其他的数据和信息,包括内部化的字符串(internalized strings)。
实际上这给 Java 开发者造成了很多麻烦,因为很难去计算这块区域到底需要占用多少内存空间。预测失败导致的结果就是产生 java.lang.OutOfMemoryError: Permgen space 这种形式的错误。除非 OutOfMemoryError 确实是内存泄漏导致的,否则就只能增加 permgen 的大小。

5.元数据区(Metaspace)
估算元数据所需空间那么复杂,Java 8 直接删除了永久代(Permanent Generation),改用 Metaspace。
类定义(class definitions)之类的信息会被加载到 Metaspace 中。元数据区位于本地内存(native memory),不再影响到普通的 Java 对象。默认情况下,Metaspace 的大小只受限于 Java 进程可用的本地内存。这样程序就不再因为多加载了几个类/JAR 包就导致 java.lang.OutOfMemoryError: Permgen space.。如果 Metaspace 失控,则可能会导致严重影响程序性能的内存交换(swapping),或者导致本地内存分配失败。要避免这个问题,可以通过参数限制元数据区的大小。

垃圾收集

1.垃圾收集器都专注于两件事情:

  • 查找所有存活对象
  • 抛弃其他的部分,即死对象,不再使用的对象。

2.有一些特定的对象被指定为 Garbage Collection Roots(GC 根元素),如下:

  • 当前正在执行的方法里的局部变量和输入参数
  • 活动线程(Active threads)
  • 内存中所有类的静态字段(static field)
  • JNI 引用

3.可达性分析
GC 遍历(traverses)内存中整体的对象关系图(object graph),从 GC 根元素开始扫描,到直接引用,以及其他对象(通过对象的属性域)。所有 GC 访问到的对象都被标记(marked) 为存活对象。使用三色标记算法对对象进行标记。

4.标记阶段
在标记阶段有几个需要注意的地方:在标记阶段,需要暂停所有应用线程,以遍历所有对象的引用关系。因为不暂停就没法跟踪一直在变化的引用关系图。这种情景叫做 Stop The World pause(全线停顿),而可以安全地暂停线程的点叫做安全点(safe point),然后,JVM 就可以专心执行清理工作。安全点可能有多种因素触发,当前,GC 是触发安全点最常见的原因

此阶段暂停的时间,与堆内存大小,对象的总数没有直接关系,而是由存活对象(alive objects)的数量来决定。所以增加堆内存的大小并不会直接影响标记阶段占用的时间
4.复制和整理算法的区别
两者都会移动所有存活的对象。区别在于,“标记—复制算法”是将内存移动到另外一个空间:存活区。“标记—复制方法”的优点在于:标记和复制可以同时进行。缺点则是需要一个额外的内存区间,来存放所有的存活对象。

问题

1.为什么有垃圾回收还会导致OOM?
2.老年代垃圾回收需要考虑跨代引用的问题吗?

参考

卡表
卡表
移动算法和复制算法
跨代引用
RSet

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值