香雨站

 找回密码
 立即注册
搜索
热搜: 活动 交友 discuz
查看: 100|回复: 20

Java为什么比Go消耗更多内存?

[复制链接]

2

主题

3

帖子

7

积分

新手上路

Rank: 1

积分
7
发表于 2023-4-21 09:53:00 | 显示全部楼层 |阅读模式
  Java比Go语言消耗更多的内存基本成为了一个共识,这是一个实践自然发现的事实。但是要说明其原因,却需要非常多细致的工作。,很多人归结于Java框架臃肿,比如spring boot的依赖就是非常多的,但是框架的臃肿是Java内存消耗巨大的核心因素吗?如果只是框架的问题,那么我们去实现或者等待别人实现一个更优质的框架即可。
  我怀着探究的态度去看来language benchmark网站上对语言的通用内存消耗对比,我简单整理了一个10种不同场景下Go和Java的内存消耗对比如下:



Java和Go语言在language benchmark的内存消耗对比


  我们发现除了reverse-complement场景,其他9个场景下都是Go语言大幅度领先于Java语言。那么我们基本得到一个实践事实:在一般场景和现代后端微服务场景下,Java比Go语言消耗更多的内存。
  有了事实,我们更想探究这个事实背后的因素。主要是要厘清为什么会发生这样的情况,这种情况究竟是短暂的,还是本质无法改善的?接下来,我接下来就一一拆解这背后因素。
(一)JIT和AOT的架构差异
  一个很显然的观点是认为Java运行态中包含了一个完整的解释器、一个JIT编译期以及一个垃圾回收器,这会显著地增加内存。Go语言直接编译到机器码,运行态只包含机器码和一个垃圾回收器。显然Go的运行态更小。


  这是一个正确的观点,但是却不是Java程序内存消耗显著大于Go程序的主要因素。事实上Java经过多年的优化和调整,解释器和JIT消耗的内存显著小于很多人的预期。我在我的笔记本上尝试了一个最小的Java可运行程序,就是一个for循环无限等待,其内存消耗如下:


  在不同的平台和机器上,最小的Java内存消耗可能变化,但是总体这部分的大小并不足以成为Java程序内存消耗几倍于Go程序的主导因素。
(二)面向对象的内存布局和面向值的内存布局
  Java是一种面向对象的语言,万物皆对象,这个重要的抽象原则是要付出一定的代价的。最重要的代价就是每个对象需要一个对象头,简单的示意图如下:


  也就是说每个Java的对象都要包含一个96 bits的对象头,比如一个32bits的integer占用的内存是多少呢?答案是96bits+32bits=128bits。
  那么Go语言每个对象消耗多少呢?首先,Go没有对象,Go不是一个面向对象的语言,Go是面向值的语言。这里简化掉Go语言中基于span的内存管理机制细节讨论,我们给出直观的结论是每一个存储到span中的值至少需要消耗2bits用于内存分配和垃圾回收管理:


  显然,Go在选择不支持面向对象时,选择了一种更加简单的内存布局方式,这种内存布局方式减省了内存消耗。
