java学习之路一些收藏

JavaGuide知识点整理——JVM垃圾回收

2022-07-30  本文已影响0人  唯有努力不欺人丶

本篇文章的基本脉络


知识点脉络

当需要排查各种内存溢出问题,当垃圾收集称为系统达到更高并发的瓶颈时,我们就需要对这些自动化技术实施必要的监控和调节。

揭开JVM内存分配与回收的神秘面纱

java的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时java自动内存管理最核心的功能是堆内存中对象的分配与回收。
java堆是垃圾收集器管理的主要区域,因此也被称为GC堆。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以java堆还可以细分为新生代,老年代。再细致一点有:Eden空间,From Survivor,To Survivor空间等。进一步划分的目的是更好的回收内存,或者更快地分配内存。
堆空间的基本结构如下:

堆的基本结构
上图所示的Eden区,s0区,s1区都属于新生代,Old Memory属于老年代。
大部分情况下,对象都会首先在Eden区分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象年龄加1.当它的年龄增加到一定程度(默认15)就会晋升到老年代中,对象晋升到老年代的年龄阈值可以通过参数-XX:MaxTenuringThreshold来设置。这个值会在虚拟机运行过程中进行调整。但是HotSpot有个机制:遍历所有对象时,按照年龄从小打大对其占用大小累积,当累积的某个年龄大小超过了s区的一半时,去这个年龄和设置的默认年龄更小的那个值作为新的晋升年龄阈值。
比如说 设置年龄10, S区内存空间共10, 1岁的2,2岁的1,3岁的4,4岁的0,5岁的2...这个时候HotSpot遍历的时候 1岁的2 + 2岁的1 +3岁的4.发现到3岁超过了s区的一半,那么会把3和10去对比,发现3更小,会把晋升年龄阈值设置为3、
动态年龄计算的代码如下:
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
  //sizes数组是每个年龄段对象大小
  total += sizes[age];
  if (total > desired_survivor_size) {
      break;
  }
  age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}

经过GC后,Eden区和From区应该呗清空。这个时候From和To会交换觉得。也就是新的To变成了上次GC前的From,新的From就是上次GC前的To。不管怎么样都会保证名为To的Survivor区域是空的、MinorGC会一直重复这样的过程。在这个过程,有可能当Minor GC后,Survivor的From区域空间不够,有一些还不达到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。下面我们用代码测试一下:
参数设置如下

-verbose:gc
-Xmx200M
-Xms200M
-Xmn50M
-XX:+PrintGCDetails
-XX:TargetSurvivorRatio=60
-XX:+PrintTenuringDistribution
-XX:+PrintGCDateStamps
-XX:MaxTenuringThreshold=3
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC

示例代码如下:

/*
* 本实例用于java GC以后,新生代survivor区域的变化,以及晋升到老年代的时间和方式的测试代码。需要自行分步注释不需要的代码进行反复测试对比
*
* 由于java的main函数以及其他基础服务也会占用一些eden空间,所以要提前空跑一次main函数,来看看这部分占用。
*
* 自定义的代码中,我们使用堆内分配数组和栈内分配数组的方式来分别模拟不可被GC的和可被GC的资源。
*
*
* */

public class JavaGcTest {

    public static void main(String[] args) throws InterruptedException {
        //空跑一次main函数来查看java服务本身占用的空间大小,我这里是占用了3M。所以40-3=37,下面分配三个1M的数组和一个34M的垃圾数组。


        // 为了达到TargetSurvivorRatio(期望占用的Survivor区域的大小)这个比例指定的值, 即5M*60%=3M(Desired survivor size),
        // 这里用1M的数组的分配来达到Desired survivor size
        //说明: 5M为S区的From或To的大小,60%为TargetSurvivorRatio参数指定,可以更改参数获取不同的效果。
        byte[] byte1m_1 = new byte[1 * 1024 * 1024];
        byte[] byte1m_2 = new byte[1 * 1024 * 1024];
        byte[] byte1m_3 = new byte[1 * 1024 * 1024];

        //使用函数方式来申请空间,函数运行完毕以后,就会变成垃圾等待回收。此时应保证eden的区域占用达到100%。可以通过调整传入值来达到效果。
        makeGarbage(34);

        //再次申请一个数组,因为eden已经满了,所以这里会触发Minor GC
        byte[] byteArr = new byte[10*1024*1024];
        // 这次Minor Gc时, 三个1M的数组因为尚有引用,所以进入From区域(因为是第一次GC)age为1
        // 且由于From区已经占用达到了60%(-XX:TargetSurvivorRatio=60), 所以会重新计算对象晋升的age。
        // 计算方法见上文,计算出age:min(age, MaxTenuringThreshold) = 1,输出中会有Desired survivor size 3145728 bytes, new threshold 1 (max 3)字样
        //新的数组byteArr进入eden区域。


        //再次触发垃圾回收,证明三个1M的数组会因为其第二次回收后age为2,大于上一次计算出的new threshold 1,所以进入老年代。
        //而byteArr因为超过survivor的单个区域,直接进入了老年代。
        makeGarbage(34);
    }
    private static void makeGarbage(int size){
        byte[] byteArrTemp = new byte[size * 1024 * 1024];
    }
}

