所以缓存列表用于响应小对象的分配,树结构用于响应大对象或者缓存列表的分配。具体思路是,定义[0,256]共计257字的缓存列表,如图4-18所示。
图4-18 多条固定长度的链表管理内存
在图4-18中,字长小于3字的缓存列表实际上并未使用,因为在JVM内部一个对象最少占用3字,主要原因是每个对象都必须有一个对象头,而对象头的大小为2字,而为了区别不同的空对象,会为每个空对象增加一个额外的字空间,所以一个对象的大小最小为3字。

另外,老生代还设置了一块专门用于处理超小对象的分配缓存空间,该缓存称为LinearAllocBlock(简称LAB)。其思路是,当缓存列表无法满足超小对象的分配请求时,从该缓存中分配对象,超小对象的上限为16字。设计超小对象的缓存的目的有两个:加速超小对象的分配效率;减少超小对象占用空闲列表,避免超小对象导致的内存碎片问题。LinearAllocBlock也是一个内存块,如图4-19所示。
图4-19 避免碎片化的小对象缓存[1]
从LAB分配的对象通常都是小对象(小于16字),需要将已经分配的对象统计到IndexedFreeList的数组中(在合并时需要这些信息)。如果对象被释放,可以直接放到IndexedFreeList中。注意,LAB预分配的空间从二叉树中获取,但是LAB仅仅是预分配空间,所以LAB的内存不能和空闲的内存块合并。
而大内存块采用的存储结构是如图4-4所示的二叉树+链表的管理形式。
老生代存在3种管理方式,分别为固定列表、LAB和二叉树。内存分配的流程图如图4-20所示。
图4-20 老生代对象分配流程图
从另一个角度出发,当发现内存空闲时,需要使用指针将内存块关联到空闲列表,这需要占用内存。而内存块空闲或者内存块被分配给对象这两种情况不可能同时存在,所以可以将内存块前面的空间进行复用:当内存块用于对象分配时,内存块的空间被识别为元数据;当内存块位于空闲列表中时,内存块的空间被用作指针(维护链表或者树结构)。空间的复用减少了额外内存的占用。
在介绍内存复用前先来回顾一下对象的内存布局。关于Java的对象,在JVM内部使用instanceOop来表示,其内存布局如图4-21所示。
图4-21 JVM对象内存布局示意图
其中markoop是元数据信息,可以保存hashcode、锁信息、gc状态信息等;klass是指向Java对象所属的Java类的指针;field是Java对象的成员变量。
在老生代的内存管理中使用两种类型的数据结构,分别是二叉树和链表。其中链表直接管理空闲内存块,树管理空闲链表。链表管理时需要链表节点(list node)来辅助,链表节点至少需要两个子指针,分别指向前序节点和后序节点,当空闲块长度未知时还需要一个表示长度的字段。二叉树需要树节点(tree node)辅助管理,树节点至少需要3个字段,分别是指向父节点、左子树和右子树的指针。比较树节点和链表节点的结构可以将其共同抽象为使用3个字段的结构,包含大小和两个指针,如图4-22所示。
图4-22 树和链表管理结构示意图
从二叉树和链表的管理结构来看,每个树节点和链表节点都需要占用额外的内存空间。在树节点和链表节点比较多的场景中,会因为辅助的内存管理结构带来不小的额外空间消耗。另外,还需要考虑这些空间消耗是使用本地内存还是堆内存,如何分配和释放这些内存,确保不会出现内存不足或者内存碎片等问题。为了解决上述问题,CMS的老生代在堆内存中分配管理结构的内存,同时将管理结构和对象头进行复用。首先内存用于对象时表示内存已使用,而内存用于管理结构时表示内存是空闲的,两者的状态是不会重复的;其次比较instanceOop和管理结构,可以发现它们都至少包含了2字的有效信息,所以可以直接将同一内存的字段复用。
内存用于Java对象分配时,内存块直接解析为instanceOop的结构。
内存空闲时,被复用为管理结构。
通过复用可以减少因内存管理带来的内存消耗。但是这也为实现带来了一定的复杂性。
图4-22仅仅是演示内存可以复用的一个抽象结构示意图。要实现内存复用,除了对数据结构需要仔细斟酌外,实现中还需要诸多的考量。比如从二叉树获取一个空闲内存块时,该选取哪个内存块?
通过上面的介绍,可以发现树节点和链表节点有所不同,但是树节点和链表节点都描述了相同大小的空闲内存块。所以树节点本质上是第一个链表的节点,但是为了方便管理,将树节点和链表区分开来。在使用内存块时,优先使用链表的内存块,主要原因是当树节点被用于分配时,需要对二叉树进行重构(保持平衡),成本比较高;只有当树节点关联的链表全部使用完后才会使用树节点。
需要注意的是,JVM中树节点管理内存块均大于256字,所以一个树节点同时包含一个链表节点并不困难。在JVM中,树节点的结构被称为TreeList,
其内存布局如图4-23所示。
图4-23 JVM中树节点内存管理示意图
当然,内存空间复用可以减少额外内存消耗,但是也增加了额外的复杂性。第一个方面表现在内存块的分配上,内存块既需要满足对象对齐要求,又需要满足接入链表的要求。在32位系统中要求剩余的内存块必须大于3字。
这在某些情况下会带来一些问题。例如内存块大小为1022字,遇到一个请求为1020字。从分配的角度来看,1022字完全可以响应1020字大小的请求,只不过在满足分配请求后,还剩余2字。但是由于2字的内存块无法接入内存链表中,因此JVM会拒绝这次分配请求。
第二个方面表现在代码实现的复杂性上。老生代回收是并发执行,意味着Mutator可以在老生代回收的过程中在老生代中分配对象。分配对象实际上包含两个动作:第一是内存分配的请求;第二是对象的初始化。
但是由于并发运行,很有可能Mutator在完成内存分配后,尚未完成对象的初始化时,GC线程访问了这一内存块,然而由于对象尚未完成初始化,即对象的元数据尚未正确设置,我们知道对象的元数据中包含了对象的大小,对于这样的情况则无法通过元数据获取对象的大小。如果需要对象的大小,该如何处理?这就需要额外的代码来处理这样的情况。关于这一问题的详细描述和解决方法在后文介绍。
本文给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-内存管理下篇文章给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-标记清除算法概述及并发算法触发时机感谢大家的支持!