(三)垃圾回收机制导致的内存利用率不同
  Java的垃圾回收机制有着悠久的历史,Java的垃圾回收机制某种意义上就是现在垃圾回收机制的发展史,我们简单看看Java的垃圾回收机制历程,大致如下:


  其中我们真正值得关注的应该是ParallelGC,G1和ZGC这3种,ParallelGC是广泛使用的JDK8的默认垃圾回收器,G1是JDK9以后的默认垃圾回收器,ZGC是未来的默认垃圾回收器。
  我们先简单看看ParallelGC的机制,这里不打算讨论每个细节,但是需要对这套垃圾回收机制有个大概的认知,方便我们后续的讨论,这套机制下把堆内存按照如下图所示区域划分:


  主要来看是划分出了新生代和老年代,其运行机制如下图所示:


  在这套管理机制下,我们知道新生代的To区域一定要是空置的,按照正常默认的JDK配置参数,1/3的堆内存是新生代,新生代中1/8的区域是To区域,综合而言也就是说至少有1/24的内存区域是需要一直空置浪费的。
  我们再看看新的默认垃圾回收器G1,G1显著转变了区域的划分,转而使用一种基于region的管理策略,简单而言就是把内存等分成不同region,每个region在不同时刻扮演eden、survivor、old、humongous这4种不同的角色。这种划分下,理论上只要任意时刻有一个region是空置的就能够进行垃圾回收,这个比例极大概览小于1/24,所以G1的内存利用率更高。


  对于Java,我们最后看看下一代垃圾回收器ZGC,ZGC目前没有基于分代实现,而是显式地把内存划分成不同大小的块,不同大小的对象从不同块上去分配内存,如果我们对比后续介绍的Go的内存管理方式,你会发现2者有着一定的共通之处。当然需要指出的是,这种不同大小对象从不同地方分配的策略最早起源自C语言的各种内存分配器,只是慢慢 开始被垃圾回收器吸取精华思想。ZGC下的内存布局如下图所示:


  综合而言,Java在发展到G1和ZGC之后,内存的利用率已经显著增加了很多,就空间利用率而言甚至某些情况下甚至是高于Go语言的,所以垃圾回收器不是Java的短板。
  我们再来看看Go语言的垃圾回收机制,这里也不会讨论Go垃圾回收的所有细节,仅仅针对内存布局来聊聊,首先我们要明确Go语言的内存管理是基于span的,简单而言一个span就是一组大小固定的空槽,每有一个size小于等于这个空槽的值申请内存,就分配一个空槽给这个值。


  我小心地避免了使用对象这个词,因为Go语言不是面向对象的,Go是面向值的语言。这种基于span和空槽(slot)的管理方式,显然会浪费内存,但是能够显著简化垃圾回收器的设计,同时也能够简化辅助内存的使用,事实上我们提到过,Go只需要使用2bits就能够跟踪和管理每个值在垃圾回收器中的状态。
  综合而言,在垃圾回收领域,Java目前在某种程度上还是领先于Go的,至少在垃圾回收器的内存利用率上,Java目前是优于Go的,Go的span管理方式简化了内存管理,增加了内存浪费率。
(四)栈的利用效率
  这一点可能是很少有人提及的一点,但是我却认为非常重要的一个因素,就是Go语言倾向于优先把可能的值分配在栈上,这样随着函数的调用和返回,许多临时的小对象就会被释放,Java虽然也有逃逸分析,但是做得更加保守,远远没有达到栈优先的地步。这和Go是面向值的策略非常相关,面向对象的语言做逃逸分析要相对复杂一些,一些不敢保证正确性的场景下就会选择保守策略。
(五)并发模型的区别
  Java的并发模型是基于线程的,每个线程默认需要消耗1MB左右的内存。Go的并发模型是基于coroutine协程的,每个协程默认消耗2KB(不足时可扩展)的内存。在后端微服务场景下,通常需要创建大量的线程/协程来处理高并发。比如我们设定系统处理能力为100个并发(并发和并行需要区分看待),那么Java程序需要100个线程,消耗100MB左右,而相同并发情况下Go程序需要100个协程,消耗200KB左右。
(六)反射机制和框架实现策略
  最后一个不得不提的点就是框架的实现策略,其中最为重要的就是反射和hashmap的使用,Java的框架实现中大量使用反射,并使用hashmap缓存信息,这2个都是极度消耗内存的行为。要想佐证这个观点,只需要启动一个最流行的spring boot应用,写一个hello world接口,然后通过对象分析,看看hashmap对象有多少个,以及reflect相关对象的数量就可以映证了。
  反射是某种意义上的元编程,几乎是框架实现的必经之路,Go的框架就不使用反射来编写框架吗?就不使用map缓存相关信息吗?答案是Go的框架中也使用reflect,当然也使用map。但是Go又占了面向interface和值的便宜,Go的反射模型要比Java的反射模型简单非常多,反射过程要产生的对象数量也少非常多。
  全篇总结而言,在内存利用效率上,Go语言确实比Java做得更好,在6个不同的角度来总结:
