【译】Java内存管理简介

本文是一篇翻译文章,这篇文章用比较通俗的语言简单介绍了 Java 的内存模型和 Java 垃圾回收器的工作流程,是一篇比较好的入门读物。

原文链接:https://dzone.com/articles/java-memory-management

你可能想,如果你是个 Java 程序员,你需要了解内存是怎么工作的吗?Java 有内存自动管理工具,一个优雅且几乎无感知的垃圾回收器,能在后台清理无用的对象,并释放内存。

当然,作为一个 Java 程序员,你不需要关注类似于销毁对象之类的事情。然而,即便在 Java 中这些是自动运行的,也不能保证它是完美运行的。如果你不知道垃圾回收器的构造以及 Java 内存的设计,你可能就会有不受垃圾回收器监控的对象,即使他们都不再被使用。

因此,了解 Java 中的内存是如何运作的十分重要,它能让你编写出高性能并且不会报 OutOfMemoryError 异常的应用。另一方面,如果确实出现了 OutOfMemoryError 异常,你也能迅速找到内存泄漏的地方。

首先,让我们看一下 Java 中的内存是如何组织的:

image-20180702234142685

简单来讲,内存被划分为两大部分:栈(stack)区域和 堆(heap)区域。当然,上图显示的内存大小比例和实际的比例并不相符。实际上,相对于栈,堆是一块相当大的内存区域。

栈(stack)

内存主要负责收集中对象的引用,以及存储基本数据类型。

另外,在栈内存中的变量有一个作用域的概念。只有当前活跃的作用域的对象能会被使用。举个例子,假设我们没有全局变量(类的属性字段等),只有局部变量,只有当编译器执行到该方法体的时候,它才能在这个方法中获得对象,并且它不能获得其他方法中的局部变量,因为其他方法中的局部变量不在作用域内。当方法执行完毕,并且返回之后,顶部的栈会被弹出,当前活跃的作用域就会变化。

可能你会发现,在上图中有多个栈内存(图中蓝色长方形)。这是因为在 Java 中,每个线程都会有自己的栈内存空间。因此,每当创建一个线程并启动的时候,它就会有自己的栈内存,并且它是不能获取其他线程的栈内存的。

堆(Heap)

这部分内存存储了对象本身。这里面的对象是被栈中的变量所引用的。举个例子,我们来分析下面这行代码会发生什么:

1
StringBuilder builder = new StringBuilder();

new 关键字会确保堆内存中有足够的空间来存储 StringBuilder 类型的对象,并且通过栈内存中的 builder 这个变量来引用该对象。

在每个 JVM 进程里,只有一个堆内存空间。因此,无论有多少个线程在运行,它们都是共享同一个堆内存。实际上,堆内存的结构和上图中有点不一样:为了方便垃圾回收,堆内存会划分成几部分。

一般来说,栈内存的大小和堆内存的大小并没有预先配置——它取决于运行的机器。然而,在后面的文章中,我们会介绍 JVM 的配置文件,该文件允许我们为运行的机器指定内存分配的大小。

引用类型

如果你仔细观察上面那张内存结构图,你可能会发现,从变量指向堆中的对象所用的带箭头的线有不同的类型。这是因为,在 Java 语言中,我们有不同类型的引用:强引用(strong reference)弱引用(weak reference)软引用(soft reference)幽灵引用(phantom reference)。垃圾回收器会针对这些不同的引用类型,实施不同的回收策略。

1. 强引用(strong reference)

这是我们最常使用到的引用类型。在上面的 StringBuilder 例子中,我们持有了一个强引用,这个强引用指向堆中的一个对象。如果一个强引用指向堆中的一个对象,或者该对象在强引用链中是可达的,那么垃圾回收器是不会回收它的。

2. 弱引用(weak reference)

简单来说,如果一个弱引用指向堆中的一个对象,那么在下一次垃圾回收器执行处理的时候,是很有可能会被回收的。创建一个弱引用的方法如下:

1
WeakReference<StringBuilder> reference = new WeakReference<>(new StringBuilder());

3. 软引用(soft reference)

这种类型的引用多用于内存敏感的场景。因为只有当你的应用程序内存不足的时候,这种类型的引用对象才会被回收。因此,只要没有严重到需要开辟新的内存空间使用的情况,垃圾回收器是不会染指软引用所指向的对象。Java 能确保所有的软引用对象会在抛出 OutOfMemoryError 之前被回收掉。Java 文档中描述:所有的软引用对象或软引用链可到达的对象都会在虚拟机抛出 OutOfMemory 异常前被清理掉all soft references to softly-reachable objects are guaranteed to have been cleared before the virtual machine throws an OutOfMemoryError)。

