垃圾判断算法
引用计数算法
在对象中添加一个属性用于标记对象被引用的次数,每多一个其他对象引用,计数+1,当引用失效时,计数-1,如果计数=0,表示没有其他对象引用,就可以被回收。
这个算法无法解决循环依赖的问题。
public class ReferenceCountingAlgorithm {
private Object instance;
public ReferenceCountingAlgorithm() {
byte [] m = new byte[20*1024*1024];
}
public static void main(String[] args) throws IOException {
ReferenceCountingAlgorithm A = new ReferenceCountingAlgorithm();
ReferenceCountingAlgorithm B = new ReferenceCountingAlgorithm();
A.instance=B;
B.instance=A;
System.gc();
}
}
这种方法实现比较简单,且效率很高,但是无法解决循环引用的问题,因此在java中没有采用此算法(但是在Python中采用的是此算法)
[Full GC (System.gc()) [PSYoungGen: 968K->0K(75776K)] [ParOldGen: 8K->824K(173568K)] 976K->824K(249344K), [Metaspace: 3273K->3273K(1056768K)], 0.0065938 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 75776K, used 650K [0x000000076b580000, 0x0000000770a00000, 0x00000007c0000000)
eden space 65024K, 1% used [0x000000076b580000,0x000000076b622a68,0x000000076f500000)
from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
to space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
ParOldGen total 173568K, used 824K [0x00000006c2000000, 0x00000006cc980000, 0x000000076b580000)
object space 173568K, 0% used [0x00000006c2000000,0x00000006c20ce328,0x00000006cc980000)
Metaspace used 3279K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 357K, capacity 388K, committed 512K, reserved 1048576K
可达性分析算法
通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系链向下搜索,如果某个对象无法被搜索到,则说明该对象无引用执行,可回收。相反,则对象处于存活状态,不可回收。
JVM中的实现是找到存活对象,未打标记的就是无用对象,GC时会回收。
哪些对象可以作为GC Root呢:
- 1、在虚拟机栈(栈帧中的本地变量表) 中引用的对象, 譬如各个线程被调用的方法堆栈中使用到的参数、 局部变量、 临时变量等。
- 2、在方法区中类静态属性引用的对象, 譬如Java类的引用类型静态变量。
- 3、在方法区中常量引用的对象, 譬如字符串常量池(String Table) 里的引用。
- 4、在本地方法栈中JNI(即通常所说的Native方法) 引用的对象。
- 5、Java虚拟机内部的引用, 如基本数据类型对应的包装对象,
一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError) 等, 还有系统类加载器。 - 6、所有被同步锁(synchronized关键字) 持有的对象。
- 7、反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等。
- 8、除了这些固定的GC Roots集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。 譬如 分代收集和局部回收(Partial GC) ,如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集) ,必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的) , 更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用, 这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去, 才能保证可达性分析的正确性。
内存池
内存池算法(不同垃圾回收算法,内存池算法不同)
基本概念
-
Memory Pool内存池
理解成一个管理员,管理的是内存块,并且会将这个大块(block)分成较小的块(smaller chunks)list<MemoryChunk *> m_chunks;
。每次你从内存池申请内存空间时,它会从先前已经分配的块(chunks)中得到,而不是从操作系统。
最大的优势在于:
1.非常少(几没有) 堆碎片
2.比通常的内存申请/释放(比如通过malloc, new等)的方式快
能做的事情:
1.向OS要内存malloc、calloc
2.释放内存没有垃圾回收器需要手动释放
3.其他打印chunk信息 -
Memory Chunk 直接持有内存
内存池(Memory Pool)管理了一个指向结构体SMemoryChunk (m_ptrFirstChunk, m_ptrLastChunk, and m_ptrCursorChunk)的指针。这些块(chunks)建立一个内存块(memory chunks)的链表。各自指向链表中的下一个块(chunk)。当从操作系统分配到一块内存时,它将完全的被SMemoryChunks管理。
typedef struct SMemoryChunk
{
TByte *Data ; // The actual Data
std::size_t DataSize ; // Size of the "Data"-Block
std::size_t UsedSize ; // actual used Size
bool IsAllocationChunk ; // true, when this MemoryChunks
// Points to a "Data"-Block
// which can be deallocated via "free()"
SMemoryChunk *Next ; // Pointer to the Next MemoryChunk
// in the List (may be NULL)
} SmemoryChunk;
每个块(chunk)持有一个指针,指针指向:
1 、一小块内存(Data),
2、从块(chunk)开始的可用内存的总大小(DataSize),
3、实际使用的大小(UsedSize),
4、以及一个指向链表中下一个块(chunk)的指针。
Memory Cell 内存细胞
真实在使用的内存
不同算法内存的使用
list<MemoryCell *> m_available_table;
所有可使用的内存
list<MemoryCell *> m_used_table;
所有被使用的内存
list<MemoryCell *> m_idle_table;
空闲内存
list<MemoryCell *> m_transer_table;
待交换内存
- 标记清除、标记-整理,这两种算法回收的是整个chunk
直接清除标记的被使用的内存
这两种算法需要的list有两个
list<MemoryCell *> m_available_table;
所有可使用的内存
list<MemoryCell *> m_used_table;
所有被使用的内存
- 分代+复制算法 8:1:1
0-8正在用(gc),from 8-9 和 to 9-10 都是1,没有回收的对象放入from,此时from变成to,to变成from进行收集对象
list<MemoryCell *> m_available_table;
所有可使用的内存
list<MemoryCell *> m_used_table;
所有被使用的内存
list<MemoryCell *> m_idle_table;
空闲内存
list<MemoryCell *> m_transer_table;
待交换内存
内存分配
-
内存分配方式:指针碰撞
假设 Java 堆中的内存是绝对规整的,所有用过的内存放一边,空闲的内存放另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
-
内存分配方式:空闲列表
如果 Java 堆 中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存是可用的。在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
-
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又与所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于Mark-Sweep 算法(标记 - 清除算法)的收集器时,通常采用空闲列表。
-
内存分配的并发控制:CAS和TLAB
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
主要通过两种方式解决:
(1)CAS加上失败重试分配内存地址。
(2)TLAB 为每个线程分配一块缓冲区域进行对象分配,new对象内存的分配均需要进行加锁,这也是new开销比较大的原因,所以Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称为TLAB,TLAB仅作用于新生代的Eden,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。使用-XX:+/-UseTLAB。
垃圾回收算法
标记-清除算法
标记阶段就是把所有的活动对象都做上标记的阶段。
- 标记阶段就是“遍历对象并标记”的处理过程。
- 标记阶段经常用到深度优先搜索。
markFromRoots():
worklist <- empty
for each fld in Roots //遍历所有mutator根对象
ref <- *fld
if ref != null && isNotMarked(ref) //如果它是可达的而且没有被标记的,直接标记该对象并将其加到worklist中
setMarked(ref)
add(worklist,ref)
mark()
mark():
while not isEmpty(worklist)
ref <- remove(worklist) //将worklist的最后一个元素弹出,赋值给ref
for each fld in Pointers(ref) //遍历ref对象的所有指针域,如果其指针域(child)是可达的,直接标记其为可达对象并且将其加入worklist中
//通过这样的方式来实现深度遍历,直到将该对象下面所有可以访问到的对象都标记为可达对象。
child <- *fld
if child != null && isNotMarked(child)
setMarked(child)
add(worklist,child)
清除阶段就是把那些没有标记的对象,也就是非活动对象回收的阶段。
- 清除阶段collector会遍历整个堆,回收没有打上标记的对象(即垃圾)。
- 内存的合并操作也是在清除阶段进行的。
sweep(start,end):
scan <- start
while scan < end
if isMarked(scan)
setUnMarked(scan)
else
free(scan)
scan <- nextObject(scan)
缺点
- 如果说你需要分配大对象,需要连续的空间你的内存是碎片化的,分配不到内存 这个时候不是因为你没有了内存分不到,而是因为你的内存不是连续的
- 效率不算高
复制算法
复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。
当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。
此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。
- 初始化堆:复制算法中,需要将堆一分为二,一半作为from,一半作为to
void gc_init(int size) {
heap_size = resolve_heap_size(size);
heap_half_size = heap_size / 2;
heap = (void *) malloc(heap_size);
from = heap;
to = (void *) (heap_half_size + from);
_rp = 0;
}
- 复制:复制时,需从GC ROOTS开始遍历对象图,对每一个存活的对象进行复制;复制后对象地址改变,还需要更新GC ROOTS引用的地址;
void copying() {
next_forwarding_offset = 0;
//遍历GC ROOTS
for (int i = 0; i < _rp; ++i) {
object *forwarded = copy(_roots[i]);
//先将GC ROOTS引用的对象更新到to空间的新对象
_roots[i] = forwarded;
}
//更新引用
adjust_ref();
//清空from,并交换from/to
swap(&from,&to);
}
- copy方法:
object *copy(object *obj) {
if (!obj) { return NULL; }
//由于一个对象可能会被多个对象引用,所以此处判断,避免重复复制
if (!obj->forwarded) {
//计算复制后的指针
object *forwarding = (object *) (next_forwarding_offset + to);
//赋值
memcpy(forwarding, obj, obj->class->size);
obj->forwarded = TRUE;
//将复制后的指针,写入原对象的forwarding pointer,为最后更新引用做准备
obj->forwarding = forwarding;
//复制后,移动to区forwarding偏移
next_forwarding_offset += obj->class->size;
//递归复制引用对象,递归是深度优先
for (int i = 0; i < obj->class->num_fields; i++) {
copy(*(object **) ((void *) obj + obj->class->field_offsets[i]));
}
return forwarding;
}
return obj->forwarding;
}
-
Forwarding pointer
转发指针(Forwarding Pointer)”在复制算法中还是一个比较重要的概念
转发指针,指的是复制时,在原对象里保留新对象的指针。为什么要保留这个指针呢?
因为需要复制的不只是对象,对象的引用关系也需要复制。比如下图,对象ACD都需要复制,且只复制了对象A时,实际上复制的对象A’(一撇)引用的CD还是未复制的
-
调整引用
在所有活动对象都复制完毕后,需要将引用的地址调整为复制后的对象地址;只需要遍历一变to空间,找到引用对象的forwarding pointer更新即可
int p = 0;
//遍历to,即复制的目标空间
while (p < next_forwarding_offset) {
object *obj = (object *) (p + to);
//将还指向from的引用更新为forwarding pointer,即to中的pointer
for (int i = 0; i < obj->class->num_fields; i++) {
object **field = (object **) ((void *) obj + obj->class->field_offsets[i]);
if ((*field) && (*field)->forwarding) {
*field = (*field)->forwarding;
}
}
//顺序访问下一个对象
p = p + obj->class->size;
}
- 缺点
1、它浪费了一半的内存。
2、如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。
标记-整理算法
- 标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
- 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
- 它GC前后的图示与复制算法的图非常相似,只不过没有了活动区间和空闲区间的区别,而过程又与标记/清除算法非常相似,我们来看GC前内存中对象的状态与布局
- Lisp2 算法
1、标记清除(代码省略)
2、整理
void compact() {
set_forwarding();
adjust_ref();
move_obj();
}
计算并设置整理后的对象forwarding指针
void set_forwarding() {
int p = 0;
int forwarding_offset = 0;
//遍历堆的已使用部分,这里不用遍历全堆
//因为是顺序分配法,所以只需要遍历到已分配的终点即可
while (p < next_free_offset) {
object *obj = (object *) (p + heap);
//为可达的对象设置forwarding
if (obj->marked) {
obj->forwarding = (object *) (forwarding_offset + heap);
forwarding_offset = forwarding_offset + obj->class->size;
}
p = p + obj->class->size;
}
}
调整对象的引用为移动后的地址
如上图所示,调整引用后,gc roots和其他对象的引用都已经更新为了预先计算的forwarding指针
void adjust_ref() {
int p = 0;
//先将roots的引用更新
for (int i = 0; i < _rp; ++i) {
object *r_obj = _roots[i];
_roots[i] = r_obj->forwarding;
}
//再遍历堆,更新存活对象的引用
while (p < next_free_offset) {
object *obj = (object *) (p + heap);
if (obj->marked) {
//更新引用为forwarding
for (int i = 0; i < obj->class->num_fields; i++) {
object **field = (object **) ((void *) obj + obj->class->field_offsets[i]);
if ((*field) && (*field)->forwarding) {
*field = (*field)->forwarding;
}
}
}
p = p + obj->class->size;
}
}
移动对象
void move_obj() {
int p = 0;
int new_next_free_offset = 0;
while (p < next_free_offset) {
object *obj = (object *) (p + heap);
if (obj->marked) {
//移动对象至forwarding
obj->marked = FALSE;
memcpy(obj->forwarding, obj, obj->class->size);
new_next_free_offset = new_next_free_offset + obj->class->size;
}
p = p + obj->class->size;
}
//清空移动后的间隙
memset((void *)(new_next_free_offset+heap),0,next_free_offset-new_next_free_offset);
//移动完成后,更新free pointer为新的边界指针
next_free_offset = new_next_free_offset;
}
- 双指针算法
前提:所有对象大小一致,如果不一致,填充数据。
区别:free指针目的是寻找空闲的空间,live的目的是寻找活动对象的地址。一个在前,一个在后。(少了一次扫描)
- 优点:
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。 - 缺点:
Lisp2:一次遍历活动对象+三次扫描整个堆,吞吐量较小。
双指针算法:限制对象的大小相同,相比于标记清除与复制,吞吐量依旧较小
分代-收集算法
分代垃圾回收在对象中引用了 “年龄” 的概念,通过优先回收容易称为垃圾的对象,从而提高垃圾回收的效率。
大部分的对象在生成后马上就变成了垃圾, 很少有对象能活得很久。分代垃圾回收利用这个经验,在对象中加入了 “年龄” 的概念,经理过一次 GC 后还活下来的对象年龄为 1 岁。
分代垃圾回收中把对象分为几代(generation),针对不同的代使用不同的 GC 算法;把新生成的对象称为年轻代 (Young Generation) 对象,到达一定年龄的对象称为老年代 (Old/Tenured Generation) 对象。
由于大多数对象都是 “朝生夕死” 的,所以可以考虑对年轻代进行 “只标记存活对象” 的算法,因为存活对象较少,所以回收效率高。
年轻代 GC 称为 Minor GC。经历多次年轻代 GC 仍然存活的对象,就可以当作老年代对象来处理。这种年轻代转移到老年代的情况称为晋升(promotion)。
因为老年代对象很难成为垃圾(经过几次 GC 还存活的对象,一般都是都是永久存活了),所以老年代 GC 的频率会很低,老年代 GC 称为 Full GC。
- 堆的结构
将堆分成了 4 个部分,从左至右分别是新生成区 (Eden),两个大小相等的幸存空间 (Survivor)From/to,以及一个老年代区 (Old Gen),Eden+Survivor 都属于年轻代区域(New Gen)。年轻代对象会分配在年轻代区域,老年代对象会分配在老年代。此处还额外准备了一个记录集(Remembered set/卡表),来存储跨代的引用(跨代引用下面会介绍)
- 年轻代 GC(Minor GC)
由于新生代对象特点是 “朝生夕死”,所以对年轻代使用复制算法;Eden 区存放的是新生成的对象,当 Eden 满了之后,年轻代 GC 就会启动,将生成空间的所有活动对象复制,不过目标区域是 Survivor 区。
Survivor 区分为了两个空间,每次回收只会使用其中的一个。当执行年轻代 GC 的时候,Eden 区的活动对象会被复制到 From 中;当第二次年轻代 GC 时,会将 Eden 和 From 区内存活的对象一起复制到 To 区,之后再把 From/To 功能 “互换”(这里的互换并没有互换数据,在程序中只是把引用换了)
下面是 From/To 互换的逻辑,只是将指针互换了以下而已(和复制算法类似):
swap((void **)&new_from,(void **)&new_to);
void swap(void **src, void **dst){
object *temp=*src;
*src=*dst;
*dst=*temp;
}
- 对象晋升 (Promotion)
对象中有一个 age 字段,代表对象经历的年轻代 GC 次数,新创建的对象年龄为 0,每经历一次年轻代 GC 还存活的对象年龄会加 1;在年轻代 GC 时,每次会检查对象的年龄,当超过一定限制(AGE_MAX 15)时,会将对象晋升到老年代(大对象直接进入老年代,超过eden 50%)
- 跨代引用(后面介绍解决)
既然有晋升的操作,那么这里会有一个问题:当对象晋升后,引用关系如何处理,对于老年代到年轻代的引用,可达性分析时怎么处理,是否还需要从 GC ROOTS 开始遍历老年代呢?
比如对象 A 晋升前,和年轻代另一个存活的对象 B 关联,A 在 GC ROOTS 中,B 不在;当对象 A 晋升后,对于 GC ROOTS 来说 B 是不可达(unreachable)的,但是对于 A 来说 B 是可达的。
或者对象 A 晋升后,又新分配了对象 C,然后用 A 引用 C,此时对于 GC ROOTS 来说,C 也是不可达的。
由于存在跨代引用的可能,所以在年轻代 GC 时,只从 GC ROOTS 开始遍历年轻代对象是不够的,还需要将老年代中引用年轻代的那部分对象也作为 GC ROOTS,这样才能保证完整的回收年轻代。
扫描老年代这部分对象看起来没问题,可是由于老年代的特点是长期存活的对象,空间很大对象很多,扫描老年代的成本要远远大于扫描 GC ROOTS,成本太高,所以直接从 GC ROOTS 遍历老年代或者顺序遍历老年代的 free-list 不合适。
三色标记与读写屏障
三色标记
- 白色:尚未访问过。
- 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
- 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色
尚未访问过(全部白色):
A访问,但是后面没有访问:
A访问,A的引用也访问了,B还未访问引用:
问题(解决在后面):
- 多标 浮动垃圾
GC线程已经标记了B,此时用户代码中A断开了对B的引用,但此时B已经被标记成了灰色,本轮GC不会被回收,这就是所谓的多标,多标的对象即成为浮动垃圾,躲过了本次GC。
多标对程序逻辑是没有影响的,唯一的影响是该回收的对象躲过了一次GC,造成了些许的内存浪费。
- 少标 浮动垃圾
并发标记开始后创建的对象,都视为黑色,本轮GC不清除。
这里面有的对象用完就变成垃圾了,就可以销毁了,这部分对象即少标环境中的浮动垃圾
- 漏标 程序会出错
漏标是如何产生的呢?GC把B标记完,准备标记B引用的对象,这时用户线程执行代码,代码中断开了B对D的引用,改为A对D的引用。但是A已经被标记成黑色,不会再次扫描A,而D还是白色,执行垃圾回收逻辑的时候D会被回收,程序就会出错了。
读写屏障
- 硬件内存屏障 X86
sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。 - JVM级别如何规范(JSR133)
LoadLoad屏障
:
对于这样的语句Load1; LoadLoad; Load2,
在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障
:
对于这样的语句Store1; StoreStore; Store2,
在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障
:
对于这样的语句Load1; LoadStore; Store2,
在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障
:
对于这样的语句Store1; StoreLoad; Load2,
在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
如何解决漏标问题
-
先分析下漏标问题是如何产生的:
条件一:灰色对象 断开了 白色对象的引用;即灰色对象 原来成员变量的引用 发生了变化。
条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。 -
读屏障 + 重新标记
在建立A对D的引用时将D作为白色或灰色对象记录下来,并发标记结束后STW,然后重新标记由D类似的对象组成的集合。重新标记环节一定要STW,不然标记就没完没了了。
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}
- 写屏障 + 增量更新(IU)
在标记程序运行过程中发生了引用链的变动通过写屏障将这个变动记录下来,这种方式解决的是条件二,即通过写屏障记录下更新,具体做法如下:对象A对D的引用关系建立时,将D加入带扫描的集合中等待扫描
void post_write_barrier(oop* field, oop new_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}
- 写屏障 + 原始快照(SATB)
这种方式解决的是条件一,带来的结果是依然能够标记到D,具体做法如下:对象B的引用关系变动的时候,即给B对象中的某个属性赋值时,将之前的引用关系记录下来。标记的时候,扫描旧的对象图,这个旧的对象图即原始快照
void post_write_barrier(oop* field, oop new_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}
- 实际应用
CMS:写屏障 + 增量更新
G1:写屏障 + SATB
ZGC:读屏障
记忆集与卡表
前面写到老年代对新生代造成的跨代引用问题解决办法
-
Region(后面还会说)
G1及其后出现的垃圾收集器ZGC、Shenandoah,它们都是基于Region的内存布局形式。它们垃圾收集的目标范围不再是整个新生代(Minor GC)、老年代(Majon GV)、整个堆(Full GC),而是一个一个的Region。因为这样的内存布局,所以G1能做到面向局部收集。
每个Region都可以被标记为E(Eden)、S(Survivor)、O(Old)、H(Humongous),但一个Region同一时刻只能是这四个中的一个。H表示巨型对象,即超过Region大小的一半的对象,会直接进入老年代由多个连续的Region存储。
Region的大小可以通过-XX:G1HeapRegionSize参数指定,如果没有显示指定,则G1会计算出一个合理的大小。Region的取值范围为1M~32M,且应为2的N次幂,所以Region的大小只能是1M、2M、4M、8M、16M、32M。比如-Xmx=16g -Xms=16g,则Region的大小等于16G / 2048=8M。也可以推理出G1推荐的管理的最大堆内存是64G。 -
RSet(Remembered Set、记忆集)
在垃圾收集过程中,会存在一种现象,即跨代引用,在G1中,又叫跨Region引用。如果是年轻代指向老年代的引用我们不用关心,因为即使Minor GC把年轻代的对象清理掉了,程序依然能正常运行,而且随着引用链的断掉,无法被标记到的老年代对象会被后续的Major GC回收。如果是老年代指向年轻代的引用,那这个引用在Minor GC阶段是不能被回收掉的,那如何解决这个问题呢?
最简单的实现方式当然是每个对象中记录这个跨Region引用记录,GC时扫描所有老年代的对象,显然这是一个相当大的Overhead。为什么呢?因为IBM做过这样的实验,发现绝大多数对象都是“朝生夕灭”,等不到进入老年代,能进入老年代的对象最多不到5%。JVM的新生代内存比例是8:1:1也是基于这个结论设定的。
最合理的实现方式自然是记录哪些Region中的老年代的对象有指向年轻代的引用。GC时扫描这些Region就行了。这就是RSet存在的意义。RSet本质上是一种哈希表,Key是Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号(第几个Card的第几个元素)。 -
Card Table(卡表)
每个Region又被分成了若干个大小为512字节的Card,这些Card都会记录在全局卡表中。Card中的每个元素对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为卡页。一个卡页的内存中通常不止一个对象,只有卡页中有一个及以上对象的字段存在着跨Region引用,这个对应的元素的值就标识为1。
比如G1默认的Region有2048个,默认每个Region为2M,那每个Region对应的Card的每个元素对应的卡页的大小为2M / 512=4K,即这4K内存中只要有一个或一个以上的对象存在着跨Region对年轻代的引用,这个卡页对应的Card的元素值为1。
这样在Minor GC时,只需要将变脏的Region中的那个卡页加入GC Roots一并扫描即可。比起扫描老年代的所有对象,大大减少了扫描的数据量,提升了效率。
- 个人理解:
rset是记录哪些Region有对新生代的引用
卡表是将一个Region分成2048份进行管理,每份叫做卡页,卡页是GC回收的最小单位。
rset是理论卡表是具体实现