JVM垃圾收集器与内存分配

概述

    说起垃圾回收,大部分人会把它和Java语言想到一块去,认为垃圾回收是Java语言的诞生产物。In fact(事实上),垃圾回收的历史比Java久远。在1960年,Lisp语言还在产生阶段,Lisp语言作者就在像这三件事情。

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

如今,内存分配与垃圾回收已经进入自动化的年代。不过我们还是要知道分配内存和回收垃圾。因为发生内存溢出,泄漏问题,在我们了解的情况下可以更好的排错。

JVM中我们把内存分为主要的两大块:

  1. 线程私有:程序计数器,本地方法栈,虚拟机栈。本部分比较好管理内存。它们的内存分配都是死的,虚拟机栈的局部变量表的栈帧基本都是确定的,随着线程的压栈和弹栈操作,程序的执行,自然会自动地销毁。
  2. 线程共享:方法区,堆内存。这里是学习内存管理的大头,这里有着太多的不确定性。interface不知道他又多少个具体的实现子类,也不知道程序究竟有多少个对象。我们就是要关注这一部分的内存,它会动态分配内存,具体的堆溢出问题我们还是要等程序跑起来才能知道。

怎么判断已经死亡

判断一个对象是不是需要回收我们现在一共有两种办法。

  1. 计数法:计数法虽然需要额外的内存来当前对象的引用数,但是他的原理简单,有地方引用我就count++,引用失效就count–。当count等于0就回收对象
/**
 * ClassName: Test
 * Description:
 * date: 2022/2/26 16:08
 *
 * @version JDK 1.8
 * @author: lisu
 */
public class Test {
    public static void main(String[] args) {
        GCObject object1 = new GCObject();
        GCObject object2 = new GCObject();
        object1.instance = object2;
        object2.instance = object1;
        object1 = null;
        object2 = null;
    }

    static class GCObject{
        public Object instance = null;
    }

}

它们之间存在相互引用,所以它们是不会被使用计数器法的VM垃圾回收的。

  1. 可达性分析法(gc root可达)/ (引用链法) Java判断对象是否存活就是这一种办法。这个办法从root节点向下查找对象。引用链经过的对象是存活的对象,没达到的对象节点就是回收的对象。
    在这里插入图片描述

上图可见,obj1~obj4都是root节点查找到了的对象,所以不会被回收。obj5 ~obj7虽然之间有引用链,但是已经和root节点断开了连接,在一开始就不可达,所以它们三个都会被回收。但是在这里,如果使用的计数器法,他们三个不会被回收,因为obj6和obj7有对象引用它,程序计数方法有1,这也是计数器的一个bug。
说到底还是引用,一个对象只有“被引用”和“未被引用”两种状态。

垃圾回收算法

两个分代假说:

  1. 弱分代假说:所有的对象都是朝生夕灭的。
  2. 强分代假说:熬过了弱分代假说越多次的对象就越难被进行销毁。

两种分代假说决定了现在堆内存的结构——分代结构。

  • 年轻代:每一次发生GC的时候判断是否是不可达对象,如果是,进行回收,不是进入幸存区。一般来说年轻代有两个幸存区,它们的内存占比是8:1:1。如果在幸存区经过了默认15次还没有被YoungGC,那么该对象就被证明了强分代假说,会进入的我们接下来介绍的老年代中。
  • 老年代:存储YongArea区域里面的老对象,还有一些大对象。接下来看看他到底长啥样子吧!

在这里插入图片描述

在这里插入图片描述
接下来进入核心区,开始介绍垃圾回收算法。

  1. 标记清除算法:这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。

在这里插入图片描述
缺点:内存过于碎片化,需要进行大量的标记,然后清除,就相当于大扫荡,所以说这样效率会很低。很容易导致边边角角都有一小块可以使用的内存。

  • 复制算法:为了解决标记清除算法的缺陷,复制算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。
    在这里插入图片描述

缺点:很明显,可以使用的内存只剩下一半。还涉及到存活对象的迁移。

  • 标记整理(压缩法):为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
    在这里插入图片描述

-分代收集算法(重点):

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。目前大部分垃圾收集器对于新生代都采取复制算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法(压缩法)

有名的垃圾收集器

垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法、分代收集算法)的具体实现,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,这里主要介绍HotSpot虚拟机中的垃圾收集器。
在这里插入图片描述

上述一共七种垃圾收集器,除了G1外都是分代理论的垃圾收集器,G1是分区理论的收集器,分区的还有ZGC,shenandoah,这里分区不做过多赘述。

  1. Serial-SerialOld:

