20200405——java之jvm 垃圾回收器和内存分配策略 二

什么叫GC
Garbage Collection

什么内存区域需要GC

共享区的都要被回收比如堆区以及方法区。

在进行内存回收之前要做的事情就是判断那些对象是‘死’的,哪些是‘活’的。常用方法有两种 引用计数法可达性分析

引用计数法

顾名思义,引用了它时,计数器+1,引用失效的时候 -1。

该方法的优点是实现简单,判断高效。缺点是无法解决 对象间相互循环引用 的问题


public class GcDemo {
    public static void main(String[] args) {
        GcObject object1 = new GcObject(); // step 1 
        GcObject object2 = new GcObject(); // step 2
        
        object1.instance = object2 ;//step 3
        object2.instance = object1; //step 4
        
        object1 = null; //step 5
        object2 = null; // step 6
        
    }
}
class GcObject {
    public Object instance = null;
}

step1: GcObject实例1的引用计数+1,实例1引用数 = 1
step2: GcObject实例2的引用计数+1,实例2引用数 = 1
step3: GcObject实例2的引用计数+1,实例2引用数 = 2
step4: GcObject实例1的引用计数+1,实例1引用数 = 2
step5: GcObject实例1的引用计数-1,结果为 1
step6: GcObject实例2的引用计数-1,结果为 1

至此发现实例1跟实例2的引用数都不为0而又相互引用。这两个实例所占有的内存则无法释放。

可达性分析

