JVM之垃圾回收机制

关于垃圾回收机制(GC)的学习,我准备从以下几个问题开始:

  1. 什么是GC?
  2. 对象在堆中如何存在?
  3. 如何判断哪些垃圾需要回收?
  4. 如何进行垃圾回收?
  5. 补充内容

一、什么是GC
在C++中,当我们每new一个对象之后,我们都需要手动将它回收。java的设计者为解决这一问题,采用java虚拟机的自动内存管理, 将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。这个机制就被称为垃圾回收机制
二、 对象在堆中如何存在
jdk1.8之后,java虚拟机将堆划分为新生代和老年代。其中新生代又被划分为Eden区和两个大小相同的Survivor区(通常用from与to进行指代),如下图:
堆
2.1大多数情况下,对象在新生代中 eden 区分配
当Eden区没有足够空间进行分配时,java虚拟机将发起一次Minor GC,用来收集新生代的垃圾。而在此次Minor GC中存活下来的对象将会被复制到to指向的Survivor区。
注意:to指向的Survivor区永远是空的那一个。当发生Minor GC时,Eden区和from区指向的Survivor中的存活对象会被复制到to指向的Survivor区中。然后交换from和to指针的指向,以保证下一次Minor GC时,to指向的Surviviour区是空的。
2.2对象从新生代Survivor区转移到老年代
情况1:长期存活对象将进入老年代
java虚拟机会记录Survivor区中的对象一共被来回复制了几次。若一个对象被复制的次数为15(默认),那么该对象将被晋升到老年代。
情况2: 动态对象年龄判定进入老年代
虚拟机并非永远要求对象年龄(复制次数)必须达到某个值才能进入老年代,若Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。
情况3:分配担保机制
当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间若虚拟机发现对象无法存入Survivor区时(Survivor空间不够),就只能通过分配担保机制把新生代的对象提前转移到老年代中。
2.3 大对象直接进入老年代
大对象是指,需要大量连续内存空间的对象。
eg. 字符串、数组
这样做的目的,为了避免为大对象分配内存时由于分配担保机制带来的复制而降低jvm的效率。

三、如何判断哪些垃圾需要回收
3.1判断死亡的对象
3.1.1引用计数法
具体实现:
为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。
当某一引用被赋值为某一对象,那么该对象的引用计数器+1。若该引用被赋为其他值,那么该引用计数器-1。
一旦某个对象的引用计数器为0,则说明该对象已经死亡,该对象便会被回收。
存在的弊端:
1.耗费空间存储计数器
2.繁琐的更新操作
3.无法处理循环引用对象
(若对象a,b互相引用,无其他指向,则其计数器永远无法为0,永远无法被回收,会造成内存泄漏)
3.1.2可达性分析法
具体实现:
将一系列GC Roots作为初始的存活对象合集作为起点,从这些节点开始往下搜索,节点所走过的路径称为引用链。
当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是死亡的,可以将其回收。完美地解决了引用计数法中的循环引用问题。
GC Roots包括(但不限于):

  • java方法栈桢中的局部变量
  • 已加载类的静态变量
  • JNI handles
  • 已启动且未停止的java线程

我们可以将GC Roots理解为由堆外指向堆内的引用。
在这里插入图片描述

存在的弊端:
在多线程情况下,其他线程可能会更新已经访问过的对象的引用,进而造成 漏报(访问前将引用设置为死亡对象)或者 误报(访问前将引用存在于引用链中,而访问后不存于其中)。
发生误报的危害较小:java虚拟机最多损失本次部分垃圾回收的机会
发生漏报的危害较大:垃圾回收器可能回收事实上仍然被引用的对象,一旦将其回收,可能会直接导致JVM崩溃。
通过Stop-the-world与安全点解决漏报、误报弊端:
解决方式: Stop-the-world,停止其他非垃圾回收线程的工作,直到垃圾回收完成。
由于上面的原因,产生了垃圾回收的暂停时间(GC pause)
Stop-the-world通过安全点机制(safepoint)来实现。当java虚拟机收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许Stop-the-world的线程进行独占的工作。
安全点补充内容:
安全点的初始目的并非让其他线程都停下,而是找到一个稳定的执行状态。在此状态下jvm的堆栈不会发生变化。这样垃圾回收器就能够安全地执行可达性分析。
java线程的几种状态中:解释执行字节码,执行即时编译器生成的机器码、线程阻塞。
阻塞的线程,处于java虚拟机线程调度器掌控之中,属于安全点
解释执行字节码,字节码与字节码之间皆可作为安全点。当有安全点请求时,执行一次字节码进行一次安全点检测。
执行即时编译器生成的机器码,HotSpot虚拟机的做法是在生成代码的方法入口以及非计数循环的循环回边处插入安全点检测。

