在 Java 虚拟机(即JVM)中,垃圾收集是自动内存管理的核心机制,其主要作用是识别并回收不再使用的对象所占用的内存空间,以避免内存泄漏和溢出。不同的垃圾收集算法有着不同的实现思路和适用场景,下面将详细介绍 JVM 中 4 种常见的垃圾收集算法。
(一)标记 - 清除算法
标记 - 清除算法是最基础的垃圾收集算法,它分为 “标记” 和 “清除” 两个阶段。在标记阶段,会遍历所有对象,标记出需要回收的垃圾对象;在清除阶段,会清除掉所有被标记的垃圾对象,释放其占用的内存空间。
标记阶段通过根搜索算法(从根对象出发,遍历所有可达对象),将可达对象标记为存活,未被标记的对象则被视为垃圾对象。清除阶段则直接回收未被标记的垃圾对象所占用的内存,使其成为可用的空闲内存。该算法是其他许多垃圾收集算法的基础,能够有效识别并回收不再被引用的对象,释放内存资源,保证程序的正常运行。
其优缺点主要为:
(1)优点:实现简单,不需要移动对象,对于存活对象较多的场景有一定效率。
(2)缺点:会产生大量不连续的内存碎片,当需要分配大对象时,可能因为找不到足够大的连续内存而不得不提前触发另一次垃圾收集;标记和清除过程的效率不高,在对象数量较多时尤为明显。
代码解释
// 模拟对象结构
class Object {
boolean isMarked = false; // 标记是否为存活对象
Object reference; // 引用的其他对象
}
// 模拟标记-清除算法
public class MarkSweepAlgorithm {
public static void main(String[] args) {
// 假设存在一系列对象
Object root = new Object();
Object obj1 = new Object();
Object obj2 = new Object();
root.reference = obj1; // 根对象引用obj1,obj1为存活对象
// obj2未被根对象或存活对象引用,为垃圾对象
// 标记阶段:标记存活对象
mark(root);
// 清除阶段:清除未标记的垃圾对象
sweep();
}
// 标记存活对象
private static void mark(Object obj) {
if (obj != null && !obj.isMarked) {
obj.isMarked = true;
mark(obj.reference); // 递归标记引用的对象
}
}
// 清除垃圾对象
private static void sweep() {
// 遍历所有对象,清除未标记的对象(实际中是管理内存地址)
// 此处简化处理,仅模拟清除逻辑
}
}
我们可以从上述代码看到标记 - 清除算法的核心思路。在标记阶段,从根对象出发,递归地将所有可达对象标记为存活;在清除阶段,遍历所有对象,回收未被标记的垃圾对象。实际 JVM 中,会通过更复杂的内存管理机制来实现这一过程。
(二)复制算法
复制算法也称标记-复制算法,其将内存空间划分为大小相等的两块,每次只使用其中一块。当一块内存用完时,将该块中所有存活的对象复制到另一块内存中,然后清除掉原内存块中所有对象,完成垃圾收集。
该算法利用了内存区域的划分,通过复制存活对象来避免内存碎片问题。在垃圾收集时,不需要标记垃圾对象,只需识别存活对象并将其复制到另一块内存,然后直接清空原内存块,简化了垃圾收集过程。解决了标记 - 清除算法产生内存碎片的问题,提高了内存分配的效率,尤其适用于存活对象较少的场景。
复制算法的优缺点主要为:
(1)优点:没有内存碎片,内存分配时只需按顺序分配即可,效率高;垃圾收集过程简单,只需复制存活对象和清空原内存块。
(2)缺点:内存利用率低,因为只有一半的内存空间被使用;当存活对象较多时,复制操作的开销会很大,效率会降低。
代码解释
// 模拟复制算法的内存区域
class MemoryArea {
Object[] objects;
int size;
public MemoryArea(int capacity) {
objects = new Object[capacity];
size = 0;
}
// 添加对象
public void add(Object obj) {
if (size < objects.length) {
objects[size++] = obj;
}
}
}
// 模拟复制算法
public class CopyingAlgorithm {
public static void main(String[] args) {
// 划分两块内存区域
MemoryArea fromSpace = new MemoryArea(10);
MemoryArea toSpace = new MemoryArea(10);
// 向fromSpace添加一些对象,其中部分为存活对象
fromSpace.add(new Object());
fromSpace.add(new Object()); // 假设为存活对象
fromSpace.add(new Object());
// 当fromSpace内存不足时,执行复制算法
copy(fromSpace, toSpace);
// 清空fromSpace,之后使用toSpace,两者角色互换
fromSpace.size = 0;
}
// 复制存活对象到toSpace
private static void copy(MemoryArea fromSpace, MemoryArea toSpace) {
for (int i = 0; i < fromSpace.size; i++) {
Object obj = fromSpace.objects[i];
if (isAlive(obj)) { // 判断对象是否存活
toSpace.add(obj);
}
}
}
// 判断对象是否存活(实际中通过根搜索算法)
private static boolean isAlive(Object obj) {
// 简化判断,假设部分对象为存活
return true;
}
}
从上述代码中,我们可以看到复制算法的主要过程。其将内存划分为 fromSpace 和 toSpace 两块,当 fromSpace 内存不足时,将其中的存活对象复制到 toSpace,然后清空 fromSpace。实际 JVM 中,如新生代的 Serial Copying 收集器就采用了类似的思想,不过通常会将新生代划分为一个 Eden 区和两个 Survivor 区,比例为 8:1:1,以提高内存利用率。
(三)标记 - 整理算法
标记 - 整理算法结合了标记 - 清除算法和复制算法的优点,同样分为标记和整理两个阶段。标记阶段与标记 - 清除算法相同,标记出存活对象;整理阶段则将所有存活对象向内存的一端移动,然后直接清除掉边界以外的内存空间。
其标记阶段通过根搜索算法标记存活对象,整理阶段通过移动存活对象,使它们紧凑地排列在内存的一端,从而消除内存碎片,此时空闲内存是连续的一块区域。解决了标记 - 清除算法的内存碎片问题,同时避免了复制算法内存利用率低的缺点,适用于存活对象较多的老年代。
标记-整理优缺点则主要有:
(1)优点:不会产生内存碎片,内存利用率高;适用于存活对象较多的场景。
(2)缺点:整理阶段需要移动大量对象,会消耗一定的时间,增加了垃圾收集的开销;移动对象后需要更新所有引用该对象的指针,实现相对复杂。
代码解释
// 模拟标记-整理算法
public class MarkCompactAlgorithm {
public static void main(String[] args) {
// 假设内存中有一系列对象,部分为存活对象,部分为垃圾对象
Object[] memory = new Object[10];
// 初始化内存中的对象(省略)
// 标记阶段:标记存活对象
markAliveObjects(memory);
// 整理阶段:移动存活对象,清除垃圾
compactMemory(memory);
}
// 标记存活对象
private static void markAliveObjects(Object[] memory) {
// 遍历内存,通过根搜索算法标记存活对象(实际实现复杂)
for (int i = 0; i < memory.length; i++) {
if (memory[i] != null && isReachable(memory[i])) {
// 标记为存活(实际中可能通过标记位实现)
}
}
}
// 判断对象是否可达(存活)
private static boolean isReachable(Object obj) {
// 简化判断逻辑
return true;
}
// 整理内存
private static void compactMemory(Object[] memory) {
int index = 0;
// 移动存活对象到内存一端
for (int i = 0; i < memory.length; i++) {
if (memory[i] != null && isAlive(memory[i])) { // 假设isAlive判断对象是否被标记为存活
memory[index++] = memory[i];
}
}
// 清除边界以外的内存
for (int i = index; i < memory.length; i++) {
memory[i] = null;
}
}
// 判断对象是否存活
private static boolean isAlive(Object obj) {
// 简化判断
return true;
}
}
从上述代码,我们可以看到标记 - 整理算法的过程。标记阶段标记出存活对象后,整理阶段将存活对象移动到内存的一端,然后清除掉剩余的垃圾对象,使空闲内存成为连续的区域。实际 JVM 中,老年代的许多垃圾收集器(如 Serial Old 收集器)采用了这种算法。
(四)分代收集算法
分代收集算法是当前商用虚拟机普遍采用的垃圾收集算法,它根据对象的存活周期将内存划分为不同的区域(通常分为新生代和老年代),针对不同区域的特点采用不同的垃圾收集算法。
新生代中,对象的存活周期短,存活对象少,因此采用复制算法,以高效地回收垃圾;老年代中,对象的存活周期长,存活对象多,因此采用标记 - 清除算法或标记 - 整理算法,以避免频繁复制对象带来的开销。其充分利用了不同代对象的特点,提高了垃圾收集的效率和性能,使 JVM 能够更好地适应不同的应用场景。
分带收集器算法的主要优缺点有:
(1)优点:结合了多种垃圾收集算法的优点,针对不同代的对象采用最合适的收集算法,整体垃圾收集效率高。
(2)缺点:实现复杂,需要对内存进行分代管理,不同代之间的对象引用关系处理起来较为繁琐。
代码解释
// 模拟分代收集算法中的内存分代
class YoungGen {
// 新生代内存区域,采用复制算法
MemoryArea eden = new MemoryArea(80); // Eden区占80%
MemoryArea survivorFrom = new MemoryArea(10); // From Survivor区占10%
MemoryArea survivorTo = new MemoryArea(10); // To Survivor区占10%
}
class OldGen {
// 老年代内存区域,采用标记-整理算法
MemoryArea old = new MemoryArea(500);
}
// 模拟分代收集器
public class GenerationalCollection {
private YoungGen youngGen = new YoungGen();
private OldGen oldGen = new OldGen();
public static void main(String[] args) {
GenerationalCollection collector = new GenerationalCollection();
// 模拟对象分配和垃圾收集过程
collector.allocateObjects();
collector.collectYoungGen(); // 收集新生代
collector.promoteToOldGen(); // 将存活较久的对象晋升到老年代
collector.collectOldGen(); // 收集老年代
}
// 分配对象
private void allocateObjects() {
// 向新生代Eden区分配对象(省略)
}
// 收集新生代(采用复制算法)
private void collectYoungGen() {
// 复制Eden区和From Survivor区的存活对象到To Survivor区
// 清空Eden区和From Survivor区,交换From和To Survivor区
}
// 将对象晋升到老年代
private void promoteToOldGen() {
// 将满足晋升条件的对象从新生代移动到老年代
}
// 收集老年代(采用标记-整理算法)
private void collectOldGen() {
// 标记老年代中的存活对象,然后进行整理
}
}
还是一样的,这个代码模拟了分代收集算法的基本思路。新生代由 Eden 区和两个 Survivor 区组成,采用复制算法进行垃圾收集;老年代采用标记 - 整理算法。当对象在新生代经历多次垃圾收集后仍然存活,就会被晋升到老年代。实际 JVM 中的 HotSpot 虚拟机就采用了这种分代收集策略,如 Parallel Scavenge + Parallel Old 收集器组合等。