最近在处理一些内存优化相关问题,本文将对内存进行一些简要记录,对为什么优化内存,和优化内存的实践做一些记录,内存优化是一个长期任务,任重而道远。
极客时间 Android开发高手课 内存优化
WeMobileDev Android内存优化杂谈
微信 Android 终端内存优化实践
Android 性能优化
美团 Androdi内存泄漏自动化链路分析组件
内存RAM就相当于PC的内存,作为手机APP运行过程中临时性数据存储的内存介质。2008年时,手机只有140MB左右内存,而现在的手机,例如华为Mate 20 Pro手机,内存都已经达到8GB了。那么是否就不用做内存优化了呢?都这么大内存,够用了。首先从手机使用的LPDDR RAM(低功率双倍数据速率内存) 内存来看,LPDDR4的性能比LPDDR3高出一倍,LPDDR4X比LPDDR4工作电压更低,你能保证国内产商在8GB内存的手机上用的是好的LPDDR吗。还有一个就是内存就这么大,你APP进行了内存优化,降低了运行时内存,性能肯定更好了,能防止一些OOM,内存过大还有可能被LMK机制给杀死,还会触发GC,总结内存优化的好处如下:
避免触发GC频繁,进而避免卡顿,进而增加用户体验,不至于到卸载APP的地步,最终营造出好的口碑
防止应用发生OOM
避免内存过大被LMK机制杀死的概率
Created 为对象分配存储空间,构造对象
InUse 对象被强引用持有
Invisible 没有被强引用持有,对象仍活着。(这个对象可能会被虚拟机下某些装载的静态变量或JNI持有,这样会导致内存泄漏)
Unreachable GC Root不可达,该引用不再被任何强引用持有
Collected 收集阶段
Finalized 等待回收
Deallocated 回收分配
其中的类似 save 和 load 操作有8种,分别是 lock、unlock、read、load、use、assign、store、write
简单来说,如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作
方法区 用于存储被虚拟机加载的类信息,常量 ,static变量等数据,字符串常量池位于方法区
堆 用于存储对象实例,new创建的保存在堆中
虚拟机栈 用于实现方法调用,每次方法调用就对应栈中的一个栈帧,栈帧包含局部变量表,操作数栈,方法接口等方法相关信息
本地方法栈 本地方法栈类似于虚拟机栈,只是保存的是本地方法的调用,在HotSpot虚拟机的实现中,虚拟机和本地方法栈被合并在一起。
程序计数器 程序计数器的功能相当于PC寄存器的功能,如果当前执行的是Java方法,则只是当前字节码指令的地址。如果执行的是本地方法,则值为Undefined。它能保证线程被中断后恢复执行时按中断时的指令进行执行下去。
其中方法区和堆是线程共享内存,虚拟机、本地方法栈、程序计数器是独立的内存,每个线程只保存自己的数据
寄存器 用来暂存指令、数据和地址的,CPU内部的元件,寄存器拥有非常高的读写速度。CPU由运算器、控制器、寄存器等组成,器件之间靠内部总线相连
直接内存 并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现
JDK 1.8之前分区是上面这种情况,但是JDK 1.8之后改成了下面图示分区,其中说明一下,在JDK 1.8前的 方法区 仅是逻辑上的分区 , 物理上并没有独立于堆而存在 ,而是位于永久代中。所以这时候的方法区也是可以被回收的。在Java 1.8版本之后Hot Spot移除了永久代,使用本地内存来存储类元数据信息,并命名为 元空间
4.1、类加载的时机
从加载到卸载,生命周期 -> 加载、验证、准备、解析、初始化、使用、卸载,共七个阶段
其中 验证、准备和解析统称为连接
被动使用 -> 通过子类引用父类的静态字段,不会导致子类初始化
被动使用 -> 通过数组定义引用类,不会触发此类的初始化
被动使用 -> 常量在编译阶段会存入调用类的常量池中,后续对常量的引用都被转化为类对自身常量池的引用了
接口也有初始化过程,与类是一致的。编译器仍然会为接口生成 "<clint>()" 类构造器,用于初始化接口中所定义的成员变量。接口初始化时,并不要求父类接口都完成了初始化,只有在用到父接口时候才会初始化。
4.2、类加载的过程
加载 Class Loading 过程
通过一个类的全限定名来获取定义此类的二进制流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
验证 ( 加载阶段与连接阶段是交叉进行的 )
确保 Class 文件的字节流包含的信息符号当前虚拟机的要求,并且不会危害虚拟机自身的安全
文件格式验证 (字节流是否符合 Class 文件格式规范)
是否以魔数0xCAFEBABE开头
主、次版本号是否在当前虚拟机处理范围之内
常量池的常量中是否有不被支持的常量类型,检查 tag 标志
指向常量的各种索引值中是否有指向不存在的常量或不符号类型的常量
CONSTANT_UTF8_info型的常量中是否有不符号UTF8编码的数据
Class 文件中各个部分以及文件本身是否有被删除的或附加的其他信息
元数据验证 (对字节码描述信息的验证,保证不存在不符合 Java 语言规范的元数据)
这个类是否有父类
这个类的父类是否继承了不允许被继承的类
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
类中的字段、方法是否与父类产生矛盾
字节码验证 (主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段主要是对方法体进行校验分析)
保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作
保证跳转指令不会跳转到方法体以外的字节码指令上
保证方法体中的类型转换是有效的
JDK 1.6 后的优化,检查 "Code" 属性的属性表 ”StackMapTable"属性的状态,JDK 1.7后类型检查失败后不能退回到类型推导。
符号引用验证
符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
符号引用中的类、字段、方法的访问型是否可被当前类访问
准备 (正式为类变量(static 修饰)分配内存并设置类变量初始值(int i -> 0),这些变量所使用的内存都将在方法区中进行分配)
解析 (虚拟机将常量池内的符号引用替换为直接引用的过程 )
初始化
"<clinit>()" 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
初始化阶段是执行类构造器 "<clint>()" 方法的过程。
在Android系统中,堆实际上就是一块匿名共享内存,由底层C库来管理,并且仍然使用libc提供的函数malloc和free来分配和释放内存。大多数静态数据会被映射到一个共享的进程中。常见的静态数据包括Dalvik Code、app resources、so文件等等。并且在大多数情况下,Android通过显示分配共享内存区域(如Ashmem或者Gralloc)来实现动态RAM区域能够在不同进程之间共享的机制。
详细请查询《深入理解Java虚拟机 第2版》,下面就回收机制和回收算法进行简要概括
内存分配有三个区域,年轻代 Eden Survivor1 Survivor2(1/8) 老年代,永久代,首先在Eden分配,后面会根据年龄计算进行回收或者进入老年代或者永生代。
垃圾回收前:首先判断对象是否存活
引用计数法,一个地方引用它就加1,失效就减1,=0就说明不可能再被引用,如果两个对象互相引用,这就没法回收了
可达性分析,通过判断一个对象到GC ROOT没有引用链相连时,则不可达
判断对象不可达后,会进行筛选,查看是否有必要执行
最后通过垃圾收集算法收集
标记-清除算法 两个阶段,标记 清除 不足:效率问题,而且会产生大量不连续的内存碎片
复制算法 将可用内存按让容量分为相等的两块,每次都只使用其中一块, 不足,将内存缩小了一半,没有大量内存回收时,太过浪费了。 (适用新生代,内存没有那么多要回收的)
标记-整理算法 标记 清除 ,让存活的对象移向一端。 (适用老年代)
ps : 其实回收算法大约由六个,了解一下吧,分别是 引用计数(Reference Counting)、标记-清除(Mark-Sweep)、复制(Copying)、标记-整理(Mark-Compact)、增量收集(Incremental Collecting)、分代(Generational Collecting)
首先了解一下,系统的进程分类,由优先级排序
前台进程
可见进程
服务进程
后台进程
空进程 ( 一个空进程就占用了10M内存,所以开进程处理一些业务需要考虑一下是否有必要)
LMK机制就是优先级越低越容易杀死,
强引用,生命周期长,永不回收
软引用,生命周期短,内存不足时,才会回收
弱引用,生命周期比软引用更短,一旦发现弱引用对象,无论当前内存空间是否充足,都会将弱引用回收。
虚引用,生命周期最短,如果一个对象仅持有虚引用,那么它相当于没有引用,在任何时候都可能被垃圾回收器回收
产生原因,频繁GC,内存重新分配和回收导致内存不稳定,在内存上呈现一种锯齿状。回收策略对于我们是优化不了(大神除外),只能从减少GC入手。
对象无用后,占有的内存没有得到回收。为什么?因为GC Roots持有该引用,或者说是该对象到GC Roots可达。这样会导致可用内存减少,频繁GC,也会导致内存抖动。
常见内存泄漏场景
单例 生命周期等于整个应用的生命周期,如果持有某个Activity,则会导致该Activity无法回收
匿名内部类 最常见的就是Handler持有Activity的引用,导致内存泄漏
资源未关闭 File、Bitmap等为close或recycle,EventBus等注册后未取消注册
类的静态变量持有大数据对象或持有非静态内部类实例
ListView 快速滑动时未使用缓存View,导致频繁创建大量对象,这个一般都不用关注了,这年头,都要用ViewHolder,至于RecyclerView,可以在RecyclerViewAdapter的onViewRecycled方法里释放图片引用操作。
WebView 在应用中只要使用一次WebView,内存就不会释放掉,一般可以为WebView开启一个独立的进程,使用AIDL与主进程通信,销毁时,直接关闭进程就好了。
常见的优化方案
SparseArray、SparseInt代替HashMap
避免基本数据类型的自动装箱和拆箱,例如int 4个字节,而Integer对象有16个字节
尽可能使用IntDef、StringDef代替枚举
使用LruCache方法来对大资源的缓存功能,例如预加载
Bitmap优化,不需要质量很好的话,一般选用ARGG_565就足够了,下文有提及。inSampleSize、inScaled、inBitmap等可以充分利用,这里需要注意在Android 4.4之前,只能重用相同大小的Bitmap内存区域,Android 4.4之后可以重用任何Bitmap的内存区域,只要这块内存比将要非陪内存的Bitmap大就可以
内存过低时,在onTrimMemory / onLowMemory 方法去释放掉图片缓存、静态缓存等
尽量使用String.valueOf 代替 ""+int ,前者效率更高,字符串拼接使用StringBuffer 、 StringBuilder
注意匿名内部类Runnable 是否持有外部类或者是Activity的引用,需要释放Activity引用,禁止采用随地new Runnable的方式来使用
OOM,程序在申请内存时,没有足够的内存分配给它,导致程序崩溃。内存泄漏,导致可用内存减少,某种合适的场景下,也会导致内存溢出。实际上,很多OOM都是由图片处理不当而产生的,现实中,一般会用Glide等图片加载框架,基本上都交给第三方库处理了。
用以下命令可以查看手机系统给APP分配的内存大小
adb shell getprop | grep dalvik.vm.heapsize复制代码
内存优化,重点就是图片相关。针对so重编/无法重编 等等优化,读者有兴趣可以自行查阅,一般接入第三方框架,基本的OOM和Native 内存泄漏都能捕获到,关键如何做到预防,通过线上内存趋势来判断是否需要大概率要OOM了。
在Android 3.0前,Bitmap对象放在堆,像素数据放在Native种,如果不手动调用recycle, Bitmap Native 内存回收完全依赖 finalize 回调,这个时机很不可控
Android 3.0 到 Android 7.0 ,将Bitmap对象和像素数据一起放在Java堆中,这样就算不调用recycle, Bitmap内存也会随着对象一起被回收。不过Bitmap 是内存消耗大户,放到Java堆中,App最大内存会不会立马被使用完了,而且放到堆中,不好好优化,还会出现频繁GC
Android 8.0 ,将像素数据放到了Native中,实现了和对象一起快速释放回收。并且还增加了硬件位图 Hardware Bitmap,减少了图片内存,提升了绘制效率
有兴趣的可以了解一下Fresco图片库把图片放到Native的过程
// 步骤一:申请一张空的 Native Bitmap Bitmap nativeBitmap = nativeCreateBitmap(dstWidth, dstHeight, nativeConfig, 22); // 步骤二:申请一张普通的 Java Bitmap Bitmap srcBitmap = BitmapFactory.decodeResource(res, id); // 步骤三:使用 Java Bitmap 将内容绘制到 Native Bitmap 中 mNativeCanvas.setBitmap(nativeBitmap); mNativeCanvas.drawBitmap(srcBitmap, mSrcRect, mDstRect, mPaint); // 步骤四:释放 Java Bitmap 内存 srcBitmap.recycle(); srcBitmap = null;复制代码
接下来记录一些实用性的优化策略
收拢图片的调用,减少不必要的图片库,还能优化包体积。
包括所有和图片相关接口操作,图片缓存策略等等,能统一的都统一起来。
设置图片的配置,例如,要求不高的图片,使用Bitmap.Config.ARGB_4444就可以了,可以套用以下公式进行计算,看看能减少多少内存
宽 *(屏幕的desityDip / drawable所在文件的desityDip) * 高 *(屏幕的desityDip / drawable所在文件的desityDip)* 4 (Bitmap.Config.ARGB_8888 每个像素占4个字节) 举个例子:屏幕密度320 ,放到drawable-hdpi 密度为240 所以一张 600 * 600 的图片所占内存为 600 * (320/240) * 600 * (320/240) = 2560000 byte = 2.56M复制代码
如果与 KPI 相悖就算了,之前页提到过,一个空进程就会占用10MB内存,如果你做内存优化,对于一些操作,没必要开进程,还要使用进程,然后引入一些进程通信的坑,那我没啥说的了。如果能提升运行速度,提升用户体验,方便管理APP,方便优化APP那还是值得探讨一下的。
之前做包体积优化时,调用过字节的MCImage , 也可以用来检测 大图 ,不过这个基于线下。
重复图片 是指 Bitmap 像素数据完全一致,但是有多个不同的对象存在。 一般使用Hprof 分析工具使用,自动将重复 Bitmap 的图片和引用堆栈输出。 HAHA库检测重复图片 ,笔者能力有限,实践中没有做这一步, 极客时间 中 有个练手项目,但是对于小公司,接入项目其实还是没有多大必要,而且还需要考虑一些兼容性问题。
这个可以做,而且个人觉得很实用,下面简单描述写如何建立
按照用户抽样,每隔一段时间采集一次 PSS (t通过Debug.MemoryInfo)、Java 堆 、图片总内存,上传到服务器,还可以把 GC 次数也统计,服务器需要设置一个开关,因为收集GC次数会影响手机性能,建议在灰度时抽样测试。
服务器 按照下面方式计算指标
内存异常率
内存UV 异常率 = PSS 超过 400 MB的UV / 采集 UV复制代码
触顶率
内存UI 触顶率 = JAVA 堆占用超过最大堆限制的 85% / 采集UV 例如 long javaMax = runtime.maxMemory(); long javaTotal = runtime.totalMemory(); long javaUsed = javaTotal - runtime.freeMemory(); // Java 内存使用超过最大限制的 85% float proportion = (float) javaUsed / javaMax;复制代码
通过前端将数据图形化,例如通过内存变化曲线等等,我们就可以很清晰来监控是否新增内存相关问题
有兴趣的同学可以查看 美团 Androdi内存泄漏自动化链路分析组件
主要是为了在用户无感知的情况下,在接近触发系统异常前,选择合适的场景杀死进程并将其重启,从而使得应用内存占用回到正常情况。
一般在凌晨2点到5点,或者主界面退出后台超过30分钟,或者是Java Heap大小快超过当前进程最大可分配的85%时,可以悄悄使用该策略,按需求来吧,一般小应用,没有这必要,没人会用很久吧。至于兜底策略,你可以试试用京东APP筛选物品、查看物品一小时,你就会发现奇妙之处。
这里只介绍下分析使用到的工具,例如 top 、adb dumpsys meminfo 查看内存信息自行查阅
可以通过adb shell am start -W[packageName]/[packageName.MainActivity] 查看启动时间,通过adb shell , dumpsys meminfo [pkg] 看一些内存信息,包括当前Activity的个数
这里有一篇详细介绍Android Profiler 下面简单介绍浮框中各个字段含义,
Java 从Java或Kotlin代码分配的对象内存
Native 从C C++ 代码分配的对象内存
Graphics 图形缓冲区队列向屏幕显示像素(包括GL表面、GL纹理)所使用内存
Stack 应用中原生堆栈 和 Java 堆栈 使用的内存,这通常与应用运行多少线程有关
Code 用于处理代码和资源(如dex 字节码、已优化或已编译的dex码、so库和字体)的内存
Other 系统不确定如何分配的内存
Allocated 应用分配的 Java/Kotlin对象数,此数字没有计入C 、C++分配的对象
Total 使用的总内存大小
点击类,出现的类列表字段含义如下
Shallow Size 对象本身占用内存的大小,不包含其引用的对象
Retained Size 对象本身的 Shallow Size + 对象能直接或间接 引用的对象 Shallow Size ,可以理解为该对象被GC之后, 能回收的内存总和
Native Size 8.0之后的手机会显示,主要反应Bitmap所使用的像素内存(8.0之后,转移到了native)
Allocated 堆中已分配的大小,即APP时机占用的内存大小
使用Profiler 最不好的一点就是有时候会导致APP卡或者无响应,千万不要以为是自己写的代码问题。
当App处于A界面时,然后打开B页面,再回到A页面,执行几次GC,如果java 指标内存没有恢复,那么这里极有可能需要优化,然后借助下面两框工具分析
Android内存泄漏检测工具LeakCanary原理分析
一般用于线下集成,查看是否有内存泄漏问题,在分析页面卡顿时,应该把集成的LeakCanary去掉,避免误差
强大的Java Heap分析 ( 下载链接 )工具,生成整体报告、分析内存问题等,一般中小公司会用MAT,大公司可能会用JHT,木知。
一般也是线下深入使用,至于线上监控,目前是想通过上面优化策略的第五点来实现,分析问题后,线下使用这些工具分析。
可以使用Android Studio Profiler 获取 java heap文件,然后把该文件转换成hprof 文件,转换方法如下
hprof-conv before.hprof after.hprof //hprof在android studio的sdk的platform-tools目录下复制代码
然后通过MAT打开转换后的标准hprof
点击下面红框中的,例如histogram,可以搜索一些Activity,查看调用链,看看内存泄漏根源,还支持泄漏预警
这里有两篇简单的介绍MAT使用介绍一 MAT使用使用介绍二 Android内存优化
有一个整型数组A,其中只有一个数出现了奇数次,其他的都出现了偶数次,请打印这个数,要求时间复杂度位O(N),空间复杂度位O(1)。难度:Easy
public static int oddApperance1(int[] A, int n) { int e = 0; for (int i = 0; i < n; i++) { e = e ^ A[i]; } return e; } 复制代码
笔记七