70

Java内存管理简介

 5 years ago
source link: http://wl9739.github.io/2018/07/04/Java内存管理简介/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

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

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

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

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

rQrQz2J.jpg!web

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

栈(stack)

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

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

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

堆(Heap)

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

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)

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

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 )。

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

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

4. 幽灵引用(phantom reference)

如果使用了幽冥引用,那么在垃圾回收器处理一次之后,我们可以确信该引用的对象不会再存在。因此此类引用的 .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

垃圾回收器的处理流程

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

RJnIfe6.jpg!web

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

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

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

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

这是一个错误的假设。

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

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

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

YjyEny3.jpg!web

当一个对象被创建的时候,它会被收集到 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,有两种具体实现:

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

建议和技巧

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

总结

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK