《深入理解Java虚拟机》阅读笔记 第三章 垃圾收集器与与内存分配策略

点击查看《深入理解Java虚拟机》阅读梳理合集

概述

垃圾收集(GC)不是Java所独创的也不是java最先提出来的。但却是由java发扬光大的。GC可以让程序员不用过多的关注于垃圾回收,而将更多的精力用在研发上。在大多数情况下,我们都不必关心GC,但如果GC成为了系统达到更高并发量的瓶颈时,我们就要对GC这个自动化的技术做一定的监控和调节。
在JVM中,程序计数器 虚拟机栈 本地方法站,随着类结构的确定,其大小也就基本确定。因此当线程销毁时,这几个部分的内存也就自然而然的被回收。
但是方法区和堆则有很多不确定性,当对这两部分进行GC时就不那么容易了。
要进行GC首先要清除三个问题
1.哪些内存需要进行回收
2.什么时候进行回收
3.如何进行回收

哪些内存需要进行回收——判断对象是否存活

堆中几乎存放着java中所有的实例对象,垃圾回收器在对堆进行垃圾回收时,首先要判断哪些对象还活着,哪些对象已经死了(没有指向他的引用)

引用计数算法

在对象中加入一个一个引用计数器,当有人引用他时,这个计数器就+1。当引用失效时计数器就-1.当计数器的值为0时,这个对象就不可能再被使用,所以就被认定为是垃圾。
引用计数算法优缺点:
优点:原理简单,实现方便,判断效率高。
缺点:会出现循环引用问题,导致内存泄漏。如果为了解决循环引用问题,需要配合大量的额外处理才能保证正确的工作。
智能指针解决循环引用问题
当一个对象A强引用B时,B只能若引用A。引用计数器也有两个,即强/弱计数器。当强引用计数器为0时(无论弱引用计数器是不是为0)都判定他为垃圾。但是这样可能会出现野指针。
主流的JVM中一般不适用引用计数算法

可达性分析算法

可达性分析算法的基本思路是根据一些被称为 GC Roots的根对象作为起始节点集,然后根据引用关系向下搜索。搜索过程所走的路线被称为引用链,如果某个对象不在引用链内(从 GC Roots不可达)就说明这个对象为垃圾。
再Java中,固定可作为GC Roots的对象包括下列几种
1.虚拟机栈(其中的本地变量表)中引用的对象。
2.静态变量引用的变量
3.常量引用的对象(如字符串常量池引用的字符串)
4.本地方法栈中引用的对象
5.JVM内部引用的对象。如基本数据类型对应的Class对象,一些常驻异常对象。还有系统类加载器
6.被同步锁持(synchronized)有的对象
7.反应Jvm内部情况的JMXBean JVMTI中注册的回调(这啥鬼东西)、本地代码缓存等。

其中第三条说字符串常量池引用的字符串也是GC Roots,那这些字符串什么时候被GC呢。。。

当然,这些GC Roots还远远不够,如分代收集和局部回收,这些只针对部分堆空间的垃圾回收时,需要引入一些其他的对象加入到GC Roots中来。因为不同区域的对象很可能存在互相的引用关系。

引用

无论是可达性分析算法还是引用计数算法都离不开引用这个概念。在最初的java中,引用关系只有一种即如果一个引用类型的对象存储的数指代表的是另一块内存的起始地址,就称该引用为某块内存/对象的引用。之后随着需要,引用的概念得到了扩展。出现了强引用 软引用 弱引用 虚引用

强引用

强引用就相当于java最开始的引用方式。如Object obj = new Object()就是强引用。有强引用存在那么就不会被作为垃圾回收掉。

软引用

只被软引用关联的对象,在系统将要发生内存溢出前,会把这些对象列进回收范围之中,进行第二次回收。如果内存还是不够才会抛出异常。可以用软引用来实现缓存功能。用SoftReference类来实现软引用

弱引用

被弱引用关联的对象,只能活到下一次GC。用WeakReference来实现弱引用

虚引用

被虚引用关联的对象的GC不会受到任何影响。虚引用的唯一作用就是为了能在这个对象被收集器回收时收到一个系统通知。

对象的生死

对象被真正清除前最多会经历两次标记。第一次为可达性分析算法中,如果没有引用链可以到达该对象,则将该对象标记为垃圾。随后jvm将会判断标记的对象是否有必要执行finalize方法,如果没有重定义finalize方法或者jvm已经执行过finalize方法了,则不会执行finalize方法,直接进入清除阶段。如果重写了且还没被执行则会把。加入到F-Queue队列中。由JVM开启一个低优先级线程来执行这些对象的finalize方法。但JVM只保证finalize方法会被执行,但是不保证等待这些对象执行。(如果等待的话,如果某个finalize方法执行时间过长则会大大影响GC效率)。