(1)Java的JIT策略比Go的AOT策略,在运行时上多占用了一些内存
(2)Java的面向对象抽象策略比Go的面向值的抽象策略在每个对象/值上多消耗了内存
(3)内存分配和垃圾回收器上,Java目前在内存利用率上领先
(4)在栈的利用方面,Go语言做得比Java更加激进
(5)并发模型上,协程模型比线程模型更加节省内存。
(5)Go的反射更加简单,导致框架的内存消耗上Go程序比Java程序优秀。
  协程这种用户态的调度模型在并发上的优越性已经被各种语言证明,当然更多语言选择的是async/await语法来支持协程。Java自己也意识到了这个问题,开启了loom project来添加相关支持,只是这个项目的进度远远慢于人们的预期。相信将来的某一天Java也能够用上协程,但是短期内在这个领域还是Go更为领先。
  多说几句题外话,我们发现Go在内存消耗上的领先,一大部分源自其砍掉了面向对象。原来的软件思潮里面,面向对象几乎就是正统的皇皇大道,但是后来我们慢慢发现继承也许不是一个好的抽象,很多时候是组合优于继承。如果把继承砍掉,那么面向对象的概念几乎也就不再那么必要了。
  如果面向对象没有带来本质上的概念优越性,又带来了许多不必要的负担和麻烦,那么可能面向对象就不是编程的必须思想。例如现在在内存利用率上看到的情况,基本都是由于Go的值模型更加简单才占到了便宜。
  我们发现在Go中利用struct和interface也能很好地抽象客观世界。另外一个编程语言界新星Rust也抛弃了面向对象的概念,在Rust中利用struct和trait抽象客观世界,也没有发现明显的短板和无法逾越的逻辑难题。Go之于Java,Rust之于C++,新生力量都不约而同的选择了砍掉面向对象,也许有时候适当做减法也是一种更大的智慧。
  那么面向对象所声称的“对象 = 数据 + 方法”是否是正确的呢?我们是否只需要孤立的值和方法?就像C语言那样,只要值和方法就足够了?我个人的观点是,数据和方法的紧密结合仍然是必要的,面向对象中多余的概念可能就是继承,没有继承的面向对象可能是一个更加优秀的选择。就像Rust中一样,struct上依然可以有方法,但是没有继承的概念。也许将来的某一天,我们像本文一样审视C++和Rust,我们说不定会发现Rust的大部分优势都是源自于砍掉了复杂的面向对象继承机制。
回复

使用道具 举报

0

主题

1

帖子

0

积分

新手上路

Rank: 1

积分
0
发表于 2023-4-21 09:53:18 | 显示全部楼层
有理有据
回复

使用道具 举报

0

主题

4

帖子

6

积分

新手上路

Rank: 1

积分
6
发表于 2023-4-21 09:54:09 | 显示全部楼层
  综合而言,在垃圾回收领域,Java目前在某种程度上还是领先于Java的
回复

使用道具 举报

1

主题

7

帖子

11

积分

新手上路

Rank: 1

积分
11
发表于 2023-4-21 09:54:53 | 显示全部楼层
分析得太棒了[赞同]
回复

使用道具 举报

0

主题

3

帖子

0

积分

新手上路

Rank: 1

积分
0
发表于 2023-4-21 09:55:11 | 显示全部楼层
写的不错
回复

使用道具 举报

1

主题

7

帖子

3

积分

新手上路

Rank: 1

积分
3
发表于 2023-4-21 09:55:45 | 显示全部楼层
看好zgc
回复

使用道具 举报

4

主题

8

帖子

18

积分

新手上路

Rank: 1

积分
18
发表于 2023-4-21 09:55:51 | 显示全部楼层
膜拜大佬
回复

使用道具 举报

1

主题

4

帖子

7

积分

新手上路

Rank: 1

积分
7
发表于 2023-4-21 09:56:43 | 显示全部楼层
第(四)部分前面,“Java目前在某种程度上还是领先于Java的”,这里笔误了?
回复

使用道具 举报

2

主题

3

帖子

7

积分

新手上路

Rank: 1

积分
7
发表于 2023-4-21 09:57:19 | 显示全部楼层
不多说,java垃圾,全民公敌,你们别搞java了,我自己搞就行了。[doge]
回复

使用道具 举报

1

主题

6

帖子

9

积分

新手上路

Rank: 1

积分
9
发表于 2023-4-21 09:58:18 | 显示全部楼层
简而言之,主要是spring等框架的错。Java有对象头,那go的interface{}占用的内存呢。在有一些jdk里对象头已经优化了。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Archiver|手机版|小黑屋|香雨站

GMT+8, 2025-7-4 07:53 , Processed in 0.189214 second(s), 23 queries .

Powered by Discuz! X3.4

© 2001-2013 Comsenz Inc.. 技术支持 by 巅峰设计

快速回复 返回顶部 返回列表