Android OutOfMemoryError原理解析

这篇文章我们直接来分析为什么我们的应用会抛出 OutOfMemoryError,以及哪些情况下会发生 OutOfMemoryError。 OOM的异常在java层只有 java,lang.OutOfMemoryError 这一个Throwable的定义,抛出这个异常的行为由jni层触发: Thread::ThrowmOutOfMemoryError Heap::ThrowOutOfMemoryError

快速理解的case

我们追溯一下哪些地方可能直接调用 Thread 的 ThrowOutOfMemoryError,先列举几个不常见不怎么需要深入去理解的case:

  • ti_class.cc MakeSingleDexFIle

这个地方发生在 ClassPreDefine 的时候

  • jni NewStringUTF:

通过jni的NewStringUTF分配字符串并且超出最大长度的时候。

  • 分配java String的时候,触发条件其实和jni类似,都是字符串太长去触发。
  • Java Unsafe分配内存失败

可以看到Unsafe是直接通过jni层malloc去分配内存的,失败了就扔oom出去。

  • Thread创建

Java Thread#start 的时候是通过 start -> nativeCreate -> Thread_nativeCreate -> Thread::CreateNativeThread -> pthread_create 执行的。 当 pthread_create 分配失败的时候,就会抛出一个 OOM:

最常见case:堆内存分配

OOM会在 Heap 的 AllocateInternalWithGc 里面抛出。所以我们需要接着上一篇文章再来看看我们的堆内存分配的步骤,在 Heap 的 AllocObjectWithAllocator 函数里会调用TryToAllocate函数去分配,如果分配失败会尝试gc后再重新分配: AllocateInternalWithGc里面的代码比较多,我不截图了,直接把每一步的关键步骤copy过来,我们大概能理解他的意思就行:

  1. 如果gc正在进行,那么等待gc结束
cpp 复制代码
if ((was_default_allocator && allocator != GetCurrentAllocator()) ||
      (!instrumented && EntrypointsInstrumented())) {
    return nullptr;
}
  1. gc结束,直接调用 TryToAllocate 分配一次内存,这里 KGrow 传false
cpp 复制代码
if (last_gc != collector::kGcTypeNone) {
    // A GC was in progress and we blocked, retry allocation now that memory has been freed.
    mirror::Object* ptr = TryToAllocate<true, false>(self, allocator, alloc_size, bytes_allocated,
                                                     usable_size, bytes_tl_bulk_allocated);
    if (ptr != nullptr) {
      return ptr;
    }
}
  1. 如果失败了,那么在调用一次gc,不清除软引用,gc成功之后再调用 TryToAllocate。
cpp 复制代码
if (last_gc < tried_type) {
    const bool gc_ran = PERFORM_SUSPENDING_OPERATION(
        CollectGarbageInternal(tried_type, kGcCauseForAlloc, false, starting_gc_num + 1)
        != collector::kGcTypeNone);

    if ((was_default_allocator && allocator != GetCurrentAllocator()) ||
        (!instrumented && EntrypointsInstrumented())) {
      return nullptr;
    }
    if (gc_ran && have_reclaimed_enough()) {
      mirror::Object* ptr = TryToAllocate<true, false>(self, allocator,
                                                       alloc_size, bytes_allocated,
                                                       usable_size, bytes_tl_bulk_allocated);
      if (ptr != nullptr) {
        return ptr;
      }
    }
}
  1. 如果还失败,那么再调用一次gc,这次gc会清除软引用。更强力一点。这里 TryToAllocate 的 KGrow 传入了 true
cpp 复制代码
PERFORM_SUSPENDING_OPERATION(CollectGarbageInternal(gc_plan_.back(), kGcCauseForAlloc, true, GC_NUM_ANY));
if ((was_default_allocator && allocator != GetCurrentAllocator()) ||
      (!instrumented && EntrypointsInstrumented())) {
    return nullptr;
}
mirror::Object* ptr = nullptr;
if (have_reclaimed_enough()) {
    ptr = TryToAllocate<true, true>(self, allocator, alloc_size, bytes_allocated,
                                    usable_size, bytes_tl_bulk_allocated);
}
  1. 如果到这一步还失败,还会根据allocator类型来做一些策略,比如RosAlloc会做一次空间压缩后再调用 TryToAllocate,代码比较多我们先跳过不看。如果这最后一步挽救措施仍然分配失败的话,那就会抛出OOM异常了:
