七叶笔记 » golang编程 » 自动的内存管理系统实操手册——Java和Golang对比篇

自动的内存管理系统实操手册——Java和Golang对比篇

导语 | 现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而PHP、Java 和Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。在《 自动的内存管理系统实操手册——Java垃圾回收篇 》和《 自动的内存管理系统实操手册——Golang垃圾回收篇 》向大家分享了Java 和 Golang 垃圾回收算法之后,今天腾讯后台开发工程师汪汇向大家总结和对比两种算法。

一、 垃圾回收区域

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,因此, Java堆和方法区是Java垃圾收集器管理的主要区域

Go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。如果只申请和分配内存,内存终将枯竭。Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。因此, Go堆是Go垃圾收集器管理的主要区域

二、 触发垃圾回收的时机

Java当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。

Java堆内存不足时,GC会被调用。但是这种情况由于java是分代收集算法且垃圾收集器种类十分多,因此其触发各种垃圾收集器的GC时机可能不完全一致,这里我们说的为一般情况。

1. 当Eden区空间不足时Minor GC;

2. 对象年龄增加到一定程度时Young GC;

3. 新生代对象转入老年代及创建为大对象、大数组时会导致老年代空间不足,触发Old GC;

4. System.gc()调用触发Full GC;

5. 各种区块占用超过阈值的情况。

Go则会根据以下条件进行触发:

  • runtime.mallocgc申请内存时根据堆大小触发GC;
  • runtime.GC用户程序手动触发GC;
  • runtime.forcegchelper后台运行定时检查触发GC。

三、收集算法

当前Java虚拟机的垃圾收集采用 分代收集 算法 ,根据对象存活周期的不同 将内存分为几块。比如在新生代中,每次收集都会有大量对象死去,所以可以选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

当前Go的都是基于 标记清除 算法进行垃圾回收

四、垃圾碎片处理

由于Java的内存管理划分,因此容易产生垃圾对象,JVM这些年不断的改进和更新GC算法, JVM在处理内存碎片问题上更多采用空间压缩和分代收集的思想 ,例如在新生代使用“标记-复制”算法,G1收集器支持了对象移动以消减长时间运行的内存碎片问题,划分region的设计更容易把空闲内存归还给OS等设计。

由于Go的内存管理的实现,很难实现分代,而移动对象也可能会导致runtime更庞大复杂,因此Go在关于内存碎片的处理方案和Java并不太一样。

1.Go语言span内存池的设计,减轻了很多内存碎片的问题 。

Go内存释放的过程如下:当mcache中存在较多空闲span时,会归还给 mcentral;而mcentral中存在较多空闲span时,会归还给mheap;mheap再归还给操作系统。这种设计主要有以下几个优势:

  • 内存分配大多时候都是在用户态完成的,不需要频繁进入内核态。
  • 每个 P 都有独立的 span cache,多个 CPU 不会并发读写同一块内存,进而减少 CPU L1 cache 的 cacheline 出现 dirty 情况,增大 cpu cache 命中率。
  • 内存碎片的问题,Go是自己在用户态管理的,在 OS 层面看是没有碎片的,使得操作系统层面对碎片的管理压力也会降低。
  • mcache 的存在使得内存分配不需要加锁。

2.tcmalloc分配机制,Tiny对象和大对象分配优化,在某种程度上也导致基本没有内存碎片会出现 。

比如常规上sizeclass=1的span,用来给<=8B 的对象使用,所以像 int32, byte, bool以及小字符串等常用的微小对象,都会使用sizeclass=1的span,但分配给他们8B的空间,大部分是用不上的。并且这些类型使用频率非常高,就会导致出现大量的内部碎片。

因此Go尽量不使用sizeclass=1的span,而是将<16B的对象为统一视为tiny对象。分配时,从sizeclass=2的span中获取一个16B的object用以分配。如果存储的对象小于16B,这个空间会被暂时保存起来 (mcache.tiny字段),下次分配时会复用这个空间,直到这个object用完为止。

以上图为例,这样的方式空间利用率是(1+2+8)/16*100%= 68.75%,而如果按照原始的管理方式,利用率是(1+2+8)/(8*3)=45.83%。源码中注释描述,说是对tiny对象的特殊处理,平均会节省20%左右的内存。如果要存储的数据里有指针,即使<= 8B也不会作为tiny对象对待,而是正常使用sizeclass=1的span。

Go中,最大的sizeclass最大只能存放32K的对象。如果一次性申请超过32K的内存,系统会直接绕过mcache和mcentral,直接从mheap上获取,mheap中有一个freelarge字段管理着超大span。

