JVM (2) 内存分配与回收(GC)

Overall

C/C++中, 当一个对象被创建后, 需要手动调用方法回收内存, 而在Java中这个工作由Java负责, 这也就是所谓内存回收与分配策略的根源。

在Java中, 内存的回收主要集中在堆, 次要集中在方法区。 而与之相对的, 非线程共享的三个区域(主要指Stack)则并非垃圾回收重点。 其原因在于, 由于Java中的动态连接的存在,包括数组在内的很多对象的创建只有在运行期间才可确定。 这就导致了 方法区与堆的内存分配往往是动态的, 因为对象的创建过程也就是向堆与方法区中内存写入内容, 索要空间的过程。相比之下, Stack中栈帧的内容在编译期间即可大概确定,其在运行期间也不会有太多变化,进而导致此部分不是垃圾回收的重点内容。 也就是说, 某个分区是否是内存回收的重点对象与否, 取决于该区域的内存是否是动态的, 也即该区域的内存是否在编译期间即可确定。

而这种对于内存回收的需求, 也就导致了各种内存回收的策略与方法, 同时也促使了内存分配策略的诞生。 总的来说, 内存回收中的主要问题为”删除对象后造成的内存空间不连续“, 以及”为了防止空间不连续而进行的对象复制整理过程中产生的开销“。 这两个问题之间, 至少从目前笔者所接触到的内容而言, 有着不可避免的trade off problem。 因此, 不同的垃圾回收算法应运而生。 随之而来的还有三条分代假说。.

所谓分代假说可理解为对”对象存活时间的假定“。 在这三条假说的帮助下,对象被assume为短期对象与长期对象。 如果对两种对象的回收分别使用不同的垃圾回收算法, 即将所有短期对象与长期对象分别集中在一起并对两个区域使用不同的垃圾回收算法, 那么上述提到的trade off problem即可在一定程度上被缓解。 这就是为什么内存分配是为了方便内存回收。

JVM 内存回收 i.e. Garbage Collection

垃圾的判定

要对垃圾进行回收, 那么首先要解决的问题即是如何判定一个对象为垃圾。 通常认为一个对象如果不被任何其他对象所使用,那么该对象就是应该被回收的。 至此, 垃圾的判定问题也就被转化为对于 ”一个对象是否被引用的判定问题“。 而针对这个问题, 如下两个方法被提出:

引用计数法

引用计数法即为每个对象维护一标志位用于记录该对象被引用的次数。

但此方法会由于循环引用而产生错误。所谓循环引用即: 三个垃圾对象 A,B,C, A引用B, B引用C, C引用A。 此场景下ABC的计数位均不为0, 即都不会被标记为垃圾, 但实际上三个对象均是垃圾。 因此在JVM中被使用的是根可达算法。

根可达算法

即通过查询从”根对象”出发的“引用链”, 判断那些对象不在引用链中,若一个对象不在引用链中则代表其永远不会被调用,进而应该被回收。

根对象包括:

  • 在虚拟机栈中的引用对象,即栈帧中局部变量表中的引用的对象。 i.e. 程序中的方法中的 参数,局部变量,临时变量等;
  • 方法区中的类静态属性引用的变量,例如static的变量等;
  • JNI引用的对象(Java Native 方法)
  • Java虚拟机内部的引用, 如基本数据类型对应的Class对象,系统类加载器等
  • 被synchronized关键字持有的对象
  • 反应JVM内部情况的JMXBean等

垃圾回收算法 - 主要针对Heap

标记-清除算法

DEF: 即先标记死亡对象,在直接将其清楚

Drawbacks:

  • 会造成内存不连续

  • 当对象数量极多时,标记清除的数量增多,效率降低

标记-复制算法

DEF: 将内存分区,按容量只使用其中一部分,当需要垃圾回收时,将存活的对象复制到未使用的那部分内存,然后将使用的内存一次性全部删除.

该算法其中一种实现为apple算法, 而apple算法正是对于堆分区进一步进行划分的原因。 该实现将新生代区域分成三块,eden区和两个suvivor区。三块区域中,其中的一个suvivor区为日常不使用的区域,只用来复制存活对象,然后清空整个eden+ 使用过的那个suvivor