注意如下输出结果汇总老年代的信息为concurrent mark-sweep generation、另外还列出了某次GC后是否重新生成了threshold。以及各个年龄占用空间大小。

2021-07-01T10:41:32.257+0800: [GC (Allocation Failure) 2021-07-01T10:41:32.257+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 1 (max 3)
- age   1:    3739264 bytes,    3739264 total
: 40345K->3674K(46080K), 0.0014584 secs] 40345K->3674K(199680K), 0.0015063 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2021-07-01T10:41:32.259+0800: [GC (Allocation Failure) 2021-07-01T10:41:32.259+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 3 (max 3)
: 13914K->0K(46080K), 0.0046596 secs] 13914K->13895K(199680K), 0.0046873 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
 par new generation   total 46080K, used 35225K [0x05000000, 0x08200000, 0x08200000)
  eden space 40960K,  86% used [0x05000000, 0x072667f0, 0x07800000)
  from space 5120K,   0% used [0x07800000, 0x07800000, 0x07d00000)
  to   space 5120K,   0% used [0x07d00000, 0x07d00000, 0x08200000)
 concurrent mark-sweep generation total 153600K, used 13895K [0x08200000, 0x11800000, 0x11800000)
 Metaspace       used 153K, capacity 2280K, committed 2368K, reserved 4480K

对象优先在Eden区分配

目前主流的垃圾收集器都会采用分代回收算法, 因此需要将堆内存分为新生代和老年代。这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
大多数情况下,对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,下面我们实际测试一下:

jvm打印GC日志的命令如下-XX:+PrintGCDetails

两个对象都没有实例化的内存情况

代码如下:

    public static void main(String[] args) throws Exception {
        byte[] allocation1, allocation2;
        allocation1 = new byte[20000*1024];
        // allocation2 = new byte[20000*1024];
    }

实例化一个后运行结果如下:


allocation1占用Eden空间

从图中可以看出Eden区占用百分之九十多了,肯定装不下实例化后的allocation2了,现在我们打开注释的代码再次运行:


老年代被占用

简单解释一下为什么会出现这种情况:因为给allocation2分配内存的时候eden区几乎被分配完了,我们刚刚讲了当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC, GC期间虚拟机又发现allocation1无法存入Survivor空间,所以只好通过分配担保机制把新生代的对象提前转移到老年代中去,老年代上的空间足够存放allocation1,所以不会出现full GC。执行Minor GC后,后面分配的对象如果能存进Eden区还是会在eden区分配内存。可以执行下面的代码验证:

至此占用eden百分之10
打开最后一个对象占用eden百分之15

由此说明新对象分配在eden区。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如字符串,数组)

为什么这么做呢?
为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象一个对象年龄计数器。
如果对象在Eden出生并经过一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1,对象在Survivor中每熬过一次Minor GC,年龄就增长一岁。当它的年龄增加到一定程度(默认15岁)就会晋升到老年代中,对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

动态对象年龄判断

大部分情况对象都会首先在Eden区域分配,在一次新生代垃圾回收之后,如果对象还存活,则会进入S0或者S1。并且对象的年龄还会加1.当年龄到大设定的阈值会晋升到老年代。
但是HotSpot还有一个机制:HotSpot遍历所有对象时,按照年龄从小到大对其大小进行累积。当累积的某个年龄大小超过Survivor区的一半时,取这个年龄和默认阈值较小的那个作为晋升年龄的阈值。代码如下:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
//sizes数组是每个年龄段对象大小
total += sizes[age];
if (total > desired_survivor_size) {
   break;
}
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}

