细说Java内存管理:栈、堆、引用类型

译文
开发 后端
本文通过介绍Java在内存方面有关堆与栈的工作方式,各种引用类型和垃圾回收种类,协助您编写出具有高性能的应用程序。

【51CTO.com快译】朋友,您在使用Java进行编程时,是否了解过其调用内存的工作原理?总的说来,作为一个不错的静默式垃圾回收器,Java具有自动管理内存的功能,能够在后台工作,清理未使用的对象,并释放内存。话虽如此,如果您的程序设计不到位,Java的垃圾收集器和内存管理特性,恐怕也无法自动生效。

可见,了解内存在Java中的实际原理是至关重要的。它不但能够辅助您编写出高性能的应用程序,还能够尽量避免程序因OutOfMemoryError而崩溃;或者是在程序运行状况不佳时,协助您快速发现内存泄漏的原因。

下面,让我们首先来看一下Java语言中的内存组织结构:

如上图所示,内存通常被分为两大部分:栈和堆。请记住,该图片中的内存类型大小与实际内存大小并不成比例。也就是说:与栈相比,堆是更大块的内存。

栈(Stack)

栈内存既负责保存那些针对堆对象(heap objects)的引用,又负责保存各种值的类型,即:存储的是数值本身,而不是对堆中某个对象的引用。在Java中,我们称为原始类型(primitive types)。

另外,栈上的变量具有一定的可见性,我们称为范围(scope)。通常,只有活跃范围(active scope)中的对象,才可以被使用。例如:假设我们没有任何全局作用域的变量(或字段),而只有局部的变量,那么如果编译器要执行某个方法的主体,就只能从栈中访问该方法主体内的对象。而且由于超出了范围,因此它无法访问其他局部变量。一旦该方法被执行完成并给出了返回,它就会弹出栈的顶部,并更改活跃范围。

也许您已经注意到,由于Java的栈内存是按照线程分配的,因此在上图中会有多个栈存储器。而且,程序在每次创建和启动一个线程时,都拥有自己的栈内存,无需也无法访问另一个线程的栈内存。

堆(Heap)

这部分的内存存储着实际对象,它们会被栈的变量所引用。让我们来看如下代码行:

  1. StringBuilder builder = new StringBuilder(); 

关键字new负责确保堆能够获取足够的可用空间。它在存储器中创建StringBuilder类型的对象,并通过“builder”的引用,其压入栈中。

由于每个正在运行的JVM进程只有一个堆内存,因此无论系统当前正在运行多少个线程,它们都会共享内存的指定部分。实际上,堆的真实结构与上图不尽相同,它会根据垃圾收集的过程,被分成几个部分。

而是否需要预定义栈和堆的最大容量,将完全取决于正在运行程序的计算机。在后面的讨论中,我们将研究JVM的相关配置,以便为正在运行的应用程序,显式地指定大小。

引用类型

如果仔细观察上述图片,您可能会注意到,来自于堆的、表示对象引用的箭头,实际上具有不同的类型。这是因为在Java编程语言中,我们具有不同类型的引用,即:强引用、弱引用、软引用、以及虚引用(phantom references)。引用类型之间的区别在于:堆上的对象在不同条件下,可以引用的垃圾回收有所不同。下面,我们来逐个进行讨论。

1.强引用

这是最流行,也是开发人员最常用的引用类型。在上述StringBuilder示例中,我们实际上对堆中的对象采取了强引用。堆上的对象不会被垃圾回收,而是有一个指向了它的强引用,或者通过一串强引用来获取该对象。

2.弱引用

弱引用可通过如下方式被创建:

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

