微信黑科技
hi
这是 dhl
的第 82 篇文章个人微信: hi-dhl
Hi 大家好,我是 DHL,就职于 美团、快手、小米。公众号:ByteCode ,专注有用、有趣的硬核原创内容,Kotlin、Jetpack、性能优化、系统源码、算法及数据结构、大厂面经
我一直认为技术是用来服务于用户,提升用户体验,而不是像 拼多多 那样,利用技术做一些不好的事,今天这篇文章主要分享微信如何利用黑科技减少 512MB 内存,从而降低 OOM 和 Native Crash 提升用户体验。
在上一篇文章 谁动了我的内存,揭秘 OOM 崩溃下降 90% 的秘密 中分享了内存相关的知识点,包含堆、虚拟内存、发生 OOM 的原因,以及 为什么虚拟内存不足主要发生在 32 位的设备上、导致虚拟内存不足的原因都有那些,目前都有哪些黑科技帮助我们去降低 OOM,有兴趣的小伙伴可以前往查看,从这篇文章开始细化每个知识点。
随着业务的增长,32 位设备上虚拟内存不足问题会越来越突出,尤其是大型应用会更加明显。除了业务上的优化之外,还需要一些黑科技尽可能降低更多的内存,而今天这篇主要分析微信分享的「堆空间减半」的方案,最高可减少 512MB 内存,从而降低 OOM 和 Native Crash,在开始之前,我们需要介绍一下 堆 相关的知识点。
根据 Android 源码中的解释,Java 堆的大小应该是根据 RAM Size
来设置的,这是一个经验值,厂商是可以更改的,如果手机 Root 之后,自己也可以改,Google 源码的设置如下如下图所示。
https://android.googlesource.com/platform/frameworks/native/+/master/build
RAM (MB)-dalvik-heap. mk | heapgrowthlimit (MB) | heapsize (MB) 需要设置 android: largeHeap 为 true |
---|---|---|
512-dalvik-heap. mk | 48 | 128 |
1024-dalvik-heap. mk | 96 | 256 |
2048-dalvik-heap. mk | 192 | 512 |
4096-dalvik-heap. mk | 192 | 512 |
6144-dalvik-heap. mk | 256 | 512 |
无论 RAM 多大,到目前为止堆的最大上限都是 512MB |
正如上面表格所示,在 AndroidManifest.xml
文件 Application
节点中设置 android:largeHeap="true"
和不设置 largeHeap
获取到的最大堆的上限是不一样的。
android:largeHeap="true">
</application>
为什么默认关闭 android:largeHeap
Java 堆用于分配 Java / Kotlin 创建的对象,由 GC 管理和回收,GC 回收时将 From Space 里的对象复制到 To Space,这两片区域分别为 dalvik-main space
和 dalvik-main space 1
, 这两片区域的大小和 Java 堆大小一样,如下图所示。
图中我们只需要关注 size(虚拟内存) 即可,如果 Java 堆的上限是 512 MB,那么 dalvik-main space(512 MB)
和 dalvik-main space 1(512 MB)
共占用 1G 的虚拟内存。
如果堆的上限越大,那么 main space
占用的虚拟内存就会越大,在 32 位设备上,用户空间可用虚拟内存只有 3G,但是如果堆上限是 512MB,那么 main space
总共占用 1G 虚拟内存,剩下只有 2G 可用,因此 Google 在默认情况下会关闭 android:largeHeap
选项,只有在有需要的时候,主动设置 android:largeHeap = true
,尝试获取更大的堆内存。
而 main space
占用虚拟内存的计算方式是不一样的。
Android 5. x ~ Android 7. x
如果设置
android:largeHeap = true
,main space size = dalvik.vm.heapsize
,如果heapsize
是 512MB,那么两个 main space 共占用 1G 虚拟内存如果不设置
largeHeap
,那么main space size = dalvik.vm.heapgrowthlimit
,如果heapgrowthlimit
是 256 MB,那么两个 main space 共占用 512 MB 虚拟内存
>= Android 8. x
无论 AndroidManifest 是否设置 android:largeHeap
,main space size = dalvik.vm.heapsize * 2
,如果 dalvik.vm.heapsize
是 512MB 那么 main space
占用 1G 的虚拟内存内存。
main space
在不同的系统分配方式是不一样的。
在
Android 5.x ~ Android 7.x
中,系统分配两块main space
,它们占用虚拟内存的大小和堆的大小是一样的在
>= Android 8.x
之后,只分配了一个main space
,但是它占用虚拟内存的大小是堆的 2 倍
如果我们能释放其中一个main space
,可以减少一半 (最高512MB) 内存,但是在不同的系统上,它们的实现方式是不一样的,所以我们要采用不同的方法来释放 main space
占用的内存。
在 Android 5. x ~ Android 7. x
5.0 之后使用的是 ART 虚拟机,在 ART 虚拟机引入了,两种 Compacting GC
分为 Semi-Space(SS)GC
(半空间压缩) 和 Generational Semi-Space(GSS)GC
(分代半空间压缩)。GSS GC
是 SS GC
的改进版本,作为 background GC
的默认实现方式。
这两种 GC 的共同点,存在两片大小和堆大小一样的内存空间分别作为 From Space
和 To Space
,这两片区域分别为 dalvik-main space1
和 dalvik-main space2
。
上面的这两块区域对应的源码 地址。
https://cs.android.com/android/_/android/platform/art/+/5f0b71ab2f60f76b5f73402bd1fdd25bbc179b6c:runtime/gc/heap.cc
执行 Compact / Moving GC
的时候才会使用到这两片区域,在 GC 执行期间,将 From Space 分配的还存活的对象会依次拷贝到 To Space 中,在复制对象的过程中 From Space 中的碎片就会被消除,下次 GC 时重复这套逻辑,但是 GSS GC 还多了一个 Promote Space
。
Promote Space
主要存储老年代的对象,老年代对象的存活性要比新生代的久,因此将它们拷贝到 Promote Space
中去,可以避免每次执行 GSS GC 时,都需要对它们进行无用的处理。
新生代和老年代采用的不同的算法:
新生代:复制算法。在两块 space 来回移动,高效且执行频繁,每次 GC 不需要挂起线程
老年代:标记-压缩算法。会在 Mark 阶段是在挂起除当前线程之外的所有其它运行时线程,然后在 Compact 阶段才移动对象,Compact 方式是 Sliding Compaction,也就是在 Mark 之后就可以按顺序一个个对象 “滑动” 到空间的某一侧,移动的时候都是在一个空间内移动,不需要多一份空间
如何释放掉其中一个 main space 占用的内存
释放方案,可以参考腾讯开源的方案 Matrix,总来的来说分为两步:
https://github.com/Tencent/matrix/blob/master/matrix/matrix-android/matrix-hooks/src/main/cpp/memory/GCSemiSpaceTrimmer.cpp
确定
From Space
和To Space
的内存地址调用
munmap
函数释放掉其中一个Space
所占用的内存
如何确定 From Space 和 To Space 的内存地址
我们需要读取 mpas 文件,然后搜索关键字 main space
和 main space 1
,就可以知道 main space
和 main space 1
的内存地址。
当我们知道 space
的内存地址之后,我们还需要确认当前正在使用的是那个 space
,才能安全的调用 munmap
函数,释放掉另外一个没有使用的 space
。
matrix 的方案,创建一个基本类型的数组,然后通过 GetPrimitiveArrayCritical
方法获取它的地址,代码如下:
调用 GetPrimitiveArrayCritical
方法会返回对象的内存地址,如果地址在那块区域,当前的区域就是我们正在使用的区域,然后我们就可以安全的释放掉另外一个 space 了。
释放掉其中一个 Space 会有问题吗?
如果我们直接释放掉其中一个 Space,在执行 Compact / Moving GC
的时候,需要将 From Space 分配的对象依次拷贝到 To Space 中,因为找不到 To Space,会引起 crash, 所以需要阻止 Moving GC
。
源码中也说明了调用 GetPrimitiveArrayCritical
方法可以阻止 Moving GC。
GetPrimitiveArrayCritical
方法会调用 IncrementDisableMovingGC 方法阻止 Moving GC
,对应的源码如下。
https://android. googlesource. com/platform/art/+/master/runtime/gc/heap.cc#956
void Heap::IncrementDisableMovingGC(Thread* self) {// Need to do this holding the lock to prevent races where the GC is about to run / running when
// we attempt to disable it.
ScopedThreadStateChange tsc(self, kWaitingForGcToComplete);
MutexLock mu(self, *gc_complete_lock_);
++disable_moving_gc_count_;
if (IsMovingGc(collector_type_running_)) {
WaitForGcToCompleteLocked(kGcCauseDisableMovingGc, self);
}
}
所以只需要调用 GetPrimitiveArrayCritical
方法,阻止 Moving GC
,也就不需要用到另外一个空间了,因此可以安全的释放掉。
阻止 Compact / Moving GC 会有性能问题吗
按照微信给出的测试数据,在性能上没有明显的变化。
OS Version >= Android 8. x
8.0 引入了 Concurrent Copying GC
(并发复制算法),堆空间也变成了 RegionSpace。RegionSpace 的算法并不是靠把已分配对象在两片空间之间来回倒腾来实现的,分析 smaps 文件,发现也只创建了一个 main space
,但是它占用的虚拟内存是堆的 2 倍,所以 8.0 之前的方案释放另外一个 space 是无法使用的。
为什么没有创建 main space2
我们从源码看一下创建 main space2 的触发条件。
if (foreground_collector_type_ == kCollectorTypeCC) {use_homogeneous_space_compaction_for_oom_ = false;
}
bool support_homogeneous_space_compaction =
background_collector_type_ == gc::kCollectorTypeHomogeneousSpaceCompact ||
use_homogeneous_space_compaction_for_oom_;
if (support_homogeneous_space_compaction ||
background_collector_type_ == kCollectorTypeSS ||
foreground_collector_type_ == kCollectorTypeSS) {
ScopedTrace trace2("Create main mem map 2");
main_mem_map_2 = MapAnonymousPreferredAddress(
kMemMapSpaceName[1], main_mem_map_1.End(), capacity_, &error_str);
}
正如如源码所示,后台回收器类型 kCollectorTypeHomogeneousSpaceCompact
和 kCollectorTypeCC
才会创建 main space2
。
kCollectorTypeHomogeneousSpaceCompact
(同构空间压缩(HSC),用于后台回收器类型)kCollectorTypeCC
(Compacting GC
) 分为两种类型Semi-Space(SS)GC
(半空间压缩)Generational Semi-Space(GSS)GC
(分代半空间压缩),GSS GC
是SS GC
的改进版本
而 Android 8.0 将 Concurrent Copying GC
作为默认方式,对应的回收器的类型是 kCollectorTypeCCBackground
。
Concurrent Copying GC
分为 Pause
, Copying
, Reclaim
三个阶段,以 Region
为单位进行 GC,大小为 256 KB。
pause: 这个阶段耗时非常少,这里很重要的一块儿工作是确定需要进行 GC 的 region, 被选中的 region 称为
source region
Copying:这个阶段是整个 GC 中耗时最长的阶段。通过将 source region 中对象根据 root set 计算并标记为 reachable,然后将标记为 reachable 的对象拷贝到
destination region
Reclaim:在经过 Copying 阶段后,整个进程中就不再存在指向 source regions 的引用了,GC 就可以将这些 source region 的内存释放供以后使用了。
Concurrent Copying GC
使用了 read barrier
技术,来确保其它线程不会读到指向 source region
的对象,所以不会将 app 线程挂起,也不会阻止内存分配。
如何减少 main space 占用的内存
Adnroid 8.0 之后使用的阿里巴巴 Patrons 的方案,在虚拟内存占用超过一定阈值时调用 RegionSpace 中的 ClampGrowthLimit
方法来缩减 RegionSpace 的大小。
但是 ClampGrowthLimit 只在 Android 9.0 以后才出现,8.0 是没有的,所以参考了 Android 9.0 的代码实现了一个 ClampGrowthLimit。
在 ClampGrowthLimit 方法中,通过调用 MemMap::SetSize
方法来调整 RegionSpace 的大小。
https://android. googlesource. com/platform/art/+/5f0b71ab2f60f76b5f73402bd1fdd25bbc179b6c/runtime/gc/space/region_space.cc#416
MemMap::SetSize 方法的实现。
https://android. googlesource. com/platform/art/+/android-9.0.0_r7/runtime/mem_map.cc#883
new_base_size_
和 base_size_
不相等的情况下会执行 munmap
函数 , munmap
释放的大小为 base_size_
和 new_base_size_
的差值。
全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎 在看、点赞、分享 给身边的小伙伴,我会持续分享原创干货!!!
推荐阅读:
Hi 大家好,我是 DHL,就职于 美团、快手、小米。公众号:ByteCode ,分享有用、有趣的硬核原创内容,Kotlin、Jetpack、性能优化、系统源码、算法及数据结构、动画、大厂面经,欢迎关注我。
哔哩哔哩:https://space.bilibili.com/498153238
掘金:https://juejin.im/user/2594503168898744
博客:https://hi-dhl.com
Github:https://github.com/hi-dhl
👇🏻 真诚推荐你关注我👇🏻
因微信公众号更改了推送机制
可能无法及时看到最新文章
将公众号设为 星标
或常为文章点 在看
即可及时收到最新文章
欢迎前往 博客 查看更多 Kotlin、Jetpack 、动画算法图解、系统源码分析等等文章。以及开源项目、LeetCode / 剑指 offer / 国内外大厂面试题 / 多线程 题解。
https://www.hi-dhl.com