
在互联网大厂的后端开发领域,Java 语言占据着举足轻重的地位。而对于 Java 开发者而言,深入理解垃圾回收机制,是写出高效、稳定代码的关键。今天,就带大家全面探索 Java 中的垃圾回收,让身为后端开发人员的你,在工作中更加得心应手。
什么是 Java 垃圾回收在 Java 中,当没有对象引用指向原先分配给某个对象的内存时,该内存便成为垃圾。JVM 的一个系统级线程会自动释放该内存块,这就是垃圾收集。简单来说,垃圾收集意味着程序不再需要的对象是 “无用信息”,这些信息将被丢弃回收。垃圾收集不仅能释放没用的对象所占用的空间,还可以清除内存记录碎片。要知道,在创建对象和垃圾收集器释放丢弃对象所占内存空间的过程中,内存会出现碎片,而垃圾收集器的工作之一就是对这些碎片进行整理,将所占用的堆内存移到堆的一端,JVM 再将整理出的内存分配给新的对象。

垃圾回收能自动释放内存空间,极大地减轻了编程的负担。这使得 Java 虚拟机具备诸多优点。一方面,它显著提高了编程效率。在没有垃圾收集机制的语言中,开发者可能要耗费大量时间去解决复杂的存储器问题,而在 Java 语言编程中,垃圾收集机制为开发者节省了大量精力。另一方面,它保护了程序的完整性,是 Java 语言安全性策略的重要组成部分。然而,垃圾收集也存在一些潜在缺点,比如其开销会影响程序性能,Java 虚拟机必须追踪运行程序中有用的对象,并最终释放没用的对象,这一过程需要花费处理器时间。早期的某些垃圾收集算法还存在不完备性,不能保证 100% 收集到所有的废弃内存。但随着垃圾收集算法的不断改进以及软硬件运行效率的不断提升,这些问题都逐渐得到解决。
如何判断对象是否可被回收引用计数法
引用计数法是唯一没有使用根集的垃圾回收算法。在这种算法中,堆中的每个对象对应一个引用计数器。当创建一个对象并赋给一个变量时,引用计数器置为 1。当对象被赋给任意变量时,引用计数器每次加 1;当对象出了作用域后(该对象丢弃不再使用),引用计数器减 1。一旦引用计数器为 0,对象就满足了垃圾收集的条件。这种算法运行较快,不会长时间中断程序执行,适合必须实时运行的程序。但它增加了程序执行的开销,因为每次对象赋给新变量或现有对象出作用域时,都要对计数器进行操作。例如,在下面的代码场景中:
Object obj1 = new Object();Object obj2 = obj1;obj1 = null;当obj1 = null时,原本obj1所指向的对象引用计数减 1,若该对象引用计数变为 0,就会被回收。
根搜索算法(tracing 算法)
根搜索算法使用了根集的概念。所谓根集,就是正在执行的 Java 程序可以访问的引用变量的集合(包括局部变量、参数、类变量)。垃圾收集首先需要确定从根开始哪些是可达的和哪些是不可达的。从根集可达的对象都是活动对象,不能作为垃圾被回收,这也包括从根集间接可达的对象。而根集通过任意路径不可达的对象符合垃圾收集的条件,应该被回收。以 “GC Root” 的对象作为起始点,开始向下搜索,搜索所走过的路径称为引用链。如果一个对象与起始点没有任何引用链,则说明不可用,需要被回收。例如,有对象 A、B、C,A 引用 B,B 引用 C,若 A 是 GC Root,那么 B 和 C 通过 A 的引用链可达,不会被回收;若没有任何 GC Root 引用 A,那么 A、B、C 都会因为与 GC Root 没有引用链而被回收。
常见的垃圾回收算法标记 - 清除算法(mark - and - sweep)
首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。简单来说,就是先找出需要回收的对象并标记,然后在标记结束后将这些被标记的对象清理掉。这种算法存在一些问题,比如效率不高,标记和清除过程的效率都较低;而且标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致大对象无法分配到足够的连续内存,从而不得不提前触发 GC,甚至出现 Stop The World(让整个应用程序暂停)的情况。
复制算法(copying)
该算法开始时把堆分成一个对象面和多个空闲面,程序从对象面为对象分配空间,当对象面满了,基于复制算法的垃圾收集就从根集中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。一种典型的基于复制算法的垃圾回收是 stop - and - copy 算法,它将堆分成对象面和空闲区域面,在对象面与空闲区域面的切换过程中,程序暂停执行。
复制算法的优点是避免了碎片化问题,但缺点也很明显,在对象存活率较高时,复制次数多,效率低,而且内存缩小了一半,还需要额外空间做分配担保(老年代) 。在新生代中,由于对象基本上都是朝生夕灭的,每次只有少量对象存活,因此新生代采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集。例如,新生代分为 1 个 Eden 和 2 个 Survivor 区(一个是 from,另一个是 to)。新创建的对象一般都会被分配到 Eden 区,如果经过第一次 GC 后仍然存活,就会被移到 Survivor 区。Survivor 区中的对象每经过一次 Minor GC,年龄 + 1,当年龄增加到一定程度时,会被移动到年老代。
标记 - 整理算法(compacting)
为了解决堆碎片问题,基于 tracing 的垃圾回收吸收了 compacting 算法的思想。在清除的过程中,算法将所有的对象移到堆的一端,堆的另一端就变成了一个相邻的空闲内存区,收集器会对它移动的所有对象的所有引用进行更新,使得这些引用在新的位置能识别原来的对象。在基于 compacting 算法的收集器的实现中,一般增加句柄和句柄表。该 GC 策略与标记 - 整理的区别在于:不是在同一个区域内进行整理,而是将 live 对象全部复制到另一个区域。
分代收集算法(generation)
由于多数对象存在的时间比较短,少数的存在时间比较长。因此,分代算法将堆分成两个或多个,每个子堆作为对象的一代(generation)。垃圾收集器将从最年轻的子堆中收集那些存在时间较短的对象。在分代式的垃圾收集器运行后,上次运行存活下来的对象移到下一最高代的子堆中,由于老一代的子堆不会经常被回收,因而节省了时间。在 Java 堆中,分为新生代、老年代和永久区(Java 8 之后改名为 Metaspace)。在新生代中,选用复制算法;针对年老代,由于这里的对象有很高的幸存度,使用 “标记 - 清理” 或 “标记 - 整理” 算法。
自适应算法(adaptive)
在特定的情况下,一些垃圾收集算法会优于其它算法。基于 adaptive 算法的垃圾收集器就是监控当前堆的使用情况,并将选择适当算法的垃圾收集器。它能根据不同的场景自动调整垃圾回收算法,以达到更好的性能表现。
实际案例分析假设我们有如下代码:
public JavaHeapTest { private static final int OUTOFMEMORY = 500 * 1024 * 1024; private String oom; public JavaHeapTest(String oom) { this.oom = oom; } public String getOom() { return oom; } public static void main(String[] args) { for(int i = 0; i < 50; i++) { JavaHeapTest javaHeapTest = new JavaHeapTest(OUTOFMEMORY); System.out.println(javaHeapTest.getOom().length()); } }}这里OUTOFMEMORY = 500 * 1024 * 1024,大于 Eden 内存的大小。新生代分配内存小,导致 Young GC 的频繁触发。同时,初始化堆内存没有和最大堆内存一致,在每次 GC 后进行内存可能重新分配。
解决方法可以是提升新生代大小,将初始化堆内存设置为最大内存,将 SurvivorRatio 由 4 修改为 8,让垃圾在新生代时尽可能的多被回收掉。具体设置如下:
-Xmn350M -> -Xmn800M-XX:SurvivorRatio = 4 -> -XX:SurvivorRatio = 8-Xms1000m -> -Xms1800m通过这样的调整,Young GC 次数会明显减少,程序性能得到提升。
总结Java 中的垃圾回收机制是一个复杂而又强大的系统,对于互联网大厂的后端开发人员来说,理解并掌握它,能够在开发过程中更好地优化代码,提高程序的性能和稳定性。从对象的生命周期,到垃圾回收算法的选择,再到实际问题的分析与解决,每一个环节都紧密相连。希望通过今天的分享,大家对 Java 垃圾回收有了更深入的认识,在今后的开发工作中能够运用这些知识,打造出更优质的后端应用。各位开发者们,对于 Java 垃圾回收,你们在实际工作中还有哪些经验或疑问呢?欢迎在评论区留言分享。