Go 和 Java 在多线程、内存模型、垃圾回收上的对比

对比 Go 和 Java 在多线程、内存模型、垃圾回收上各自是如何实现有何异同

线程模型

1. JDK 19 以下 版本(排除最早的 Green Threads 老版本)

aaaa 采用 1 :1 的线程模型,即每个用户线程对应一个内核线程

  • 线程的创建、销毁、切换都由操作系统的调度器完成,JVM 不参与
  • 目前主流操作系统使用抢占式线程调度策略,当 JVM 线程阻塞时,比如 Object.wait()、Thread.sleep()、Thread.join()、LockSupport.park() 等导致线程进入 WAITING 或 TIMED_WAITING 状态,或者抢占 synchronized 锁时进入 BLOCKED 状态,线程都会释放 CPU,只有回到 RUNNABLE 状态后线程才能被系统调度器重新调度进入 RUNNING 状态
  • JVM 线程在遇到 I/O 阻塞时仍然保持 RUNNABLE 状态,但其对应的内核态线程会被操作系统挂起直到 I/O 操作完成
  • JVM 的最大线程数出了自身的配置外,还受限于 Linux Kernel 的线程数限制
  • JVM 的线程创和切换涉及用户态和内核态的切换、CPU 上下文切换,开销较大,通常采用线程池来减少线程创建的开销

2. JDK 19 及以上版本

参考资料 aaa JDK 19 引入了 virtual thread 虚拟线程,采用 M:N 的线程模型,一个内核线程对应一个用户态的平台线程,一个平台线程对应多个虚拟线程

  • 虚拟线程的调度、销毁、切换都由 JVM 完成,不需要操作系统调度器参与
  • JVM 对虚拟线程采用协作式线程调度策略,当线程阻塞时会主动从平台线程上卸载,让调度器调度其他线程运行
  • 虚拟线程的最大线程数不受 Linux Kernel 的线程数限制,只受限于 JVM 的配置和系统内存,可以创建大量虚拟线程用以处理 I/O 密集型任务
  • 虚拟线程的创建和切换都在用户态完成,避免了用户态和内核态的切换、CPU 上下文切换的开销

3. Go

Go (1.1 版本以后)也采用 M:N 的线程模型,同时还采用了 GMP 调度器来调度协程的执行 aaa

  • G 代表协程,是最小的调度单位
  • M 代表内核线程
  • P 代表逻辑处理器,每个 P 和 M 一对一绑定,每个 P 维护一个本地队列用于存放待执行的 G 运行过程:
  1. goroutine 创建时,如果有 P 的本地队列未满,则直接放入本地队列,如果全部 P 的本地队列已满,则将 G 放入全局队列
  2. M 从 绑定的 P 的本地队列获取 G 执行,如果本地队列为空,则从全局队列中获取 G 执行
  3. Work Stealing 机制:当 M 绑定的 P 本地队列为空且全局队列也为空时,M 可以从其他 P 的本地队列中窃取 G 来执行,以避免空闲的 M 被销毁
  4. Handoff 机制:当 M 因系统调用阻塞时,P 和 M 解绑并寻找其他空闲的 M 绑定,如果没有空闲的 M 则创建新的 M
  5. 当 G 因用户态阻塞时,G 会被 M 放回队列状态从 _Gruning 变为 _Gwaiting,M 会继续执行其他 G
  6. 基于协作的抢占调度(1.2 以前):Go 运行时会启动一个名为 sysmon 的 M 来监控 runtime 的 GC 情况,向长时间运行的 G (超过 10ms)发出抢占调度,将其 stackguard0 设置为 StackPreempt,当 G 下一次执行函数时会被 runtime 强制调度,被移入队列中。但因为只有在执行函数时才会检查 stackguard0,所以对执行 for 循环对这种 G 无效
  7. 基于信号的抢占调度(1.2 以后):将被抢占的 G 标记为可抢占状态,同时向 M 发送抢占信号 SIGURG,操作系统会中断 M 的执行并执行信号处理函数,信号处理函数会将 G 的状态切换到 _Gpreempted 并移入队列中

内存管理

GO

Go 内存分配

