愿历尽千帆 归来仍少年

第三视角: 一个ART GC的优化故事

字数统计: 3,589阅读时长: 13 min
2021/11/01

前言

从事过整机性能优化的同事,在实际项目上相信都遇到过GC引起的性能问题。有了上一篇文章的铺垫,阅读本文将会比较轻松。
笔者在梳理GC这块的代码过程中,深感其复杂并非一蹴而就,如果你对其某处代码的设计缘由不甚理解,不妨试着去追踪它的提交记录。
通过其一系列的patch更新以及comment,跟着提交owner的思路历程,便可以了解这个”成品”是如何被加工出来的。

故事的缘起

笔者在梳理GC这块的代码时,偶然看到一处友商的提交,不禁有点好奇。为了探究其修改的缘由,仔细阅读了这笔修改的提交更新以及所有的comment之后,觉得其中一些思路对我们有启发作用,故而决定将这个思路过程尽可能的还原出来。
PS: 友商员工技术之扎实令人印象深刻,采用第三视角纯粹是为了提升趣味性,本文无任何戏谑意思

1
2
3
主角: 卢卡斯(谷歌员工),大壮(友商员工)    
配角: 汉斯(谷歌员工)
旁白: 笔者

故事开始

大壮在国内一家手机厂商搬砖,负责整机性能方面的研究。最近大壮有点闷闷不乐,项目上一些GC相关的性能问题困扰了他许久,具体是什么GC问题呢?
大壮通过不限于Systrace等手段,发现在一些场景下比如后台运行较多应用时,GC会消耗较多CPU资源,加剧系统负担,表现出的现象就是更加卡顿了。
于是,自信上进的大壮开始着手调研该如何解决这个问题,最终有了一个方案:
既然GC会在后台运行较多应用时争抢CPU,那么在CPU负载高的时候降低GC的触发,CPU负载低的时候再恢复,这不就可以了吗?

  1. 怎么降低GC频率呢?Multiplier机制。
  2. 怎么统计CPU负载呢?大壮看了下代码中原本就有GC期间占据的CPU数据。

看起来小菜一碟嘛,熟悉GC流程代码的大壮很快就做出了第一笔修改。

修改Multiplier

考虑到HeapGrowthMultiplier这个接口是谷歌原有的,存在多处会调用,为了不影响其他调用,贴心的大壮加了一个单独的接口HeapGrowthMultiplierExt, 这个接口最终会调用HeapGrowthMultiplier,只是在此之前会有一些判断条件。比如是否处于亮屏,不同的radio下返回不同的Multiplier值。
通过前一篇文章的分析,我们知道Multiplier的改变会引起下次GC水位的提升,总的而言,值越高GC的触发频率就会更低。
runtime/gc/collector/garbage_collector.cc
图片
runtime/gc/heap.cc

图片

降低GC转换的次数

图片
前后台切换会导致GC转换,大壮心想有一些本身占据堆内存就比较小的进程,他们很多时候的GC转换带来的收益并不明显。
特别是内存比较充足的项目这种GC转换似乎有点多余,那么通过判断堆使用大小,去掉一些”不必要”的GC转换,这样就可以降低系统负担。

旁白:
为了可拓展性,尽可能的不要引入硬编码,即便你是通过大量数据实测出来的最优值,除非是一些约定俗成的值。提到硬编码这种情况,在实际项目上经常出现,很多时候这种代码的引入,是由于Reviewer的疏忽没有发现或者说默许了。

谷歌Review

很快,大壮的提交修改引起了卢卡斯的注意,看完了大壮的修改后,卢卡斯脑袋上顶着大大的问号。
对于如下大壮添加的计算gc期间CPU负载的做法。

1
2
uint64_t gc_cpu_time = thread_cpu_end_time - thread_cpu_start_time;  
float ratio = static_cast<float>(gc_cpu_time) / duration_ns;

卢卡斯认为这只是本次GC周期内的CPU负载情况,且这个值在绝大部分时候都应该是接近1的,并不能说明未来一段时间的CPU情况。
另外,GC等待checkpoint或者其他原因都可能会导致GC等待,但是这些等待的时间不会计入gc_cpu_time中,所以这种计算方式只能说一定程度上可能暗示了当前的负载情况,但是远远达不到可靠的地步。

旁白:
注意这里谷歌提到了未来的CPU使用情况,是因为这个比例值是本次GC周期计算的,你可以理解为是一个瞬时值,但是抑制GC的情况是需要等到
下次GC触发才会发生的,是一个未来发生的事情,那么等到下次GC的时候,本次GC算出的负载可能就不适用了。

大壮:
大壮看完大G的回复之后,虎躯一震,这修改。。怕是要凉啊!于是大壮赶紧回复到:
在一些性能较差设备上,如果后台开启很多应用,很多时候这个比例值会低于0.5甚至触及0.2。
另外,我们做过功耗测试,这修改后的效果杠杠的,改善很明显(使用时间增加了18分钟)。

卢卡斯:
看完大壮理直气壮的回复后,卢卡斯回复到:这里亮屏的判断,并根据不同的radio返回不同的Multiplier,这个修改同样会作用到所有系统进程(如system_server、systemui等),作用于
系统进程是一种错误的行为。还有根据不同的radio即你认为的代表负载情况,返回不同的Multiplier值。这些Multiplier值你是怎么得出来的?
比如你上面写的当ratio < 0.3时,返回Multiplier 6,这个值6怎么来的?为何不是返回8?为啥不是9?为啥不是一个10000000000?(谷歌没说这句话)。

