【Java】刚刚!突然!紧急通知!垃圾回收!
文章目录
- 【Java】刚刚!突然!紧急通知!垃圾回收!
- 从C语言的内存管理引入:手动回收
- Java的垃圾回收机制
- 引用计数器
- 循环引用问题
- 可达性分析法与GC Root
- GC Root的典型例子
- 标记-清除算法
- 优点
- 缺点
- 复制算法
- 工作原理
- 优点
- 缺点
- 标记-整理算法
- 工作原理
- 优点
- 缺点
- 应用场景
- 分代回收机制
- 堆内存的分代
- 各代的垃圾回收策略
- 新生代垃圾回收(Minor GC)
- 老年代垃圾回收(Major GC 或 Full GC)
- 结语
本文将先简要介绍C语言的手动内存回收机制,然后深入探讨Java的垃圾回收(GC)机制,包括引用计数器、可达性分析法、GC root、标记-清除算法、复制算法、标记-整理算法以及分代回收机制。
从C语言的内存管理引入:手动回收
首先让我们从C语言说起,在C语言中,我们需要手动管理内存的分配和释放。这一需求主要通过malloc
、calloc
、realloc
和free
函数来实现:
malloc
:用于动态分配内存。calloc
:类似于malloc
,在分配内存的同时,初始化分配的内存块为零。realloc
:用于调整先前分配的内存块的大小。(重置内存块大小)free
:用于释放动态分配的内存。
虽然这种手动管理提供了很大的灵活性,但也容易导致内存泄漏(未释放不再使用的内存)和悬挂指针(指向已释放内存的指针)。这就需要我们特别小心,确保每一块动态分配的内存都能被适时释放。
笔者在底下提供了一个C语言链表的示例,当我们创建一个链表时:
我们使用malloc,为结点结构体的指针分配内存。
而在删除节点时,我们采用free函数来进行内存的释放。
Java的垃圾回收机制
与C语言不同,Java提供了自动内存管理功能。Java的垃圾回收机制旨在自动回收不再使用的对象所占用的内存,从而减轻程序员的负担并提高内存管理的安全性。
引用计数器
最简单的垃圾回收机制之一是引用计数器。它通过维护每个对象的引用计数来跟踪对象是否可以被回收:
- 引用计数增加:每当一个新的引用指向对象时,引用计数加1。
- 引用计数减少:每当一个引用被销毁或被设置为指向另一个对象时,引用计数减1。
- 回收对象:当对象的引用计数变为0时,说明该对象不再被使用,可以被回收。
然而,引用计数器存在一个明显的缺点,即无法处理循环引用(两个对象相互引用)。
循环引用问题
接下来我们介绍循环引用,其实从字面意思就可以理解循环引用的含义,我们看如下示例:
class Node {Node next;
}Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
在这个例子中,a对象中包含一个b对象的成员变量,而b对象中间包含一个a对象的成员变量,此时两个成员变量的引用计数器都是2。
在这个例子中,即使a
和b
对象的引用离开了作用域,它们的引用计数器仍然不为0(因为它们互相引用),导致内存泄漏。
为了解决这样的循环引用问题,我们引入新的垃圾回收算法:可达性分析法。
可达性分析法与GC Root
要理解可达性分析法,首先我们可以看一下GC Root的概念。
二叉树大家肯定都知道,GC Root实际上就是一种垃圾回收的树形结构的根节点。我们通过一组被称为GC Root的根对象作为起点,沿着这些根对象的引用链进行搜索。如果一个对象能从GC Root到达,那么它就是可达的(alive),否则就是不可达的,可以被回收。
- GC Root:通常包括当前在栈中引用的对象、静态变量引用的对象以及JNI(Java Native Interface)引用的对象等。
- 可达对象:从GC Root开始,所有可以通过引用链访问到的对象都是可达的。
- 不可达对象:如果一个对象没有从GC Root出发的任何引用链到达,则认为该对象是不可达的,可以被回收。
GC Root的典型例子
- 虚拟机栈中的引用对象:如栈帧中的局部变量和输入参数。
- 方法区中的静态引用:如类的静态属性。
- 方法区中的常量引用:如常量池中的引用。
标记-清除算法
标记-清除算法是最早且最基本的垃圾回收算法之一。标记-清除算法分为两个阶段:标记阶段和清除阶段。
- 标记阶段:
- 从GC Root集合开始,遍历对象引用图,标记所有可达的对象。
- 标记过程通常是递归的,沿着对象引用链进行,直到所有可达的对象都被标记。
- 清除阶段:
- 遍历堆中的所有对象,回收未被标记的对象。
- 未标记的对象被认为是不可达的,可以被垃圾回收器回收。
优点
- 简单直接:算法简单,易于实现。
- 无需移动对象:对象在内存中的位置不会改变,减少了对象移动的开销。
缺点
- 内存碎片:清除阶段后,未被回收的对象会在堆中留下许多空闲区域,导致内存碎片。频繁的内存碎片会降低内存分配效率。
- 标记和清除过程需要遍历所有对象:在大堆内存中,遍历所有对象可能导致较长的暂停时间。
复制算法
工作原理
复制算法将堆内存分为两部分,通常是等大小的两个半区:From空间和To空间。垃圾回收时,仅使用其中一个半区,另一个半区作为备用空间。
-
分配阶段:
- 对象只在From空间中分配内存。
- To空间为空闲的,等待垃圾回收。
-
复制和清理阶段:
- 从GC Root开始,遍历所有可达的对象,并将它们复制到To空间。
- 复制过程中,保持对象的引用关系。
- 完成复制后,From空间中的所有对象被认为是不可达的,可以被回收。
- 交换From和To空间的角色,下一次分配和垃圾回收使用新的From空间。
-
示例:
假设有A块等待垃圾回收:
回收之后会变成~:
优点
- 无内存碎片:对象被紧凑地复制到新的空间,不会留下内存碎片。
- 分配速度快:由于始终从一个连续的空闲区域分配内存,分配速度很快。
缺点
- 内存利用率低:由于堆内存被划分为两个半区,同时只使用一半内存,导致内存利用率低。
- 对象复制开销:复制对象到新的空间需要额外的开销,特别是当对象较多时。
标记-整理算法
标记-整理算法(Mark-Compact Algorithm)是一种改进的垃圾回收算法,用于解决标记-清除算法产生的内存碎片问题。它结合了标记-清除和复制算法的优点,通过整理内存来提高内存分配效率。下面将详细分析标记-整理算法的工作原理、优缺点及其适用场景。
工作原理
标记-整理算法也分为两个主要阶段:标记阶段和整理阶段。
-
标记阶段:
- 从GC Root集合开始,遍历对象引用图,标记所有可达的对象。
- 这一步与标记-清除算法中的标记阶段相同,标记过程是递归的,沿着对象引用链进行,直到所有可达的对象都被标记。
-
整理阶段:
- 遍历整个堆,将所有存活的对象向一端移动(通常是堆的起始位置),保持对象之间的紧密排列。
- 更新所有对象的引用,以反映它们的新位置。
- 移动完成后,释放未被标记对象的内存,未被标记的对象被回收,形成一块连续的空闲区域。
优点
- 无内存碎片:对象被紧密排列在一起,没有内存碎片,提高了内存利用率。
- 高效的内存分配:由于所有存活对象被移动到堆的一端,剩下的内存是连续的,内存分配速度更快。
- 适用于长生命周期对象:尤其适合老年代(Old Generation)的垃圾回收,因为老年代对象生命周期较长,不需要频繁移动。
缺点
- 对象移动开销:整理阶段需要移动对象,并更新引用,增加了额外的开销,尤其是在老年代中存活对象较多时。
- 暂停时间长:标记和整理过程会导致应用暂停,可能影响实时性要求较高的应用。
标记-整理算法减少了内存碎片化,同时避免了复制算法中需要双倍内存的缺点。
应用场景
- 标记-清除算法:适用于内存紧张、不希望频繁移动对象的场景,如老年代(Old Generation)的垃圾回收。
- 复制算法:适用于对象生命周期短、需要快速回收的场景,如新生代(Young Generation)的垃圾回收。JVM中的新生代垃圾回收器通常使用复制算法。
- 标记-整理算法:对象较大且数量较多的场景,当对象较大且数量较多时,标记-整理算法可以通过紧凑排列对象,减少内存浪费,提高内存利用率。
分代回收机制
分代回收机制的核心思想是将堆内存划分为几个代,根据对象的生命周期长短来进行不同的管理和回收。大多数对象的生命周期很短,少数对象存活时间较长。通过这种划分,可以有针对性地采用不同的垃圾回收算法,提高回收效率和性能。
堆内存的分代
JVM中的堆内存通常划分为以下几代:
新生代(Young Generation):
- Eden区:所有新创建的对象首先分配在Eden区。
- 两个Survivor区(S0和S1):用于在新生代中进行对象复制和存活对象的管理。每次垃圾回收时,存活的对象从Eden区和一个Survivor区复制到另一个Survivor区。
老年代(Old Generation):存活时间较长、从新生代晋升的对象存放在老年代。
永久代(PermGen)或元空间(Metaspace):存储类的元数据(方法、类结构等)。在Java 8之前为永久代(PermGen),Java 8及之后为元空间(Metaspace)。
各代的垃圾回收策略
新生代垃圾回收(Minor GC)
新生代的垃圾回收频繁,通常采用复制算法(Copying Algorithm)。当Eden区填满时,触发Minor GC:
- 存活对象复制:存活的对象从Eden区和一个Survivor区复制到另一个Survivor区。
- 对象晋升:当对象经过多次Minor GC后依然存活(达到一定年龄),或Survivor区空间不足时,存活对象晋升到老年代。
- Eden区清空:所有存活对象复制后,Eden区和一个Survivor区被清空,另一个Survivor区保留存活对象。
Minor GC的频率较高,但由于新生代对象生命周期短,存活对象少,因此回收速度较快。
老年代垃圾回收(Major GC 或 Full GC)
老年代的垃圾回收通常采用标记-整理算法(Mark-Compact Algorithm) 或 标记-清除算法(Mark-Sweep Algorithm):
- 标记阶段:从GC Root开始,标记所有可达的对象。
- 整理阶段(标记-整理算法):将所有存活对象向一端移动,保持对象之间的紧密排列,释放未标记对象的内存。
- 清除阶段(标记-清除算法):回收未标记对象的内存,但可能产生内存碎片。
Major GC或Full GC的频率较低,但由于老年代对象较多且存活时间长,回收过程较慢,可能导致较长的暂停时间。
结语
Java的垃圾回收机制通过自动化内存管理,极大地减轻了开发者的负担,同时提升了程序的安全性和稳定性。尽管Java的GC机制复杂多样,但其核心思想都是为了更高效地管理内存,避免内存泄漏和碎片化。
许多现代编程语言,如Java、C#、Python等,都内置了垃圾回收机制。通过学习垃圾回收,可以更深入地理解这些语言的设计思想和实现细节。不同语言的垃圾回收机制有所不同,了解这些差异可以帮助我们在具体语言中应用最佳实践,编写高效的代码。
此后,笔者还会介绍Java中的垃圾回收器相关知识,以及有可能的JVM调优。
参考资料与文献:
20、垃圾回收算法之可达性分析法和GC Roots是什么?_哔哩哔哩_bilibili
【JVM】万字长文!深入详解Java垃圾回收(GC)机制_java gc-CSDN博客