一进程一世界,一个进程在进行垃圾回收的时候,另一个进程可能才刚刚启动、坐拥大片空闲~
在了解Android如何做内存管理前,首先还是需要祭出Android 进程运行时内存分布图:
如上图所示:一个进程运行时内存是分区管理的,每个分区对应了一类生命周期相似或者相同的对象,分区管理的实质是按生命周期维度来管理内存。
整体来说,从左到右的分区颜色越来越深,代表了分区内部存储对象的生命周期越来越长,内存分配和释放的频次越来越低;不同的分区有着不同的内存分配和回收策略,接下来具体展开来讲。
一、JVM内存
1.1 Java stack 和 Native stack
在 JVM 中,Java栈(Java Stack)和本地方法栈(Native Stack)是两个重要的内存区域,他们的生命周期都与线程绑定,在线程创建时分配内存,线程销毁时(线程运行完毕自动销毁)释放内存。
在Android中,当创建一个线程的时候,JVM就会为它分配一个Java栈(Java Stack)、本地方法栈(Native Stack)和线程PC寄存器(记录线程运行位置),Java栈和本地方法栈各自有默认的初始化大小、同时都支持动态扩容,但扩容有上限,就是说一个线程的Java栈和本地方法栈占用的内存有上下限,不同设备的这两个上下限是有不同配置的,后面会有实测数据。
栈里面存储的数据叫栈帧,它与方法一一对应,栈帧随着方法调用而创建,随方法运行结束而出栈销毁。当方法A调用方法B时,就会为方法B创建栈帧,放在栈顶,把方法A的栈帧压入栈中,当方法B运行完毕就会把B的栈帧从栈顶出栈销毁,继续运行方法A。栈帧中存储着方法的入参、局部变量(基本数据类型直接存储;对象存储在堆中,栈中存对象引用)、运算过程值(过程值中, 也是基本类型存在栈帧,过程对象存堆中, 引用在栈中)、返回值等。如果线程请求分配的栈容量超过上限,Java虚拟机就会抛出一个StackoverflowError,一般是方法调用太深、递归太多、局部变量太多等原因。
如果一个线程在创建和动态扩容时候无法为它的栈申请到足够的内存,JVM就会抛出一个OOM错误,这种问题不一定是该线程造成的,我们需要从整个进程和整个设备的内存使用情况来分析。
实测数据
在华为pad创建100个一直运行的线程,平均每个线程会带来内存30K-50K的内存增量(主是栈内存);在vivo 100手机手机上创建100个一直运行的线程,平均每个线程会带来100K左右内存增量。但这各种网上文档上说,一个线程基本是默认Java栈1M、Native栈1M有较大差异,差在哪里了呢,需要各位大佬继续研究解答。
小结
Java栈(Java Stack)和本地方法栈的内存管理是比较简单的,基本上是用完就释放的策略,无需JVM GC机制管理。我们需要注意的是:
- 避免方法调用太深、递归太多、局部变量太多等原因造成的StackoverflowError;
- 线程创建是有最小内存占用的,另外线程切换也是有性能成本的,太频繁创建销毁线程也会带来更多的内存碎片,所以实际中遇到频繁使用线程的场景,尽量使用线程池来完成。
1.2 线程PC寄存器
每个线程都有独立的 PC 寄存器,它也是在线程创建时分配内存,线程销毁时(线程运行完毕自动销毁)释放内存;PC 寄存器存储对应线程执行到的字节码指令地址,PC 寄存器是一个非常小的内存区域,通常占用 4 字节(32 位系统)或 8 字节(64 位系统),一个进程的所有PC寄存器占有内存总和是:单个线程占用数(4Byte或者8Byte)乘以线程个数。
PC寄存器区占用内存非常小,而且能够随着线程销毁而立马自动回收,所以无需JVM GC机制管理,也不会有StackOverflowError以及OOM ,是内存问题定位时候无需关心的区域。
1.3 Java堆-java heap
在Java中,所有对象和数组的实际内存分配总是发生在堆中,无论一个对象是类的成员变量、类的静态变量、方法中的局部变量,当它创建的时候,都要去Java堆中去申请内存,内存不够就会触发GC后再分配,内存申请不到就抛出OOM异常,了解Java堆内存管理对于优化程序的性能和避免内存泄漏至关重要。
Java堆在进程启动时创建,在进程结束时候销毁,Java堆通常分为以下几个部分:
下面从Java堆的内存分配和回收机制、容量配置、内存泄漏三个方面来具体展开:
内存分配和回收机制
新生代(Young Generation)包括Eden区和两个Survivor区(S0和S1),新创建对象都放在Eden区、当Eden区满时,GC就会把Eden区和当前Survivor区中应该存活的对象复制到另一个空白的Survivor区,然后把Eden区和当前Survivor区清理干净,在这个过程中存活的对象生命值+1,当一个对象生命值大于某个阈值-比如15,GC就会认为它可能会存在较长时间,就会把它从新生代移动到老年代,这种垃圾清理就叫 标记—复制算法,它的优点是速度快,不产生内存碎片,缺点是需要一个额外空白的空间,新生代的GC是比较频繁快速的,所以叫做Minor GC(小垃圾回收)。
当老年代无法再分配空间时,就会触发Full GC,所谓Full GC就是对新生代和老年代都进行GC,老年代一般采用标记-整理算法,首先需要鉴别对象是否需要继续存活(标记),然后把不需要的对象清除,把需要存活的对象整理到一个连续的内存区域,这种算法优点是不产生内存碎片、也不需要预留额外空白空间,缺点是多了一步内存整理,需要消耗更多的CPU时间,又因为在GC内存整理、移动等关键步骤,为保证内存一致性是要stop the world -暂停所有线程的,频繁的Full GC会导致应用性能大大下降。
现代的垃圾回收器正在努力减少或消除GC导致的Stop the world暂停,以提高应用的性能和响应性。在Android方面,虚拟机ART采用自适应策略,根据运行时的内存分配和回收情况,自动调整年轻代和老年代的大小,减少长时间的GC停顿,平衡内存使用率和垃圾回收性能开销,比如Minor GC比较频繁,而且每次GC后,释放的内存占新生代总内存的比例较低,就会适当增加新生代内存大小。
容量配置
Android进程的Java堆容量配置有四个值需要关注
- 初始值: 取决于厂商设备内存配置和系统版本,华为pad经测试10M左右;
- totalMemory和freeMemory:JVM会根据进程和系统内存状态,一点点从系统挖内存过来用,当前JVM已经从操作系统分配的堆内存大小就是totalMemory,而freeMemory则totalMemory中未使用的内存量
// App进程的虚拟机已经申请到的内存
int totalMemory= (int) (Runtime.getRuntime().totalMemory() / (1024*1024));
// App进程虚拟机已申请到但还未使用的内存
int freeMemory=(int)(Runtime.getRuntime().freeMemory() / (1024*1024));
- maxMemory:该进程可以使用的最大Java堆内存,如果配置了android:largeHeap="true",应用就能获取更大的堆内存。
// App进程的虚拟机可使用最大java堆内存
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / (1024*1024));
在华为 pad上实验配置
MemoryInfo: this app's JVM can use Max java memory is 384MB
MemoryInfo:after set android:largeHeap = true, this app's JVM can use Max java memory is 512MB
内存泄漏
Android 采用可达性分析法来确定哪些对象应该存活,具体了来说就是在触发GC时候,只要直接或者间接被GC ROOT(①虚拟机栈中的引用的对象;②仍处于存活状态中的线程对象;③方法区中静态引用指向的对象;④Native 方法中 JNI 引用的对象;)引用,就应该存活(其他对象则会被回收)。 一个对象生命周期应该结束,却依旧处在某一种GC ROOT的引用链上,我们就说就出现了内存泄露。这里不具体展开,详细可以看什么是内存泄露和谈谈内存泄露检测的工具的使用两篇文章。
思考
方法的局部对象为啥不在方法执行完毕后,立马释放内存呢?可以从内存碎片、方案复杂度、额外资源消耗等进行思考,欢迎各位大佬来评论区交流~
1.4 运行时常量池和方法区
一个类中的常量、各种值类型符号、方法代码、编译器生成常量等一个类的一切编译后信息都存储在这个区域。如果把程序运行比喻做菜,那么这两个区域存储的就是菜谱。
当一个类被执行到相关代码,那么它的代码就会被读入运行时常量池和方法区;当一个类被卸载,它在方法区和运行时常量池中占用的内存会被释放掉,但类一旦加载,除了进程结束只有个别情况(①系统内存紧张 ②一些厂商的JVM会定期会期检查和卸载一些不再使用的类)会被卸载。。
虽然这两个区域并不是JVM自动垃圾回收机制管理的区域,但我们还是可以通过代码重新设计来对这两个区域做内存优化的,比如如果一个工具类很大,有几十个方法,但代码中使用的就两三个,我们就要对这个类进行缩减;再比如一个类中,包含了一些常用的方法,还有一些异常处理的代码,那么就要分开,因为异常处理的代码有可能就不会使用到,总之内存优化就是要想尽一些办法,避免当前不使用的类、对象、方法占用内存。
二、Native内存
2.1 本地堆-native heap
Native heap的内存分配和回收都是手动进行,开发者需要了解清楚每个对象的生命周期,在它生命周期开始的时候为它分配内存,在它生命周期结束释放内存。
Native heap的内存泄漏检测原理有两种 :
1)通过检内存分配方法malloc和回收方法free是否配对;
2)在程序运行结束后检查分配出去的内存区是否被回收;
除了这种侠义的内存泄漏,我们还可以dump特定时间点的Native堆,通过分析该时间点应该销毁的对象是否还存在来辨别内存泄漏。
2.2 文件映射区
在Android系统中,内存映射(mmap)用于将文件或设备映射到进程的虚拟地址空间,以提高访问效率和,我们使用adb shell dumpsys meminfo packageName的时候,就可以看到常见的几种内存映射文件:
Total Dirty Clean Dirty Total
// native代码编译后文件 、可以认为是本地代码区
.so mmap 25327 1596 18652 34 52464
.jar mmap 3199 0 692 0 31132
.apk mmap 2782 48 2084 0 5264
// ttf是字体文件
.ttf mmap 1796 0 1692 0 2140
.dex mmap 27692 27432 104 0 29116
.oat mmap 141 0 12 0 1424
.art mmap 10650 8380 1856 40 21316
Other mmap 44992 8 44896 0 45836
各种mmap的内存管理由Liunx内核负责,当我们使用一个so库、一个字体文件、一个jar时候,就会被系统映射到内存中,当设备内存压力大尝试释放一些不常用的mmap文件、当进程结束释放所有mmap。虽然这个区域的内存管理看起来跟App没有关系,但是我们还是可以做一些内存优化工作的, 比如 so不用的时候及时卸载,常用库和不常用库分离等等,原则就是按照使用频率和生命周期来组织代码库、引入代码库。
2.3 Ashmem(Anonymous Shared Memory)
该区域是不同进程间共享的内存,主要用于进程间通信、数据共享,常见的有:
- Binder 机制:Binder 是 Android 中进程间通信(IPC)的主要机制,使用 Ashmem 共享内存来提高通信效率。
- 多媒体数据共享:例如,在多媒体播放或图像处理过程中,不同进程之间需要共享大量的数据,使用 Ashmem 可以减少数据复制,提高性能;
- 缓存机制:在一些需要跨进程共享缓存数据的场景中,例如图像缓存、资源共享等,Ashmem 可以提供高效的共享内存支持。
它的内存管理各位大佬来思考下,打在评论区吧~
2.4 GL mtrack(Graphics Library Memory Tracker)
这部分内存跟踪主要用于监控和管理 GPU 内存的分配和释放,以确保图形资源的有效利用。
它的内存管理各位大佬来思考下,打在评论区吧~
总结
内存管理的维度是进程,一进程一世界,两个世界的关联在于资源的共享和竞争;内存管理终极目标是“让对象在生命开始和绽放时候有舞台和空间,让对象在完成使命时能够及时退场、还世界一片清净”;内存管理的基本原则是按照生命周期来进行分区管理,不同的区域有着不同分配-回收频率和策略;内存管理要平衡内存碎片、内存使用率、应用性能等核心问题,为进程世界持续高效稳定地运行保驾护航。