6.1 Java内存管理的基本概念

在 Java 中,对象占用的内存在不需要使用对象时会自动回收。这个过程叫作垃圾回收(或自动内存管理)。垃圾回收这项技术在 Lisp 等语言中已经存在好多年了,习惯使用 C 和 C++ 等语言的程序员要花点儿时间适应,因为在这些语言中必须调用 free() 函数或使用 delete 运算符才能回收内存。

6.1 Java内存管理的基本概念 - 图1 Java 是一门用起来很舒心的语言,原因之一是,不用动手销毁自己创建的每一个对象。也是基于这个原因,较之不支持自动垃圾回收机制的语言,使用 Java 编写的程序较少存在缺陷。

不同的虚拟机使用不同的方式实现垃圾回收,而且规范没有对如何实现垃圾回收做强制要求。本章后面会讨论 HotSpot JVM,这虽然不是你会用到的唯一一个 JVM,但部署在服务器端的应用最常使用这个 JVM,而且 HotSpot 是现代化生产环境使用的典型 JVM。

6.1.1 Java中的内存泄露

Java 支持垃圾回收,因此可以显著减少内存泄露的发生几率。分配的内存没有回收,就会发生内存泄露。乍看起来,垃圾回收似乎能避免一切内存泄露的发生,因为这个机制能回收所有不再使用的对象。

但是,在 Java 中,如果不再使用的对象存在有效(但不再使用)的引用,仍然会发生内存泄露。例如,如果某个方法运行的时间很长(或者一直运行下去),那么这个方法中的局部变量会一直保存对象的引用,远超实际所需的时间,如下述代码所示:

  1. public static void main(String args[]) {
  2. int bigArray[] = new int[100000];
  3. // 对bigArray做些计算,得到一个结果
  4. int result = compute(bigArray);
  5. // 不再需要使用bigArray了。如果没有引用指向bigArray,就会被垃圾回收
  6. // 但是bigArray是局部变量,在方法返回之前始终指向那个数组
  7. // 可是这个方法还没有返回,因此我们要自己动手销毁引用
  8. // 告知垃圾回收程序回收这个数组
  9. bigArray = null;
  10. // 无限循环,处理用户的输入
  11. for(;;) handle_input(result);
  12. }

使用 HashMap 或类似的数据结构关联两个对象时,也可能会发生内存泄露。就算有一个对象不再需要使用了,哈希表中仍然存有两个对象之间的关联,因此在回收哈希表之前,这两个对象一直存在。如果哈希表的生命周期比其中的对象长得多,就可能导致内存泄露。

6.1.2 标记清除算法简介

JVM 确切知道它分配了哪些对象和数组,这些对象和数组存储在某种内部数据结构中,我们称这种数据结构为分配表(allocation table)。JVM 还能区分每个栈帧(stack frame)里的局部变量指向堆(heap)里的哪个对象或数组。最后,JVM 能追踪堆中对象和数组保存的引用,不管引用多么迂回,都能找到所有仍然被引用的对象和数组。

因此,运行时能判断已经分配内存的对象什么时候不再被其他活动对象或变量引用。遇到这种对象时,解释器知道它可以放心地回收这个对象的内存,然后回收内存。注意,垃圾回收程序还能检测到相互引用的对象,如果没有其他活动对象引用这些对象,就将其内存回收。

在应用线程的堆栈跟踪中,从其中一个方法的某个局部变量开始,沿着引用链,如果最终能找到一个对象,我们称这个对象为可达对象(reachable object)。这种对象也叫活性对象。1

1从 GC Roots 对象开始向下穷根揭底的探索过程称为活性对象的传递闭包(transitive closure)——这个术语从图论抽象数学中借用而来。

6.1 Java内存管理的基本概念 - 图2 除了局部变量之外,引用链还可以从其他几个地方开始。通向可达对象的引用链根部一般称为 GC Root。

知道这些简单的定义之后,我们来看一种基于这些原则回收垃圾的简单方式。

6.1.3 基本标记清除算法

垃圾回收过程经常使用(也是最简单)的算法是标记清除(mark and sweep)。整个过程分为三步。

(1) 迭代分配表,把每个对象都标记为“已死亡”。

(2) 从指向堆的局部变量开始,顺着遇到的每个对象的全部引用向下,每遇到一个之前没见过的对象或数组,就把它标记为“存活”。像这样一直向下,直到找出能从局部变量到达的所有引用为止。

(3) 再次迭代分配表,回收所有没标记为“存活”的对象在堆中占用的内存,然后把这些内存放回可用内存列表中,最后把这些对象从分配表中删除。

6.1 Java内存管理的基本概念 - 图3 上面概述的标记清除过程是这个算法理论上最简单的形式。在后面的几节中会看到,真正的垃圾回收程序做的事情比这要多。上面的概述是为了打好理论基础,目的就是易于理解。

因为所有对象的内存都由分配表分配,所以用完堆内存之前会触发垃圾回收程序。在上述对标记清除算法的描述中,垃圾回收程序需要互斥存取整个堆,因为应用代码一直在运行中,会不断创建和修改对象,导致结果腐化。

图 6-1 展示了在应用线程运行过程中尝试回收对象的后果。

{%}

图 6-1:堆内存的变化

为了避免发生这种问题,在上述简单的垃圾回收过程中,应用线程会停顿一下(这个停顿叫 Stop-The-World,STW)——先停止所有应用线程,然后回收垃圾,最后继续运行应用线程。应用线程执行到一个安全点(safepoint)时,例如循环的开始处或即将调用方法时,运行时会让应用线程停顿一下,因为运行时知道在安全点可以放心地停止运行应用线程。

开发者有时会担心这种停顿,但是对大多数主流应用场景来说,Java 都运行在操作系统之上,进程会不断交替进出处理器内核,因此一般无需担心这些短暂的额外停顿。HotSpot 会做大量工作来优化垃圾回收,减少 STW 时间,这一点对减轻应用的工作负担来说十分重要。下一节会介绍一些优化措施。