Drawbacks:

  • 对象多时复制将变得heavy

  • 会浪费一部分内存日常无法使用

标记-整理算法

DEF: 即在标记后,将所有存活的对象移动到内存中的一块区域,然后清理掉其他区域。该算法与“标记-复制”算法的本质思路相同。 其唯一区别在于, 不维护一块空间日常不使用, 而是在垃圾清理时,把所有或者的对象复制到内存中的一块区域,进而解决了 浪费内存的问题。

Drawbacks:

  • 移动对象很heavy
  • 该操作需暂停用户进程

方法区回收

方法区回收则主要回收”废弃的常量“以及“废弃类型”。

对于废弃的常量的回收与对 对象的回收很类似。 例如一个字符串曾进入常量池, 但其已经不在被使用,则会被清理。 其他常量池中的类, 方法符号引用皆为同理。

JVM 内存分配

为了方便内存回收管理, JVM中的堆进一步被分为: 新生代老年代。依据对象年龄,将其集中分配于堆中的不同区域以达到方便管理回收内存的作用。 而分配的合理性即为三条分代假说。 此处需注意, 所谓对堆的进一步划分是从GC角度而言, JVM规范并未对此有规定。换句话说, 对于堆的区域划分由不同的JVM实现决定。

有了新生代与老年代以后, 即可对于不同的分区采取不同的垃圾回收算法。同时也可以只对其中一个分区进行GC操作。 不同的GC操作分为:

  • Minor GC:只对新生代GC
  • Major GC:只对老年代GC
  • Full GC: 收集整个Java堆和方法区的垃圾收集

新生代

所谓新生代即”熬过GC次数较少的“对象。 新生代区域又分为 Eden 空间和Survivor空间。 Survivor空间有进一步分为To_Survivor & From_Survivor。

其中Eden空间占新生代整个空间的 8/10, 其余两个survivor空间分别占1/10。 而新生代空间整体又占整个堆的1/3。 但注意这些比例皆可调整。

新对象通常创建在Eden空间, 若Eden空间内存不够, 则发起依次Minor GC。 新生代空间中使用的GC算法是标记-复制算法, 之所以使用该算法, 依据的是三条分代假说中的弱分代假说—- 绝大多数对象都死的快。 因为有了大多数新生对象都死的快这个前提, 使得”复制这个操作不会很Heavy, 因为大多数新生代中的对象都将被清除, 每次复制的对象仅是很少一部分“

同时使用在新生代中的假说还有 ”跨代引用假说 —- 跨代引用只占极少一部分“。 该理论意义为,其使得系统无需在为了少量的跨代引用扫描整个老年代,也不必浪费空间为每个对象记录是否存在跨代引用,取而代之的是在新生代中建立一个全局数据结构记录老年代中哪一块内存存在跨代引用,只有其中记录的老年代才会加入GC Roots的扫描。

简单说, 当进行Minor GC时, 是跨代引用假说避免了扫描所有老年代。 如果不建立这条假说, 那么由于鼓励老年代可能引用新生代对象, 进而只对新生代进行回收时还需扫描整个老年代。

老年代

大对象将直接进入老年代, 而所谓大对象, 即指”需要大块连续内存的对象“, 比如长字符串, 长数组等。 除了大对象外, ”熬过多次垃圾回收的对象“也将被移至老年代。 这也是依据了强分代假说

具体来讲, 对象通常被创建在新生代Eden区, 而每个对象被创建时, 其对象头内会被设置一”年龄计数器“, 每当一个对象熬过一次GC, 则年龄加1, 当一个新生代中的对象经过第一次Minor GC后仍存活, 且Survivor能够容纳它的话, 则该对象被转移至survivor空间。 当age超过设定值(默认15), 则会被转移至老年代

同时, 由于大对象将直接进入老年代, 那么频繁的创建生命很短的大对象则是一个很”繁重“的操作。原因在于大对象需要大块连续内存开销, 进而可能触发GC操作, 并且 当进行复制操作时, 大对象意味着很高的复制开销。

Created by Shain at 2021/12/3, Melbourne, Australia.