很多主流商用语言(如Java、C#)都采用 引用链法 判断 Java对象是否存活,

将一系列的 GC Roots 对象作为起点,从这些起点开始向下搜索。

在Java语言中,可作为GC Roots的对象包含以下几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象。(可以理解为:引用栈帧中的本地变量表的所有对象)

方法区中静态属性引用的对象(可以理解为:引用方法区该静态属性的所有对象)

方法区中常量引用的对象(可以理解为:)引用方法区中常量的所有对象

本地方法栈中(Native方法)引用的对象(可以理解为:)引用Native方法的所有对象

首先第一种是虚拟机栈中的引用的对象,我们在程序中正常创建一个对象对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的。

第二种是我们在类中定义了全局的静态的对象,也就是使用了static关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为GC Roots是必须的。

第三种便是常量引用,就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots。最后一种是在使用JNI技术时,有时候单纯的Java代码并不能满足我们的需求,我们可能需要在Java中调用C或C++的代码,因此会使用native方法,JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为GC Roots。

在这里插入图片描述

当一个对象到 GC Roots 没有任何引用链相连时,则判断该对象不可达。
注意: 可达性分析 仅仅只是判断对象是否可达,但还不足以判断对象是否存活 / 死亡
当在 可达性分析 中判断不可达的对象,只是“被判刑” = 还没真正死亡

第一次标记和筛选

对象 在 可达性分析中 被判断为不可达后,会被第一次标记 & 准备被筛选

不筛选:继续留在 ”即将回收“的集合里,等待回收;
筛选:从 ”即将回收“的集合取出

筛选的标准:该对象是否有必要执行 finalize()方法
若有必要执行(人为设置),则筛选出来,进入下一阶段(第二次标记 & 筛选);
若没必要执行,判断该对象死亡,不筛选 并等待回收.
当对象无finalize()方法 或finalize()已被虚拟机调用过,则视为“没必要执行”
第二次标记和筛选
当对象经过了第一次的标记 & 筛选,会被进行第二次标记 & 准备被进行 筛选

关于方式描述

该对象会被放到一个 F-Queue 队列中,并由 虚拟机自动建立、优先级低的Finalizer 线程去执行 队列中该对象的finalize()
finalize()只会被执行一次
但并不承诺等待finalize()运行结束。这是为了防止 finalize()执行缓慢 / 停止 使得 F-Queue队列其他对象永久等待。

筛选标准

在执行finalize()过程中,若对象依然没与引用链上的GC Roots 直接关联 或 间接关联(即关联上与GC Roots 关联的对象),那么该对象将被判断死亡,不筛选(留在”即将回收“集合里) 并 等待回收

整体流程如下
在这里插入图片描述

浅谈引用

强引用

一般的Object obj = new Object() ,就属于强引用。

软引用

SoftReference:一些有用但是并非必需,用软引用关联的对象,系统将要发生OOM之前,这些对象就会被回收

弱引用

WeakReference :一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

虚引用
PhantomReference:幽灵引用,最弱,object.fun() 都无法执行 。 被垃圾回收的时候收到一个通知

方法区

方法区一般存放类加载信息,字符串常量,静态变量或者静态不可变量等数据。回收静态常量跟无用类,静态常量一般就用引用可达法即可,但判断无用的类:要以下三个条件都满足

该类所有的实例都已经回收,也就是 Java 堆中不存在该类的任何实例
加载该类的 ClassLoader 已经被回收
该类对应的 java.lang.Class 对象没有任何地方呗引用,无法在任何地方通过反射访问该类的方法


GC 算法

标记-清除算法(Mark-Sweep)

算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它的主要不足空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

在这里插入图片描述

复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,还要来回移动数据。

在这里插入图片描述

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

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

在这里插入图片描述


GC算法综合用

JVM区域总体分两类,heap区和非heap区

heap区分为

Eden Space(伊甸园)、字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的survivor区域。

Survivor Space(幸存者区)

Old Gen(老年代)

年轻代

年轻代发生的GC是Minor GC

为什么会有年轻代

我们先来屡屡,为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。你先想想,如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

年轻代中的GC

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1,为啥默认会是这个比例,接下来我们会聊到。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。一般是15

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

在GC开始的时候,对象只会存在于**Eden区和名为From的Survivor区,**Survivor区To是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到To,而在From区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到To区域。经过这次GC后,Eden区和From区已经被清空。这个时候,From和To会交换他们的角色,也就是新的To就是上次GC前的From,新的From就是上次GC前的To。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

也就是每次新生代中可用内存空间为整个新生代容量的90%(8+1),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清理或者标记—整理算法来进行回收。

在这里插入图片描述
年轻代为什么要有survivor?

Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代

为什么survivor有两个?

只有一个的话在GC的过程中是会产生内存碎片的。至于为什么是两个,因为两个就可以满足要求了,大于2个每一个survivor空间都太小了。

老年代

在老年代发生的GC称:Major GC

MajorGC采用标记—清除算法(或者标记—整理算法)
MajorGC的耗时比较长,因为要先整体扫描再回收,MajorGC会产生内存碎片。为了减少内存损耗,一般需要合并或者标记出来方便下次直接分配。

Old Gen老年代,用于存放新生代中经过多次垃圾回收仍然存活的对象,也有可能是新生代分配不了内存的大对象会直接进入老年代。经过多次垃圾回收都没有被回收的对象,这些对象的年代已经足够old了,就会放入到老年代。

当老年代被放满的之后,虚拟机会进行垃圾回收,称之为Major GC。由于Major GC除并发GC外均需对整个堆进行扫描和回收,因此又称为Full GC。

heap区即堆内存,整个堆大小=年轻代大小 + 老年代大小。堆内存默认为物理内存的1/64(<1GB);默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制,可以通过MinHeapFreeRatio参数进行调整;默认空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制,可以通过MaxHeapFreeRatio参数进行调整。

在这里插入图片描述

枚举根节点

Java代码GC的时候 会STW(Stop the World)。也就是停止所有的工作线程,“你们先别干活,我先来清理清理垃圾!”。目前用的就是可达性分析来操作。即使在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

根节点主要在全局性的引用(常量、类静态属性)和执行上下文(栈帧中的本地变量表)中。那我们如果要一个一个的找过去就很慢。并且我们的HotSpot又是准确性GC,也就是它需要知道某个位置上的某个数据的类型,类型是准确的。这样它就能准确的知道这块数据类型是不是它关心的指针也就是引用啦!

在HotSpot中是用了一种叫OopMap的结构来存放一个对象内什么偏移量上是什么类型的数据。在类加载过程中就会进行记录。可以把OopMap理解为一个附加信息,或者说一件衣服的吊牌,咱们看吊牌就知道这衣服啥做的。所以GC在扫描的时候就可以直接看这些“吊牌”来知道信息了。

安全点

OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,提高了GC的空间成本。所以要在特定的位置SafePoint才记录OopMap,所以用一些比较关键的点来记录就能有效的缩小记录所需的空间。因此GC不是随时随地来的,得到达安全点时才可以开始GC。
对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所以线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:

抢先式中断(Preemptive Suspension)
其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

主动式中断(Voluntary Suspension)
而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

GC回收器

前面说到的GC算法跟GC年代都是方法论,那么垃圾回收器的具体实现由于不同厂商跟不同版本还是略有不同的。
主要的回收器如下:

在这里插入图片描述
在这里插入图片描述

不同垃圾回收器相互协作关系如下:
在这里插入图片描述
Serial 收集器

这是一个单线程收集器,最古老版本。意味着它只会使用一个 CPU 或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。

在这里插入图片描述
ParNew 收集器

简单认为是 Serial 收集器的并行多线程版本。

在这里插入图片描述

Parallel Scavenge 收集器

这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。

CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。
虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

Serial Old 收集器

收集器的老年代版本,单线程

在这里插入图片描述

Parallel Old 收集器

在这里插入图片描述

CMS 收集器
Concurrent Mark Sweep:划时代的GC器,让垃圾回收线程跟用户线程可以同时工作,收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于标记—清除算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

初始标记-短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
并发标记-和用户的应用程序同时进行,进行GC RootsTracing的过程
重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
并发清除 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

G1 收集器

面向服务端的垃圾回收器。
优点:并行与并发、分代收集、空间整合、可预测停顿。
运作步骤:

初始标记(Initial Marking)
并发标记(Concurrent Marking)
最终标记(Final Marking)
筛选回收(Live Data Counting and Evacuation)

发布了955 篇原创文章 · 获赞 43 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_36344771/article/details/105334258