public class Test18 {
    
    
    static Test18 save = null;

    @Override
    protected void finalize() throws Throwable {
    
    
        super.finalize();
        System.out.println(this);
        save = this;
        System.out.println(this);
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        Test18 t18 = new Test18();
        t18 = null;
        System.gc();
        //finalize方法优先级低,暂停1s确保finalize方法已经执行
        //Thread.sleep(1000);
        if(save == null){
    
    
            System.out.println("复活失败");
        }else {
    
    
            System.out.println("复活成功");
        }

    }
}

在这里插入图片描述

public class Test18 {
    
    
    static Test18 save = null;

    @Override
    protected void finalize() throws Throwable {
    
    
        super.finalize();
        System.out.println(this);
        save = this;
        System.out.println(this);
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        Test18 t18 = new Test18();
        t18 = null;
        System.gc();
        //finalize方法优先级低,暂停1s确保finalize方法已经执行
        Thread.sleep(1000);
        if(save == null){
    
    
            System.out.println("复活失败");
        }else {
    
    
            System.out.println("复活成功");
        }

    }
}

在这里插入图片描述
finalize虽然能用来救活对象并且可以用来释放资源。但是这个方法并不被建议使用。

回收方法区

方法区的垃圾收集主要包括两个部分,常量和类型。
回收常量的过程和回收堆中的对象非常相似。如果某个常量已经没有任何String对象引用他,并且虚拟机中也没有其他地方引用这个字面量。如果这个时候发生GC,而且JVM判断需要的话,那么这个常量将会被清除。常量池中其他的类(接口) 方法 字段的符号引用也与此类似。
如果要判断一个类型是否被使用要麻烦的多。需要满足以下三个条件
1.该类所有实例(派生子类)已经被回收
2.加载该类的加载器已被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi JSP的重加载等,否则很难达成
3.该类对应的Class对象没有在任何地方被引用(不能通过反射创建该对象)

分代收集理论

分代收集理论是大多数商用虚拟机遵循的理论。分代收集理论实质上是一套符合大多数程序运行实际情况的经验法则。它建立在三个分代假说之上。
1.弱分代假说:绝大多数对象都是朝生夕死的。
2.强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
3.跨代引用相较于同代引用,只占很小一部分
分代收集算法,一般将java堆分成两个部分,一个是新生代一个是老年代。其中新生代中的对象通常活不过一次GC。二老年代中的对象,一般很难清除。如果每次垃圾回收时,只针对那些大多数对象都消亡,只有少数对象存活的新生代进行垃圾回收,效率会快很多(只需关关注如何保留少量对象,而不是去标记那些大量的要被回收的对象。)。而将那些难以消亡的对象放到老年代,降低对老年代的GC频率。这样就同时兼顾了垃圾回收的时间开销,也兼顾了内存空间的有效利用。
(只有CMS垃圾回收器有专门针对老年代的垃圾行为)
为了解决跨代引用的问题,可以在青年代加一个全局的数据结构(记忆集),他把老年代分成若干个小块,用来表示老年代中哪一块内存存在快带引用。再进行minor GC(Young GC)时,会把这些内存块中的对象也加入到GC Roots中来。

标记-清除算法

标记-清除算法是最早也是最基础的垃圾收集算法。故名思意它存在两个过程,分别为标记和清除。jav根据GC Roots 来寻找垃圾对象。标记出所有的垃圾对象,然后统一回收掉所有被标记的对象。也可以反过来,标记哪些对象存活,然后标记出那些未被标记的对象。
这个算法有两个缺点。
1.会产生碎片问题
2.执行效率不稳定,如果堆中有大量对象,并且其中的很多对象都要被回收,这样标记动作和清除动作将会花费更多的时间。

标记-复制算法

标记-复制算法,简称为复制算法。他将可用内存空间分成两部分,每次只是用一部分。GC时把存活对象复制到另一块区域,然后直接把原来的区域一次性全清理掉。当存活的对象比较少时,此算法效率很高。但是当存活对象较多时,将会花费很多的复制开销。当然此算法最大的问题是,要浪费掉一半的内存。在新生代就是用了这种算法,将新生代分成一个eden区和两个survivor区。每次分配内存只是用eden区和一个survivor区。(默认比例 8:1:1)
。如果每次存活的对象超过10%的话,就会出现问题了。因此新生代还存在一个分配担保机制,超过10%的部分直接进入老年代。

标记-整理算法

复制算法虽然解决了标记清除算法内存碎片的问题。但是由于他要浪费一半的内存空间,并且不适合用在存活对象较多的情况。因此老年代并不适合使用复制算法。标记-整理算法是对标记-清除算法的另一种优化。其中标记过程两者一样。但是之后的操作并不是对可回收对象进行回收,而是让所有活着的对象都向内存的一端移动,然后直接清理掉边界以外的内存空间。由于要对对象进行移动,这就要更新所有引用这些对象的指针。标记整理算法更注重于吞吐量,而标记-清除算法更注重延迟。(这与Parallel old使用标记整理算法 而CMS使用标记清除算法相吻合。前者注重吞吐量后者保证时延)。