Serial收集器是最基本、发展历史最悠久的收集器。它是一种单线程垃圾收集器,这就意味着在其进行垃圾收集的时候需要暂停其他的线程,也就是之前提到的"Stop the world(STW/停止整个世界)"。虽然这个过程是在用户不可见的情况下把用户正常的线程全部停掉,听起来有点狠。Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,采用“标记-整理算法”进行回收。其运行过程与Serial收集器一样。Serial Old收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。这点是很难让人接受的。Serial、Serial Old收集器的工作示意图如下:
在这里插入图片描述
尽管由以上不能让人接受的地方,但是Serial收集器还是有其优点的:简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得较高的手机效率。到目前为止,Serial收集器依然是Client模式下的默认的新生代垃圾收集器。

  1. Parnew:ParNew收集器是Serial收集器的多线程版本

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。除去性能因素,很重要的原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。但是,在单CPU环境中,ParNew收集器绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。然而,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。
在这里插入图片描述

  1. CMS:CMS收集器(Concurrent Mark Sweep)的目标就是获取最短回收停顿时间。在注重服务器的响应速度,希望停顿时间最短,则CMS收集器是比较好的选择。 整个执行过程分为以下4个步骤:
  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

初始标记和重新标记这两个步骤仍然需要暂停Java执行线程,初始标记只是标记GC Roots能够关联到的对象,并发标记就是执行GC Roots Tracing的过程,而重新标记就是为了修正并发标记期间因用户程序执行而导致标记发生变动使得标记错误的记录。
在这里插入图片描述

整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,因此,总体上CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS的优点很明显:并发收集、低停顿。由于进行垃圾收集的时间主要耗在并发标记与并发清除这两个过程,虽然初始标记和重新标记仍然需要暂停用户线程,但是从总体上看,这部分占用的时间相比其他两个步骤很小,所以可以认为是低停顿的。

尽管如此,CMS收集器的缺点也是很明显的:

对CPU资源太敏感,这点可以这么理解,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢

CMS收集器无法处理浮动垃圾。所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”

由于CMS收集器是基于“标记-清除”算法的,前面说过这个算法会导致大量的空间碎片的产生,一旦空间碎片过多,大对象就没办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有连续的足够大的空间放下这个对象,所以虚拟机就会触发一次Full GC(这个后面还会提到)这个问题的解决是通过控制参数-XX:+UseCMSCompactAtFullCollection,用于在CMS垃圾收集器顶不住要进行FullGC的时候开启空间碎片的合并整理过程。

  1. Parallel Scavenge:Parallel Scavenge收集器是新生代垃圾收集器,使用复制算法,也是并行的多线程收集器。与ParNew收集器相比,很多相似之处,但是Parallel Scavenge收集器更关注可控制的吞吐量。吞吐量越大,垃圾收集的时间越短,则用户代码则可以充分利用CPU资源,尽快完成程序的运算任务。

Parallel Scavenge收集器使用两个参数控制吞吐量:

XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间

XX:GCRatio 直接设置吞吐量的大小。

直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间下降的同时,吞吐量也下降了。除此之外,Parallel Scavenge收集器还可以设置参数-XX:+UseAdaptiveSizePocily来动态调整停顿时间或者最大的吞吐量,这种方式称为GC自适应调节策略,这点是ParNew收集器所没有的。

  1. Parallel Old:Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法进行垃圾回收。其通常与Parallel Scavenge收集器配合使用,“吞吐量优先”收集器是这个组合的特点,在注重吞吐量和CPU资源敏感的场合,都可以使用这个组合。
    在这里插入图片描述

实战(内存分配和垃圾回收)

对象的内存分配,概念上说,实在堆上分配。实际上也有可能在即时编译的时候拆散为标量在栈上分配。分代理论中,对象的分配一开始在新生代中,如果是大对象,也有可能被直接分配到老年代。所以说规则不固定。对象首先在年轻代的Eden分配,但Eden没有足够的内存了,会进行YoungGC。
接下来,我们尝试分配三个2M大小的和一个4M大小的对象。

/**
 * ClassName: ShowAllLocation
 * Description:
 * date: 2022/2/26 19:07
 *
 * @version JDK 1.8
 * @author: lisu
 */
public class ShowAllLocation {

    private static final int _1MB = 1024 * 1024 ;

    public static void testAllLocation() {
        byte[] allLocation1, allLocation2, allLocation3, allLocation4;
        allLocation1 = new byte[2 * 1024];
        allLocation2 = new byte[2 * 1024];
        allLocation3 = new byte[2 * 1024];
        allLocation4 = new byte[4 * 1024];
    }

    public static void main(String[] args) {

        ShowAllLocation.testAllLocation();

    }
}

在这里插入图片描述
测试一下大对象直接分配在老年代会不会直接OOM。

/**
 * ClassName: TestBigObjOOM
 * Description:
 * date: 2022/2/26 19:13
 *
 * @version JDK 1.8
 * @author: lisu
 */
public class TestBigObjOOM {

    private final static int _1MB = 1024 * 1024 ;

    public static void main(String[] args) {

        byte[] bigObject = new byte[100000 * _1MB] ;
    }
}

在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值