还有一点:默认晋升年龄并不都是15,区分垃圾收集器的。CMS默认的就是6.

主要进行GC的区域

针对HotSpot VM的实现,它里面的GC其实准确的分类只有两种:

空间分配担保

空间分配担保是为了确保在Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间。

JDK6.24之前,在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure 参数的设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将会尝试一次Minor GC。尽管这次Minor GC是有风险的。如果小于或者XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
JDK6.24以后的会泽变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行MinorGC,否则将进行Full GC。

对象已经死亡

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1.当引用失效,计数器就减1.任何时候计数器为0的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其主要原因就是它很难结果循环引用的问题。所谓对象的相互引用问题,就是两个对象互相引用,除此之外再无其他引用。因为互相引用导致计数器不为0.于是引用计数法无法通知GC回收他们。代码如下:

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;

    }
}

可达性分析算法

这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,证明此对象是不可用的,需要被回收。

下图中的Object6-Object10虽然有引用关系,但是他们到GC Roots不可达,因为是需要被回收的对象。


image.png

哪些对象可以作为GC Roots呢?

对象可以被回收,就代表一定会被回收么?
即使在可达性分析法中不可达的对象,也并非是"非死不可"的,这时候它们暂时处于"缓刑阶段"。要真正宣告一个对象死亡。至少要经历两次标记过程:可达性分析法中不可达的对象被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行finalize方法,当对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

再谈引用

无论是否通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判断对象的存活都与"引用"有关。
JDK1.2之前,java中引用的定义很传统:如果reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2之后,java对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用四种(引用强度逐渐减弱)

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

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

弱引用
如果一个对象只具有弱引用,也类似于可有可无的生活用品。弱引用与软引用的区别在于:只有弱引用的对象拥有更短暂的生命周期。在垃圾回收器扫描他所管辖的内存区域时,一旦发现了只有弱引用的对象,不管当前内存是否足够,都会回收它,不过由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只有弱引用的对象。

虚引用
虚引用顾名思义形同虚设,并不会决定对象的声明周期,如果一个对象只有虚引用,那么就和没有引用一样。任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。

虚引用和软引用和弱引用的区别:虚引用必须和引用队列联合使用。当垃圾回收器准备回收一个对象的时候,如果发现它还有虚引用,就会在回收对象之前把这个虚引用加入到与之关联的引用队列中。程序可以判断引用队列中是否已经加入了虚引用。来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

特别注意的是,在程序设计中一般很少使用弱引用和虚引用,使用软引用的情况比较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出等问题的产生。

如何判断一个常量是废弃常量?

运行时常量池主要回收的是废弃的常量,那么我们如何判断一个常量是废弃常量呢?
JDK1.7之前运行时常量池逻辑包含字符串常量池存在方法区,此时HotSpot虚拟机对方法区的实现为永久代
JDK1.7字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池。也就是说字符串常量池被单独拿到堆中,运行时常量池剩下的东西还是在方法区中,也就是永久代。
JDK1.8HotSpot移除了永久代,用元空间取而代之。这时候字符串常量池还是在堆中,运行时常量池还是在方法区。只不过是从永久代变成了元空间。

假如在字符串常量池中存在字符串"abc",如果当前没有任何String对象引用该字符串常量的话,说明常量"abc"是废弃常量,如果这时候发生内存回收且有必要的话,"abc"就会被系统清理出常量池了。

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?
判定一个常量是否废弃比较简单,而判断一个类是否是无用的类的条件则相对苛刻许多,类需要同时满足下面三个条件才算是无用的类:

虚拟机对可以满足上述三个条件的无用类进行回收,这里说的仅仅是可以,而不是和对象一样不使用了就必然被回收。

垃圾收集算法

标记-清除算法

该算法分为标记和清除两个阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到的。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题
  2. 空间问题(标记清除后悔产生大量不连续的碎片)
    标记清除

标记-复制算法

