传统的系统级编程语言(主要指C/C++)中,程序员必须对内存小心的进行管理操作,控制内存的申请及释放。稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位。
过去一般采用两种办法:
1、内存泄露检测工具:
这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。
2、智能指针:
这是 c++ 中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是对程序员有一定的学习成本(并非语言层面的原生支持)。
为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。
C++通过指针引用计数来回收对象,但是这不能处理循环引用。为了避免引用计数的缺陷,后来出现了标记清除,分代等垃圾回收算法。
垃圾回收机制分类:
一、引用计数法:
这是最简单的一种垃圾回收算法:对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。即:
1、使用前,为了保护对象不被销毁,计数 +1
2、使用完后,计数 -1,计数减到 0 之后,就可以安全销毁了
优点:
是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa框架,php,python等。
简单引用计数算法也有明显的缺点:
1.1、频繁更新引用计数降低了性能。(一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放)
1.2、循环引用问题。(当对象间发生循环引用时引用链中的对象都无法得到释放。)
C++通过指针引用计数来回收对象,但是这不能处理循环引用。为了避免引用计数的缺陷,后来出现了标记清除,分代等垃圾回收算法。
二、标记-清除(mark and sweep)
该方法分为两步:
1、标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;
2、标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。
这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!
后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。
三、分代收集(generation)
经过大量实际观察得知,在面向对象编程语言中,绝大多数对象的生命周期都非常短。
分代收集的基本思想是:
1、将堆划分为两个或多个称为 代(generation)的空间。
2、新创建的对象存放在称为 新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多)。
3、随着垃圾回收的重复执行,生命周期较长的对象会被 提升(promotion)到老年代中。
4、新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。
因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。
jvm 就使用的分代回收的思路。
GO的垃圾回收器
Go 垃圾回收的主要流程
三色标记法,主要流程如下:
- 所有对象最开始都是白色。
- 从 root 开始找到所有可达对象,标记为灰色,放入待处理队列。
- 遍历灰色对象队列,将其引用对象标记为灰色放入待处理队列,自身标记为黑色。
- 处理完灰色对象队列,执行清扫工作。
从根处扫描,把所有的根扫描完,每个根扫描到底。按照之前的三色标记来说,扫描完了的是黑色,正在扫描的是灰色的,没扫描的是白色的。根扫描完了,那么最后只会剩下两种颜色的,黑色,白色。白色就是没用的垃圾,这种清理掉就没事。
Go的垃圾回收官方形容为 非分代 非紧缩 写屏障 并发标记清理。标记清理算法的字面解释,就是将可达的内存块进行标记mark
,最后没有标记的不可达内存块将进行清理sweep
。
gc主要的三个步骤:扫描,回收,清扫。
底线:golang 只需要保证一个点,回收的一定是不用的垃圾,那么就不会出功能性问题,回收的慢点一定程度都 ok 的。
判断一个对象是不是垃圾需不需要标记,就看是否能从当前栈或全局数据区 直接或间接的引用到这个对象。这个初始的当前goroutine的栈和全局数据区称为GC的root区。扫描从这里开始,通过mark root
将所有root区域的指针标记为可达,然后沿着这些指针扫描,递归地标记遇到的所有可达对象。因此引出几个问题:
- 标记清理能不能与用户代码并发
- 如何获得对象的类型而找到所有可达区域 标记位记录在哪里
- 何时触发标记清理
如何并发标记
大概理解所谓并发标记,首先是指能够跟用户代码并发的进行,其次是指标记工作不是递归地进行,而是多个goroutine并发的进行。前者通过write-barrier解决并发问题,后者通过gc-work队列实现非递归地mark可达对象。
write-barrier:
垃圾回收中的 write barrier 可以理解为编译器在写操作时特意插入的一段代码,对应的还有 read barrier。
为什么需要 write barrier,很简单,对于和用户程序并发运行的垃圾回收算法,用户程序会一直修改内存,所以需要记录下来
gc-work
如何非递归的实现遍历mark可达节点,显然需要一个队列。
这个队列也帮助区分黑色对象和灰色对象,因为标记位只有一个。标记并且在队列中的是灰色对象,标记了但是不在队列中的黑色对象,末标记的是白色对象。
root node queue
while(queue is not nil) {
dequeue // 节点出队
process // 处理当前节点
child node queue // 子节点入队
}
总结一下并发标记的过程:
gcstart
启动阶段准备了N个goMarkWorkers
。每个worker都处理以下相同流程。- 如果是第一次mark则首先
markroot
将所有root区的指针入队。 - 从gcw中取节点出对开始扫描处理
scanobject
,节点出队列就是黑色了。 - 扫描时获取该节点所有子节点的类型信息判断是不是指针,若是指针且并没有被标记则
greyobject
入队。 - 每个worker都去gcw中拿任务直到为空break。
问两个问题:
- 那么根是什么?
- 回收的内存是哪里的内存?
答案:
- 栈是根,是扫描的起点,还有一些全局变量也是根,是起点。
- 所谓垃圾只对于堆上内存来说,栈上内存是编译器管理的,堆上内存是业务分配,垃圾回收器回收。
内存屏障
怎么解决这个问题?接下来就是内存屏障出场了。golang 内存屏障也有一个演进过程:
- 插入写屏障
- 混合写屏障(插入写屏障 + 删除写屏障)
先说屏障的本质:
- 内存屏障只是对应一段特殊的代码
- 内存屏障这段代码在编译期间生成
- 内存屏障本质上在运行期间拦截内存写操作,相当于一个 hook 调用
屏障的作用:
- 通过 hook 内存的写操作时机,阻止一些事情的发生,或者说做好一些标记工作,从而保证垃圾回收的正确性
概念:
踩内存:总的来说,是访问了不应该访问的内存地址。尤其在C指针中。可以访问不合法的内存。
1:访问越界数组
2、访问已经被free释放掉的内存
3、栈内存访问越界