背景
Android 为每一个 App 分配了一个进程,Android 针对为每个应用分配的堆大小设置了硬性限制,Android 会限制每个应用的堆大小,如果在达到堆的上限之后,还再尝试分配内存,此时会发生 OutOfMemoryError(内存溢出)。
1.1 获取内存
ActivityManager activityManager = (ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE)
activityManager.getMemoryClass();
1.2 设置大小的地方
在 AndroidRuntime.cpp 中 getMemoryClass 就是对应的 heapgrowthlimit 这个值
parseRuntimeOption("dalvik.vm.heapstartsize", heapstartsizeOptsBuf, "-Xms", "4m");
parseRuntimeOption("dalvik.vm.heapsize", heapsizeOptsBuf, "-Xmx", "16m");
parseRuntimeOption("dalvik.vm.heapgrowthlimit", heapgrowthlimitOptsBuf, "-XX:HeapGrowthLimit=");
parseRuntimeOption("dalvik.vm.heapminfree", heapminfreeOptsBuf, "-XX:HeapMinFree=");
parseRuntimeOption("dalvik.vm.heapmaxfree", heapmaxfreeOptsBuf, "-XX:HeapMaxFree=");
parseRuntimeOption("dalvik.vm.heaptargetutilization",
heaptargetutilizationOptsBuf,
"-XX:HeapTargetUtilization=");
1.3 查看应用堆内存
可以通过 ActivityManager.MemoryInfo 对象查看 当前的可用内存,最大内存,以及 低内存的阈值
ActivityManager activityManager = (ActivityManager) this.getSystemService(ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
// 可用内存
memoryInfo.availMem;
// 低内存的阈值
memoryInfo.threshold;
// 总内存
memoryInfo.totalMem;
二 Android内存分配与回收机制
2.1 JVM的内存模型
方法区
它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。1.8之前 更愿意把方法区称为“永久代”,1.8之后永久代移除了,变成了元空间,永久代 和 元空间 都是 JVM 方法区规范的一种实现而已,JDK1.7(包括)之后将常量池从永久代(PermGen)中移动到Java堆内存中了,1.8 永久代删除换成元数据
本地方法栈
本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的,各版本虚拟机自由实现 ,HotSpot直接把本地方法栈和虚拟机栈合二为一
java堆 又称GC堆
是Java虚拟机所管理的的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,从内存回收的角度来看,由于现在收集器基本采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等,成员变量就是放在这里的, JDK1.7(包括)之后将常量池从永久代(PermGen)中移动到Java堆内存中了
虚拟机栈
线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack-Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等消息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。间,也会oom
程序计数器
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。它可以看作是当前线程所执行的字节码的行号指示器。class 是用javap命令反编译,可以看出来 每行的序号。主要是用来记录当前线程的位置,当线程切换时,来记录改线程的下一条运行的指令。 这里解释一下为什么每个线程都需要一个线程计数器,JVM的多线程是通过线程轮流切换分配执行时间来实现的,在任何时刻,每个处理器都只会执行一个线程中的指令,当线程进行切换的时,为了线程能恢复当正确的位置,所以每个线程必须有个独立的线程计数器,这样才能保证线程之间不互相影响
2.2 对象的生命周期
在JVM运行空间中,对象的整个生命周期大致可以分为7个阶段:创建阶段(Creation)、应用阶段(Using)、不可视阶段(Invisible)、不可到达阶段(Unreachable)、可收集阶段(Collected)、终结阶段(Finalized)与释放阶段(Free)。上面的这7个阶段,构成了 JVM中对象的完整的生命周期。下面分别介绍对象在处于这7个阶段时的不同情形
创建阶段(Creation)
创建阶段为对象分配内存,调用构造函数,初始化属性。当对象创建后会存储在JVM的heap堆中等待使用,先看是否符合逃逸分析,再看是否开启了线程分配缓冲池,再判断是否是大对象。 据统计 90%的都会用一次,new 出来之后,gc 时就会回收 所以,新对象先放到 edog,回收一次,有可能都回收了,如果没回收进入 form区,开始正规的垃圾回收,form to form to
应用阶段(Using)
至少又一个强引用指向的对象被称为处于使用阶段
不可视阶段(Invisible)
引用对象超出其作用域后就变为不可见的,方法内的对象,在方法外是不可见的
不可到达阶段(Unreachable)
对象处于不可达阶段是指该对象不再被 GC root 引用所持有
可收集回收阶段(Collected)
当垃圾回收器发现该对象已经处于“不可达阶段”而且垃圾回收器已经对该对象的内存空间又一次分配做好准备时,则对象进入了“收集阶段”。假设该对象已经重写了finalize()方法,则会去运行该方法的终端操作
终结阶段(Finalized)
当对象运行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。
释放阶段或者再利用(Free)
垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间又一次分配阶段”。
2.2 可以作为gc root
-
虚拟机栈(栈帧中的局部变量表)中引用的对象。
-
方法区中静态类属性引用的对象。
-
方法区中常量引用的对象。
-
本地方法栈中JNI(即一般说的Native方法)引用的对象
2.3 垃圾回收算法
具体的GC可以查看Android GC 原理探究
标记-清理算法(Mark and Sweep GC)
用于老年代、永久代,(扫描两边,第一遍 标记,第二遍清除)先标记处所有的该回收的,然后清除,内存空间不连续,所以当有12个地址值的时候,
- 假如这里有90%的需要回收,那太麻烦了,假如有10%回收还好点 所以,标记清除 适合老年代 和 永久代
- 造成内存空间不连续,也就是内存碎片
标记-整理算法 (Mark-Compact)
用于老年代、永久代,先需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高,
- 涉及到了对象移动,地址引用需要更新,用户线程需要暂停,整体效率偏低
复制算法
新生代:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
- 效率问题:在对象存活率较高时,复制操作次数多,效率降低;
- 空间问题:內存缩小了一半;需要額外空间做分配担保(老年代) 这是标准的,但是jvm还有个eden区 8:1:1 以前的利用率是50%,现在有了eden区,和 一个from区,相当于利用率达到90%
2.4 Dalvik虚拟机的GC
主流的大部分Davik采取的都是标记-清理(Mark and Sweep)回收算法,也有实现了拷贝GC的,这一点和HotSpot是不一样的,Dalvik虚拟机的GC 会 Stop The World,这个时候整个程序的线程就会挂起,并且虚拟机内部的所有线程也会同时挂起,等待GC结束。在内存紧张的时候就会频繁执行GC,也就是STW这个动作,这样就会造成丢帧,界面卡顿的现象
2.5 ART的GC
ART运行时内部使用的Java堆的主要组成包括Image Space、Zygote Space、Allocation Space和Large Object Space四个Space,Image Space用来存在一些预加载的类, Zygote Space和Allocation Space与Dalvik虚拟机垃圾收集机制中的Zygote堆和Active堆的作用是一样的,Art在GC上不像Dalvik仅有一种回收算法,Art在不同的情况下会选择不同的回收算法,比如Alloc内存不够的时候会采用非并发GC,而在Alloc后发现内存达到一定阀值的时候又会触发并发GC。同时在前后台的情况下GC策略也不尽相同。后台GC的时候,不会Stop The World,Art相对Dalvik内存分配的效率提高了10倍,GC的效率提高了2-3倍。
三 进程优先级
当手机不能为系统释放足够的内存。在这种情况下,系统会使用 onTrimMemory() 通知应用内存不足,应该减少其分配量。如果这还不够,内核会开始终止进程以释放内存。它会使用低内存终止守护进程 (LMK) 来执行此操作
-
后台应用:之前运行过且当前不处于活动状态的应用。LMK 将首先从具有最高 oom_adj_score 的应用开始终止后台应用。终止的顺讯是下面的
-
上一个应用:最近用过的后台应用。上一个应用比后台应用具有更高的优先级(得分更低),因为相比某个后台应用,用户更有可能切换到上一个应用。
-
主屏幕应用:这是启动器应用。终止该应用会使壁纸消失。
-
服务:服务由应用启动,可能包括同步或上传到云端。
-
可觉察的应用:用户可通过某种方式察觉到的非前台应用,例如运行一个显示小界面的搜索进程或听音乐。
-
前台应用:当前正在使用的应用。终止前台应用看起来就像是应用崩溃了,可能会向用户提示设备出了问题。
-
持久性(服务):这些是设备的核心服务,例如电话和 WLAN。
-
系统:系统进程。这些进程被终止后,手机可能看起来即将重新启动。
-
原生:系统使用的极低级别的进程(例如,kswapd)
四 内存泄漏 以及 检查
4.1 内存泄漏的几个场景
-
资源性对象未关闭 File,Cursor,Stream当不用的时候及时关闭
-
单例模式的Context引起的内存泄漏 假如有个单利需要传入Context,我们最好使用 Application ,不要使用 Activity 或者 Service的。可以避免泄漏
-
非静态内部类创建静态实例引起的内存泄漏 非静态的内部类会自动持有外部类的引用,创建的静态实例就会一直持有的引用,可以考虑把内部类声明为静态的
-
Handler其实跟上面的一样,非静态的Handler 会引用到外部类的引用,如果用静态Handler的话,会引起 一连串的 静态属性。所以可以记住RxJava
-
对象没有反注册的,比如 BroadCastReciver 、 EventBus 、 RxJava的 Disposable.dispose
-
WebView的泄漏,有的人解决办法是开新进程,但是我也没看到那个几个app开启了新的进程呀(待解决)
-
集合对象没有及时清理引起的内存泄漏
-
线程造成的内存泄漏以及Runable
4.2 检测内存泄漏
4.2.1 MAT
4.2.2 Android Studio的 profiler
4.2.3 LeakCanary
前两个都是可视化的,当时一般的时候,我们用 LeakCanary 来检测内存泄漏,原理是
- 初始化:直接debugImplementation就能实现,他是在 ContentProvider里面做的初始化,当打包的时候,会合并各个清单文件。里面注册的 ContentProvider,ContentProvider 会在 Application的 attachBaseContext 之后, onCreate之前创建。在ContentProvider的出事时候,
- 引用队列可以配合软引用、弱引用及虚引用使用,引用的对象将要被JVM回收时,会将其加入到引用队列中。
- 注册 Application.ActivityLifecycleCallbacks 监听Activity的生命周期,以及 fragmentManager.registerFragmentLifecycleCallbacks监听Fragment的生命周期。
- 比如监听 onActivityDestroyed方法,当监听到这个方法调用的时候,把Activity 全部放到观察数组中,并且用引用队列包裹这个activity,生成key(UUID),然后过5s,看看引用队列里面有没有这个key,如果有,证明回收了,然后把观察数组中的remove掉这个key,此时如果这个数组里面的count > 0 ,证明有可能是怀疑的泄漏,然后 调用 Runtime.getRuntime().gc(),之后再 看看 引用队列有没有这个数据,如果有,然后把观察数组中的remove掉这个key,之后再看观察数组中的count,如果小于5,只是提示一下。如果count 大于 5 (防止卡顿),就开始使用 shark (2.0之前是haha)分析堆栈信息。用可达到性分析,找到最短的链路,
五 引用类型以及引用队列
我们知道在Java中除了基础的数据类型以外,其它的都为引用类型。而Java根据其生命周期的长短将引用类型又分为强引用、软引用、弱引用、虚引用
5.1 引用类型
强引用
强引用是使用最普遍的引用。如果一个对象具有强引用,JVM 宁愿抛出OOM也不会回收他
软引用
如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用
弱引用
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象
虚引用
相当于没有引用
5.2 引用队列
引用队列可以配合软引用、弱引用及幽灵引用使用,当引用的对象将要被JVM回收时,会将其加入到引用队列中,LeakCanary就是用的这个原理来检测Activity或者Fragment是否有泄漏
开启严格模式
比如在主线程读取数据库,读写文件操作,网络操作,你可以设置StrictMode监听那些潜在问题,出现问题时,打印日志,或者是 直接 抛异常。
public void onCreate() {
if (DEVELOPER_MODE) {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork() // or .detectAll() for all detectable problems
.penaltyLog()
.build());
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.penaltyDeath()
.build());
}
super.onCreate();
}
六 OOM 以及查找OOM
其实在 Android 中把Bitmap 处理好的话,基本上也就不会OOM了,Bitmap 是个重头戏,当然了在实际项目中我们一般用 Glide 就可以搞定了
6.1 造成OOM原因有哪些
- Java堆内存不足
- 堆内存碎片太多,导致放一个稍微大点就OOM了
- FB句柄太多
- 线程数量太多(我在华为荣耀30上,创建了1600多个线程就OOM了)
- 虚拟内不足
- 内存抖动,会导致GC频繁,界面卡顿,严重可能OOM
6.2 避免OOM的方案
6.2.1 监听低内存的时候释放资源
您可以在 Activity.onTrimMemory() Fragement.OnTrimMemory() Service.onTrimMemory() ContentProvider.OnTrimMemory() 类中实现 ComponentCallbacks2 接口,或者是在 Application 重写onTrimMemory方法,监听低内存的回调,做相应的释放回调官方文档
6.2.2 使用更加轻量的数据结构
常规 HashMap 实现的内存效率可能十分低下,因为每个映射都需要分别对应一个单独的条目对象。我们可以考虑使用ArrayMap/SparseArray而不是HashMap等传统数据结构,SparseArray 类的效率更高,因为它们可以避免系统需要对键(有时还对值)进行自动装箱,比如ArrayMap用时间换空间,我们可以考虑使用ArrayMap 用的是二分查找的方式,速度没有HashMap快,但是性能比Hashmap好,1000以内的google推荐使用 ArrayMap, SparseArray、SparseBooleanArray、LongSparseArray,使用这些API可以让我们的程序更加高效,他省去了自动装箱封箱功能
6.2.3 推荐使用 MMKV或者是DataStore用来替代SharePreference
MMKV和DataStore使用的都是Protobuf进行数据序列化的,SharePreferenc 使用的是Xml,Protocol Buffers性能优于Xml,MMKV的原理使用的MMap(内存映射),复制一次数据,也可以局部变更,然而 SharePreferenc 使用传统的IO操作,复制两次数据,同时并不能局部改变,每次改变都会写入整个xml,无论是commit还是apply都会造成ARN剖析 SharedPreference apply 引起的 ANR 问题
6.2.4 序列化数据推荐使用 Protobuf
Protobuf 是 Google 设计的一种无关乎语言和平台,并且可扩展的机制,用于对结构化数据进行序列化。该机制与 XML 类似,但更小、更快也更简单。如果您决定针对数据使用 Protobuf,则应始终在客户端代码中使用精简版 Protobuf。常规 Protobuf 会生成极其冗长的代码,这会导致应用出现多种问题,例如 RAM 使用量增多、APK 大小显著增加以及执行速度变慢。
6.2.5 避免内存抖动
正常的GC并不会对性能造成影响,如果在短时间内发生许多垃圾回收事件,就可能会快速耗尽帧时间。系统花在垃圾回收上的时间越多,能够花在呈现或流式传输音频等其他任务上的时间就越少,频繁GC 会有 Stop The World 的操作。会造成卡顿,内存碎片等等。避免在 大量for 分配多个临时对象, 减少在 onDraw() 初始化对象。
6.2.6 避免使用枚举
枚举会使class变大,在项目中宁愿写几个常量用IntDef注解表示 也不用枚举
6.2.7 避免直接 new Thread(),要用线程池 或者 是协程
线程是Android中非常可贵的一个东西,他占用的资源要比普通的类要多很多,我再华为荣耀30手机上,创建了 1600个线程,就OOM了
6.3 Bitmap 的使用
其实这个是个重头戏,android中 只要把 Bitmap 处理好,基本上不会OOM的,当我们在项目中一般用 Glide 基本上搞定,但是还要说几句。
Bitmap格式
-
argb_8888:每个像素占32位,a=8;r=8;g=8;b=8;一共8*4=32位,一个字节8位,共占四个字节;
-
argb_4444:每个像素占16位,2个字节;
-
rgb_565:每个像素占16位,2个字节;g代表的6位无效保留,
-
alpha_8:每个像素占8位,1个字节;
Bitmap 到底有多占内存
Bitmap有Api即 getByteCount(),具体的计算逻辑请看Android坑档案:你的Bitmap究竟占多大内存?
sd卡上 或者 网络下载的
假如 500x500的图片放到sd卡上,或者是网络下载的,当用 argb_8888 格式的时时候 算法是: 500x500x4 = 1000000 B = 1000000 / 1024 / 1024 = 0.95M,rag_565 要比 argb_8888 小一半
放在 drawable-xxdpi 中的,
如果是放在 drawable 文件夹中 ,是会根据 放drawable的文件夹不同以及手机屏幕密度改变而改变的,假如放到 drawable-xxdpi(对应的屏幕密度是480)中的话,手机对应的密度是440的话,还是 argb_8888
长/文件夹的密度*手机的密度 * 宽/文件夹的密度*手机的密度 * 4=(500/480 * 440) * (500/480 * 440) * 4 = 840277 B = 0.8M
也可以记 长 * 宽 * (文件夹密度/手机真实的密度)* 图片的格式
Bitmap的压缩
算了 还是看 腾讯的文章吧Android中图片压缩分析
大图检测的plugin
参考链接
google官网管理应用内存
Android内存优化之OOM
Android性能优化典范 - 第2季
计算性能优化
Android性能优化之内存优化
Android线上OOM问题定位组件
Android坑档案:你的Bitmap究竟占多大内存?