Java内存管理

By | 2022年1月26日

这篇文章会深入分析下Java的内存管理机制,看过之后应该会对Heap内存是如何工作的,以及各种类型和内存回收机制的理解有些帮助。

有时候估计你自己也在想,作为一个Java工程师来讲,关于内存这块到底要了解哪些知识呢?Java有自己的内存管理机制,有一个挺不错的垃圾回收器可以使得那些无用的对象在后台不知不觉中回收掉。这样看来作为Java工程师来说貌似不需要为此时烦恼和下功夫,但是总有例外对吧,时常会遇到内存溢出的问题。

所以知道内存这块是如何工作的是很重要的,它有助于你写出更加高性能的代码以及避免可能出现的OOM问题,换句话说,即便出现遇到了OOM的情况,那么我们也可以快速排查问题不是。

总体内存结构

Memory structure

如上图所示,Java内存被分为了两大部分,栈内存(Stack)和堆内存(Heap),图上两部分的大小比例并不真实,真实的情况是堆内存的大小远远大于栈内存。

栈内存(Stack)

栈内存用来存储堆内存中对象的引用以及值类型的数据。

换句话说,栈内存存储的内容都是有作用域(Scope)的,都是当前正在使用的作用域。比如假设我们没有任何的全局变量只有一些局部变量,如果代码执行到了方法内部,那么这是只能看到栈内存里面这个方法的数据,其他方法里面的变量数据看不到。一旦这个方法执行完毕,这些局部变量将会被出栈,当前作用域范围进行切换。

在上面图中你也许看到了栈内存有很多部分。这是因为在Java中栈内存是由Thread来分配的。所以呢,一个Thread的创建就会伴随这它自己的栈内存空间的开辟,它无法访问其他Thread里面的数据。

堆内存

这部分内存存储这实际的对象,这些对象的引用存储在栈内存中。举个例子说明下:

StringBuilder builder = new StringBuilder();

这个句话里面,new这个关键字会导致在堆内存中创建一个StringBuilder类型的对象,然后创建一个指向它的引用builder,并把它放到栈内存中。

上面的过程发生在一个java虚拟机只有一块堆内存的情况下,不管有多少个线程在跑,他们都共享这一块,但是实际上是跟图中所有有些区别的,堆内存本身又被分为了几个部分,这样做是便于内存的垃圾回收。

栈内存和堆内存的最大值是没有预定义的,这个取决于运行所在的机器。

引用类型

如果你仔细看了上面那张图片,引用的线是有不同类型的,这是因为在Java这门语言中有不同的几种引用类型:强引用(Strong)、弱引用(Week)、软引用(Soft)和幽灵引用(Phantom),这些类型的不同点在于它们引用的对象对于垃圾回收器来说有着不同的回收标准。

强引用(Strong)

这是我们用到的最常见的引用类型,在上面的那个例子里面,我们实际上就创建了一个强引用,在堆内存中的对象如果有被强引用引用着或者说在整个强引用的链条中可达,那么垃圾回收器就不会回收它。

弱引用(Week)

基本上来说,一个被弱引用引用的对象会在下一轮的垃圾回收处理中回收掉,下面是一个弱引用的例子

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

对于弱引用来说,一个适合的场景就是缓存。假如你获取了一些数据到内存中,这些数据呢有机会被再次用到,但是又不确定什么时候被用到,这时就可以用这个弱引用。

不过当被回收后再次用这个对象,这个对象就变成了null了,我们可以通过WeakHashMap<K,V>来到这个目的也是不错的选择

软引用(Soft)

这些类型的引用用于对内存更敏感的场景,因为只有在应用程序内存不足时才会对这些引用进行垃圾收集。 因此,只要没有紧急需要释放一些空间,垃圾收集器就不会触及软可达对象。 Java 保证在抛出 OutOfMemoryError 之前清理所有软引用的对象。 Javadocs 声明,“所有对软可访问对象的软引用都保证在虚拟机抛出 OutOfMemoryError 之前已被清除。”