注意:不可达的对象并非“非死不可”!!!
当对象被判断为不可达以后,该对象暂时处于“缓刑阶段”,要真正被回收,至少要经历两次标记过程。
可达性分析法中不可达的对象被第一次标记并且进行一次筛选,当此对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过时,虚拟机将不会对这两种情况下的对象执行回收操作。否则被判定需要执行回收操作。
第一次标记中被判定为需要执行回收操作的对象会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被回收。

3.2判断废弃常量
常量池主要回收的是废弃的常量。
若常量池中存在字符串“test”,若当前没有任何String对象引用该字符串常量的话,那就说明常量“test”是废弃常量,如此时发生内存回收的话而且有必要的话,“test”就会被系统清理出常量池。
3.3判断无用类
方法区主要回收的是无用的类
“无用的类”需要满足以下三个条件:

  1. 该类所有的实例均已被回收
  2. 加载该类的ClassLoader已被回收
  3. 该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

四、如何进行垃圾回收
4.1垃圾回收的三种方式
4.1.1清除算法
分“标记”和“清除”两个阶段:
1.标记处所有存活的对象
2.在标记完成后统一回收所有被未标记的对象。
在这里插入图片描述
清除算法是最基础的收集算法,效率很高,但存在两个缺陷:

  1. 会造成内存碎片,由于jvm堆中对象必须是连续分布的,因此可能出现空闲内存空间足够,但是无法分配内存的极端情况。
  2. 分配效率低,若是一块连续的内存空间,我们可以通过指针加法来做分配。而对于空闲列表,java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

4.1.2压缩算法
同样分为两个过程,标记以及压缩
标记过程同清除算法的标记过程一样。
压缩过程则是将存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。然后把其他位置的标记的内存清空。
在这里插入图片描述
优劣:
解决内存碎片化问题
但是压缩算法的性能开销较大
4.1.3复制算法
将内存区域分为两等分,分别用两个指针from和to维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,将存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。
在这里插入图片描述
优劣:
解决内存碎片化问题
但是堆空间的使用效率极其低下
4.2分代收集算法
当前虚拟机的垃圾回收均采用分代收集算法。即根据对象存活周期的不同将内存分为几块,一般将java内存分为新生代和老年代,各个年代有合适自己的对应的收集算法。
在新生代中采用复制算法,因为每次收集都会有大量对象死去,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
在老年代采用清除算法或者压缩算法,因为老年代对象存活率是比较高的,且没有额外的空间对其进行分配担保,所以我们必须使用这两种算法进行垃圾收集。
4.3垃圾收集器
针对新生代的三个垃圾收集器:
1. Serial收集器(重要)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器,它在进行垃圾收集工作时必须暂停其他所有工作线程,直至它收集结束。
采用标记-复制算法
优势:简单而高效

2. ParNew收集器
ParNew收集器其实是Serial收集器的多线程版本,处理使用多线程进行垃圾收集外,其余行为和Serial收集器完全一样。
采用标记-复制算法
是许多运行在Server模式下的虚拟机的首要选择。

3. Parallel Scavenge收集器
Parallel Scavenge收集器类似于ParNew收集器,其关注点是吞吐量(高效率的利用CPU)。
吞吐量就是CPU中用于运用用户代码的时间和CPU总消耗时间的比值。
采用标记-复制算法
Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在的话可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。
此收集器不可与CMS收集器一起使用

针对老年代的三个收集器:
1. Serial Old收集器
Serial收集器的老年代版本,它同样是一个单线程收集器。
采用标记-压缩算法
它主要有两大用途:
一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用
二是CMS收集器的后备方案
2. Parallel Old收集器
Parallel Scavenge收集器的老年代版本,可以看成Serial Old收集器的多线程版本。
采用标记-压缩算法
使用多线程和“标记-整理”算法。
在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。

3. CMS收集器(Concurrent Mark Sweep) (重要)
CMS收集器是HotSpot虚拟机第一款真正意义上的并发收集器,他第一次实现了让垃圾收集线程与用户线程(基本上)同时工作的收集器,是一种以获取最短回收停顿时间为目标的收集器。使用与注重用户体验的应用。
采用标记-清除算法
收集过程分4个步骤:

  1. 初始标记
    暂停所有其他线程,记录下雨root直接相连的对象,速度很快。
  2. 并发标记
    同时开始GC和用户线程,用一个闭包结构去记录可达对象。因为用户线程可能会不断地更新引用域,所以GC线程无法保证可达性分析的实时性。所以需要记录发生引用更新的地方。
  3. 重新标记
    为修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间要长,但是远远比并发标记阶段的时间要短。
  4. 并发清除
    开启用户线程,同时GC线程开始对未标记区域做清扫
    在这里插入图片描述
    这是一款优秀的收集器,它的优劣之处:
    优点:并发收集,低停顿
    缺点:对CPU资源敏感、无法处理浮动垃圾、会产生大量空间碎片