HotSpot虚拟机的算法细节实现

根节点枚举

除了内存整理过程外,根节点的枚举也是导致STW的一大原因。固定可作为GC Roots的节点主要在全局性的引用(如常量 静态属性)与执行上下分(栈帧中的本地变量表)。尽管目标明确但是高效查找过程并不容易。如果进行简单的遍历,那么将会花费大量的时间。HotSpot使用一组称为OopMap的数据结构来达到这个目的。一旦类加载完成的时候,hotsport就会把对象内存上什么偏移量上是什么类型的数据计算出来。在即时编译过程中,也会在特定的位置记录下栈里和寄存器里那些位置是引用。这样垃圾收集器在扫描的时候就可以直接获知这些信息了

安全点

可以导致引用关系变化或者说导致OopMap内容变化的指令非常多,不可能在每个地方都生成对应的OopMap。因此只会在某些特定的地方生成OopMap对象,这些地方被称为安全点。这些安全点的特征就是指令序列的复用,例如方法调用,循环跳转,异常跳转等。
因此只要在GC时,让所有线程都停在安全点即可。那要怎么实现这一点呢。
1.主动式中断,当垃圾收集器需要终端现成的时候,系统把所有线程都停下,然后判断是否在安全点,然后让不在安全点的线程继续运行,让他们一会再次中断。知道所有线程都在安全点上。这种方法几乎没人使用
2.主动式中断,当垃圾收集器需要终端现成的时候,系统设置一个标志位,当线程到达安全点时就会判断这个标志位,如果为真,就在安全点挂起。除了在安全点会判断也会在申请内存的地方判断。

安全区域

安全区域相当于拉长了的安全点,当有一些对象处于无法响应虚拟机的中断请求时(例如sleep),线程不能再走到安全点挂起自己。安全区域是指一段代码片段中不会导致引用关系发生变化。当线程进程安全区域时就会生命自己在安全区域中,当垃圾回收器要中断线程时就会跳过这些安全区中的线程。当要离开安全区时要判断是否完成了根节点的枚举。如果没完成就要一直等待。知道完成根节点的枚举

记忆集和卡表

当进行分代垃圾收集的话,就难免会遇到一个问题——引用跨代问题。为了解决这个问题可以用记忆集来完成。
记忆集是一种记录非清理区域指向清理区域指针的抽象数据结构,如果不考虑效率和成本的话,最简单的方法就是用非收集区域中所有跨代引用的对象数组来实现记忆集。
但是收集器只需要通过记忆集判断出哪一块非清理区域有指向收集区域的指针即可。并不需要知道这些跨代指针的全部细节。
因此在涉及记忆集时就可以选择更加大的记忆粒度了。下面列举了几个记录精度
1.字节精度,每个记录精确到一个机器字节。即该字节包括跨代指针
2.对象精度:即该对象包括跨代指针
3.卡精度:每个记录精确到一块内存,即该内存中存在跨代指针。

其中卡精度所指的是一种称为卡表的方式去实现记忆集。这也是目前最常用的实现方式。

并发的可达性分析

在这里插入图片描述
上图描述了并发出现的对象消失问题。
对象消失必须满足下列两个条件
1.赋值器插入了一条或多条从黑色对象到白色对象的新引用
2.赋值器删除了全部从灰色对象到该白色对象的引用
黑色:引用对象都遍历了
灰色:还有引用没遍历
白色:不在引用链中
为了避免这个问题,有两种解决方式
1.增量更新:记录下新插入白色引用的黑色对象,在扫描完之后,再对这些黑色对象进行一次扫描。相当于当黑色对象一旦新插入了指向白色对象的引用,那么它就变成灰色
2.原始快照:当灰色对象要删除指向白色对象的引用时,就将这个要删除的引用记录下来,等扫描结束之后,再扫描一次。即无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照来进行搜索。

经典的垃圾收集器

在这里插入图片描述
连线表明支持组合使用。JDK 9表示在JDK9之后就不支持了。

Serial收集器

Serial是一款新生代的单线程垃圾收集器。这个单线程说的不仅仅是他只会使用一个处理器或一条收集线程去完成垃圾收集工作。更重要的是强调他在垃圾回收时,必须停掉所有工作线程即STW。Serial收集器简单高效(相较于其他收集器的单线程模式),适合用在内存资源受限的环境。一般用于客户端

ParNew收集器

