目录
第一类:Serial收集器&Serial Old收集器(串行收集)
第二类:ParNew收集器&Parallel Old收集器&Parallel Scavenge收集器(并发收集)
一、GC的作用
GC:全称是Garbage Clean(垃圾回收)。我们平时写代码的时候,经常会申请内存。例如:
创建变量、new对象、加载类...
但是,由于内存空间是有限的,因此就需要"有借有还"。
如下代码,就是申请了两个变量:一个a,另外一个是object
int a=3;
Object obj=new Object()
申请变量的时机&销毁变量的时机
申请一个变量(申请内存的时机)是确定的。就是new或者int a=...这种。但是,这个变量什么时候不需要使用了,那这个时期就不确定了。
例如:内存释放得偏早:如果还想要使用obj对象,但是如果这一个对象被回收了,那这样就显得不合理了。
又或者:内存的释放比较偏迟,对象一直占着"坑位"。
对于内存什么时候被释放这个问题, 不同的语言有不同的处理方式。
对于C语言:程序没有提供垃圾回收机制。因此当内存需要释放的时候,必须由程序员手动进行释放(调用free函数),因此,就会引入一个臭名昭著的问题,那就是"内存泄漏"。
内存泄漏
如果申请的内存越来越多,那么就意味着可用的内存越来越少,最终无内存可用了。这种现象就叫做"内存泄漏"。
虽然垃圾回收可以让开发的程序员专注于设计业务上面的代码,无需关心内存泄露的问题,但是仍然有一定的劣势。
-
定义:内存泄露是指程序在运行过程中未能释放不再使用的内存,导致可用内存逐渐减少。
-
原因:
-
动态分配的内存(如堆内存)在使用完后未被释放。
-
对象被意外保留(如未清除的缓存、未关闭的资源或未释放的引用)。
-
-
影响:
-
长期运行的程序会逐渐消耗更多内存。
-
最终可能导致内存泄漏(Memory Leak)错误,程序崩溃。
-
public class MemoryLeak {
private static List<Integer> list = new ArrayList<>();
public void addData() {
while (true) {
list.add(123); // 不断向列表中添加数据,但从未清理
}
}
}
内存溢出(oom)
-
定义:内存溢出是指程序在申请内存时,没有足够的可用内存来满足需求。
-
原因:
-
内存泄露导致可用内存逐渐减少。
-
程序需要处理的数据量过大,超过了可用内存。
-
内存分配不合理(如一次性加载过多数据)。
-
-
影响:
-
程序抛出
OutOfMemoryError
(在 Java 等语言中)或直接崩溃。
-
-
举例:
例子1:程序在申请内存的时候,没有足够的内存提供给申请者使用。这种现象就被称为"内存溢出"。
例如给一个int类型空间的大小,却存储一个long类型的数据,这样就会导致"内存溢出"。
例子2:
-
定义:内存溢出是指程序在申请内存时,没有足够的可用内存来满足需求。
-
原因:
-
内存泄露导致可用内存逐渐减少。
-
程序需要处理的数据量过大,超过了可用内存。
-
内存分配不合理(如一次性加载过多数据)。
-
-
影响:
-
程序抛出
OutOfMemoryError
(在 Java 等语言中)或直接崩溃。
-
public class OutOfMemoryExample {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 不断分配内存,最终耗尽
}
}
}
-
解决方法:
-
优化内存使用,减少不必要的数据加载。
-
增加可用内存(如调整 JVM 堆大小)。
-
修复内存泄露问题。
-
栈溢出(StackOverflow)
-
定义:栈溢出是指程序调用栈(stack)的空间被耗尽,通常是由于过深的递归调用或无限递归导致的。
-
原因:
-
递归函数没有正确的终止条件。
-
函数调用层次过深,超出了栈的容量。
-
-
影响:
-
程序会崩溃,并抛出
StackOverflowError
(在 Java 等语言中)或类似的异常。
-
def infinite_recursion():
infinite_recursion() # 无限递归,导致栈溢出
infinite_recursion()
-
解决方法:
-
优化递归,确保有正确的终止条件。
-
增加栈大小(如调整 JVM 栈大小)。
-
改用迭代代替递归。
-
对比总结:
特性 | 内存泄露(Memory Leak) | 内存溢出(Out of Memory) | 栈溢出(StackOverflow) |
---|---|---|---|
内存区域 | 堆内存(heap) | 堆内存(heap) | 栈内存(stack) |
触发原因 | 未释放不再使用的内存 | 内存不足,无法满足分配需求 | 递归调用过深或无限递归 |
影响 | 内存逐渐耗尽,最终导致 OOM | 程序抛出 OOM 错误或崩溃 | 程序立即崩溃 |
典型错误 | 无直接错误,但最终可能导致 OOM | OutOfMemoryError | StackOverflowError |
解决方法 | 修复未释放的内存,使用工具检测泄露 | 优化内存使用,增加可用内存 | 优化递归,增加栈大小,改用迭代 |
内存只有512M,进程分配1G内存可以实现吗?
可以,但依赖操作系统的虚拟内存机制:
虚拟内存支持:操作系统通过 分页机制(Paging) 和 交换空间(Swap),允许进程分配超过物理内存的虚拟内存(如1G)。
实现方式:
物理内存占用:进程实际使用的活跃数据(如代码段、堆栈)驻留在物理内存中。
交换空间支持:非活跃数据(如未使用的内存页)会被换出到磁盘交换分区(Swap),腾出物理内存空间。
代价:频繁换页会导致 性能下降(磁盘I/O瓶颈),进程可能变得卡顿。
如何关闭换页(Swap)机制?
禁用交换分区:
sudo swapoff -a # 临时关闭所有交换分区
sudo vim /etc/fstab # 永久关闭:注释掉所有包含 "swap" 的行
禁用分页(仅限物理内存):
实际无法完全禁用分页机制(内核强制管理内存),但禁用Swap后,系统只能使用物理内存,无法换出内存页到磁盘。
风险:物理内存耗尽时,系统会直接触发 OOM Killer,杀死进程。
OOM Killer 在内存不足时选择杀死哪些进程?
机制:内核为每个进程计算
oom_score
(基于内存占用、优先级等),选择分数最高的进程终止。关键影响因素:
内存占用:占用物理内存越多,得分越高。
进程优先级:低优先级进程(如用户进程)更容易被杀。
运行时间:短生命周期进程(如临时任务)优先级更低。
调整参数:通过
oom_score_adj
手动干预(值越低越不易被杀)。
垃圾回收的劣势
1、引入了额外的开销(消耗资源更多了)
2、可能会影响程序的流畅运行:垃圾回收经常会出现:STW(stop the work)问题。
二、GC的工作过程
回收的是什么样的对象
在上一篇文章当中,我们提到了:JVM的内存区域划分主要分为4个部分:
程序计数器、栈、堆、方法区。
对于栈区,只要方法返回之后,就会自动从栈上面消失了,不需要GC。
对于堆区,就很需要GC了,因为堆区当中存放的大量都是new出来的对象。
我们来画一张图,描述一下根据内存使用与否的图:
因此,需要回收的对象,都是一些没有使用,但是同时也占用着内存的对象。
回收垃圾的过程
垃圾回收的过程,分为两大阶段:
第一阶段:找垃圾/判定垃圾
方案1:基于引用计数(非Java语言)
针对每一个对象,都引入一小块的内存,保存这一个对象有多少个引用指向它。
例如:(此时有两个引用都指向new Test()对象)
//t1引用指向new Test()对象
Test t1=new Test();
//t2引用指向new Test()对象
Test t2=t1;
那么,此时在new Test()当中,就会有一个引用计数器,显示指向这个对象的引用个数为2。
那么,当引用计数为0的时候,也就意味着此时没有引用指向这个对象了,需要GC对于这一个对象进行回收操作。
什么时候引用计数为0呢?下面举一个例子:
private static void func2() {
//让t1指向new Test1()对象
Test1 t1=new Test1();
//让t2指向new Test1()对象
Test1 t2=t1;
}
在一个方法当中,两个引用(t1,t2)同时指向了new Test1()对象。 当调用func2()方法的时候,t1和t2引用会保存在func2()方法的栈帧上面。两个引用同时指向了堆上面的new Test1()对象。
当func2()调用结束之后,会从栈帧上面消失,那么t1和t2引用也会随之消失。
那么,也就意味着:new Test2()这一个对象没有引用指向它了,认为它是一个"垃圾",也就会被回收。
引用计数方式的缺陷
缺点1:空间利用率比较低
每一个new的对象都必须要搭配一个计数器来记录几个引用。引用计数器的大小为4个字节,但是如果一个对象除了引用计数器以外的部分本身也就只有4个字节大小,那么就意味着比较浪费空间。
缺点2:会有循环引用的问题
下面,来举一个例子说明一下什么是循环引用问题:
首先:创建一个Test类,在内部有一个属性,就是Test t=null;
然后:在测试类当中,创建这一个类的实例对象:
class Test {
Test t = null;
}
/**
* @author 25043
*/
public class Test2 {
public static void main(String[] args) {
Test t1 = new Test();
Test t2 = new Test();
}
}
到这一步的时候,来画一个引用——对象的指向图:
然后,接下来:执行下面的代码:
public static void main(String[] args) {
//对象1
Test t1 = new Test();
//对象2
Test t2 = new Test();
t1.t = t2;
t2.t = t1;
}
到了这一步,再画一下引用指向的图:(把t2引用指向的对象赋给了对象1的t属性、把t1引用指向的对象赋给了对象2的t属性)
到这一步的时候:
对象1有两个引用指向(t1、t2.t);
对象2有两个引用指向(t2、t1.t)。
接下来:令:t1=null,t2=null。
public static void main(String[] args) {
//对象1
Test t1 = new Test();
//对象2
Test t2 = new Test();
t1.t = t2;
t2.t = t1;
t1 = null;
t2 = null;
}
那么此时可以认为:
t1的指向为null,并且t2的指向为null。
那么对应的指向对象1的引用减少了一个,只剩下(t2.t)
同时,指向对象2的引用也减少了一个,只剩下(t1.t)
两个对象的引用计数器各自减少为1。
由于引用计数不为0,也就是两个对象互相引用。那么,这两个对象无法被回收。但是,外部的引用又无法访问这两个对象。因此,这两个对象就永远无法被回收,也永远无法被使用。这样,也就出现了内存泄漏。
由于上述的两个缺点,因此引入了方案2(基于Java语言的解决方案:基于可达性分析)
方案2:可达性分析(基于Java语言)
通过一个额外的线程,定期地针对整个内存空间的对象进行扫描。
有一些起始的位置(称为GCroots),然后类似于深度优先搜索的方式,把可以访问到的对象都标记一遍。那么,带有标记的对象就是"可达的"。没有被标记的对象,那就是"垃圾"。这样就很好地解决了对象不可达的问题。(避免了两个对象相互引用、但是没有外部引用指向的问题)
尽管可达性分析方法可以有效解决引用循环的问题,但是如果一个程序当中的对象特别多,那么也一定会造成比较大的性能损耗,因为整个搜索的过程也是比较消耗时间的。
GCRoots是哪些变量(3类)
第一类:栈上的局部变量;
第二类:常量池当中的引用指向的变量;
第三类:方法区当中的静态成员指向的对象。
一个引用置为null之后,它之前指向的对象会立刻被回收吗?
不会!
原因1:一个引用被置为null之后,如果这一个对象仍然有其他的引用指向,那么仍然被认为是"可达"的。
原因2:即使一个对象没有任何引用指向,那么也需要等到GC扫描一轮结束之后,被判定为不可达的"垃圾对象"。才会被回收。
垃圾回收机制会回收长时间没有使用的基本数据类型变量吗?
基本数据类型变量:
在Java、C#等语言中,基本数据类型变量(如
int
、float
、boolean
等)通常存储在 栈内存 中(如果是局部变量),或者作为对象的一部分存储在 堆内存 中(如果是类的成员变量)。
栈内存:局部变量在方法执行完毕后会自动释放,不依赖垃圾回收机制。
堆内存:如果基本数据类型是对象的成员变量,当对象不再被引用时,垃圾回收器会回收整个对象,包括其成员变量。
垃圾回收机制:
垃圾回收器主要管理 堆内存,回收不再被引用的对象。因此,如果基本数据类型是对象的成员变量,且对象不再被引用,垃圾回收器会回收它。
第二阶段:回收垃圾(释放内存)
回收垃圾主要分为三种策略:
策略1:标记-清除策略;
策略2:复制算法;
策略3:标记-整理策略
下面,将分别介绍这三种策略:
策略1:标记-清除策略
标记,就是可达性分析的过程。例如在一次搜索当中,发现了以下几个部分是"垃圾"。清除,就是直接释放内存。
策略1存在问题分析:(内存碎片)
此时如果直接释放,虽然内存的确还给了操作系统了,但是内存还是离散的,也就不是连续的,这样带来的问题就是"内存碎片",影响程序的运行效率。
策略2:复制算法
为了解决内存碎片,引入的复制算法。如下图:把内存一分为二:
然后,把正常的对象(没有被标记为垃圾的对象)的拷贝到令一半。
最后,把左侧的空前全部释放掉。此时,内存碎片问题就迎刃而解了。
复制算法存在问题分析:
复制算法有效解决了上述的内存碎片问题,但是,仍然有以下的两个问题没有解决:
问题1:内存空间利用率低,只能利用一半的空间。
问题2:开销大。如果垃圾比较少,那么这种搬运得不偿失。
策略3:标记——整理
这个过程,就是把正常的对象(没有被标记为垃圾的)往前搬运。最后,释放掉最后面的内存。
下图当中,灰色部分的为垃圾。
但是,这个拷贝也是有开销的。
上述的3种方案,虽然可以解决问题,但是都有缺陷。因此,实际上JVM当中,会结合多种方案一起来实现,并不是采用单一的策略。这种方式,就是"分代回收"。
分代回收
分代回收,其实就是针对对象进行"分类",根据对象的"年龄"进行回收。
对象的年龄:每熬过GC的一轮扫描没有被回收,那么对象的年龄就+1岁。这个年龄存储在"对象头"当中。
大致是这样的一个过程:
存储对象的内存区域大致就被分为了两部分:新生代和老年代
在新生代当中,分为了两部分:伊甸区和幸存区。一共有2个幸存区
步骤1:对于刚刚产生的对象,都会被存放在"伊甸区"。
步骤2:如果熬过一轮GC,那么就会被拷贝到"幸存区",(应用了复制算法)。但是大部分对象都熬不过一轮的GC。
步骤3:在后续的几轮GC当中,幸存区的对象就在两个幸存区当中来回拷贝。此处也是采用了"复制算法",来淘汰掉一些对象。
步骤4:经过了多轮的GC后,如果一个对象还是没有被淘汰,那么就会被放入"老年代"。此时就认为这个对象存活的可能性就比较大了。对于老年代的对象来说,GC扫描的次数就远远低于新生代了。同时,老年代当中采用的就是"标记——整理"的方式来回收。
但是,有一种特殊的情况,就是当一个对象特别"大",也就是占用内存比较多的时候,无需经过多轮GC的扫描,就可以直接进入"老年代"了,因为回收这一类的对象比较消耗性能。
调用了System.gc()之后,会立即触发垃圾回收吗?
不一定会的!!
当程序调用System.gc()之后,JVM会把垃圾回收请求放入到一个请求队列当中。
然后在后续程序运行的某个时间点,JVM会自动检查当前内存的使用情况,并根据垃圾回收算法等因素,决定是否进行垃圾回收。
但是,也有可能会立即进行垃圾回收,这个过程是不确定的。
三、垃圾收集器有哪些
第一类:Serial收集器&Serial Old收集器(串行收集)
这两个垃圾收集器是串行收集的。那么也就意味着,在垃圾的扫描和释放的时候,其他的业务线程都需要停止工作。这种方式扫描得慢、释放得慢、也产生了严重的STW。
第二类:ParNew收集器&Parallel Old收集器&Parallel Scavenge收集器(并发收集)
这三个收集器、引入了多线程的方式来进行回收,也就是"并发收集"。并不影响业务线程执行业务代码。
第三类:CMS收集器
执行步骤:
步骤1:找到GCRoots(会引发STW)
找到GCRoots,但是会引起短暂的STW。
步骤2:并发标记
和业务线程一起执行。
步骤3:重新标记(会引发STW)
步骤4:回收内存
这个步骤,也是和业务线程一起执行的。
第四类:G1收集器
把整个内存,分成了很多个小的区域(Region)
给这些Region进行了不同的标记。有一些region存放新生的对象,有一些存放老年代的对象。
然后一次扫若干个Region(但不是全部扫完)
G1收集器和CMS收集器的区别
G1回收器和CMS回收器在Java虚拟机中都有各自的特点和用途,它们之间存在一些显著的区别。
使用范围:
CMS收集器主要是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用。
而G1收集器的收集范围则涵盖了老年代和新生代,它将Java堆划分为多个大小相等的区域(Region),每个区域可以是Eden区、幸存者区或老年代区。
垃圾碎片:
CMS收集器使用的是“标记-清除”算法进行的垃圾回收,这种算法容易产生内存碎片。而G1收集器则使用的是“标记-整理”算法,它会在垃圾回收过程中进行空间整合,降低内存空间碎片的产生。
垃圾回收过程:
两者的垃圾回收过程也有所不同。CMS回收器的过程包括初始标记、并发标记、重新标记和并发清理。而G1回收器的过程包括初始标记、并发标记、最终标记和筛选回收。
特性:CMS回收器的主要特点是低停顿和并发,有助于保障系统的响应速度。而G1回收器则具有软实时(soft real-time)的特性,它允许用户设定一个暂停时间目标(Pause Time Goal),并根据这个目标来调整回收策略,以实现在要求的时间内完成垃圾回收。
总的来说,G1回收器和CMS回收器在使用范围、垃圾碎片处理、垃圾回收过程和特性等方面都有所不同。选择哪种回收器取决于具体的应用场景和需求。