前 言
从实践中看,Golang(以下简称Go)应用程序比Java占用更少的内存,这与它们的运行时环境有关,其运行时自带了内存动态分配和自动垃圾回收的管理机制,本文通过分析Go与Java在内存管理机制上的差异,以期对两者在运行时内存方面有更进一步的认识。本文以Go(1.12)和当前使用较多的JDK8 HotSpot VM为例进行说明。
本篇文章包含以下内容:
介绍Go与Java的运行时内存结构差异
介绍Go与Java的内存资源占用差异
介绍Go与Java如何为对象分配内存
介绍Go与Java的内存回收策略差异
内存结构差异
应用程序要能在linux系统上运行(其他平台类似),其可执行文件要求符合ELF规范(Executable and Linkable Format,可执行和可链接格式)。操作系统加载目标可执行文件到内存中并以独立进程方式运行程序。
操作系统为每个进程分配一个连续的虚拟内存地址空间,并将该进程内存空间划分成多个不同用途的逻辑区域。JVM进程以及Go进程的内存结构如下图所示:
图1
Java用户程序是运行在Java虚拟机(以下简称“JVM”)之上的,从上图我们看到用户程序的字节码指令并没有存储到JVM进程的堆空间或者text段中。实际上,虚拟机将用户程序字节码放在了使用本地直接内存实现的方法区中,并不占用虚拟机的堆内存。
JVM的堆空间保存Java用户程序运行时创建的对象和字符串常量池等数据,栈空间则为Java线程提供了私有的内存区域。在HotSpot VM的实现中,Java线程栈使用操作系统栈和线程模型表示,且Java方法与本地方法共享同一个栈区。因此虚拟机栈与本地方法栈其实是同一个区域。
Go与Java有较大不同,Go进程空间的text段不但保存了内置的运行时机器指令,而且还有用户程序的机器指令(Go在编译时就已确定)。堆内存区则为用户程序创建对象提供了存储空间。Go天然支持并发编程模型,采用了系统线程与用户线程(goroutine)相结合的实现机制,进程栈空间为系统线程提供了栈内存,而用户线程栈的内存默认从堆中分配。
Java内存结构
1. 运行时内存
Java程序的运行时内存被划分为元数据区(方法区)、堆、虚拟机栈、本地方法栈、程序计数器5个部分,这可以看作是JVM对进程可用堆、栈空间进行的二次分配,以满足运行Java用户程序的内存需求。其内存结构如下图所示:
图2
上图中堆内存对应了图1中JVM的堆内存,虚拟机栈、本地方法栈、程序计数器则对应了图1中JVM的栈区,元数据区则是JVM另外开辟的内存块。
· 元数据区是JVM向操作系统申请的堆外内存,用于实现“方法区”,主要存储虚拟机加载class的类信息、JIT编译的代码、运行时常量池等数据,其默认大小由系统的可用物理内存上限限制。
· 堆内存是被所有线程共享的一块内存区域,在虚拟机启动时创建。它存放了包括几乎所有的Java对象以及数组,这也是垃圾收集器关注的主要内存区。由于逃逸分析等优化技术,对象也有可能被分配到栈上。
· 虚拟机栈即我们常说的栈空间,其生命周期与线程相同。线程的栈空间存储了方法调用的栈帧,每个栈帧则存储局部变量表、操作数栈、动态链接、方法出口等信息。
· 本地方法栈为JVM使用到的Native方法提供内存空间,而虚拟机栈为JVM执行Java方法提供内存空间。HotSpot将虚拟机栈与本地方法栈合并管理。
· 程序计数器是一块较小的内存空间,用来指向当前线程需要执行的下一条字节码指令。
元数据区、堆内存为所有线程共享,多线程访问时需要进行同步控制。虚拟机栈、本地方法栈、程序计数器都是线程私有的内存空间(堆的TLAB空间也是线程私有的空间),访问时无需加锁,速度很快。
2. 分代的堆内存
Java用户程序创建的对象主要存放在Java堆内存中,操作非常频繁,而且大部分对象的生命周期都很短暂。根据对象存活的特点,Java堆空间进一步划分为新生代和老年代,其中新生代又可以细分为eden区和两块相等大小的survivor区。JVM根据对象根据存活周期将其存放在不同的内存代中,不同的内存代可以采用不同的垃圾收集器回收内存。如下图所示:
图3
· 新生代将内存划分为eden空间和两块较小的survivor空间,每次使用eden和其中一块survivor。当回收时,将eden和survivor中还存活着的对象一次性地复制到另外一块survivor空间上,最后清理掉eden和刚才用过的survivor空间,这为新生代“标记-复制”回收内存提供算法实现便捷性。eden区与survivor区的大小比例是8:1:1。
· 老年代一般存放存活周期较长的对象以及大对象。老年代采用“标记-清除”或“标记-整理”算法回收内存。
· 新建对象总是优先在新生代的eden区中分配,“熬过”多次GC的对象可以从新生代晋升到老年代。
Go内存结构
1. 运行时内存
Go的运行时内存就是操作系统分配给进程空间的堆、栈内存,在堆内存的使用上不像JVM分代管理,而是采用分层级的内存管理模式。
2. 分层级的堆内存
Go的堆内存直接采用了TCMalloc库的内存管理模型。TCMalloc库是Google开发的现代内存分配器,其基本特征是内存分层级、对抗内存碎片以及快速分配等。Go语言根据自身需求对TCMalloc做了很多优化,但仍保留了其基本架构。
Go的内存分配器是分层级的,由mcache/mcentral/mheap 三个组件构成。因此整个堆内存结构可以看成是三层级的内存模型,其结构如下图所示:
图4
· 缓存组件mcache与工作线程(goroutine)绑定,是goroutine私有的内存空间。在mcache中为对象分配内存时,无需竞争,性能很高。
· 中间组件mcentral 只负责一种规格(size class)的内存块,为mcache缓存组件提供备用的特定规格的可用空间。mcache的内存扩容请求会被分散到不同的mcentral 组件上,以减小共享内存的竞争锁粒度。
· 堆组件mheap负责管理用户程序的所有可用堆内存空间以及为大对象直接分配内存。它为上层组件提供扩容支持。当空间不足时,mheap组件向操作系统申请内存。
中间组件mcentral、堆组件mheap为所有工作线程(goroutine)所共享,所以在内存分配时通常存在同步竞争的情况。
“多出来”的方法区
Go程序指令被操作系统加载到内存并保存在text段中,其大小在运行时基本是确定的。
而JVM的text段存储的是虚拟机本身的指令数据,Java用户程序的字节码被加载并存储在堆外内存的元数据区(方法区)中。Java字节码的加载过程如下图所示:
图5
· 在程序启动、运行期间,JVM中的类装载器子系统按需动态加载类文件(包括Java API基础类库、用户程序class文件、第三方依赖库等)、以及由字节码框架动态生成的类信息,这些数据经加载、验证、准备、解析、初始化等一系列过程最终都会保存在方法区中。
· 程序运行时,执行引擎中的解释器解释执行方法区中的字节码指令。混合模式下的JIT编译器将探测到的热点代码编译为本地可执行的机器指令,编译的机器指令也保存在方法区中。
随着程序不断运行,方法区所占的内存空间可能会越来越大,存储的数据有时候能达到数百兆;而对该内存区域中类型卸载的条件又比较苛刻,内存回收效率并不如堆内存理想。
这“多出来”的用于存储Java用户程序字节码指令的区域是Java程序比Go消耗更多内存的一个重要因素。
对象内存分配差异
不同的内存结构决定了对象内存分配方式的差异,也会给垃圾回收带来影响。Go以及Java都支持变量的逃逸分析,逃逸到栈上的对象会随着方法退出而自然回收,而分配到堆内存的对象则需要垃圾收集器回收才能释放出内存空间。因而我们更多关注对象在堆上分配空间的过程。
Java对象
JVM会为新创建的线程在stack栈区分配一块私有的线程栈空间。某一个线程中创建的Java对象可能被分配在新生代,也可能分配在老年代,其具体的分配方法如下图所示:
图6
JVM执行线程的创建对象指令,会向堆内存申请对象空间。堆内存被所有线程共享,在为对象分配空间时需要同步锁定,这会降低内存分配的效率。用户可以设置-XX: +UseTLAB参数启用TLAB功能,这样JVM总是优先尝试在当前线程的TALB空间为对象分配内存。TLAB(Thread-Local Allocation Buffer,线程本地分配缓冲区)是JVM预先为每一个线程在堆区中划分的一小块私有内存空间,线程分配的对象空间总是在自己的TLAB上分配,无需加锁。如果TLAB内存用完了则重新申请一块新的TLAB。如果在TLAB中分配失败,则会尝试在新生代中继续分配操作。一般过程如下所示:
1)首先检查该类是否已加载、解析、初始化。如果没有,则执行类加载的过程。
2)分配对象内存时,检查是否启用-XX: +UseTLAB参数。如果启用TLAB,则直接从当前线程的TLAB空间以lock free方式分配指定大小的内存块,速度很快。
■ 如果TLAB剩余空间大于其可浪费空间阈值,则直接在新生代中分配。
■ 否则,JVM会尝试为当前线程重新开辟一块TLAB空间。
3)如果未启用UseTLAB或者TLAB分配失败,JVM将继续在eden区或老年代上为对象分配空间,这时需要做同步操作。
4)对象内存分配成功后,JVM初始化对象零值、设置对象头等元信息,执行对象初始化方法,最后将对象引用压入线程的栈内存中。
新建对象总是分配在新生代的eden区,当空间不够时,会存放在老年代中。如果剩余空间还是不够,JVM会申请扩容或触发一次GC回收内存后继续在各个内存代中尝试分配。
Java大对象的分配过程稍有不同,JVM总是直接在老年代中为其分配存储空间。我们可以通过-XX:PretenureSizeThreshold参数来设定大对象的阈值,该参数默认值为0,说明对象总是先在eden区分配,不管这个对象有多大。
Go对象
Go内存分配器管理span以及object两种类型的内存块。span是Go内存管理的基本单元,由多个地址连续的页(8k大小的page内存块)组成的⼤块内存。object则是将span按照特定规格(size class)切分成的多个⼩块,每个⼩块都可以用来存储⼀个对象。
Go的object内存块大小为8字节的整倍数,被划分为67种规格。Go对象的内存分配就是从有限的67种规格中找出与对象大小最合适的一块可用内存块的过程。Go使用“空闲列表”方式管理可分配的内存空间,相同规格的内存块连接成一个双向链表。以下是Go object的size class对应表,其中包含66种规格,另外一种规格是大于32KB的大对象:
根据不同对象的规格大小,Go内存分配器有不同的内存分配逻辑。比如零长度对象由于没有可读写内容,在分配时不同类型可能指向同一位置,如struct{}与[0]int。内存分配器会将小于16字节且不包含指针(noscan)的微小对象组合起来,并尝试用单个object内存块存储以减少内存浪费。32KB以内大小的小对象使用mcache组件进行分配,大于32KB的大对象直接在mheap组件管理的堆上分配。
用户程序中创建的对象大部分是小对象,这也是内存分配器的重心所在,小对象分配在每个goroutine mcache中避免了竞争锁提升了分配效率。如前所述,Go内存分配器采用分层级组件的方式来管理应用的堆内存,为小对象分配内存需要多级组件相互协作完成。分配过程如下图所示:
图7
· mcache缓存是goroutine的私有内存空间,直接为当前goroutine无锁分配小对象的内存块,速度很快。内存分配器首先根据对象大小获取mcache中对应size class的span链表,并从表头span中提取object块进行分配。
· 如果分配器发现mcache下没有对应规格的可用span资源,则会尝试从堆区相应class的mcentral区域中申请扩容(mcentral是所有线程共享,在为mcache扩容时总是会先lock)。分配器将申请到的span资源链接到mcache链表,继续为对象分配object块空间。
· 如果mcentral中没有找到可用的内存块,分配器会向mheap申请扩容,扩容成功后继续为对象分配内存。
对于大对象,Go的内存分配器直接在mheap分配内存,如果没有找到合适的span内存块,分配器将向操作系统申请扩容后继续分配。提取的span内存块如果超过了对象规格所需的页数,分配器将尝试分割该span合适大小分配给对象,并合并剩余的空间归还给mheap管理,以减少堆内存碎片。
由上可知,小对象内存分配,Go mcache方式与Java TLAB方式相似,都是从堆内存中为线程划分私有空间以便进行快速分配,但是仍然会有所差异:
垃圾收集差异
内存结构划分、对象分配方式与垃圾收集策略密切相关,而且自动GC很大程度上会成为影响系统性能的瓶颈。Go与Java的GC策略是判断对象是否存活,并对其进行标记,或采用“复制”算法、“清除”算法、“整理”算法等完成不可引用对象的内存回收操作。目前垃圾收集过程中总是会遇到STW(stop the world)的问题。
Java GC
·垃圾收集器一览
JVM的垃圾收集是分代的收集。比如新生代采用“标记-复制”算法进行回收,老年代通常使用“标记-清理”或“标记-整理”算法。其中在G1收集器下,内存的划分又稍有不同了。
以下是JVM中主要的垃圾收集器实现,其中JDK8默认GC组合是Parallel Scavenge(新生代)和Parallel Old(老年代):
· GC触发时机一览
JVM的GC策略根据内存分代可以分为Minor GC(新生代垃圾收集)和Full GC(Major GC,老年代垃圾收集)两类。它们的触发时机一般如下表所示:
Go GC
Go只有一种垃圾收集器,其基于优化改进的“标记-清除”算法,特征为“非分代、非紧缩、写屏障、三色标记、并发标记清理”。Go的“非分代”内存管理使得Go并不需要实现多种GC算法策略,“非紧缩”的特征使得回收的内存块非常容易的复用,较少产生内存碎片,基本上不需要压缩整理。Go的垃圾收集器与JVM中的CMS垃圾收集器原理上是非常相似的。
· GC中的STW问题
垃圾收集器在回收对象内存的过程中,总是需要挂起所有的用户线程(即STW,stop the world),以避免GC线程在回收时对象的引用关系还在不断变化导致回收结果不准确。但是STW可能会因GC时间过长而使得用户线程长时间的停顿,这对追求响应速度的程序来说将是令人难以接收的。Go GC的目标就是尽量减小STW的时间,以使得程序能够获取最大限度的响应速度。
Go GC线程与用户线程是并发的,其过程可以分为如下四个阶段:
■ Sweep termination:清理掉意外遗留的span内存块,只有上一次的GC清除工作完成了才能开始下一次GC。
■ Mark:
1)初始标记,需要STW。准备GC Roots对象的扫描、开启写屏障等。
2)并发标记,GC Roots到所有的对象的可达性分析,采用三色标记法。
■ Mark termination:
重新标记,需要STW。重新扫描部分GC Roots对象,修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。
■ Sweep:
根据标记结果并发清除,回收内存。
可见,在初始标记以及重新标记期间仍然存在STW问题。Go GC虽然没有完全消除STW,但在整个GC回收周期中,已将STW局限在有限的2个阶段,这让程序实时性有了很大改善。
·GC触发时机一览
Go的堆内存没有分代,每次GC时都要回收整个堆中的对象。
上文我们只对Go与Java在内存管理方面做了一个简要的比较分析,其中还有很多方面以及细节并未展开或涉及。我们看到了Go与Java在内存管理上采用的不同策略所带来的一些影响:Java的分代内存管理支持各个内存代选择合适的GC算法实现,通常GC只需要对某一个内存代进行回收;Go对整块堆内存分层级管理,GC时就不得不扫描整块区域。Java与Go为了实现对象空间的快速分配,都为线程分配了一块私有堆内存。Java分配确定的大小对象内存,回收时更容易造成堆内存碎片问题。Go按size class的分块对象内存模式虽然在重用性上得到了改善,但是又造成了一些浪费。可见Go与Java 在内存管理上各有特点。
Go语言社区活跃,生态也在逐渐繁荣。Go与Java各有优势,两者之间也有许多可以相互借鉴之处,因此它们未来的发展也非常值得期待。