为了解决效率问题,标记-复制算法出现了,它可以将内存分为大小相同的两块,每次使用其中一块,当这一块内存用完了以后,就将还存活的对象复制到另一块去,然后再把使用的空间一次性清理掉。这样每次的内存回收都是对内存区间的一半进行回收。


标记-复制

标记-整理算法

根据老年代的特点提出的一种标记算法,标记过程和标记-清除算法一样。但是后续步骤不是直接对可回收对象回收。而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。


标记-整理

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法。这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几个块。一般java将堆分为新生代和老年代。这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以采用标记-复制算法。只需要付出少了对象的复制成本就可以完成每次的垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对他们进行分配担保,所以我们必须选择标记-清除或者标记-整理算法进行垃圾回收。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但是并不是要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更没有万能的垃圾收集器。我们能做到的就是根据具体的应用场景选择合适自己的垃圾收集器。试想一下:如果有一种任何场景下都适用的完美收集器存在,那么HotSpot虚拟机就不会实现那么多不同的垃圾收集器了。

Serial收集器

Serial串行收集器是最基本,即使最悠久的垃圾收集器了。这是一个单线程的收集器。它的“单线程”的意义不仅仅意味着它只会适用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其它所有线程(Stop The World),直到它收集结束。

Serial收集器新生代采用标记-复制算法,老年代采用标记-整理算法。

image.png

虚拟机的设计者们当然知道STW会带来不良的用户体验。所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿。目前是没有不会停顿的)。
但是Serial收集器有一个优于其他收集器的地方:它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择。

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为(控制参数,收集算法,回收策略等)都是Serial收集器完全一样。

image.png
他是许多运行在Server模式下的虚拟机首要选择,除了Serial手机七万,只有它能和CMS收集器(真正意义上的并发收集器)配合工作。

并行和并发概念补充:

Parallel Scavenge收集器

Parallel Scavenge收集器也是标记-复制算法的多线程收集器,看上去和ParNew一样,但是他有个特别的地方:
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间和CPU总消耗时间的比值。Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或者最大吞吐量。如果对于收集器不了解的手动优化存在困难的时候,用Parallel Scavenge收集器配合自适应调节策略,把内存管理优化交给虚拟机完成也是一个不错的选择。

Parallel Scavenge新生代采用标记-复制算法,老年代采用标记-整理算法

这是JDK8默认的收集器。我们可以用指令查看:

java -XX:+PrintCommandLineFlags -version

JDK8默认Parallel Scavenge_old

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。主要有两大用途:一种用途是JDK1.5及其以前的版本中和Parllel Scavenge搭配使用。另一种是作为CMS的后备方案。

Parallel Old收集器

Parallel Scavenge收集器的老年代版本。使用多线程和标记-整理算法。在注重吞吐量和CPU资源的场合,都可以有限考虑Parallel Scavenge收集器 和 Parallel Old收集器.

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS收集器是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程和用户线程(基本上)同时工作。
从名字上Mark Sweep这两个此可以看出CMS收集器是一种标记-清除算法实现的。它的运作过程比前几种垃圾收集器更复杂一点。整个过程分为四种:

G1收集器

G1是一款面向服务器的垃圾收集器。主要针对配备多颗处理器以及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
被视为JDK1.7中HotSpot虚拟机的一个重要进化特征,它具备以下特点:

G1收集器运作大致分为下面几个步骤:

G1收集器在后台维护了一个优先列表,每次根据允许的时间优先回收价值最大的Region,这种使用Region划分内存空间以及优先级的区域回收方式,保证了G1收集器在有限的时间内尽可能高的收集率(把内存化整为零)。
比如一个Region预计100ms回收20M垃圾,另一个Region预计10ms回收100M垃圾,那么10ms100M的这个就会优先回收。

ZGC收集器

ZGC(The Z Garbage Collector)是JDK11推出的一款实验性的低延迟垃圾回收器。设计目标如下:

从设计目标上看ZGC适用于大内存低延服务的内存管理。其实在极度追求用户体验的情况下,不管是CMS还是G1都会有所不足。感兴趣的可以去看下美团团队分享的关于ZGC的介绍,内容比较多,我就不搬运了~附上链接:新一代垃圾回收器ZGC的探索与实践

本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利,每天进步一点点~

上一篇下一篇

猜你喜欢

热点阅读