GC及历史演进

栈和堆

在程序开发中最难以调试的问题莫过于野指针和并发问题。

野指针即是同一个对象,有着多个指针引用,当对象在某一处释放掉了而另一处还不知情依旧使用;或者是不再指向任何对象的指针,也是在java中最为经典的NullPointerExcetion

那么它是怎么产生的呢?大多语言在运行期间内存中的表现都有着栈,堆这两个概念

如下面这段代码

1
2
3
4
5
6
7
8
9
10
public class Test{
static void print(Object o){
Object p = o;
out(p);
}
public static void main(String[] args){
Object o = new Object();
print(o);
}
}

20230825003845image.png

如对于java,每个线程对应一个栈,栈中存放着方法的路径引用,而堆是作为动态分配的区域,存放着不同指针指向的实际资源。

对于图中指针o,p的引用,如果在print之前发生了其他事(如指向的对象在堆中被其他数据覆盖,或是print之前释放掉了o指向的资源…..),就会造成空指针或野指针的问题

再来说说并发问题,这个就是老生常谈了,即多个线程栈同时访问一块内存空间

如果单一的问题还不是最糟糕,那么两者结合就是地狱了

语言的发展历史

语言的发展很大原因是这两部分在推进,最开始c/c++使用手动管理内存,通过malloc()申请内存,使用后通过free()释放掉,但程序不是一条路走到黑,它可能有着数以上百的运行分支,那么释放内存分配内存的时机会造成各种各样的bug。如忘记释放造成内存泄漏,释放多次,野指针满天飞等情况

于是,紧跟着产出了一些内存管理方便的语言,如Go/Python/Java。当然并不是说就完全没有了野指针空指针等问题,而是引入了GC的概念-Gabarge Collector。也就是垃圾收集器

以前我们不止需要分配对象,还得负责释放对象,而GC的引入便是负责了后者的工作,我们只管分配,回收释放的工作交给GC,极大的提升了开发效率

别高兴的太早了,不管是java还是go,依然没有解决空指针野指针的问题。虽然在java中有着Options等处理空指针的类,但根本上并没有解决,我们的代码中还是充斥着大量的if(obj!=null)。而且gc会占用cpu资源,这导致它们的执行效率不如c/c++

什么是垃圾(garbage)?

既然将内存释放的工作交给GC了,那么怎么定义垃圾呢?在程序运行过程中,没有任何指针引用的肯定就是垃圾了,但怎么定位垃圾呢,其中有多种方式

Reference count 引用计数法

它的核心思维是使用一个计数器来标识对象被多少个指针所使用,当为0时即确定为垃圾进行回收。

python就使用了这种方式,但引入计数法存在着问题

20230825011638image.png

如上图这样的情况,三个对象都引用了另一个对象,但没有任何外部指针来引用其中的一个

这样三个垃圾互相指向,如果使用计数法无法定位其中任何一个垃圾,所以java并没有采用这样的方式

Root Searching 根可达算法

java采用的是根可达算法,其通过一系列名为”Gc Roots”的对象作为出发点,从这些节点开始往下搜索,搜索走过的路径称为引用链,当一个对象到Gcroots没有任何引用链相连时,则证明该对象是不可用的

其中能作为根节点的对象有很多

(1). 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

(2). 方法区中的类静态属性引用的对象。

(3). 方法区中常量引用的对象。

(4). 本地方法栈中JNI(Native方法)引用的对象。

其中任何一项拎出来都能啃半天,最简单的即是,main入口方法中任何变量都能算根

常用的垃圾回收算法和垃圾回收器

当我们知道了什么是垃圾后,就得把它清掉了,常见的垃圾清除算法有三种

Mark-Sweep标记清除

它分为标记和清除两个阶段,通过根可达算法标记所有的垃圾,在标记完成后统一进行清除。

20230825014059image.png

这种清除方式有一个问题,那就是在多次标记清除之后,内存碎片化严重,当一次性需要分配大块的内存空间时,会触发新一次的垃圾收集动作


Copying 复制算法

复制算法针对标记清除算法的缺点,在其基础上进行了改进。它将内存可用容量分割为大小相等的两块,每次只使用其中一块,当上面的内存用完了,将存活的对象复制到另一块中,再把原先已使用的内存进行一次性清除

20230825015336image.png

它的缺点也很明显,每次只是用1/2,可用内存少了整整一半!

为了解决copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法

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