像弱引用一样,可以通过下面的方式进行创建

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

幽灵引用(Phantom)

用于安排事后清理操作,因为我们确定对象不再存在。 仅与引用队列一起使用,因为此类引用的 .get() 方法将始终返回 null。 这些类型的引用被认为比终结器更可取。

字符串是怎么被引用的

在Java中String类型是有点不同的,字符串字面量是不可变的,也就是说只要对字符串字面量做了修改就会产生一个新的字符串对象,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 localPrefix = new Integer(297).toString(); //1

那么结果会输出Strings are different

垃圾回收

基于上面的讨论,根据栈内存中引用类型的不同,在特定的时间有些对象会被标记成垃圾对象,从而被垃圾回收器回收掉。

Garbage eligible objects

举个例子说,图中所有红色的表示要被回收的对象,也许你注意到有几个红色的之间也是强引用的,但是也被标红了,这是因为他们与栈内存中的引用失去了联系,失去了联系也就意味着不会再被访问到,因此也是需要被回收的对象。

进一步来讲,

  • 垃圾回收器是一个Java自动触发运行的程序
  • 垃圾回收器是一个比较重的程序,因为当它运行时,其他线程会被暂停

尽管垃圾回收器是自动运行的,但是也可以通过调用System.gc()来进行手动触发,但是不会在调用的时候立即执行,具体什么时候执行还是由Java自己决定,因此不建议通过此方式来释放内存

垃圾回收器类型

实际上,Java提供了三种类型的垃圾回收器,工程师可以自己指定,默认情况,Java会根据所运行的硬件自己来决定

  • Serial GC 串行垃圾回收器,一个单线程收集器。 主要适用于数据使用量小的小型应用程序。 可以通过指定命令行选项来启用:-XX:+UseSerialGC
  • Parallel GC 并行垃圾回收器,即使从命名上看,Serial 和 Parallel 的区别也在于 Parallel GC 使用多个线程来执行垃圾收集过程。 这种 GC 类型也称为吞吐量收集器。 可以通过显式指定选项来启用它:-XX:+UseParallelGC
  • Mostly concurrent GC 是一种以获取最短停顿时间为目标的收集器,如果您还记得,本文前面提到过垃圾收集过程实际上非常昂贵,并且当它运行时,所有线程都被暂停。 但是,我们有这种主要是并发的 GC 类型,它表明它与应用程序并发工作。 但是,它“大部分”是并发的是有原因的。 它不能 100% 与应用程序同时工作。 线程暂停一段时间。 尽管如此,为了获得最佳的 GC 性能,暂停保持尽可能短。 实际上,有两种类型的并发 GC:
    • Garbage First – 回收优先,会在特定的时间暂停来进行垃圾回收,通过显示指定选项来启用它:-XX:+UseG1GC
    • Concurrent Mark Sweep – 并发标记清除,他会尽量锁定程序的暂停时间,通过显示指定选项来启用它:-XX:+UseConcMarkSweepGC,在JDK9中被抛弃了。

一些建议

  • 定义变量作用域(Scope)越小越好,这样用完后就可以更快的被回收
  • 对一些重型对象在用完之后手动赋值null,这样也能加快对象的回收
  • 避免使用finalizers,它不能确保某些事情,但是却会加大对象的回收时间
  • 能用弱引用的时候就不用强引用
  • 如果系统发生了OutOfMemoryError,就加些辅助配置–XX:HeapDumpOnOutOfMemory,这样会打印出更多有用的信息,有助于排查

结语

了解内存是如何组织的可以让您在内存资源方面编写良好和优化的代码。 有利的是,您可以通过提供最适合您正在运行的应用程序的不同配置来调整正在运行的 JVM。 如果使用正确的工具,发现和修复内存泄漏只是一件容易的事。