1
2
3
4
5
6
7
8
9
10
11
if (screenOn) {
float ratio = current_gc_iteration_.GetRunningRatio();
if (!CareAboutPauseTimes()) {
if (ratio < 0.3) return 6.0;
if (ratio < 0.5) return 4.0;
if (ratio < 0.7) return 2.0;
} else {
if (ratio < 0.3) return 7.0;
if (ratio < 0.6) return 5.0;
}
//....

旁白:给谷歌提代码一定要避免提交这种硬编码

大壮:
此时的大壮,刚从食堂吃完午饭回来的路上,一路上大壮都在和同事吐槽食堂的饭菜又贵又难吃。
坐到座位上,看到卢卡斯的连环追问,大壮有点发蒙。这些数据我经过一些性能和功耗测试,是有帮助的,既然你们提出来这样的修改不合理,那么请告诉我,还有没有其它办法可以降低高负载情况下GC的频率。

卢卡斯:
此时的卢卡斯正喝着咖啡,窗外阳光正明媚,卢卡斯回过头和旁边的汉斯说到,这天气不去钓鱼真是浪费生命啊,汉斯点头如蒜捣,没错没错。
这个时候,卢卡斯看了下时间快4点了,准备收拾下班,这个时候看到了大壮的comment,回复到:
在堆使用较少的情况下不进行GC我觉得有意义,这种情况下我认为是可以直接跳过GC转换的,而不是引入进程状态切换次数这个变量。

旁白:谷歌的这句话,在一些场景下跳过GC更有意义,正是这句话改变了修改的方向

卢卡斯旁边的汉斯看到了大壮的这段回复,心里OS: 上一次这么无语还是在上一次, 忍不住回复到:
卢卡斯,大壮的这种改法让我感觉也很不舒服,也许我们在一些场景下GC的次数有点多了,但是能不能用更合理的方式来解决这个问题呢?比如依据自上次GC以来分配的字节数作为依据, 对于某些应用程序来说,转换可能会频繁发生,并且每次执行GC并不总是可取的。
用比例值或许更合理?例如:“如果我们已经消耗了超过30%的free heap,我们将在转换时GC”。

旁白:汉斯的这句话奠定了这个修改的基本方向,依据自上次GC以来的分配数值作为依据,采用比例的方式,不用管这个进程的大小。

大壮:
此时的大壮正在工位上,跟旁边的测试妹纸炫耀自己昨天一口气钓了十几斤的鲫鱼,妹纸看大壮的眼神满是崇拜。大壮说的有点渴了,坐下来喝口水时,看到了卢卡斯的回复,思考片刻后,觉得谷歌提出的修改建议确实更加合理。
于是讲起了之前遇到的案例:
最早是我们遇到的一个Launcher的卡顿例子,同一uid下的5个进程同时在后台执行collectorTransionGc,导致系统负载过高。四个子进程的堆大小都小于5MB,不信我给你们看Systrace。对于这种内存消耗很小的进程,真的大可不必每次都GC,所以我们希望能够限制这种类型的collectortransiongc。因为从实际用户的GC数据中可以发现,这种类型的CollectorTransitionGC的总量非常大。

卢卡斯:
卢卡斯觉得不应该仅限于小内存进程,应该一视同仁,所以卢卡斯觉得基于堆的大小进行限制不是太好。卢卡斯想起了汉斯提出的建议: 根据自上次GC以来分配的大小作为跳过GC转换的条件。
汉斯的这个建议确实更加合理一些,这样的话,所有的应用都有机会跳过一些”不必要”的GC。卢卡斯想了下,与其跳过一些转换GC,不如让它更简单,比如: 如果自上次GC以来的分配小于3MB,GC转换时跳过GC。

旁白:
卢卡斯认同汉斯的通过自上次GC以来分配的大小作为依据,但是忽略了汉斯提出另一个意见,那就是通过堆空闲内存的使用率来作为触发条件,这里卢卡斯提出的小于3M就不进行GC,也正是这句话给了大壮一些误导。

大壮:
听完卢卡斯说的堆使用小于3M就不进行GC转换后,大壮进行了一次修改,加了一个阈值

1
static constexpr size_t kDefaultTransitionThreshold = 5 * MB;

并且在GC转换的地方加了一个判断,如果低于这个阈值,就跳过本次的GC转换
图片

卢卡斯:
看完大壮的修改后,卢卡斯后悔了,这里搞个固定的阈值看起来并不合适。因为如果这个进程是一个占用内存比较小的进程,那么5M对于这个进程来说并不容易达到,这会导致GC的触发频率被大幅降低,导致这个进程的堆大小”不合适”的增大。
这个时候卢卡斯想起了汉斯的通过比例的建议,没错,通过比例更合理一些,于是卢卡斯提出了: 上次GC后新增大小如果小于
UnignedDifference(target_footprint_.load(std::memory_order_relaxed)- num_bytes_alive_after_gc_)/4 话,则跳过本次GC转换,并且对于LowMemoryMode设备避免跳过GC

旁白:卢卡斯的这番话基本成形了这个修改,即消耗没有达到堆空闲的1/4,此时有GC转换请求的话,则直接丢弃。

大壮:
看完卢卡斯的建议后,大壮开始着手进行了修改,关于如何计算GC后新分配的值。
大壮想到了在GC后算出已分配的减去GC前计算出的已分配大小,修改完之后再次更新了patch,很快卢卡斯在大壮的修改下加了comment
图片

旁白:这是并发GC的特色所在,分配的同时可能伴随着GC,所以需要考虑GC期间释放的空间大小。

最终修改思想

  1. 进程状态变化触发GC转换
  2. 计算出自上次GC后新分配字节大小,由于并发缘故,需考虑GC期间释放字节数。
  3. 堆可增长上限减去自上次GC后已分配数值,得出的值即可增长空间,取其四分之一作为分界线
  4. 上次GC后新分配字节数值如果小于第三步算出的可增长空间的1/4,则跳过本次GC转换请求。

写在最后

谈到设计思想,笔者想起一句话:Talk is cheap. Show me the code!
每次看到这句话,都感觉被无端斥责了一番。
笔者曾经特地查询了下Linus说这句话的语境背景,最后得出结论Linus说这句话时,其实是在一个讨论回复中,结合当时的上下文语境,Linus说出这句时明显是带有情绪的,但是奇怪的是这句特定语境下的话,在国内却被大肆宣传甚至被一些人奉为经典。
笔者在实际项目上见到过太多shit一样的代码,盲目的追求代码数量,粗制滥造毫无设计可言,更不要说代码规范这些基础原则了,阅读起来你都恨不得砸了键盘。
如果你很崇尚“show me the code”,给你这样一堆dog shit代码,你能从中看出什么呢?
并不是盲目崇拜谷歌,笔者在阅读谷歌代码的提交记录过程中,能够感受到其思维之严谨,考虑之周到,修改之谨慎。所以推荐大家空闲的时候,可以多阅读谷歌的修改记录。
最后多说一句,笔者粗浅的认为,相比于最终的成品代码,当然前提是优秀的代码。笔者认为其思路旅程也很重要,因为是人的思想最终孕育出这块美丽的代码。


只要不给社会添麻烦,做一个废柴并不丢人

CATALOG
  1. 1. 前言
  2. 2. 故事的缘起
  3. 3. 故事开始
    1. 3.1. 修改Multiplier
    2. 3.2. 降低GC转换的次数
  4. 4. 谷歌Review
  5. 5. 最终修改思想
  6. 6. 写在最后