转载

JVM之垃圾回收(二)

之前对标记和清除垃圾收集的介绍主要是理论性的。在实践中,需要进行大量的调整,以适应真实的场景和需求。举个简单的例子,为了安全地继续分配对象,JVM还需要做以下事情。

碎片和压缩

每次清理垃圾时,JVM必须确保填充了不可访问对象的区域可以重新使用。这可能会导致内存碎片,这与磁盘碎片类似,会导致两个问题:

  • 写操作变得更加耗时,因为寻找下一个足够大的空闲块不再是一个简单的操作。
  • 当创建新对象时,JVM是在连续块中分配内存。因此,如果碎片碎到没有单个空闲碎片的大小足以容纳新创建的对象的时候,就会发生分配错误(事实上有大量空闲内存)。

为了避免这些问题,JVM正需要确保碎片不会失控。因此,在垃圾收集期间也会发生“内存碎片整理”过程,而不仅仅是标记和清理。这个过程中需要将所有可访问的对象彼此重新定位,消除(或减少)碎片。下面是一个例子:

JVM之垃圾回收(二)

分代假设

前面说过,执行垃圾收集时需要完全停止应用程序。很明显,对象越多,收集所有垃圾所需的时间就越长。但是如果我们有可能使用更小的内存区域呢?有研究人员调查了这种可能性,发现应用程序中的大多数分配可以分为两类:

  • 大多数对象很快就不用了
  • 对象通常不能存活很长时间

这些观察结果形成了一个弱分代假设。根据这个假设,虚拟机中的内存被分为年轻代( Young Generation)和老年代(Old Generation)。老年代有时也被称为永久代。

JVM之垃圾回收(二)

拥有这样独立的、单独的可清理区域允许使用许多不同的算法,这些算法在提高GC性能方面取得了长足的进步。

这并不是说这种方法没有问题。首先,来自不同代的对象实际上可能彼此有引用,这些引用在收集代时也被视为“事实上的”GC根。

但最重要的是,分代假设可能并不适用于某些应用。由于GC算法是针对“早逝”或“可能永远存在”的对象优化的,所以JVM对那些“中等”预期寿命的对象的回收表现非常糟糕。

内存池

稍微了解JVM的对堆中内存池的划分应该很熟悉。不太常见的是垃圾收集如何在不同的内存池中执行其职责。在不同的GC算法中,一些实现细节可能会有所不同,但是本章中的概念实际上是相同的。

JVM之垃圾回收(二)

Eden区

Eden区通常是内存中创建对象时分配对象的区域。由于通常有多个线程同时创建许多对象,Eden区中进一步划分为一个或多个线程本地分配缓冲区(简称TLAB),这些TLAB缓冲区由于是线程独享的,因此允许JVM在TLAB中直接为一个线程分配对象,避免了与其他线程进行昂贵的同步操作。

当在TLAB中分配对象失败时(通常是因为那里没有足够的空间),分配将转移到共享的Eden区。如果没有足够的空间,就会触发年轻代中的垃圾收集过程来释放更多的空间。如果垃圾收集也没有在Eden中产生足够的空闲内存,那么对象将在老年代中分配。

当Eden区进行垃圾回收时,GC Roots遍历所有可访问的对象,并将它们标记为活动对象。

之前提到到对象可以可能存在跨代的链接,所以一种直接的方法是检查其他区对Eden区的所有引用。不幸的是,这样做将首先破坏分代的意义。JVM有一个诀窍:卡片标记。本质上,JVM只是标记了Eden中“脏”对象的粗略位置,这些对象可能与老年代对象有链接。

JVM之垃圾回收(二)

标记阶段完成后,Eden区中的所有活动对象都被复制到一个Survivor区。整个Eden区现在是空的,可以重用它来分配更多的对象。这种方法称为“标记和复制”:活动对象被标记,然后复制(而不是移动)到幸存者空间。

Survivor区

紧挨着Eden区的是两个Survivor区,分别称为from和to。需要注意的是,两个Survivor区中的一个总是空的。

当进行新生代收集的时候,空的紧挨着Eden区的是两个Survivor区将开始有数据。新生代的所有活动对象(包括Eden空间和Survivor区中非空的“from”空间)都被复制到Survivor区中“to”空间。在这个过程完成之后,“to”区现在包含对象,而“from”不包含对象。此时他们的角色互换了。

JVM之垃圾回收(二)

在两个Survivor区之间复制活动对象的过程会重复几次,直到某些对象被认为已经成熟并且“足够老”为止。请记住,根据分代假设,已经存在一段时间的对象预计将继续使用很长时间。

这样的“终身”对象就可以推广到老年代。当这种情况发生时,对象不会从一个Survivor空间移动到另一个Survivor空间,而是移动到老年代空间,它们将驻留在老年代空间中,直到无法访问为止。

为了确定对象是否“足够老”到可以考虑将其传播到老年代空间,GC跟踪特定对象存活的集合数量。在每一代对象完成一次GC之后,仍然存活的对象的年龄就会增加。当年龄超过一定的阈值时,对象将被提升到老年代空间。

老年代

老年代内存空间的实现要复杂得多。老年代通常要大得多,并且被占用的对象不太可能是垃圾。

老一代的GC比年轻一代发生的频率要低。而且,由于大多数对象都被认为是老一代的活对象,所以没有标记和复制发生。相反只是移动对象以最小化碎片。清理老年代空间的算法通常建立在不同的基础上。原则上,所采取的步骤如下:

  • 在GC Roots可达的所有对象旁边设置标记位来标记可访问对象
  • 删除所有不可达的对象
  • 通过将活动对象拷贝至以老年代起始空间开头的连续空间来压缩老年代的空间

永久代

在Java 8之前,有一个特殊的空间称为“永久代”,存放的是类信息之类的元数据。此外,一些额外的东西,如内部化字符串,保存在永久代中。永久代实际上给Java开发人员带来了很多麻烦,因为很难预测这些数据到底需要多少空间。这些失败预测的结果以 java.lang.OutOfMemoryError: Permgen space的形式出现。除非这种OutOfMemoryError错误的原因是实际的内存泄漏,否则解决这个问题的方法就是简单地增加与的permgen大小,如下:

java -XX:MaxPermSize=256m com.mycompany.MyApplication
复制代码

元数据

由于预测永久代数据的需求是一项复杂且不方便的工作,所以在Java8中删除了永久代,代之以元数据空间。此时,大多数杂项都被移到了常规Java堆中。

然而,类定义现在被加载到称为Metaspace中。它位于本机内存中,不会干扰常规堆对象。默认情况下,元数据空间大小仅受Java进程可用的本机内存数量的限制。这将使开发人员避免向应用程序中再添加一个类就会导致 java.lang.OutOfMemoryError: Permgen space错误的问题。

请注意,拥有这样看似无限的空间并不是没有代价的——让元空间不受控制地增长,可能会引入大量swap操作并导致本机分配失败。

如果仍然希望在这种情况下保护程序,可以限制元数据空间的增长,比如限制元空间大小为256 MB:

java -XX:MaxMetaspaceSize=256m com.mycompany.MyApplication
复制代码
原文  https://juejin.im/post/5d20951ef265da1bb13f5324
正文到此结束
Loading...