和弱引用类似,软引用的创建方式如下:

1
SoftReference<StringBuilder> reference = new SoftReference<>(new StringBuilder());

4. 幽灵引用(phantom reference)

如果使用了幽冥引用,那么在垃圾回收器处理一次之后,我们可以确信该引用的对象不会再存在。因此此类引用的 .get() 方法始终返回 null。通常认为,使用这类引用,优于使用 finalize() 方法。

字符串类型的对象是如何被引用的

String 类型的对象在 Java 中会有区别待遇。String 是不可变类型,也就是说,每次你对字符串做一些操作的时候,另一个对象就会在堆中被创建出来。对于字符串类型,Java 在内存中管理着一个常量池,也就是说 Java 会把常用的字符串尽可能地重复使用。比如:

1
2
3
4
5
6
7
String localPrefix = "297"; //1
String prefix = "297"; //2
if (prefix == localPrefix) {
System.out.println("Strings are equal" );
} else {
System.out.println("Strings are different");
}

当代码运行时,会打印如下的语句:

Strings are equal

这就证明了,比较两个 String 类型的引用,他们实际上指向的是堆中的相同对象。然而,这种情况并不适用于用来计算的 String 类型。假设我们修改上面的第一行代码:

1
String localPrefix = new Integer(297).toString();	// 1

输出:

Strings are different

在这个例子中,我们实际上看到的是堆中的两个不同对象。如果我们认为这种计算类型的 String 对象会被经常使用,我们就可以通过在计算结束后,调用 .intern() 方法,强制让 JVM 将该字符串添加进字符串常量池中:

1
String localPrefix = new Integer(297).toString().intern(); //1

这样,运行结果就是:

Stirngs are equal

垃圾回收器的处理流程

正如我们之前所讨论的那样,根据栈内存中的变量与堆内存中对象的不同类型的引用,在某个确切时间点时,对象会获得被垃圾回收器处理的资格。

image-20180703005156338

比如,上图中所有红色的对象,都有被垃圾回收器处理的资格。你可能注意到了,堆中有几个对象,它们之间是强引用关系。然而,它们与栈失去了引用,就不再可达了,因此这几个对象也就变成了垃圾。

再更深入了解之前,有几件事情是需要你了解的:

  • 处理程序是被 Java 自动触发的,也是由 Java 决定什么时候启动该程序。
  • 实际上垃圾回收期运行的时候,是很费资源的。当垃圾回收器运行时,你的应用中所有的线程都会被暂停(暂停时长由垃圾回收器类型所决定,这个稍后再聊)。
  • 这个处理流程其实相当复杂。

即时决定什么时候运行垃圾回收器的是 Java,你也可以显式地调用 System.gc( ) 方法来告诉垃圾回收器运行,是吗?

这是一个错误的假设。

显式调用 System.gc() 命令,只是你要求 Java 运行垃圾回收器,然而,再次强调,是否运行垃圾回收器是 Java 决定的。因此,不建议调用 System.gc() 方法。

由于这是一个相当复杂的流程,并且会影响性能,因此它用一种更加智能的方式来实现,这就是所谓的 标记 - 清除算法。Java 会分析栈中的变量,然后标记所有需要保留的对象。最后,所有未被标记的对象将会被清除

所以,Java 并没有收集任何垃圾。实际上,垃圾越多,被标记的对象越少,处理流程越快。为了优化这个方案,堆内存被划分为多个区间,我们可以使用 JVisualVM 工具来将内存使用情况可视化,这个工具是 JDK 自带的,你所需要做的就是安装一个 Visual GC 插件,这个插件可以允许你查看内存的实际结构。

image-20180703090122510

当一个对象被创建的时候,它会被收集到 Eden 空间(1),由于 Eden 空间并不是很大,因此操作该空间会非常迅速。垃圾回收器在 Eden 空间里面标记需要存活的对象。

当该对象被垃圾回收器处理了一次后,它会被转移到所谓的 S0 空间(2)。垃圾回收器在 Eden 空间第二次运行的时候,他会把所有存活的对象转移到 S1 空间(3)。同样的,在 S0 空间(2)的对象也会被转移到 S1 空间(3)。

如果一个对象经历了 X 次垃圾回收器的处理后仍然存活了下来( X 的值取决于 JVM 的实现,在我这里,X 的值是 8 ),那么它就很可能永远都会活下来了,并且它会被转移到 老年代(Old Generation) 空间(4)。