3.Go的对象(即struct类型)是可以分配在栈上的。

Go会在编译时做静态逃逸分析(Escape Analysis), 如果发现某个对象并没有逃出当前作用域,则会将对象分配在栈上而不是堆上,从而减轻了GC内存碎片回收压力。

比如如下代码:

 func F() {

  temp := make([]int, 0, 20) //只是内函数内部申请的临时变量,并不会作为返回值返回,它就是被编译器申请到栈里面。

  temp = append(temp, 1)

}




func main() {

  F()

}
  

运行代码如下,结果显示temp变量被分配在栈上并没有分配在堆上:

 hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m

# hello

./new1.go:4:6: can inline F

./new1.go:9:6: can inline main

./new1.go:10:3: inlining call to F

./new1.go:5:14: make([]int, 0, 20) does not escape

./new1.go:10:3: make([]int, 0, 20) does not escapeh
  

当我们把上述代码更改:

 package main

import "fmt"




func F() {

  temp := make([]int, 0, 20)

  fmt.Print(temp)

}




func main() {

  F()

}
  

运行代码如下,结果显示temp变量被分配在堆上,这是由于temp传入了print函数里,编译器会认为变量之后还会被使用。因此就申请到堆上,申请到堆上面的内存才会引起垃圾回收,如果这个过程(特指垃圾回收不断被触发)过于高频就会导致GC压力过大,程序性能出问题。

 hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m

# hello

./new1.go:9:11: inlining call to fmt.Print

./new1.go:12:6: can inline main

./new1.go:8:14: make([]int, 0, 20) escapes to heap

./new1.go:9:11: temp escapes to heap

./new1.go:9:11: []interface {}{...} does not escape

<autogenerated>:1: .this does not escape
  

五、“GC Roots” 的对象选择

在Java中由于内存运行时区域的划分,通常会选择以下几种作为“GC Roots” 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 本地方法栈(Native 方法)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • Java虚拟机内部引用;
  • 所有被同步所持有的对象。

而在Java中的不可达对象有可能会逃脱。即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;此外Java中由于存在运行时常量池和类,因此也需要对运行时常量池和方法区的类进行清理。

而Go的选择就相对简单一点,即全局变量和G Stack中的引用指针,简单来说就是全局量和go程中的引用指针。因为Go中没有类的封装概念,因而GC Root选择也相对简单一些。

六、写屏障

为了解决并发三色可达性分析中的悬挂指针问题,出现了2种解决方案,分别是分别是“Dijkstra插入写屏障”和“Yuasa删除写屏障”。

在java中,对上述2种方法都有应用,比 如 CMS是基于“Dijkstra插入写屏障”做并发标记的,G1、Shenandoah则是使用“Yuasa删除写屏障”来实现的。

在Go语言v1.7版本之前,运行时会使用Dijkstra插入写屏障保证强三色不变性,Go语言在v1.8组合Dijkstra插入写屏障和Yuasa删除写屏障构成了混合写屏障,混合写屏障结合两者特点,通过以下方式实现并发稳定的GC:

1.将栈上的对象全部扫描并标记为黑色。

2.GC期间,任何在栈上创建的新对象,均为黑色。

3.被删除的对象标记为灰色。

4.被添加的对象标记为灰色。

由于要保证栈的运行效率,混合写屏障是针对于堆区使用的。即 栈区不会触发写屏障,只有堆区触发 ,由于栈区初始标记的可达节点均为黑色节点,因 而也不需要第二次STW下的扫描。本质上是融合了插入屏障和删除屏障的特点,解决了插入屏障需要二次扫描的问题。同时针对于堆区和栈区采用不同的策略,保证栈的运行效率不受损。

七、总结

从垃圾回收的角度来说,经过多代发展,Java的垃圾回收机制较为完善,Java划分新生代、老年代来存储对象。对象通常会在新生代分配内存,多次存活的对象会被移到老年代,由于新生代存活率低,产生空间碎片的可能性高,通常选用“标记-复制”作为回收算法,而老年代存活率高,通常选用“标记-清除”或“标记-整理”作为回收算法,压缩整理空间。

Go是非分代的、并发的、基于三色标记和清除的垃圾回收器,它的优势要结合它tcmalloc内存分配策略才能体现出来,因为小微对象的分配均有自己的内存池,所有的碎片都能被完美复用,所以GC不用考虑空间碎片的问题。

相关文章