本文是一篇翻译文章,这篇文章用比较通俗的语言简单介绍了 Java 的内存模型和 Java 垃圾回收器的工作流程,是一篇比较好的入门读物。
原文链接: https://dzone.com/articles/java-memory-management
你可能想,如果你是个 Java 程序员,你需要了解内存是怎么工作的吗?Java 有内存自动管理工具,一个优雅且几乎无感知的垃圾回收器,能在后台清理无用的对象,并释放内存。
当然,作为一个 Java 程序员,你不需要关注类似于销毁对象之类的事情。然而,即便在 Java 中这些是自动运行的,也不能保证它是完美运行的。如果你不知道垃圾回收器的构造以及 Java 内存的设计,你可能就会有不受垃圾回收器监控的对象,即使他们都不再被使用。
因此,了解 Java 中的内存是如何运作的十分重要,它能让你编写出高性能并且不会报 OutOfMemoryError 异常的应用。另一方面,如果确实出现了 OutOfMemoryError 异常,你也能迅速找到内存泄漏的地方。
首先,让我们看一下 Java 中的内存是如何组织的:
简单来讲,内存被划分为两大部分: 栈(stack) 区域和 堆(heap) 区域。当然,上图显示的内存大小比例和实际的比例并不相符。实际上,相对于栈,堆是一块相当大的内存区域。
栈内存主要负责收集 堆 中对象的引用,以及存储基本数据类型。
另外,在栈内存中的变量有一个 作用域 的概念。只有当前活跃的作用域的对象能会被使用。举个例子,假设我们没有全局变量(类的属性字段等),只有本地变量,只有当编译器执行到该方法体的时候,它才能在这个方法中获得对象,并且它不能获得其他方法中的本地变量,因为其他方法中的本地变量不在作用域内。当方法执行完毕,并且返回之后,顶部的栈会被弹出,当前活跃的作用域就会变化。
可能你会发现,在上图中有多个栈内存(图中蓝色长方形)。这是因为在 Java 中,每个线程都会有自己的栈内存空间。因此,每当创建一个线程并启动的时候,它就会有自己的栈内存,并且它是不能获取其他线程的栈内存的。
这部分内存存储了对象本身。这里面的对象是被栈中的变量所引用的。举个例子,我们来分析下面这行代码会发生什么:
StringBuilder builder = new StringBUilder();
new
关键字会确保堆内存中有足够的空间来存储 StringBuilder
类型的对象,并且通过栈内存中的 builder
这个变量来引用该对象。
在每个 JVM 进程里,只有一个堆内存空间。因此,无论有多少个线程在运行,它们都是共享同一个堆内存。实际上,堆内存的结构和上图中有点不一样:为了方便垃圾回收,堆内存会划分成几部分。
一般来说,栈内存的大小和堆内存的大小并没有预先配置——它取决于运行的机器。然而,在后面的文章中,我们会介绍 JVM 的配置文件,该文件允许我们为运行的机器指定内存分配的大小。
如果你仔细观察上面那张内存结构图,你可能会发现,从变量指向堆中的对象所用的带箭头的线有不同的类型。这是因为,在 Java 语言中,我们有不同类型的引用: 强引用(strong reference) , 弱引用(weak reference) , 软引用(soft reference) 和 幽灵引用(phantom reference) 。垃圾回收器会针对这些不同的引用类型,实施不同的回收策略。
这是我们最常使用到的引用类型。在上面的 StringBuilder
例子中,我们持有了一个强引用,这个强引用指向堆中的一个对象。如果一个强引用指向堆中的一个对象,或者该对象在强引用链中是可达的,那么垃圾回收器是不会回收它的。
简单来说,如果一个弱引用指向堆中的一个对象,那么在下一次垃圾回收器执行处理的时候,是 很有可能 会被回收的。创建一个弱引用的方法如下:
WeakReference<StringBuilder> reference = new WeakReference<>(new StringBuilder());
这种类型的引用多用于内存敏感的场景。因为只有当你的应用程序内存不足的时候,这种类型的引用对象才会被回收。因此,只要没有严重到需要开辟新的内存空间使用的情况,垃圾回收器是不会染指软引用所指向的对象。Java 能确保所有的软引用对象会在抛出 OutOfMemoryError
之前被回收掉。Java 文档中描述: 所有的软引用对象或软引用链可到达的对象都会在虚拟机抛出 OutOfMemory 异常前被清理掉
( all soft references to softly-reachable objects are guaranteed to have been cleared before the virtual machine throws an OutOfMemoryError )。
和弱引用类似,软引用的创建方式如下:
SoftReference<StringBuilder> reference = new SoftReference<>(new StringBuilder());
如果使用了幽冥引用,那么在垃圾回收器处理一次之后,我们可以确信该引用的对象不会再存在。因此此类引用的 .get()
方法始终返回 null
。通常认为,使用这类引用,优于使用 finalize()
方法。
String
类型的对象在 Java 中会有区别待遇。String 是不可变类型,也就是说,每次你对字符串做一些操作的时候,另一个对象就会在堆中被创建出来。对于字符串类型,Java 在内存中管理着一个常量池,也就是说 Java 会把常用的字符串尽可能地重复使用。比如:
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 类型。假设我们修改上面的第一行代码:
String localPrefix = new Integer(297).toString(); // 1
输出:
Strings are different
在这个例子中,我们实际上看到的是堆中的两个不同对象。如果我们认为这种计算类型的 String 对象会被经常使用,我们就可以通过在计算结束后,调用 .intern()
方法,强制让 JVM 将该字符串添加进字符串常量池中:
String localPrefix = new Integer(297).toString().intern(); //1
这样,运行结果就是:
Stirngs are equal
正如我们之前所讨论的那样,根据栈内存中的变量与堆内存中对象的不同类型的引用,在某个确切时间点时,对象会获得被垃圾回收器处理的资格。
比如,上图中所有红色的对象,都有被垃圾回收器处理的资格。你可能注意到了,堆中有几个对象,它们之间是强引用关系。然而,它们与栈失去了引用,就不再可达了,因此这几个对象也就变成了垃圾。
再更深入了解之前,有几件事情是需要你了解的:
即时决定什么时候运行垃圾回收器的是 Java,你也可以显式地调用 System.gc( )
方法来告诉垃圾回收器运行,是吗?
这是一个错误的假设。
显式调用 System.gc( )
命令,只是你要求 Java 运行垃圾回收器,然而,再次强调,是否运行垃圾回收器是 Java 决定的。因此,不建议调用 System.gc( )
方法。
由于这是一个相当复杂的流程,并且会影响性能,因此它用一种更加智能的额方式来实现,这就是所谓的 标记 - 清除
算法。Java 会分析栈中的变量,然后标记所有需要保留的对象。然后,所有未被标记的对象将会被清除。
所以,Java 并没有收集任何垃圾。实际上,垃圾越多,被标记的对象越少,处理流程越快。为了优化这个方案,堆内存被划分为多个区间,我们可以使用 JVisualVM 工具来将内存使用情况可视化,这个工具是 JDK 自带的,你所需要做的就是安装一个 Visual GC 插件,这个插件可以允许你查看内存的实际结构。
当一个对象被创建的时候,它会被收集到 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 有三种类型的垃圾回收器,程序员可以选择使用其中一种。一般情况下,JVM 会根据底层硬件来选择垃圾回收器类型。
1. 串行 GC—— 一种单线程回收器。通常用在小型应用里面,处理少量的数据。可以使用 -XX: +UseSerialGC
来指定并启用该类型的 GC。
2. 并行 GC—— 从名字中就可以看出来,和序列化 GC 的不同点在于,并行 GC 使用多线程来执行垃圾回收处理程序。这种 GC 能处理大量的数据。可以使用 -XX:+UserParallelGC
来指定并启用该类型 GC。
3.伪并发 GC—— 在前面的文章中,我们提到垃圾回收处理程序是非常消耗资源的,当它运行的时候,所有的线程都会暂停。而这种伪并发 GC,它可以做到和应用程序几乎同时工作,当然并不会 100% 和应用程序并发,所有的应用线程仍然会暂停一段时间,而这暂停时间会保持尽可能短,以获得最好的 GC 性能。实际上,对于这种伪并发 GC,有两种具体实现:
-XX:+UseG1GC
开启。 -XX:+UseConcMarkSweepGC
指定。在 JDK 9 中,这种 GC 已被弃用。 null
。这样会让这些对象能够被垃圾回收器处理。 finalize()
方法。它会降低处理性能,并且不能保证任何事情。使用幽冥引用来清理相应的对象。 -Xms512m -Xmx1024m -Xss128m -Xmn256m
OutOfMemoryError
异常崩溃了,而你需要额外信息来检测内存泄漏情况,就可以使用 -XX:HeapDunpOnOutOfMemory
参数。设置了该参数后,在下次遇到同样的错误时,会生成一个 heap dump 文件来供你分析。 -verbose:gc
选项来获得垃圾回收器的日志输出。每次垃圾回收器清理了空间之后,会生成一份相应的输出文件。 了解内存是如何组织的,会帮助你写出优秀的内存使用相关的代码。你可以根据你的应用具体情况提供不同的配置项,以调整 JVM,从而使得 JVM 运行最佳配置。如果使用正确的工具,查找和修复内存泄漏也是一件简单的事情。