在这里插入图片描述
ParNew相当于Serial的多线程版本。ParNew可以并行的进行垃圾回收。除此之外他俩几乎完全相同。PartNew(一直)和Serical(JDK1.9之前)都可以和CMS(Concurrent Mark Sweep)组合使用。CMS是第一款支持并发垃圾回收的垃圾收集器。实现了垃圾回收线程和用户线程同时工作(基本上)。

Parallel Scavenge收集器

Parallel Scavenge收集器和ParNew收集器一样都是基于标记-复制算法的。也都是支持并行垃圾收集的新生代垃圾收集器。Parallel Scavenge的特别之处在于,他更关注吞吐量。即在保证吞吐量的情况下,尽可能的提高响应速度。
Parallel Scavenge收集器有一个自适应调节模式,在该模式下虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间,或者最大的吞吐量。

Serial Old收集器

在这里插入图片描述
Serial Old收集器是Serial收集器的老年代版本,也是单线程的。使用标记-整理算法。一般用于客户端模式下。
如果用在了服务器模式下,一般有两种情况
1.作为CMS的后备方案
2.与ParallelScavenge搭配使用

Parallel Old收集器

在这里插入图片描述
Parallel Old收集器是Parallel Scavenge的老年代版本。使用标记-整理算法。他俩搭配使用时才能最大程度发挥出“吞吐量优先”的效果

CMS收集器

CMS是一款老年代垃圾收集器,是一个以获取最短回收停顿的垃圾收集器。
在这里插入图片描述
CMS使用的是标记-清除算法。他的实现相比于前集中垃圾收集器更加复杂一点。一共分为4步
1.初始标记:仅标记与GC Roots直接相连的对象。STW
2.并发标记:并发遍历这个这个对象图的过程。
3.重新标记:对一些索引发生变化的对象再次遍历(增量更新)STW
4.并发清除:由于使用标记-清除算法,不需要移动对象。
CMS被称为并发低停顿收集器,虽然CMS很优秀但是他还是有三个明显的缺点。
1。CMS垃圾清理线程会与工作线程一起工作,会占用一部分线程(或者说处理器计算能力),而导致应用程序变慢。
2.CMS无法清除浮动垃圾(浮动垃圾是指在并发垃圾回收过程中,标记过后,工作线程又出现的垃圾。)因为是并发执行,因此会预留一部分老年代空间给正在运行的程序。但是如果在垃圾清楚过程中,预留的空间不够了,就会触发备用方案。用Seral Old进行Full GC。
3.由于使用标记-清除算法,会出现空间碎片。(Full GC时可以选择进行整理)

Garbage First收集器(G1)

G1收集器
在这里插入图片描述
G1收集器是垃圾收集器技术发展史上里程碑式的成果。从G1开始最先进的垃圾收集器的设计向导不约而同地变为追求能够应付应用的内存分配速率。而不追求一次把整个java堆全部清理干净。
G1将堆内存化整为零,分成了多个独立地Region。G1不在坚持固定大小以及固定数量的分代区域划分。每一个Region都可以根据需要扮演新生代的Eden Survivor 老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。Region中还有一种特殊的H区域,当一个对象大小超过1/2个Region时,大多数时候H将被当作老年代来处理。 相对于整个堆来说使用的是标记-整理算法。相对于region来说使用的是复制算法。这与CMS相比,完全不会产生空间碎片问题。
G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为最小的回收单元,即每次收集的内存空间都是Region大小的整数倍。这样可以避免在整个堆进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的价值大小。价值即所获得的空间大小,然后在后台维护一个优先队列,每次根据用户允许的收集停顿时间,优先处理那些回收价值收益最大的那些Region。这保证了G1在有限的时间内获取尽可能高的效率。
G1的垃圾收集过程
1.初始标记:仅仅标记Gc Roots能直接关联到的对象。并且利用TAMS指针,在Region中划取一块内存以供GC时并发生成对象。STW
2.并发标记:接着根据第一步标记的对象,继续遍历对象图。
3.最终标记:通过步骤二记录的原始快照(SATB),继续遍历。
4.筛选回收:更新Region的统计数据,对各个Region的回收价值喝成本进行排序。根据用户指定的停顿时间,来选择Region构成回收集。把存活的复制到另一个Region,然后清除旧Region。
G1依然存在跨域引用的问题,并且更加严重,每个Region都有一个卡表。卡表中记录着别的Region指向自己的指针,并标记这些指针分别在那些卡页的范围内。
G1在并发标记时依然会出现问题。G1的解决方法是利用原始快照的方式

补充:
1.当survivor区相同年龄的对象,占的空间大于1/2时,将所有大于等于这个年龄的对象直接放入老年代
2.可以通过设置参数的方式,设置个阈值,当要分配的对象大于这个阈值时直接在老年代创建
3.每个线程在Eden区有一个私有的TLAB,可以用来并发分配对象。

猜你喜欢

转载自blog.csdn.net/qq_30033509/article/details/114876536