弱引用的一种最佳使用场景是缓存方案。设想,您检索了一些数据,并且希望将其存储在内存中,以便下次能够直接作出响应。当然,您并不确定何时或者是否有对该数据的请求。那么,您就可以对其采用弱引用,以免堆上的对象被垃圾收集器回收掉,以致在检索该对象时,返回null值。可见,WeakHashMap

  1. /** 
  2.     * The entries in this hash table extend WeakReference, using its main ref 
  3.     * field as the key
  4. */ 
  5. private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> { 
  6.     V value; 

一旦WeakHashMap中某个键被垃圾回收,那么整个条目就会从映射中被删除。

3.软引用

此类引用可用于那些对于内存非常敏感的方案。例如,只有在应用程序的内存不足时,该引用才会被垃圾回收。也就是说,不到迫不得已,垃圾收集器就不会处置软引用对应的对象。而且,Java的相关文档已提到了:在虚拟机抛出OutOfMemoryError之前,所有软引用的对象早已被清除了。

与弱引用类似,我们可以按照如下方式来创建软引用:

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

4.虚引用

虚引用可被用于事后清理操作,毕竟我们可以确定对象已不复存在。此类引用的.get()方法将始终返回null。虚引用必须和引用队列(ReferenceQueue)一起使用。也就是说,当垃圾回收器准备回收某个对象时,如果发现它尚存有虚引用,就会在回收该对象的内存之前,把虚引用加入到与之相关联的引用队列中。

如何引用字符串

Java中的字符串(String)类型有些特殊,它是不可变的。这就意味着程序每次使用字符串进行操作时,实际上都会在堆上创建另一个对象。而对于字符串而言,由于Java管理着内存中的字符串池,因此Java会尽可能地存储和重用字符串。例如:

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

在上述代码被运行后,将会打印出:

字符串相等(Strings are equal)

可见,事实证明,在比较了String类型的两个引用之后,这些引用实际上指向的是堆上的相同对象。但是,这对于那些经过计算的字符串来说是无效的。例如:我们将上述代码的//1行更改为:

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

那么输出则变为:

字符串不同(Strings are different)

可见,在这种情况下,堆上有两个不同的对象。如果我们认为经过计算的字符串会经常被使用的话,则可以在经过计算的字符串末尾,添加.intern()方法来,强制JVM将其添加到字符串池中。如下代码再次修改了//1行:

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

那么输出则变为:

字符串相等(Strings are equal)

垃圾收集程序

如前所述,根据栈中的变量被保存到堆中的对象所引用类型,在某个时间点,该对象将会成为垃圾收集器的“合格对象”。 

如上图所示,所有红色的对象都有资格被垃圾收集器收集。您可能会注意到,堆上有一个对象具有其他对象的强引用(例如,既可以是对其进行引用的列表,又可以是具有两种引用类型字段的对象)。由于它丢失了在栈中的引用,程序无法再对其进行访问,因此它也成为了垃圾。

在向下更深入讨论之前,让我们先明确如下三点:

  • 此过程由Java自动触发,并由Java决定何时、以及是否启动此过程。
  • 当垃圾收集器运行时,应用程序中的所有线程都将暂停,因此该过程代价不菲。
  • 该过程并非仅为垃圾收集和释放内存那么简单。

鉴于这是一个非常复杂的过程,并且可能会影响程序的性能,因此我们可以使用所谓的“标记和清扫(Mark and Sweep)”过程,即:让Java分析栈中的变量,并“标记”所有需要保持活跃状态的对象,然后清除所有未在使用的对象。显然,被标记为垃圾的对象越多,需要保持活跃的对象就越少,该过程就会越快。为了使之更加高效,我们可以使用Java JDK附带的工具—JvisualVM,来可视化内存的使用情况和其他实用的信息。当然,您需要安装一个名为Visual GC的插件,以查看到内存的实际结构。

如上图所示,我们创建了一个对象,并将其分配到Eden(1)空间上。由于Eden空间并不大,因此很快就会被填满。此时,垃圾收集器运行在Eden空间上,并将各个对象标记为活跃。

一旦某个对象在垃圾回收的过程中留存下来,它就将会被移到所谓的留存空间--S0(2)中。当垃圾收集器第二次在Eden空间上运行时,它会将所有留存的对象移到S1(3)空间中。同样,当前在S0(2)上的所有对象,也都被移到S1(3)空间中。如果一个对象经过n轮垃圾回收,仍被留存下来的话,那么它被视为需要持久存在,并被移入Old(4)空间。

至此,在垃圾回收器graph(6)中,您会看到各种对象在每一次运行后,被转移到留存空间,Eden空间同时也会重新产生。而Metaspace(5)可被用于让元数据存储JVM加载的各种类。

上述图片实际上是一个Java 8的应用程序。而在Java 8之前的版本中,内存结构会略有不同。metaspace实际上被称为PermGen空间。例如,在Java 6中,该空间还存储了字符串池的内存。因此,如果Java 6应用程序中的字符串过多,就可能会崩溃。

垃圾收集器(GC)的类型

实际上,JVM具有如下三种类型的垃圾收集器,可供开发人员进行选择。默认情况下,Java会根据实际环境中的底层硬件,来进行选用。

1. 串行GC – 单线程收集器。它通常适用于数据量较少的小型应用程序。您可以通过指定命令行选项:-XX:+UseSerialGC,来启用。

2. 并行GC – 吞吐量收集器。它是使用多个线程来执行垃圾收集的过程。您可以通过显式指定选项:-XX:+UseParallelGC,来启用。

3. 并发GC – 如前文所述,垃圾收集过程在运行时,会暂停所有的线程。而并发GC的许多操作(并非所有)与应用程序的业务,存在着并发关系。在具有多个处理内核的计算机上,应用程序线程可以在收集的并发期间使用处理器,因此并发垃圾收集器线程不会暂停应用程序。其效果当然会使得停顿的时间更短,但是应用程序可用的处理器资源也会相应地变得更少,而且可能出现降速,特别是当应用程序正在最大限度地使用所有处理内核时。通常有两种并发GC可被选用:

3.1垃圾优先 – 它在满足垃圾收集暂停时间目标的同时,实现了高吞吐量。您可以通过:-XX:+UseG1GC,来启用它。

3.2并发标记清扫 – 此收集器适用于那些追求更短的垃圾收集暂停时间,且能够与垃圾收集共享处理器资源的应用程序。您可以通过:-XX:+UseConcMarkSweepGC,来启用它。不过从JDK 9开始,该GC类型不再被推荐使用。

技巧和窍门

  • 为了最大程度地减少内存的占用,请尽可能地限制变量的范围。记住,在每次弹出栈顶部的作用域时,该作用域中的引用都会被丢失,并会导致对象被判定为适合垃圾收集。
  • 将过时的引用标记为null,以便让此类引用对象适合垃圾收集。
  • 由于finalizer会减慢垃圾收集的过程,因此最好使用虚引用。
  • 不要在可以使用弱引用或软引用之处使用强引用。最常见的内存陷阱是​​在缓存场景中,即使数据不在被需要,却仍要保留在内存中。
  • JVisualVM还具有在特定时间点进行堆转储的功能,因此您可以针对每个类分析其占用的内存量。
  • 根据您的应用程序需求来配置JVM。在运行应用程序时,可明确指定JVM堆的尺寸,分配合理的初始和最大内存量。您可以借鉴如下指定原则:
  1. 初始堆的尺寸 -Xms512m – 将初始堆的尺寸设置为512 MB。
  2. 最大堆的尺寸 -Xmx1024m – 将最大堆的尺寸设置为1024 MB。
  3. 线程栈大小 -Xss1m – 将线程栈的大小设置为1 MB。
  4. 新生大小 -Xmn256m – 将新生的大小设置为256 MB。
  • 如果Java应用程序出现OutOfMemoryError,而且崩溃了,那么您可以使用–XX:HeapDumpOnOutOfMemory参数,来运行该过程。它将会在下一次发生此类错误时创建一个堆dump文件,以方便您收集到内存泄漏的相关信息。
  • 请使用-verbose:gc选项,来获取垃圾收集到的输出。

小结

综上所述,了解内存的组织方式,您不但可以从内存资源的合理使用角度,编写出良好且经过优化的代码,还可以通过优化配置,来调整正在运行的JVM。此外,通过使用恰当的工具,您还可以轻松地修复各类程序中的内存泄漏错误。

原文标题:Java Memory Management,作者:Constantin Marian

【51CTO译稿,合作站点转载请注明原文译者和出处为51CTO.com】

责任编辑:华轩 来源: 51CTO
相关推荐

2009-06-08 18:05:00

Java进阶引用

2009-06-03 15:52:34

堆内存栈内存Java内存分配

2023-11-01 08:07:42

.NETC#

2009-06-08 22:01:03

Java堆Java栈区别

2013-07-23 06:47:55

Android内存机制Android堆和栈Android开发学习

2009-11-25 11:08:28

JVM基础概念数据类型堆与栈

2011-06-09 11:36:00

java

2023-01-09 08:00:41

JavaScript闭包

2023-12-26 12:37:08

内存模型堆排序

2022-03-16 08:39:19

StackHeap内存

2018-04-17 14:41:41

Java堆内存溢出

2011-07-22 16:50:05

JAVA

2020-05-27 21:13:27

JavaJVM内存

2016-12-20 15:35:52

Java堆本地内存

2012-02-20 11:33:29

Java

2021-12-13 09:26:31

JS代码前端

2011-07-22 17:06:22

java

2019-12-12 11:19:33

JVM内存线程

2010-02-04 14:41:52

Android菜单类型

2010-03-10 09:27:36

Linux链接文件类型
点赞
收藏

51CTO技术栈公众号