cpp 复制代码
if (ptr == nullptr) {
    ScopedAllowThreadSuspension ats;
    ThrowOutOfMemoryError(self, alloc_size, allocator);
}

上述流程画到图里便于理解: 那么 TryToAllocate 里面是如何判断内存是否足够呢?在分配器分配的地方都会调用 IsOutOfMemoryOnAllocation 函数来判断内存是否够,而 TryToAllocate传入的kGrow参数也是在这个函数使用: 这里会对比目标内存大小和最大限制 growth_limit_,如果大于growth_limit_,那么就肯定是OOM了。 如果grow是true的话,就会在没有超出最大限制的条件下扩容。所以在分配的时候,前面一次很弱的gc是不清楚软引用+不扩容,后面一次就会升级成清楚软引用+扩容,所以可见虚拟机在保证内存分配尽量成功的前提下,也考虑到了尽量不要占用过多的系统资源。 当前内存分配的总大小是从num_bytes_allocated_里面获取的。 这个变量的新增在2个地方能看到:

  1. AllocObjectWithAllocator 结尾,加上的大小就是各个分配器里熟悉的 bytes_tl_bulk_allocated:
  1. LargeObjectSpace分配成功:

看到这里我们能得到一个结论了,别看art虚拟机把内存分配分成了一大堆Space,像LargeObjectSpace这种,在arm64上分配了固定大小,在非arm64上没有明显限制,但是在堆内存分配的时候,总内存计算是把这些Space都算到一起去了的。

内存优化机制

了解了art里heap的内存分布和对象回收机制,我们基于这些知识点总结一些对应的内存优化思路。

减少不合理的内存分配和内存占用

对应的一些思路包括:

  • 减少不必要的内存缓存,缓存要设计机制及时清理
  • 减少不必要的预加载,按需初始化分配对象
  • 减少不必要的大对象分配,例如重复new的List、Map等容器
  • 适当做一下虚拟内存优化,32bit虚拟内存有限而Java层最终分配还是依赖虚拟内存
  • 通过inlinehook修改Android8以下 Bitmap 的内存分配,减少Java堆内存占用
及时释放内存
  • 避免java层的内存泄漏
  • 防止虚拟内存泄漏出现的线程创建失败、fd溢出
修改堆内存计数黑科技方案
  • 通过inlinehook修改堆内存计数。把LargeObjectSpace的计数单独魔改。这样理论上在arm64架构上可用堆内存变为 进程可用堆内存 * 2,而在非arm64架构上,可用对内存会变成 进程可用堆内存+可用虚拟内存

该方案原理可以参考文章:《拯救OOM!字节自研 Android 虚拟机内存管理优化黑科技 mSponge》 链接:juejin.cn/post/705257... 该方案没有公开源码,我写了一个复现的demo,源码仅供参考:github.com/shaomaichen...

总结

除了上述一些内存优化机制的总结,还有一点比较重要就是治理内存的必要性,内存缺乏治理除了直接OOM的导致,在内存不足的条件下分配,即使没有达到OOM的条件,也仍然可能触发多次的GC,而GC是一个比较占用资源的行为,很可能在一些设备上导致主线程抢占不到时间片,从而影响到APP的各种其他性能指标,例如ANR率,Crash率和卡顿。

相关推荐
黄林晴2 小时前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我2 小时前
flutter 之真手势冲突处理
android·flutter
法的空间3 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止3 小时前
深入解析安卓 Handle 机制
android
恋猫de小郭3 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
jctech3 小时前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户2018792831673 小时前
为何Handler的postDelayed不适合精准定时任务?
android
叽哥3 小时前
Kotlin学习第 8 课:Kotlin 进阶特性:简化代码与提升效率
android·java·kotlin
Cui晨3 小时前
Android RecyclerView展示List<View> Adapter的数据源使用View
android
氦客3 小时前
Android Doze低电耗休眠模式 与 WorkManager
android·suspend·休眠模式·workmanager·doze·低功耗模式·state_doze