到目前为止,如果你观察垃圾回收器的图表(6),每次垃圾回收器运行的时候,你都可以看到对象被转移到了其他生存空间,并且 Eden 区域被释放了空间,如此循环。老年代也可以被垃圾回收器回收,但是由于它比 Eden 空间的内存大,因此这种情况并不会经常出现。元数据空间(5)通常用来存储加载到 JVM 中的类的元数据。

上面那张图战士的是一个 Java 8 的应用程序。在 Java 8 之前,内存的结构会有点不一样。元数据空间被称为 永久代(Permanent Generation)空间。举个例子,在 Java 6 中,这部分空间也用来存放字符串常量池。因此如果你在 Java 6 的应用程序中有非常多的字符串,那么它就很容易崩溃。

通常,在 Java 8 以前,堆内存的空间会划分为 新生代老年代永久代。其中,把 Eden 空间、S0 空间和 S1 空间统称为新生代(Young Generation)。

垃圾回收器类型

实际上,JVM 有三种类型的垃圾回收器 (GC),程序员可以选择使用其中一种。一般情况下,JVM 会根据底层硬件来选择垃圾回收器类型。

1. 串行 GC —— 一种单线程回收器。通常用在小型应用里面,处理少量的数据。可以使用 -XX: +UseSerialGC 来指定并启用该类型的 GC。

2. 并行 GC —— 从名字中就可以看出来,和序列化 GC 的不同点在于,并行 GC 使用多线程来执行垃圾回收处理程序。这种 GC 能处理大量的数据。可以使用 -XX:+UserParallelGC 来指定并启用该类型 GC。

3.伪并发 GC —— 在前面的文章中,我们提到垃圾回收处理程序是非常消耗资源的,当它运行的时候,所有的线程都会暂停。而这种伪并发 GC,它可以做到和应用程序几乎同时工作,当然并不会 100% 和应用程序并发,所有的应用线程仍然会暂停一段时间,而这暂停时间会保持尽可能短,以获得最好的 GC 性能。实际上,对于这种伪并发 GC,有两种具体实现:

  • 3.1 G1垃圾回收器——一种暂停时间在可接受范围内的高吞吐量 GC,使用 -XX:+UseG1GC 开启。
  • 3.2 并发标记扫描垃圾回收器 —— 最小化应用线程暂停时间的 GC,可以通过 -XX:+UseConcMarkSweepGC 指定。在 JDK 9 中,这种 GC 已被弃用。

建议和技巧

  • 尽可能使用局部变量(在方法内部定义的变量)。这样能尽可能减少环境的影响。记住每当栈顶部的可见区域被弹出的时候,该区域的引用就会丢失,相应的对象就有机会被垃圾回收器处理。
  • 把不再使用的对象的引用置为 null。这样会让这些对象能够被垃圾回收器处理。
  • 避免使用 finalize() 方法。它会降低处理性能,并且不能保证任何事情。使用幽冥引用来清理相应的对象。
  • 可以使用弱引用或软引用,就不要使用强引用。最常见的使用内存陷阱就是缓存相关的方案。即使这些数据不再被需要,也仍然会存储在内存中。
  • 根据你的应用来配置你的 JVM 参数。显式指定 JVM 的堆大小。由于内存收集程序也比较消耗资源,所以需要给堆内存分配一个合理的初始值,以及指定一个最大值。如果应用所需内存超过了初始大小,JVM 会自动扩大使用内存。使用下面的命令来设置内存大小:
    • 初始化堆内存大小:-Xms512m——设置堆初始化大小为 512M。
    • 堆内存的最大值:-Xmx1024m——设置堆内存的最大值为 1024M。
    • 每个线程的栈内存大小:-Xss128m——设置每个线程的栈内存大小为 128M。
    • 年轻代内存大小:-Xmn256m——设置年轻代大小为 256M。
  • 如果 Java 程序因为 OutOfMemoryError 异常崩溃了,而你需要额外信息来检测内存泄漏情况,就可以使用-XX:HeapDunpOnOutOfMemory 参数。设置了该参数后,在下次遇到同样的错误时,会生成一个 heap dump 文件来供你分析。
  • 使用 -verbose:gc 选项来获得垃圾回收器的日志输出。每次垃圾回收器清理了空间之后,会生成一份相应的输出文件。

总结

了解内存是如何组织的,会帮助你写出优秀的内存使用相关的代码。你可以根据你的应用具体情况提供不同的配置项,以调整 JVM,从而使得 JVM 运行最佳配置。如果使用正确的工具,查找和修复内存泄漏也是一件简单的事情。

如果觉得文章对你有帮助,请我喝杯可乐吧