横跨新生代和老年代的垃圾收集器
G1收集器
G1(Garbage-First)是一款面向服务器的垃圾回收器,主要针对配备多颗处理器及大容量内存的机器。
采用标记-压缩算法
优势:

  • 以极高概率满足GC停顿时间要求
  • 具备高吞吐量性能特征

实际上,G1收集器已经打乱了前面所说的堆结构。在G1中,堆被划分成许多个连续的区域(region)。每个区域大小相等,在1M~32M之间。
(JVM最多支持2000个区域,可推算G1能支持的最大内存为2000*32M=62.5G。区域(region)的大小在JVM初始化的时候决定,也可以用-XX:G1HeapReginSize设置。)
因此在G1中,不存在物理上的新生代和老年代,但是它们存在于逻辑上,使用一些非连续的区域组成。
在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是它名字的由来。

具体收集过程:
新生代中的收集:
G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。
被圈起的绿色部分为新生代的区域(region),经过Young GC后存活的对象被复制到一个或者多个区域空闲中,这些被填充的区域将是新的新生代;当新生代对象的年龄(逃逸过一次Young GC年龄增加1)已经达到某个阈值(ParNew默认15),被复制到老年代的区域中。
回收过程是停顿的(STW,Stop-The-Word);
回收的过程多个回收线程并发收集。
回收完成之后根据Young GC的统计信息调整Eden和Survivor的大小,有助于合理利用内存,提高回收效率。

老年代中的收集:
和CMS类似,G1收集器收集老年代对象会有短暂停顿。
会经历以下几个阶段:

  1. 标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。
  2. Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。
  3. Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
  5. Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并清空回收区域并把它返回到空闲区域链表中。
  6. 复制/清除过程后。回收区域的活性对象已经被集中survivor区域和老年代区域。

G1具备以下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记-压缩”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。

4.4Minor Gc和Full GC 的不同之处

  • 新生代GC(minor GC):指发送在新生代的垃圾回收动作,Minor GC非常频繁,回收速度也一般较快
  • 老年代GC(Full GC): 指发送在老年代的垃圾回收动作,出现了Full GC 经常会伴随至少一次Minor GC(并非绝对,假如持续创建大对象就不会),其速度要比Minor GC至少慢10倍以上。

五、补充内容
5.1 引用
引用可分为4种,强引用、软引用、弱引用、虚引用(引用强度逐渐降低)

  1. 强引用
    以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

  2. 软引用(SoftReference)
    如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
    软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

  3. 弱引用(WeakReference)
    如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
    弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  4. 虚引用(PhantomReference)
    "虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
    虚引用主要用来跟踪对象被垃圾回收的活动。
    虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
    特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

5.2 TLAB(Thread Local Allocation Buffer)用于解决多线程竞争堆内存分配问题
通常来说,当我们调用new指令时,它会在Eden区中划出来一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里面划空间是需要进行同步的。否则将有可能出现两个对象共用一段内存的事故。
java虚拟机的解决办法就是 TLAB:
每个线程可以想JVM申请一段连续的内存,作为线程私有的TLAB,这个操作需要加锁,线程需要维护两个指针,一个指向TLAB中空余内存的起始位置,另一个指向TLAB末尾。
当执行new指令,可以直接通过指针加法来实现,即把指向空余内存起始位置的指针加上所请求的字节数。
若加法后空余内存指针的值仍小于或者等于指向末尾的指针,则代表分配成功。
否则,TLAB已经没有足够的空间来满足本次新建操作,这时候需要申请新的TLAB。
5.3 卡表 用于解决减少老年代全堆空间扫描问题
因为Minor GC 只针对新生代进行垃圾回收,所以在枚举 GCRoots时,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,JVM引入了 卡表 技术,大致地标出了可能存在老年代到新生代引用的内存区域。进而避免了全堆空间的扫描。
该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。若存在,我们就认为这张卡是脏的。
在进行Minor GC时,我们就可以不用扫描整个老年代,而是在卡表里寻找脏卡,并将脏卡中的对象加入到GC Roots当中。当完成所有脏卡的扫描后,JVM便会将所有脏卡的标识位清零。
由于Minor GC伴随着存活对象的复制,而复制就需要更新指向该对象的引用,因此在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代的对象的引用。

参考来源:
Snailclimb大神
极客时间-深入拆解java虚拟机

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

喜鹊先生Richard

随缘~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值