该算法标记阶段和Mark-Sweep一样,但不同的是在标记完成之后会闲将存活对象都向一端移动,然后清理存活对象边界以外的垃圾

20230825015716image.png

当然,缺点就是效率比其他两种低了很多,毕竟除了标记动作,还得进行存活对象的排列移动

那么jvm的GC是怎么做的呢?如果将内存当做只有一块可使用的话,三种算法都有缺点。jvm将一整块内存区域划分为不同的块综合应用不同的回收算法

GC垃圾回收器的演化过程

jvm将堆内存分区为 新生代(new) 和老年代(old)

20230825020358image.png

刚诞生的对象优先往新生代存放,每个对象都有自己的“年龄”,当新生代中的对象经历过一次垃圾回收后还存活,那么它的年龄+1,当其年龄超过某个阈值,那么就是老东西了,往老年代移动

在新生代中采用Copying算法,老年代采用Mark-Compact算法

新生代中的垃圾回收称为YG,一次性的回收可能回收到90%的对象,100个对象我只剩下10个对象存活,只分成两块是不是太浪费了?于是在分代模型中,比例为8:1:1,占8成的区域称为伊甸(eden),生命诞生的地方,刚分配的对象会被存放在这里,垃圾回收后存活的存放至占1的survivor区域,伊甸剩下的对象全部清除,在下一次回收中,同时扫描前两个区域,存活的对象搬至第二个Survivor,如此往复。装不下了或是年龄到某个阈值了,迁到老年。当老年代区域满了会触发全区的gc

说回垃圾收集器,GC的演化随着内存大小的不断增进而推进

在只有几m到几十m的时候,只需要Serial收集器

20230825023056image.png

Serial收集器

Serial(串行)收集器是历史最悠久的垃圾收集器。在jdk1.3之前它是年轻代唯一的收集器,它是一个单线程收集器,它只会使用一条垃圾收集线程去完成工作,在它工作的时候会暂停掉所有的工作线程,称为STW(Stop The World),直到它收集结束世界才会重启

这也意味着它在工作的时候会给用户带来卡顿现象造成不良的体验,由于没有线程之间的交互开销,自然可以获得高效的效率

Serial收集器在老年代和新生代的工作方式相同,不同的只是在年轻代中使用复制算法,后者则使用标记-整理算法

后面内存增长到上百兆甚至1G了,如果依然使用Serial收集器,那可能得忍受程序暂停半小时这样的问题,这个时候ParNew出现了,它采用了多线程并行的垃圾收集

工作在年轻代的ParNew叫做Parallel Scavenge,工作在老年代的ParNew称为Parallel Old。简称PS+PO,在1.8中,如果没有过任何设置,这是默认的垃圾回收器方案

ParNew不止使用了多线程提高效率,它更关注的是吞吐量(高效率的利用CPU,也指系统在一个单位时间内可承受的请求数量,表现了系统的承压能力)

慢慢的,内存增长到了上百G,这个时候发现想要提高垃圾回收的效率就牵扯到了操作系统的一个问题

线程数越多效率就越高吗?

答案是不一定,多线程并不意味着真正的同时运行。而是cpu时间片的切换,一个cpu同时只能运行一个线程,16核的cpu同时运行16个线程,这个时候你来了一百个线程,徒增了许多切换的开销不说,对效率并没有作用,甚至线程切换所占的资源超过了线程运行。

所以到了这个时候,增加多的线程并不会提高多大的效率,这个时候,诞生了

Concurrent GC

后面所产生的包括CMS,G1,ZGC都来自于Concureent

Concurrent让GC线程和工作线程可以同时运行,也就是并发。在这之前,GC一运行其他工作线程都得STW。也可以理解为,程序一边工作GC一边清理垃圾

这就是GC回收的历史演进了,当掌握了大概的体系,后面就可以开始对细节的探究及调优了

参考:

介绍 - 《GC参考手册-Java版》 - 书栈网 · BookStack

JVM调优实战1-7p

多线程就一定快吗?天真!_线程越多越快吗_守望之名的博客-CSDN博客

什么是QPS、TPS、吞吐量?- 高并发名词概念_吞吐量qps_一边学习一边哭的博客-CSDN博客

垃圾回收的三种算法_垃圾回收算法_vvuz2的博客-CSDN博客

根可达性算法:是什么、怎么做、为什么_根可达算法_键盘上のDancer的博客-CSDN博客

C++ :引用计数(reference count) 实现_c++引用计数的实现_WangJ_F_的博客-CSDN博客