Android 系统对每个app都会有一个最大的内存限制,如果超出这个限制,就会抛出 OOM,也就是Out Of Memory 。本质上是抛出的一个异常,一般是在内存超出限制之后抛出的。最为常见的 OOM 就是内存泄露(大量的对象无法被释放)导致的 OOM,或者说是需要的内存大小大于可分配的内存大小,例如加载一张非常大的图片,就可能出现 OOM。
常见的 OOM
堆溢出
堆内存溢出是最为常见的 OOM ,通常是由于堆内存已经满了,并且不能够被垃圾回收器回收,从而导致 OOM。
线程溢出
不同的手机允许的最大线程数量是不一样的,在有些手机上这个值被修改的非常低,就会比较容易出现线程溢出的问题
FD数量溢出
文件描述符溢出,当程序打开或者新建一个文件的时候,系统会返回一个索引值,指向该进程打开文件的记录表,例如当我们用输出流文件打开文件的时候,系统就会返回我们一个FD,FD是可能出现泄露的,例如输入输出流没有关闭的时候,详细可参考 Android FD泄露问题
虚拟内存不足
在新建线程的时候,底层需要创建 JNIEnv 对象,并且分配虚拟内存,如果虚拟内存耗尽,会导致创建线程失败,并抛出 OOM。
Jvm,Dvm,Art的内存区别
Android 中使用的是基于 Java 语言的虚拟机 Dalvik / ART ,而 Dalvik 和 ART 都是基于 JVM 的,但是需要注意的是 Android 中的 虚拟器和标准的 JVM 有所不同,因为它们需要运行在 Android 设备上,因此他们具有不同的优化和限制。
在回收方面,Dalvik 仅固定一种回收算法,而 ART 回收算法可在运行期按需选择,并且ART 具备内存整理能力,减少内存空洞。
JVM
JVM 是一个虚构出来的计算机,是通过在实际计算机上仿真各种计算机功能来实现的,它有完善的(虚拟)硬件架构,还有相应的指令系统,其指令集基于堆栈结构。使用 Java虚拟机
就是为了支持系统操作无关,在任何系统中都可以运行的程序。
JVM 将所管理的内存分为以下几个部分:
- 方法区
各个线程锁共享的,用于存储已经被虚拟机加载的类信息,常量,静态变量等,当方法区无法满足内存分配需求时,将会抛出 OutOfMemoryError 异常。
- 常量池 常量池也是方法区的一部分,用于存放编译器生成的各种自变量和符号引用,用的最多的就是 String,当 new String 并调用intern 时,就会在常量池查看是否有该字符串,有则返回,没有则创建一个并返回。
- Java 堆 虚拟机内存中最大的一块内存,所有通过 new 创建的对象都会在堆内存进行分配,是虚拟机中最大的一块内存,也是gc需要回收的部分,同时OOM也容易发生在这里。 从内存回收角度来看,由于现在收集器大都采用分代收集法,所以还可以细分为新生代,老年代等。 根据 Java 虚拟机规定,Java 堆可以处于物理上不连续的空间,只要逻辑上是连续的就行,如果对中没有可分配内存时,就会出现 OutOfMemoryError 异常
- Java 栈 线程私有,用来存放 java 方法执行时的所有数据,由栈贞组成,一个栈贞就代表一个方法的执行,每个方法的执行就相当于是一个栈贞在虚拟机中从入栈到出栈的过程。栈贞中主要包括,局部变量,栈操作数,动态链接等。 Java 栈划分为操作数栈,栈帧数据和局部变量数据,方法中分配的局部变量在栈中,同时每一次方法的调用都会在栈中奉陪栈帧,栈的大小是把双刃剑,分配太小可能导致栈溢出,特别是在有递归,大量的循环操作的时候。如果太大就会影响到可创建栈的数量,如果是多线程应用,就会导致内存溢出。
- 本地方法栈 与 java 栈的效果基本类似,区别只不过是用来服务于 native 方法。
- 程序计数器 是一块较小的空间,它的作用可以看做是当前线程锁执行字节码的行号指示器,用于记录线程执行的字节码指令地址,使得线程切换时能够恢复到正确的执行位置。
DVM
原名 Dalvik 是 Google 公司自己设计用于 Android 平台的虚拟机,本质上也是一个 JAVA 虚拟机,是 Android 中 Java 程序运行的基础,其指令基于寄存器架构,执行其特有的文件格式-dex。
DVM 运行时堆
DVM 的堆结构和 JVM 的堆结构有所区别,主要体现在将堆分成了 Active 堆 和 Zygote 堆。Zygote 是一个虚拟机进程,同时也是一个虚拟机实例孵化器,zygote 堆是 Zygote 进程在启动时预加载的类,资源和对象,除此之外我们在代码中创建的实例,数组等都是存储在 Active 堆中的。
为什么要将 Dalvik 堆分为两块,主要是因为 Android 通过 fork 方法创建一个新的 zygote 进程,为了尽量避免父进程和子进程之间的数据拷贝。
Dalvik 的 Zygote 对存放的预加载类都是 Android 核心类和 Java 运行时库,这部分很少被修改,大多数情况下子进程和父进程共享这块区域,因此这部分类没有必要进行垃圾回收,而 Active 作为程序代码中创建的实例对象的堆,是垃圾回收的重点区域,因此需要将两个堆分开。
DVM 回收机制
DVM 的垃圾回收策略默认是标记清除算法(mark-and-sweep),基本流程如下
- 标记阶段:从根对象开始遍历,标记所有可达对象,将它们标记为非垃圾对象
- 清楚阶段:遍历整个堆,将所有未被标记的对象清除
- 压缩阶段(可选):将所有存货的对象压缩到一起,以便减少内存碎片
需要注意的是 DVM 垃圾回收器是基于标记清除算法的,这种算法会产生内存算法,可能会导致内存分配效率降低,因此 DVM 还支持分代回收算法,可以更好的处理内存碎片问题。 在分代垃圾回收中,内存被分为不同的年代,每个年代使用不同的垃圾回收算法进行处理,年轻代使用标记复制算法,老年代使用标记清除法,这样可以更好的平衡内存分配效率和垃圾回收效率
ART
ART 是在 Android 5.0 中引入的虚拟机,与 DVM 相比,ART 使用的是 AOT(Ahead of Time) 编译技术,这意味着他将应用程序的字节码转换为本机机器码,而不是在运行时逐条解释字节码,这种编译技术可以提高应用程序的执行效率,减少应用程序启动时间和内存占用量
JIT 和 AOT 区别
- Just In Time DVM 使用 JIT 编译器,每次应用运行时,它实时的将一部分 dex 字节码翻译成机器码。在程序的执行过程中,更多的代码被编译缓存,由于 JIT 只翻译一部分代码,它消耗更少的内存,占用更少的物理内存空间
- Ahead Of Time ART 内置了一个 AOT 编译器,在应用安装期间,她将 dex 字节码编译成机器码存储在设备的存储器上,这个过程旨在应用安装到设备的时候发生,由于不在需要 JIT 编译,代码的执行速度回快很多
ART运行时堆
与 DVM 不同的是,ART 采用了多种垃圾收集方案,每个方案会运行不同的垃圾收集器,默认是采用了 CMS (Concurrent Mark-Sweep) 方案,也就是并发标记清除,该方案主要使用了 sticky-CMS 和 partial-CMS。根据不同的方案,ART 运行时堆的空间也会有不同的划分,默认是由四个区域组成的。
分别是 Zygote、Active、Image 和 Large Object 组成的,其中 Zygote 和 Active 的作用越 DVM 中的作用是一样的,Image 区域用来存放一些预加载的类,Large Object 用来分配一下大对象(默认大小为12kb),其中 Zygote 和 Image 是进程间共享的,
为什么会出现 OOM?
出现 OOM 是应为 Android 系统对虚拟机的 heap 做了限制,当申请的空间超过这个限制时,就会抛出 OOM,这样做的目的是为了让系统能同时让比较多的进程常驻于内存,这样程序启动时就不用每次都重新加载到内存,能够给用户更快的响应
Android 获取可分配的内存大小
代码语言:javascript复制val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
manager.memoryClass
返回当前设备的近似每个应用程序内存类。这让你知道你应该对应用程序施加多大的内存限制,让整个系统工作得最好。返回值以兆字节为单位; 基线Android内存类为16 (恰好是这些设备的Java堆限制); 一些内存更多的设备可能会返回24甚至更高的数字。
我使用的手机内存是 16 g,调用返回的是 256Mb,
manager.memoryClass 对应 build.prop 中 dalvik.vm.heapgrowthlimit
申请更大的堆内存
代码语言:javascript复制val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
manager.largeMemoryClass
可分配的最大对内存上限,需要在 manifest 文件中设置 android:largeHeap="true" 方可启用
manager.largeMemoryClass 对应 build.prop 中 dalvik.vm.heapsize
Runtime.maxMemory
获取进程最大可获取的内存上限,等于上面这两个值之一
/system/build.prop
该目录是Android内存配置相关的文件,里面保存了系统的内存的限制等数据,执行 adb 命令可看到 Android 配置的内存相关信息:
代码语言:javascript复制adb shell
cat /system/build.prop
默认是打不开的,没有权限,需要 root
打开后找到 dalvik.vm 相关的配置
代码语言:javascript复制dalvik.vm.heapstartsize=5m #单个应用程序分配的初始内存
dalvik.vm.heapgrowthlimit=48m #单个应用程序最大内存限制,超过将被Kill,
dalvik.vm.heapsize=256m #所有情况下(包括设置android:largeHeap="true"的情形)的最大堆内存值,超过直接oom。
未设置android:largeHeap="true"的时候,只要申请的内存超过了heapgrowthlimit就会触发oom,而当设置android:largeHeap="true"的时候,只有内存超过了heapsize才会触发oom。heapsize已经是该应用能申请的最大内存(这里不包括native申请的内存)。
OOM 演示
堆内存分配失败
堆内存分配失败对应的是 /art/runtime/gc/heap.cc ,如下代码
代码语言:javascript复制oid Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type) {
// If we're in a stack overflow, do not create a new exception. It would require running the
// constructor, which will of course still be in a stack overflow.
if (self->IsHandlingStackOverflow()) {
self->SetException(
Runtime::Current()->GetPreAllocatedOutOfMemoryErrorWhenHandlingStackOverflow());
return;
}
//....
std::ostringstream oss;
size_t total_bytes_free = GetFreeMemory();
oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free
<< " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM,"
<< " target footprint " << target_footprint_.load(std::memory_order_relaxed)
<< ", growth limit "
<< growth_limit_;
self->ThrowOutOfMemoryError(oss.str().c_str());
}
通过上面的分析,我们也知道系统对每个应用都做了最大内存的约束,超过这个值就会 OOM ,下面通过一段代码来演示一下这种类型的 OOM
代码语言:javascript复制fun testOOM() {
val manager = requireContext().getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
Timber.e("app maxMemory ${manager.memoryClass} Mb")
Timber.e("large app maxMemory ${manager.largeMemoryClass} Mb")
Timber.e("current app maxMemory ${Runtime.getRuntime().maxMemory() / 1024 / 1024} Mb")
var count = 0
val list = mutableListOf<ByteArray>()
while (true) {
Timber.e("count $count total ${count * 20}")
list.add(ByteArray(1024 * 1024 * 20))
count
}
}
上面代码中每次申请 20mb,测试分为两种情况,
未开启 largeHeap:
代码语言:javascript复制 E app maxMemory 256 Mb
E large app maxMemory 512 Mb
E current app maxMemory 256 Mb
E count 0 total 0
E count 1 total 20
E count 2 total 40
E count 3 total 60
E count 4 total 80
E count 5 total 100
E count 6 total 120
E count 7 total 140
E count 8 total 160
E count 9 total 180
E count 10 total 200
E count 11 total 220
E count 12 total 240
java.lang.OutOfMemoryError: Failed to allocate a 20971536 byte allocation with 12386992 free bytes and 11MB until OOM, target footprint 268435456, growth limit 268435456
......
可以看到一共分配了 12次,在第十二次的时候抛出了异常,显示 分配 20 mb 失败,空闲只有 11 mb,
开启 largeHeap
代码语言:javascript复制app maxMemory 256 Mb
large app maxMemory 512 Mb
current app maxMemory 512 Mb
E count 0 total 0
E count 1 total 20
E count 2 total 40
E count 3 total 60
E count 4 total 80
E count 5 total 100
E count 6 total 120
E count 7 total 140
E count 8 total 160
E count 9 total 180
E count 10 total 200
E count 11 total 220
E count 12 total 240
E count 13 total 260
E count 14 total 280
E count 15 total 300
E count 16 total 320
E count 17 total 340
E count 18 total 360
E count 19 total 380
E count 20 total 400
E count 21 total 420
E count 22 total 440
E count 23 total 460
E count 24 total 480
E count 25 total 500
FATAL EXCEPTION: main
Process: com.dzl.duanzil, PID: 31874
java.lang.OutOfMemoryError: Failed to allocate a 20971536 byte allocation with 8127816 free bytes and 7937KB until OOM, target footprint 536870912, growth limit 536870912
可以看到分配了25 次,可使用的内存也增加到了 512 mb
创建线程失败
线程创建会消耗大量的内存资源,创建的过程涉及 java 层 和 native 层,本质上是在 native 层完成的,对应的是 /art/runtime/thread.cc ,如下代码
代码语言:javascript复制void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
//........
env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer, 0);
{
std::string msg(child_jni_env_ext.get() == nullptr ?
StringPrintf("Could not allocate JNI Env: %s", error_msg.c_str()) :
StringPrintf("pthread_create (%s stack) failed: %s",
PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
ScopedObjectAccess soa(env);
soa.Self()->ThrowOutOfMemoryError(msg.c_str());
}
}
这里借用网上的一张照片来看一下创建线程的流程
根据上图可以看到主要有两部分,分别是创建 JNI Env 和 创建线程
创建 JNI Env 失败
FD 溢出导致 JNIEnv 创建失败
代码语言:javascript复制E/art: ashmem_create_region failed for 'indirect ref table': Too many open files java.lang.OutOfMemoryError:Could not allocate JNI Env at java.lang.Thread.nativeCreate(Native Method) at java.lang.Thread.start(Thread.java:730)
虚拟内存不足导致 JNIEnv 创建失败
代码语言:javascript复制E OOM_TEST: create thread : 1104
W com.demo: Throwing OutOfMemoryError "Could not allocate JNI Env: Failed anonymous mmap(0x0, 8192, 0x3, 0x22, -1, 0): Operation not permitted. See process maps in the log." (VmSize 2865432 kB)
E InputEventSender: Exception dispatching finished signal.
E MessageQueue-JNI: Exception in MessageQueue callback: handleReceiveCallback
MessageQueue-JNI: java.lang.OutOfMemoryError: Could not allocate JNI Env: Failed anonymous mmap(0x0, 8192, 0x3, 0x22, -1, 0): Operation not permitted. See process maps in the log.
E MessageQueue-JNI: at java.lang.Thread.nativeCreate(Native Method)
E MessageQueue-JNI: at java.lang.Thread.start(Thread.java:887)
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: Process: com.demo, PID: 3533
E AndroidRuntime: java.lang.OutOfMemoryError: Could not allocate JNI Env: Failed anonymous mmap(0x0, 8192, 0x3, 0x22, -1, 0): Operation not permitted. See process maps in the log.
E AndroidRuntime: at java.lang.Thread.nativeCreate(Native Method)
E AndroidRuntime: at java.lang.Thread.start(Thread.java:887)
创建线程失败
虚拟机内存不足导致失败
native 通过 FixStackSize 设置线程大小
代码语言:javascript复制static size_t FixStackSize(size_t stack_size) {
if (stack_size == 0) {
stack_size = Runtime::Current()->GetDefaultStackSize();
}
stack_size = 1 * MB;
if (kMemoryToolIsAvailable) {
stack_size = std::max(2 * MB, stack_size);
} if (stack_size < PTHREAD_STACK_MIN) {
stack_size = PTHREAD_STACK_MIN;
}
if (Runtime::Current()->ExplicitStackOverflowChecks()) {
stack_size = GetStackOverflowReservedBytes(kRuntimeISA);
} else {
stack_size = Thread::kStackOverflowImplicitCheckSize
GetStackOverflowReservedBytes(kRuntimeISA);
}
stack_size = RoundUp(stack_size, kPageSize);
return stack_size;
}
代码语言:javascript复制W/libc: pthread_create failed: couldn't allocate 1073152-bytes mapped space: Out of memory
W/tch.crowdsourc: Throwing OutOfMemoryError with VmSize 4191668 kB "pthread_create (1040KB stack) failed: Try again"
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
at java.lang.Thread.nativeCreate(Native Method)
at java.lang.Thread.start(Thread.java:753)
线程数量超过限制
用一段简单的代码来测试一下
代码语言:javascript复制fun testOOM() {
var count = 0
while (true) {
val thread = Thread(Runnable {
Thread.sleep(1000000000)
})
thread.start()
count
Timber.e("current thread count $count")
}
}
通过打印日志发现,一共创建了 2473 个线程,当然这些线程都是没有任务的线程,报错信息如下所示
代码语言:javascript复制pthread_create failed: couldn't allocate 1085440-bytes mapped space: Out of memory
Throwing OutOfMemoryError "pthread_create (1040KB stack) failed: Try again" (VmSize 4192344 kB)
FATAL EXCEPTION: main
Process: com.dzl.duanzil, PID: 18085
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
at java.lang.Thread.nativeCreate(Native Method)
通过测试可以看出来,具体的原因也是内存不足引起的,而不是线程数量超过限制,可能是测试的方法有问题,或者说是还没有达到最大线程的限制,由于手机没有权限,无法查看线程数量限制,所以等有机会了再看。
OOM 监控
我们都知道,OOM 的出现就是大部分原因都是由于内存泄露,导致内存无法释放,才出现了 OOM,所以监控主要监控的是内存泄露,现在市面上对于内存泄露检查这方面已经非常成熟了,我们来看几个常用的监控方式
LeakCanary
使用非常简单,只需要添加依赖后就可以直接使用,无需手动初始化,就能实现内存泄露检测,当内存发生泄露后,会自动发出一个通知,点击就可以查看具体的泄露堆栈信息
LeakCannary 只能在 debug 环境使用,因为他是在当前进程 dump 内存快照,会冻结当前进程一段时间,所以不适于在正式环境使用。
Android Profile
可以以图像的方式直观的查看内存使用情况,并且可以直接 capture heap dump,或者抓取原生内存(C/C ) 以及 Java/Kotlin 内存分配。只能在线下使用,功能非常强大,可是吧内存泄露,抖动,强制 GC 等。
ResourceCanary
ResourceCanary 属于 Matrix 的一个子模块,它将原本难以发现的 Acivity 泄露和 Activity 泄露和重复创建的沉余的 Bitmap 暴露出来,并提供引用链等信息帮助排查这些问题
ResourceCanary 将检测和分析分离,客户端只负责检测和dump内存镜像文件,并且对检查部分生成的 Hprof 文件进行了裁剪,移除了大部分无用数据。也增加了 Bitmap 对象检测,方便通过减少沉余 Bitmap 数量,降低内存消耗。
使用可查看 Matrix
KOOM
上面的两者都只能在线下使用,而 KOOM 可以再线上使用,KOOM 是快手出的一套完整的解决方案,可以实现 Java,native 和 thread 的泄露监控
使用可查看 KOOM
OOM 优化
OOM 的优化其实相当于是内存优化,对于这部分内容,网上有一篇讲的非常详细的文章 **深入探索 Android 内存优化**,该文章内容非常全面且难度也比较高,建议仔细阅读。
参考链接
【性能优化】大厂OOM优化和监控方案
深入探索 Android 内存优化
DVM和ART原理初探
Android OOM 问题探究
....