垃圾回收(Garbage Collection,简称GC),是编程语言运行时自带的内存管理功能,例如Java、python、js、ruby、Erlang等语言都有其GC功能。
一个进程拥有的资源包括栈和堆,栈通常存储临时变量,在函数返回时就自动释放。堆则存储程序动态申请的内存,例如c中的malloc。这一部分申请的内存需要程序员手动进行回收,否则将存在内存泄漏的风险。但是将回收内存交给程序员,显然是很容易犯错的。后续的很多语言都加上了GC功能,在程序运行时,由虚拟机或者Runtime进行垃圾回收,减轻了程序员的工作量,也使得代码编写更加容易。
进程
创建一个进程他会包含两个部分
一:地址空间区域 (每个进程理论都会有我们上面说的那4G地址空间,但是实际可用不到2G)
二:管理进程的内核对象 (这个由系统自动创建)进程他本不是CPU最小调度单位,只是把可执行文件或者DLL文件装载到进程地址空间来,格式是PE格式。包括代码区、数据区、文本区、然后由线程执行,线程才是CPU的调度单位,每个进程至少都有一个线程,那就是主线程,如果没有一个线程,那进程也没有存在的必要了,那系统就会撤销该进程,进程内核对象就是管理引用计数的。
用户区他有堆区、栈区、全局区、静态区,不同的方式定义变量,会分配在不同的区域。
Int a; (分配在栈区)
int *a = new int(); (分配在堆区)
char *p= “123456”; (123456\0在常量区,p在栈上)。
栈是向下生长的,堆是向上生长。
线程的默认栈空间是多大呢? 查MSDN说是1M 其实没有1M将近1M的样子,线程的数量理论没有限制,但是会受限于内存大小,并且不是线程多就性能快,线程过多会导致上下文切换频繁。
进程的堆栈大小:
32位Windows,一个进程栈的默认大小是1M,在vs的编译属性可以修改程序运行时进程的栈大小。
Linux下进程栈的默认大小是10M,可以通过 ulimit -s查看并修改默认栈大小。
默认一个线程要预留1M左右的栈大小,所以进程中有N个线程时,Windows下大概有N*M的栈大小。
堆的大小理论上大概等于进程虚拟空间大小-内核虚拟内存大小。windows下,进程的高位2G留给内核,低位2G留给用户,所以进程堆的大小小于2G。Linux下,进程的高位1G留给内核,低位3G留给用户,所以进程堆大小小于3G。
进程的最大线程数:
32位windows下,一个进程空间4G,内核占2G,留给用户只有2G,一个线程默认栈是1M,所以一个进程最大开2048个线程。当然内存不会完全拿来做线程的栈,所以最大线程数实际值要小于2048,大概2000个。
32位Linux下,一个进程空间4G,内核占1G,用户留3G,一个线程默认8M,所以最多380个左右线程。(ps:ulimit -a 查看电脑的最大进程数,大概7000多个)
栈和堆的区别
①管理方式:栈由编译器自动管理;堆由程序员控制,使用方便,但易产生内存泄露。
②生长方向:栈向低地址扩展(即”向下生长”),是连续的内存区域;堆向高地址扩展(即”向上生长”),是不连续的内存区域。这是由于系统用链表来存储空闲内存地址,自然不连续,而链表从低地址向高地址遍历。
③空间大小:栈顶地址和栈的最大容量由系统预先规定(通常默认2M或10M);堆的大小则受限于计算机系统中有效的虚拟内存,32位Linux系统中堆内存可达2.9G空间。
④存储内容:栈在函数调用时,首先压入主调函数中下条指令(函数调用语句的下条可执行语句)的地址,然后是函数实参,然后是被调函数的局部变量。本次调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的指令地址,程序由该点继续运行下条可执行语句。堆通常在头部用一个字节存放其大小,堆用于存储生存期与函数调用无关的数据,具体内容由程序员安排。
⑤分配方式:栈可静态分配或动态分配。静态分配由编译器完成,如局部变量的分配。动态分配由alloca函数在栈上申请空间,用完后自动释放。堆只能动态分配且手工释放。
⑥分配效率:栈由计算机底层提供支持:分配专门的寄存器存放栈地址,压栈出栈由专门的指令执行,因此效率较高。堆由函数库提供,机制复杂,效率比栈低得多。Windows系统中VirtualAlloc可直接在进程地址空间中分配一块内存,快速且灵活。
⑦分配后系统响应:只要栈剩余空间大于所申请空间,系统将为程序提供内存,否则报告异常提示栈溢出。
操作系统为堆维护一个记录空闲内存地址的链表。当系统收到程序的内存分配申请时,会遍历该链表寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点空间分配给程序。若无足够大小的空间(可能由于内存碎片太多),有可能调用系统功能去增加程序数据段的内存空间,以便有机会分到足够大小的内存,然后进行返回。,大多数系统会在该内存空间首地址处记录本次分配的内存大小,供后续的释放函数(如free/delete)正确释放本内存空间。
此外,由于找到的堆结点大小不一定正好等于申请的大小,系统会自动将多余的部分重新放入空闲链表中。
⑧碎片问题:栈不会存在碎片问题,因为栈是先进后出的队列,内存块弹出栈之前,在其上面的后进的栈内容已弹出。而频繁申请释放操作会造成堆内存空间的不连续,从而造成大量碎片,使程序效率降低。
可见,堆容易造成内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和内核态切换,内存申请的代价更为昂贵。所以栈在程序中应用最广泛,函数调用也利用栈来完成,调用过程中的参数、返回地址、栈基指针和局部变量等都采用栈的方式存放。所以,建议尽量使用栈,仅在分配大量或大块内存空间时使用堆。
使用栈和堆时应避免越界发生,否则可能程序崩溃或破坏程序堆、栈结构,产生意想不到的后果。
垃圾回收算法
目前最基本的垃圾收集算法有四种,标记-清除算法(mark-sweep),标记-压缩算法(mark-compact),复制算法(copying)以及引用计数算法(reference counting).而现代流行的垃圾收集算法一般是由这四种中的其中几种算法相互组合而成,比如说,对堆(heap)的一部分采用标记-清除算法,对堆(heap)的另外一部分则采用复制算法等等。
作者:可文分身
链接:https://www.jianshu.com/p/b0f5d21fe031
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
引用计数
现代编程语言比如Lisp,Python,Ruby等的垃圾收集算法采用的就是引用计数算法。
引用计数法
引用计数算法很简单,它实际上是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加一,如果删除对该对象的引用,那么它的引用计数就减一,当该对象的引用计数为0时,那么该对象就会被回收。
比如说,当我们编写以下代码时,
String p = new String("abc")
abc这个字符串对象的引用计数值为1.
而当我们去除abc字符串对象的引用时,则abc字符串对象的引用计数减1
p = null
由此可见,当对象的引用计数为0时,垃圾回收就发生了。这跟前面三种垃圾收集算法不同,前面三种垃圾收集都是在为新对象分配内存空间时由于内存空间不足而触发的,而且垃圾收集是针对整个堆中的所有对象进行的。而引用计数垃圾收集机制不一样,它只是在引用计数变化为0时即刻发生,而且只针对某一个对象以及它所依赖的其它对象。所以,我们一般也称呼引用计数垃圾收集为直接的垃圾收集机制,而前面三种都属于间接的垃圾收集机制。
而采用引用计数的垃圾收集机制跟前面三种垃圾收集机制最大的不同在于,垃圾收集的开销被分摊到整个应用程序的运行当中了,而不是在进行垃圾收集时,要挂起整个应用的运行,直到对堆中所有对象的处理都结束。因此,采用引用计数的垃圾收集不属于严格意义上的"Stop-The-World"的垃圾收集机制。这个也可以从它的伪代码实现中看出:
New(): //分配内存
ref <- allocate()
if ref == null
error "Out of memory"
rc(ref) <- 0 //将ref的引用计数(reference counting)设置为0
return ref
atomic Write(dest, ref) //更新对象的引用
addReference(ref)
deleteReference(dest)
dest <- ref
addReference(ref):
if ref != null
rc(ref) <- rc(ref)+1
deleteReference(ref):
if ref != null
rc(ref) <- rc(ref) -1
if rc(ref) == 0 //如果当前ref的引用计数为0,则表明其将要被回收
for each fld in Pointers(ref)
deleteReference(*fld)
free(ref) //释放ref指向的内存空间
对于上面的伪代码,重点在于理解两点,第一个是当对象的引用发生变化时,比如说将对象重新赋值给新的变量等,对象的引用计数如何变化。假设我们有两个变量p和q,它们分别指向不同的对象,当我们将他们指向同一个对象时,下面的图展示了p和q变量指向的两个对象的引用计数的变化。
String p = new String("abc")
String q = new String("def")
p = q
当我们执行代码p=q时,实际上相当于调用了伪代码中的Write(p,q), 即对p原先指向的对象要进行deleteReference()操作 - 引用计数减一,因为p变量不再指向该对象了,而对q原先指向的对象要进行addReference()操作 - 引用计数加一。
第二点需要理解的是,当某个对象的引用计数减为0时,collector需要递归遍历它所指向的所有域,将它所有域所指向的对象的引用计数都减一,然后才能回收当前对象。在递归过程中,引用计数为0的对象也都将被回收,比如说下图中的phone和address指向的对象。
环形数据问题
但是这种引用计数算法有一个比较大的问题,那就是它不能处理环形数据 - 即如果有两个对象相互引用,那么这两个对象就不能被回收,因为它们的引用计数始终为1。这也就是我们常说的“内存泄漏”问题。比如下图展示的将p变量赋值为null值后所出现的内存泄漏。
标记清除
基本概念
在了解标记-清除算法前,我们先要了解几个基本概念。
首先是mutator和collector,这两个名词经常在垃圾收集算法中出现,collector指的就是垃圾收集器,而mutator是指除了垃圾收集器之外的部分,比如说我们应用程序本身。mutator的职责一般是NEW(分配内存),READ(从内存中读取内容),WRITE(将内容写入内存),而collector则就是回收不再使用的内存来供mutator进行NEW操作的使用。
第二个基本概念是关于mutator roots(mutator根对象),mutator根对象一般指的是分配在堆内存之外,可以直接被mutator直接访问到的对象,一般是指静态/全局变量以及Thread-Local变量(在Java中,存储在java.lang.ThreadLocal中的变量和分配在栈上的变量 - 方法内部的临时变量等都属于此类).
第三个基本概念是关于可达对象的定义,从mutator根对象开始进行遍历,可以被访问到的对象都称为是可达对象。这些对象也是mutator(你的应用程序)正在使用的对象。
算法原理
顾名思义,标记-清除算法分为两个阶段,标记(mark)和清除(sweep).
在标记阶段,collector从mutator根对象开始进行遍历,对从mutator根对象可以访问到的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象。
而在清除阶段,collector对堆内存(heap memory)从头到尾进行线性的遍历,如果发现某个对象没有标记为可达对象-通过读取对象的header信息,则就将其回收。
从上图我们可以看到,在Mark阶段,从根对象1可以访问到B对象,从B对象又可以访问到E对象,所以B,E对象都是可达的。同理,F,G,J,K也都是可达对象。到了Sweep阶段,所有非可达对象都会被collector回收。同时,Collector在进行标记和清除阶段时会将整个应用程序暂停(mutator),等待标记清除结束后才会恢复应用程序的运行,这也是Stop-The-World这个单词的来历。
接着我们先看下一般垃圾收集动作是怎么被触发的,下面是mutator进行NEW操作的伪代码:
New():
ref <- allocate() //分配新的内存到ref指针
if ref == null
collect() //内存不足,则触发垃圾收集
ref <- allocate()
if ref == null
throw "Out of Memory" //垃圾收集后仍然内存不足,则抛出Out of Memory错误
return ref
atomic collect():
markFromRoots()
sweep(HeapStart,HeapEnd)
而下面是对应的mark算法:
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)
在mark阶段结束后,sweep算法就比较简单了,它就是从堆内存起始位置开始,线性遍历所有对象直到堆内存末尾,如果该对象是可达对象的(在mark阶段被标记过的),那就直接去除标记位(为下一次的mark做准备),如果该对象是不可达的,直接释放内存。
在mark阶段结束后,sweep算法就比较简单了,它就是从堆内存起始位置开始,线性遍历所有对象直到堆内存末尾,如果该对象是可达对象的(在mark阶段被标记过的),那就直接去除标记位(为下一次的mark做准备),如果该对象是不可达的,直接释放内存。
sweep(start,end):
scan <- start
while scan < end
if isMarked(scan)
setUnMarked(scan)
else
free(scan)
scan <- nextObject(scan)
缺点
标记-清除算法的比较大的缺点就是垃圾收集后有可能会造成大量的内存碎片,像上面的图片所示,垃圾收集后内存中存在三个内存碎片,假设一个方格代表1个单位的内存,如果有一个对象需要占用3个内存单位的话,那么就会导致Mutator一直处于暂停状态,而Collector一直在尝试进行垃圾收集,直到Out of Memory。
标记压缩算法
内存碎片一直是非移动垃圾回收器(指在垃圾回收时不进行对象的移动)的一个问题,比如说在前面的标记-清除垃圾回收器就有这样的问题。而标记-压缩垃圾回收算法能够有效的缓解这一问题。
算法原理
既然叫标记-压缩算法,那么它也分为两个阶段,一个是标记(mark),一个是压缩(compact). 其中标记阶段跟标记-清除算法中的标记阶段是一样的,可以参考前面的文章。
而对于压缩阶段,它的工作就是移动所有的可达对象到堆内存的同一个区域中,使他们紧凑的排列在一起,从而将所有非可达对象释放出来的空闲内存都集中在一起,通过这样的方式来达到减少内存碎片的目的。
在压缩阶段,由于要移动可达对象,那么需要考虑移动对象时的顺序,一般分为下面三种:
任意顺序 - 即不考虑原先对象的排列顺序,也不考虑对象间的引用关系,随意的移动可达对象,这样可能会有内存访问的局部性问题。
线性顺序 - 在重新排列对象时,会考虑对象间的引用关系,比如A对象引用了B对象,那么就会尽可能的将A,B对象排列在一起。
滑动顺序 - 顾名思义,就是在重新排列对象时,将对象按照原先堆内存中的排列顺序滑动到堆的一端。
现在大多数的垃圾收集算法都是按照任意顺序或滑动顺序去实现的。下面我们分别来看下它们各自的算法原理。
Two-Finger 算法
Two-Finger算法来自Edwards, 它在压缩阶段移动对象时是任意顺序移动的,它最适用于处理包含固定大小对象的内存区域。由于Mark阶段都是跟标记-清除算法一致的,这里我们只关注Compact阶段。
Two-Finger算法是一个Two Passes算法,即需要遍历堆内存两次,第一次遍历是将堆末尾的可达对象移动到堆开始的空闲内存单元去,第二次遍历则需要修改可达对象的引用,因为一些可达对象已经被移动到别的地址,而原先引用它们的对象还指向着它们移动前的地址。
在这两次遍历过程中,首尾两个指针分别从堆的头尾两个位置向中间移动,直至两个指针相遇,由于它们的运动轨迹酷似两根手指向中间移动的轨迹,因此称为Two Finger算法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K1dV2tWx-1649314949692)(https://www.hengyumo.cn/momoclouddisk/file/download?code=202203150948210_image.png)]
第一次遍历
下面我们先看下第一遍遍历的伪代码 - 来自<<GC手册>>,
compact():
relocate(HeapStart,HeapEnd)
updateReferences(HeapStart,free)
relocate(start,end)
free <- start
scan <- end
while free < scan
//找到一个可以被释放的空间
while isMarked(free)
unsetMarked(free)
free <- free + size(free)
//找到一个可以移动的可达对象
while not isMarked(scan) && scan > free
scan <- scan - size(scan)
if scan > free
unsetMarked(scan)
move(scan, free) //将scan位置的可达对象移动到free位置上
*scan <- free //将可达对象移动后的位置写到原先可达对象处于的位置
free <- free + size(free)
scan <- scan - size(scan)
第一次遍历的原理是,头指针(free)沿着堆头向堆尾前进,直到找到一个空闲的内存单元(即没有被标记为可达对象的内存单元),如遇到可达对象,则清除其标记。接着尾指针(scan)从堆尾向堆头方向前进,直到找到一个被标记为可达的内存单元。最后,collector将可达对象从尾指针(scan)指向的位置移动到头指针(free)指向的位置,最后将可达对象移动后的位置(当前free指针指向的位置)写到原先可达对象处于的位置(当前尾指针scan指向的位置), 为下一次的遍历 - 更新对象相互间的引用做好准备。注:当移动可达对象时,其引用的对象在可达对象移动后保持不变,如下图中的G对象移动后依然指向位置5和位置10。
第二次遍历
而第二次的遍历则是为了更新引用关系,一个可达对象可以被其他对象引用,比如上图中的K对象,如果其被移动后,引用它的对象比如说G并不知道它被移动了,那么这第二次的遍历就是为了告诉G它所引用的对象K已经被移动到新的位置上去了,它需要更新它对K的引用。
updateReferences(start,end)
for each fld in Roots //先更新mutator根对象所引用的对象关系
ref <- *fld
if ref >= end
*fld <- *ref
scan <- start
while scan < ned
for each fld in Pointers(scan)
ref <- * fld
if ref >= end
*fld <- *ref
scan <- scan + size(scan)
第二次遍历,collector先会对根对象进行遍历,比如根对象2引用着位置6的内存单元,根据算法,该位置大于等于end指针所指向的位置 - 即第一次遍历free指针和scan指针相遇的位置,那么我们就认为这个位置的对象已经被移动,需要更新根对象2的引用关系,即从引用位置6改为引用位置2(位置6的内存单元中记录着该对象被移动后的新位置)。同理,在移动G对象的时候,也是要判断看G所引用的内存单元位置是否大于end指针指向的位置,如果小于,则不处理。否则则修改G的引用关系。
LISP2 算法
Lisp2算法是一种应用更为广泛的压缩算法,它属于滑动顺序算法中的一种。它跟Two-Finger算法的不同还在于它可以处理不同大小的对象,而不再是固定大小的对象。同时,计算出来的可达对象的迁移地址需要额外的空间进行存储而不再是复写原先对象所在的位置。最后,Lips2算法需要进行3次堆内存的遍历。
第一次遍历
第一次遍历,collecor仅仅是计算和记录可达对象应该迁移去的地址。
compact():
computeLocations(HeapStart,HeapEnd,HeapStart)
updateReferences(HeapStart,HeapEnd)
relocate(HeapStart,HeapEnd)
computeLocations(start,end,toRegion):
scan <- start
free <- toRegion
while scan < end
if isMarked(scan)
forwardingAddress(scan) <- free
free <- free + size(scan)
scan <- scan + size(scan)
- 指针free, scan同时指向堆起始位置,同时scan指针向堆尾移动,目的是要找到被标记的可达对象。
- 找到可达对象后,在scan指针对应的位置分配一个额外的空间来存储该可达对象应该迁移到的地址 - 就是free指针指向的位置0,同时free指针向堆尾移动B对象大小的距离- free’指针指向的位置。最后scan指针继续往前走,直到寻找到下一个可达对象D - scan’指针指向的位置。
- 同理,在可达对象D处分配一块空间来保存对象D应该迁移到的位置,由于B对象已经占用了2个内存单元,所以对象E的迁移地址是从位置2开始,也就是当前free指针指向的位置。
- 指针free,scan继续向前移动。
- 第一次遍历完后,所有的可达对象都有了对应的迁移地址,free指针指向位置9,因为所有的可达对象总共占了9个单元大小的空间。
第二次遍历
第二次遍历主要是修改对象间的引用关系,基本跟Two Finger算法的第二次遍历一样。
updateReferences(start,end):
for each fld in Roots
ref <- *fld
if ref != null
*fld <- forwardingAddress(ref)
scan <- start
while scan < end
if isMarked(scan)
for each fld in Pointers(scan)
if *fld != null
*fld <- forwardingAddress(*fld)
scan <- scan + size(scan)
- 修改根对象的引用关系,根对象1引用对象B,对象B的迁移地址为0,于是collector将根对象对B对象的引用指向它的迁移地址 - 位置0, 现在A对象所处的位置。
- 同理,对于根对象2,3都执行同样的操作,将它们对其所引用的对象的引用修改为对应的它们所引用的对象的迁移地址。
- 通过scan指针遍历堆内存,更新所有的可达对象对其引用对象的引用为其引用对象的迁移地址。比如说,对于可达对象B, 它引用了对象D,D的迁移地址是2,那么B直接将其对D对象的引用重新指向2这个位置。
- 第二次遍历结束后的对象之间的引用关系。
第三次遍历
第三次遍历则是根据可达对象的迁移地址去移动可达对象,比如说可达对象B,它的迁移地址是0,那么就将其移动到位置0,同时去除可达对象的标记,以便下次垃圾收集。
relocate(start,end):
scan <- start
while scan < end
if isMarked(scan)
dest <- forwardingAddress(scan)
move(scan,dest) //将可达对象从scan位置移动到dest位置
unsetMarked(dest)
scan <- scan + size(scan)
所有可达对象移动结束后,内存单元展示为:
缺点
标记-压缩算法虽然缓解的内存碎片问题,但是它也引用了额外的开销,比如说额外的空间来保存迁移地址,需要遍历多次堆内存等。
半区复制算法
半区复制算法的目的也是为了更好的缓解内存碎片问题。对比于标记-压缩算法, 它不需要遍历堆内存那么多次,节约了时间,但是它也带来了一个主要的缺点,那就是相比于标记-清除和标记-压缩垃圾回收器,它的可用堆内存减少了一半。同时对于大对象,复制比标记的代价更大。所以半区复制算法更一般适合回收小的,存活期短的对象。
三色抽象法(三色标记法)
在我们深入半区复制算法原理前,我们需要了解下什么是三色抽象法。对于一个对象,垃圾收集器可以将其标记为灰色,黑色和白色中的一种,每种颜色代表不同的含义,
灰色 - 表示垃圾收集器已经访问过该对象,但是还没有访问过它的所有孩子节点。
黑色 - 表示该对象以及它的所有孩子节点都已经被垃圾收集器访问过了。
白色 - 表示该对象从来没有被垃圾收集器访问过,这就是非可达对象。
三色抽象法也可以用在标记-清除算法和标记-压缩算法。当垃圾收集结束后,可达对象都被标记为黑色,非可达对象都被标记为白色,不会有灰色对象存在。在半区复制算法里,我们也采用了三色抽象法来标记对象。
算法原理
之所以叫半区复制,是因为它将堆内存对半分为两个半区,只用其中一个半区来进行对象内存的分配,如果在这个半区内存不够给新的对象分配了,那么就开始进行垃圾收集,将这个半区中的所有可达对象都拷贝到另外一个半区中去,然后继续在另外那个半区进行新对象的内存分配。半区复制算法中比较常见是Cheney算法。
下面是复制算法的伪代码:
atomic collect():
flip()
scan <- free
for each fld in Roots
process(fld)
while not isEmpty(worklist)
ref <- remove(worklist)
scan(ref)
flip():
fromspace, tospace <- tospace, fromspace
top <- tospace+extent //界定堆内存的边界
free <- tospace
scan(ref):
for each fld in Pointers(ref)
process(fld)
process(fld):
fromRef <- *fld
if fromRef != null
*fld <- forward(fromRef) //将可达对象复制后的地址写到原对象的位置上,当作迁移地址
forward(fromRef):
toRef <- forwardingAddress(fromRef) //读取该位置上对象的迁移地址
if toRef == null //如果该位置上的对象没有迁移地址,那就说明它还没有被复制,需要复制到tospace中去
toRef <- copy(fromRef)
return toRef
copy(fromRef):
toRef <- free
free <- free + size(fromRef)
move(fromRef, toRef) //从fromRef位置复制对象到toRef位置上
forwardingAddress(fromRef) <- toRef //将地址toRef写到fromRef位置上的对象中去
add(worklist, toRef)
return toRef
remove(worklist):
ref <- scan
scan <- scan + size(scan)
return ref
实际上半区复制算法的实现跟标记-压缩算法的实现差不多, 都是采用的深度遍历算法,理解该算法的关键点是,怎么计算可达对象的迁移地址(forwardingAddress) - 看copy(fromRef)方法的实现, 以及怎么更新对象间的引用关系。
半区复制算法的过程如下:
我们假设A,B对象是根对象。
- 首先先交换左右半区(ToSpace, FromSpace), 同时设置free指针和top指针。
- 遍历处理根对象A,B。先将A对象复制到free指针指向的位置,同时将A对象复制后的地址(迁移地址)写到原先A对象所在的位置,图中虚线的箭头表示。可以看到A对象已经被collector访问过了,但是还没有访问其孩子节点,所以将其标为了灰色。紧接着scan,free指针继续向前移动。
- 由于是深度遍历算法,紧接collector会先遍历处理A对象所引用的对象C,当发现对象C没有迁移地址时,说明它还没有被复制,由于它又是可达对象,所以接着collector会将它复制到当前free指针指向的位置,即对象A后面。对象C复制完后,会用其复制后的地址来更新A原先对C的引用,同时也写到原先C对象所在的地址上。
- 接着collector会处理对象C的孩子节点(深度遍历算法),由于对象C没有引用任何对象,于是对象C的处理结束,将其标记为黑色。然后collector接着处理A对象的另外一个孩子节点E对象,处理方式跟处理对象C一致。
- 对象E也没有孩子节点,collector也将其标识为黑色。
- 到目前为此,A对象也全部处理结束了,于是collector将其标识为黑色,然后接着去处理对象B。当复制B对象结束后,发现B对象所引用的对象C有迁移地址,于是就更新其对对象C的引用,使其指向FromSpace半区中对象C的迁移地址 - 即C对象复制后所在ToSpace的地址。这个情况下就不需要再次复制对象C了。
- 当所有的可达对象都从FromSpace半区复制到ToSpace半区后,垃圾收集结束。新对象的内存分配从free指针指向的位置开始进行分配。
其实,从垃圾收集过程中对象的移动顺序来看,collector将相邻的对象都复制在相近的位置上,它属于前面我们在标记-压缩算法里所说的“线性顺序”。
GO的垃圾回收器
go语言垃圾回收总体采用的是经典的mark and sweep算法。
1.3版本以前,golang的垃圾回收算法都非常简陋,然后其性能也广被诟病:go runtime在一定条件下(内存超过阈值或定期如2min),暂停所有任务的执行,进行mark&sweep操作(标记清除算法),操作完成后启动所有任务的执行。在内存使用较多的场景下,go程序在进行垃圾回收时会发生非常明显的卡顿现象(Stop The World)。在对响应速度要求较高的后台服务进程中,这种延迟简直是不能忍受的!这个时期国内外很多在生产环境实践go语言的团队都或多或少踩过gc的坑。当时解决这个问题比较常用的方法是尽快控制自动分配内存的内存数量以减少gc负荷,同时采用手动管理内存的方法处理需要大量及高频分配内存的场景。
1.3版本开始go team开始对gc性能进行持续的改进和优化,每个新版本的go发布时gc改进都成为大家备受关注的要点。1.3版本中,go runtime分离了mark和sweep操作,和以前一样,也是先暂停所有任务执行并启动mark,mark完成后马上就重新启动被暂停的任务了,而是让sweep任务和普通协程任务一样并行的和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行。go team自己的说法是减少了50%-70%的暂停时间。
1.4版本(当前最新稳定版本)对gc的性能改动并不多。1.4版本中runtime很多代码取代了原生c语言实现而采用了go语言实现,对gc带来的一大改变是可以是实现精确的gc。c语言实现在gc时无法获取到内存的对象信息,因此无法准确区分普通变量和指针,只能将普通变量当做指针,如果碰巧这个普通变量指向的空间有其他对象,那这个对象就不会被回收。而go语言实现是完全知道对象的类型信息,在标记时只会遍历指针指向的对象,这样就避免了C实现时的堆内存浪费(解决约10-30%)。
1.5版本go team对gc又进行了比较大的改进(1.4中已经埋下伏笔如write barrier的引入),官方的主要目标是减少延迟。go 1.5正在实现的垃圾回收器是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。分代算法上文已经提及,是一种比较好的垃圾回收管理策略,然1.5版本中并未考虑实现;我猜测的原因是步子不能迈太大,得逐步改进,go官方也表示会在1.6版本的gc优化中考虑。同时引入了上文介绍的三色标记法,这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间。
go 的三色回收
go 的三色回收
哪三色?
白色对象:程序生成的对象都是白色对象,GC结束后还是白色的会被回收
灰色对象:可达对象,在GC遍历时遍历到的对象,GC结束后不会回收,正常是变为黑色
黑色对象:保留对象,在灰色遍历完之后,或者无接下去可达对象的灰色对象,最后都变为灰色对象
步骤
步骤一、将程序以根节点展开
步骤二、所有对象放入白色集合
步骤三、根节点开始遍历,遍历到的标为灰色
步骤四、遍历灰色,将遍历到的放入灰色,原本灰色的放入黑色
步骤五、重复上一步直到无灰色对象
步骤六、回收白色对象
基于标记清扫的例子,看一下三色并发标记法是如何实现的,记住GC开始时还是需要STW
根据步骤一,将会变成如下:
将程序以根节点展开
根据步骤二,我们会建立白色对象,灰色对象和黑色对象集合。首先的是将所有对象先放到白色对象,最后剩下的白色对象,也就是要回收的对象。
步骤三,遍历白色对象内的根对象,也就是对象1(dx1)和对象4。将遍历到的对象,由白色标记为灰色。
步骤四,遍历灰色,将遍历到的放入灰色,原本灰色的放入黑色。以对象1为例,对象2会从白色标机为灰色,而对象1会被标记为黑色。
步骤五,循环第四步,直到没有灰色对象为止。
步骤六,当没有灰色对象后,回收白色对象
为何在GC一开始需要STW
看下图,要扫描对象2的时候。对象2删除对对象6的引用,同时,对象4创建指针指向对象6。此时继续标记,因为对象4不会再被扫描,所以,对象6会被当做回收对象。到最后就是,对象4指向的对象6会被清扫。因此,在没有STW的时候会发生如下两种情况:
白对象被黑引用
灰对象与可达对象失去关系
可见,其实STW是为了保护在程序执行过程中,由于额外的新增或删除导致不可控的对象丢失的一个做法。因为STW是需要暂定整个程序,所以STW的时间成为了一个指标,也上升为GC算法的一直指标,也是后面GC算法优化的一个点。
因此提出2个概念用于防止上述情况发生:
强三色不变式:不允许黑色对象引用白色对象
弱三色不变式:所有黑色对象引用的白色对象都必须在灰色对象保护链下
插入屏障
强三色不变式: 黑色引用白色对象后,白色对象变为灰色对象
如下图例子,遍历完对象1和对象4之后,分别引用对象7和对象8。根据插入屏障强三式,此时对象8会标记为灰色,再下个循环标记的时候,对象8可以标记为黑色保留下来。
可以看到对象7其实是没有像对象8一样,立马被标记为灰色。这是因为,栈容量小,反应速度要求高,不能用插入屏障的机制。因此,在堆对象扫描完之后,会对栈对象STW,然后通过三色并发标记清扫,完成GC。即,此例中的对象7得以保留。
删除屏障
弱三色不变式:被删除对象为灰色或白色,则标记为灰色对象
如下,为了保证对象没有被误扫除,在扫描对象1和对象4时,对象1删除对对象2的指向后。根据删除屏障弱三式,对象2会被标记为灰色。按照三色并发标记,最终GC如下右侧图。不过可以看到,虽然在一定程度上降低了对象被误扫除的机率,但同时降低了GC的精度。例如对象2和对线6就需要在下次GC的时候才会销毁。
GO 1.8混合写屏障
可以看到之前的插入屏障和删除屏障有明显的自身缺陷:
插入屏障:需要对栈对象重新STW遍历
删除屏障:回收精度低
混合写屏障,就是结合两者优势,又中和两者的劣势。混合写屏障减少STW,并且减少了扫描栈对象的时间。混合写屏障会做如下操作:
- GC开始时,将栈全部标记为黑色
- GC期间,任何创建在栈上的对象都标记为黑色
- 被删除的对象标记为灰色
- 被添加的对象标记为灰色
栈对象引用堆对象
遍历对象4时,删除对对象5的引用,对象1创建对对象5的引用,对象5被标记为灰色。
某个栈对象引用另一个栈对象的引用
在栈上创建对象7,对象7会被标记为黑色。同时,对象7创建对对象6的引用,对象2删除对对象6的引用。由于都在栈上操作,因此没有标记色动作。
某个堆对象引用另一个堆对象的引用
遍历对象4时,删除对对象5的引用。同时,对象7创建对对象5的引用。对象5被标记为灰色,避免被错误回收。
堆对象引用栈对象
遍历对象4时,删除对对象5的引用。同时,对象4创建对对象2的引用。对象5会被标记为灰色,为了保证下游对象不被误回收。
GO GC流程
Go GC对应5个流程分别是:
GC Mark Prepare
标记准备阶段,为并发标记做准备工作,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等 --> STW
GC Mark
扫描标记阶段,扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空 --> 后台并发
GC Mark Termination
标记终止阶段,保证一个周期内标记任务完成,停止写屏障。同时由于Mark和用户代码是并发执行,所以需要重新扫描(re-scan)全局指针和栈。 --> STW
GC Off SWEEP
内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 --> 后台并发
GCoff
内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 --> 后台并发
GO GC API
runtime.GC:手动触发GC
runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分GC相关的统计信息
debug.FreeOSMemory:手动将内存归还给操作系统
debug.ReadGCStats:读取关于GC的相关统计信息
debug.SetGCPercent:设置GOGC调步变量
debug.SetMaxHeap:设置GO程序堆的上限值
————————————————
CSDN博主「非晓为骁」 原文链接:https://blog.csdn.net/weixin_40242845/article/details/114744783
观察go gc
package main
func allocate() {
_ = make([]byte, 1<<20)
}
func main() {
for n := 1; n < 100000; n++ {
allocate()
}
}
配置环境变量:GODEBUG=gctrace=1 ./main
ms clock, 0+0/0/0+0 ms cpu, 7->11->4 MB, 8 MB goal, 24 P
gc 4243 @2.198s 15%: 0+0.52+0 ms clock, 0+0/0/0+0 ms cpu, 7->13->6 MB, 8 MB goal, 24 P
gc 4244 @2.199s 15%: 0+0.51+0 ms clock, 0+0/0/0+0 ms cpu, 10->14->4 MB, 12 MB goal, 24 P
gc 4245 @2.200s 15%: 0+0.51+0 ms clock, 0+0/0.50/0.51+0 ms cpu, 7->9->2 MB, 8 MB goal, 24 P
gc 4246 @2.200s 15%: 0+0+0 ms clock, 0+0/0/0+0 ms cpu, 4->6->2 MB, 5 MB goal, 24 P
gc 4247 @2.201s 15%: 0+0.021+0 ms clock, 0+0.021/0/0+0 ms cpu, 4->7->3 MB, 5 MB goal, 24 P
gc 4248 @2.201s 15%: 0+0.51+0 ms clock, 0+0/0.52/0.50+0 ms cpu, 5->7->2 MB, 6 MB goal, 24 P
gc 4249 @2.201s 15%: 0+0.51+0 ms clock, 0+0/1.0/0+0 ms cpu, 4->9->5 MB, 5 MB goal, 24 P
gc 4250 @2.202s 15%: 0+0.51+0 ms clock, 0+0/0/0+0 ms cpu, 9->13->4 MB, 10 MB goal, 24 P
gc 4251 @2.202s 15%: 0+0+0 ms clock, 0+0/0/0+0 ms cpu, 7->11->4 MB, 8 MB goal, 24 P
gc 4252 @2.203s 15%: 0+0+0 ms clock, 0+0/0/0+0 ms cpu, 7->9->2 MB, 8 MB goal, 24 P
gc 4253 @2.203s 15%: 0+0+0 ms clock, 0+0/0/0+0 ms cpu, 4->8->4 MB, 5 MB goal, 24 P
gc 4254 @2.203s 15%: 0+0.51+0 ms clock, 0+0.51/0.51/0.52+0 ms cpu, 7->10->3 MB, 8 MB goal, 24 P
gc 2 @0.001s 3%: 0.023+0.29+0.10 ms clock, 0.13+0.043/0.021/0+0.61 ms cpu, 5->6->1 MB, 6 MB goal, 6 P
字段 | 含义 |
---|---|
gc 2 | 第二个 GC 周期 |
3% | 该 GC 周期中 CPU 的使用率 |
0.023 | 标记开始时, STW 所花费的时间(wall clock) |
0.029 | 标记过程中,并发标记所花费的时间(wall clock) |
0.10 | 标记终止时, STW 所花费的时间(wall clock) |
0.13 | 标记开始时, STW 所花费的时间(cpu time) |
0.043 | 标记过程中,标记辅助所花费的时间(cpu time) |
0.021 | 标记过程中,并发标记所花费的时间(cpu time) |
0.0 | 标记过程中,GC 空闲的时间(cpu time) |
0.61 | 标记终止时, STW 所花费的时间(cpu time) |
5 | 标记开始时,堆的大小的实际值 |
6 | 标记结束时,堆的大小的实际值 |
1 | 标记结束时,标记为存活的对象大小 |
6 | 标记结束时,堆的大小的预测值 |
6 | P 的数量 |
可以这么理解,本次GC为第二次GC周期,占用了3%的CPU来执行这次GC。本次GC所花的STW扫描时间,STW并发标记时间,即STW清扫的总时间为0.10ms wall clock。然后,本次GC占用的CPU时间为0.61ms。标记钱实际堆大小为5MB,结束时为6MB,清扫存活下来的为1MB。此GC基于6核CPU(约等于)。
wall clock 是指开始执行到完成所经历的实际时间,包括其他程序和本程序所消耗的时间;cpu time 是指特定程序使用 CPU 的时间;他们存在以下关系:
wall clock < cpu time: 充分利用多核
wall clock ≈ cpu time: 未并行执行
wall clock > cpu time: 多核优势不明显
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
for n := 1; n < 1000; n++ {
allocate()
}
}
func allocate() {
_ = make([]byte, 1<<20)
}
执行完代码后可以得到一个trace.out,然后再执行go tool trace trace.out,会启动一个web server:
点击第一个链接view trace
蓝色区域就是背景GC在执行的时间
堆就是我们通过allocate分配的内存
绿色为用户代码运行在系统线程上
debug.ReadGCStats收集gc信息
func main() {
go printGCStats()
for n := 1; n < 1000; n++ {
time.Sleep(2 * time.Millisecond)
allocate()
}
select {
}
}
func printGCStats() {
t := time.NewTicker(time.Second)
s := debug.GCStats{}
for {
select {
case <-t.C:
debug.ReadGCStats(&s)
fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal)
}
}
}
func allocate() {
_ = make([]byte, 1<<20)
}
打印:
gc 18 last@2022-03-15 20:12:26.7580645 +0800 CST, PauseTotal 0s
gc 39 last@2022-03-15 20:12:27.7519005 +0800 CST, PauseTotal 0s
gc 60 last@2022-03-15 20:12:28.7779838 +0800 CST, PauseTotal 0s
gc 80 last@2022-03-15 20:12:29.7373957 +0800 CST, PauseTotal 0s
gc 101 last@2022-03-15 20:12:30.7511432 +0800 CST, PauseTotal 15.8µs
gc 122 last@2022-03-15 20:12:31.7456046 +0800 CST, PauseTotal 15.8µs
gc 143 last@2022-03-15 20:12:32.77689 +0800 CST, PauseTotal 24.8µs
gc 164 last@2022-03-15 20:12:33.7526677 +0800 CST, PauseTotal 24.8µs
gc 182 last@2022-03-15 20:12:34.743256 +0800 CST, PauseTotal 24.8µs
gc 202 last@2022-03-15 20:12:35.7370795 +0800 CST, PauseTotal 24.8µs
gc 224 last@2022-03-15 20:12:36.7626665 +0800 CST, PauseTotal 24.8µs
gc 244 last@2022-03-15 20:12:37.7416131 +0800 CST, PauseTotal 24.8µs
gc 249 last@2022-03-15 20:12:38.0043974 +0800 CST, PauseTotal 24.8µs
gc 249 last@2022-03-15 20:12:38.0043974 +0800 CST, PauseTotal 24.8µs
type GCStats struct {
LastGC time.Time // 上次一次收集时间
NumGC int64 // gc总次数
PauseTotal time.Duration // 总共gc暂停的时间
Pause []time.Duration // 暂停的时间切片,每次暂停的花费时间,倒叙
PauseEnd []time.Time // 暂停结束的时间点,倒叙
PauseQuantiles []time.Duration
}
ReadGCStats将垃圾收集信息填入stats里。stats.Pause字段的长度是依赖于系统的;stats.Pause切片如果长度足够会被重用,否则会重新申请。ReadGCStats可能会使用stats.Pause切片的全部容量。
如果stats.PauseQuantiles字段是非空的,ReadGCStats会在其中填写说明暂停时间分配的分位数。例如,如果len(stats.PauseQuantiles)为5,该字段会被填写上0%、25%、50%、75%、100%位置的分位数(就是说,不大于该位置暂停时间的暂停次数占总暂停次数的比例分别是0%、25%……)