Go 向操作系统申请的连续虚拟内存在物理上分为三个区域:

  • spans 存储指向内存管理单元 mspan 的指针,每个 mspan 包含 N 个 page
  • bitmap 标识 arena 中的 page 是否包含对象及是否被 GC 标记,bitmap 中两个 bit 对应 arena 中一个 byte
  • arena 实时上的堆,每 8KB 划分为一个最小的内存申请单位 page 在逻辑管理上进一步划分:
  • mspan:对应 n 个 连续的page,在逻辑上 mspan 被划分为更小的 object,每个 object 的大小由 spanclass 决定。Go 划分了从小到大 67 种 mspan 用来保存不同大小的对象
  • heapArena:64 位的 X86 架构下每个 heapArena 对应 64MB 的内存,对应多个 mspan
  • mheap:由一个 heapArena 二维数组构成,对应物理上的整个 arena
  • mcache:Go 为每个 P 维护一个 mcache 用来缓存 mspan,其中将 mspan 按是否被 GC scan 分为两类,每类按 spanclass 进一步划分为多个 mspan 列表。mcache 动态地从 mcentral 中获取 mspan 并缓存。
  • mcentral:每个 mcentral 负责缓存一类 spanclass 的全部 mspan,负责向 mcache 分配 mspan,当 mcentral 缓存的 mspan 不足时,向 mheap 申请新的 mspan,如果 mheap 也不足则向 OS 申请一组新的 page。mcentral 带有全局锁,并发访问时需要加锁。

垃圾回收

Go 采用 Mark & Sweep 标记清理 的垃圾回收方法,不对内存做整理,不对内存里的对象做分代。
运行过程包含五个阶段: 1.

三色标记法

三色标记法用三种颜色表示三种类型的对象:

  • 白色 GC scan 未访问过的对象
  • 灰色 GC scan 访问过的对象,但其引用的一个或多个对象还未被 scan
  • 黑色 GC scan 访问过的对象,且其引用的所有对象都已被 scan 堆内的对象随时间和 GC scan 的推进会不断从白色变为灰色,再变为黑色,就像一条波浪从黑色向白色推进,其中灰色就是黑白交界的波面 wavefront,当波面随着推进消失时只剩下黑色和白色的对象,其中白色就是需要回收的不可达对象。
并发标记回收引入的问题

如果在标记和清理的过程中没有 STW,用户态代码可能会改变对象引用关系,导致在标记回收过程中出现:1. 被标记为白色的对象被黑色对象引用;2. 同时对该白色对象在其他灰色对象处的引用被移除。这样的情况下白色对象会被错误回收。
只要能够避免上述两个情况的任意一个,这一问题就不会发生:

  1. 如果情况 1 被避免,则所有白色对象只会被灰色对象引用,这样的情况下如果发生情况 2 则白色对象不会被scan到,被回收;
  2. 如果情况 2 被避免,则至少有一个灰色对象引用白色对象,这样的情况下如果发生情况 1 则白色对象会被scan到,不会被回收。
强三色不变性和弱三色不变性

根据上述的情况,将三色不变性进一步细化:

  • 强三色不变性:在原三色不变性基础上,不允许存在黑色对象引用白色对象的情况
  • 弱三色不变性:在原三色不变性基础上,允许存在黑色对象引用白色对象的情况,但要求白色对象必须被其他灰色对象引用
写屏障

赋值器:指的是用户态代码
回收器:指 GC 线程
Go 引入了赋值器的写屏障,使赋值器在进行指针操作时,能够“通知”回收器,进而不会破坏三色不变性。

  1. 插入屏障
  • 赋值器在新增一个从黑色对象到白色对象的引用时,会通过写屏障将白色对象标记为灰色,从而实现强三色不变性
  • 为降低引入插入屏障的性能开销,Go 在栈上对象插入引用时不开启插入屏障,而是标记这个栈,最后回收前再 STW 扫描被标记的栈
    参考
  1. 删除屏障
    赋值器从灰色白色对象中删除对白色对象的引用时,将被删除对象从白色标记为灰色,从而实现弱三色不变性
  2. 混合写屏障
  • GC 开始时将所有栈上可达的对象标记为黑色
  • GC 期间栈上新建的对象标记为黑色
  • 堆上新建的对象标记为灰色
  • 堆上被删除的对象标记为灰色

参考

https://draven.co/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/#%E6%8A%A2%E5%8D%A0%E5%BC%8F%E8%B0%83%E5%BA%A6%E5%99%A8