了解JVM内存管理
的基础内容,对我们理解内存分配有很大的帮助:比如Java堆
的原理,JVM
如何判断对象的存活、几种垃圾回收算法:
关于这部分,可以参考笔者之前写的JVM|翻越内存管理的墙。
在 Android
中有个机制叫 Low Memory Killer,当 Cached Pages
太少时,就会被触发。它的工作方式是根据进程的优先级,选择性地杀死某个进程,释放该进程占用的所有资源以满足内存分配需要。
如果 LMK
杀掉的是用户正在交互或可以感知的进程,将会导致非常不友好的用户体验。所以 Android的SystemServer
进程维护了一张进程优先级列表,LMK
根据这张表来决定先杀死哪个进程。从后台、桌面、服务、前台,直到手机重启。这些按照优先级排队等着被kill
设备的物理内存被分为很多页(Page),Linux Kernel
将会持续跟踪每个进程使用的Pages
,所以只要对进程使用的Pages
进行计数即可,但实际情况远比这要复杂的多,因为有些 Pages 是进程间共享的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-etCevIcP-1659968097701)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0b82a94cd8bf48249929fac420d4c2ee~tplv-k3u1fbpfcp-watermark.image?)]
因此共享内存页计数的方法有多种:
实际上,至少需要系统级别的上下文才能知道识别 RSS
与USS
。所以通常都是使用 PSS
来计算,可以使用以下命令查看一个进程的 PSS
使用情况:
adb shell dumpsys meminfo -s [process]
Activity
对象被生命周期更长的对象通过强引用持有,使Activity
生命周期结束后仍无法被GC机制回收,从而泄漏Activity
持有的大量View
和其他对象。导致其占用的内存空间无法得到释放。
内存泄漏主要分两种情况,一种是同一个对象泄漏,还有一种情况更加糟糕,就是每次都会泄漏新的对象,可能会出现几百上千个无用的对象。
从来源上这类例子是举不完的,比如:
Activity
泄漏。BraodcastReceiver
,ContentObserver
,FileObserver
,Cursor
,Callback
等在Activity onDestroy
或者某类生命周期结束没有unregister
或者close
掉Runnable
持有Activity
,或者此Runnable
本身并不耗时,但在它前面有个耗时的 Runnable
堵塞了执行线程导致此 Runnable
一直没机会从等待队列里移除,也会引发 Activity
泄漏等等。更多的泄露场景可参考:
内存泄露从入门到精通三部曲之常见原因与用户实践
LeakCanary 和 ResourceCanary
LeakCanary
能给出可读性非常好的检测结果,在 Activity 泄漏检测、分析上完全可以代替人力。美中不足的是LeakCanary
把检测和分析报告都放到了一起,流程上更符合开发和测试是同一人的情况,对批量自动化测试和事后分析就不太友好了。
ResourceCanary 是腾讯质量监控平台 Matrix
的一部分,参与到每天的自动化测试流程中。ResourceCanary
做了以下改进:
1.分离检测和分析两部分逻辑
事实上这两部分本来就可以独立运作,检测部分负责检测和产生 Hprof
及一些必要的附加信息,分析部分处理这些产物即可得到引发泄漏的强引用链。这样一来检测部分就不再和分析、故障解决相耦合,自动化测试由测试平台进行,分析则由监控平台的服务端离线完成,再通知相关开发同学解决问题。三者互不打断对方进程,保证了自动化流程的连贯性。
2.裁剪 Hprof 文件,降低后台存档 Hprof 的开销。
就 Activity
泄漏分析而言,我们只需要 Hprof
中类和对象的描述和这些描述所需的字符串信息,其他数据都可以在客户端就地裁剪。由于 Hprof
中这些数据比重很低,这样处理之后能把 Hprof 的大小降至原来的 1/10 左右,极大降低了传输和存储开销。
MAT(Memory Analyzer Tool)
MAT
工具全称为Memory Analyzer Tool
,一款详细分析Java堆内存的工具,该工具非常强大,为了使用该工具,我们需要hprof
文件。
hprof
文件可以通过Android Profiler
获取,使用方式可参考Android Profiler 工具常用功能
但是hprof
文件文件不能直接被MAT使用,需要进行一步转化,可以使用hprof-conv
命令来转化,Android Studio
也可以直接转化。
MAT
中有个Leaks suspects视图,可以非常方便的帮助我们定位到内存泄露的地方。MAT
使用方式可以参考:
Android 内存泄漏MAT使用详解
Activity
等组件的引用应该控制在 Activity
的生命周期之内; 如果不能就考虑使用 getApplicationContext
或者 getApplication
,以避免Activity
被外部长生命周期的对象引用而泄露。Handler
持有的引用对象最好使用弱引用,资源释放时清空Handler
里面的消息。比如在Activity onStop
或者 onDestroy
的时候,取消掉该 Handler
对象的 Message
和 Runnable
。Runnable
执行耗时操作,注意在页面返回时及时取消或者把 Runnable
写成静态类。如果线程类是内部类,改为静态内部类。线程内如果需要引用外部类对象如context
,需要使用弱引用。有时候会遇到一些难以立即解决的泄漏,可以采取一些措施规避:
Activity
对 View
的引用、回收 View
中的 Drawable
,降低 Activity
泄漏带来的影响Application Context
获取某些系统服务实例,规避系统带来的内存泄漏LeakCanary
给出的建议进行规避在程序里每创建一个对象,就会有一块内存分配给它。每分配一块内存,程序的可用内存也就少一块。当程序被占用的内存达到一定临界程度,GC
也就是垃圾回收器(Garbage Collector)就会出动,来释放掉一部分不再被使用的内存。
如果在短时间频繁创建出一大批只被使用一次的对象(比如在onDraw()
里写了创建对象的代码,当界面频繁刷新的时候),这就会导致内存占用的迅速攀升。然后很快,可能就会触发 GC
的回收动作。
频繁创建这些对象会造成内存不断地攀升,在刚回收了之后又迅速涨起来,那么紧接着就是又一次的回收。这么往复下来,最终导致一种循环,一种在短时间内反复地发生内存增长和回收的循环。这种循环往复的状态,专业称呼叫 Memory Churn
。Android
的官方文档里把它翻译成内存抖动。
一句话概括,内存抖动就是指,短时间内不断发生内存增长和回收的循环往复状态。
(PS:要关注概念背后的东西,而不是概念这个词本身)
关于内存抖动,推荐看下凯哥的这个视频/文章,:
「内存抖动」?别再吓唬面试者们了行吗
在实践中,我们在 onDraw() 里创建的对象往往是绘制相关的对象,而这些对象又经常会包含通往系统下层的 Native 对象的引用,这就导致在 onDraw() 里创建对象所导致的内存回收的耗时往往会更高,直白地说就是—界面更卡顿。
内存抖动的解决方法一般都很简单。关键是如何发现抖动,定位到具体位置?
内存抖动的定位可直接使用Memory Profiler
,发生内存抖动时,Memory Profiler
会显示明显的锯齿状效果,我们选择内存变化锯齿状的区域。
然后点击Allocations
进行对象分配数量排序(如果发生了内存抖动,大概率的是在对象数量多的地方出现了问题,因此先进行对象数量排序)
找到排在前几位的对象,查看其调用栈。
上述文字不够清晰的话,可以参照这个图文并茂的例子:
Android 内存优化一 内存抖动的定位及优化
开发过程中,基本都会遇到java.lang.OutOfMemoryError
,该错误就是常说的OOM
引发的,OOM
就是OutOfMemory
,内存溢出。这种错误解决起来相对于一般的Exception
或者Error
都要难一些,因为错误产生的根因不是很明显,所以定位OOM
问题,依旧是重中之中。
要定位OOM
问题,首先需要弄明白Android
中有哪些原因会导致OOM
,Android
中导致OOM
的原因主要可以划分为以下几个类型:
通过Runtime.getRuntime.MaxMemory()
可以得到Android
中每个进程被系统分配的内存上限,当进程占用内存达到这个上限时就会发生OOM
,这也是Android
中最常见的OOM
类型:为对象分配内存时达到进程的内存上限。
此时就回出现常见的错误的日志打印格式:
oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM";
堆内存分配失败,通常说明进程中大部分的内存已经被占用了,且不能被垃圾回收器回收,一般来说此时内存占用都存在一些问题,例如内存泄漏等。要想定位到问题所在,就需要知道进程中的内存都被哪些对象占用,以及这些对象的引用链路。而这些信息都可以在Java
内存快照文件中得到,调用Debug.dumpHprofData(String fileName)
函数就可以得到当前进程的Java
内存快照文件,即HPROF
文件。
线下的定位方法,和内存泄露的定位方式一样,可以通过Android Profiler
获取hprof
文件`,再通过MAT(Memory Analyzer Tool) 查看具体的内存被哪些对象占用了。
内存的监控方案,可以学习下KOOM(Kwai OOM, Kill OOM)
,是快手性能优化团队在处理移动端OOM
问题的过程中沉淀出的一套完整解决方案。
KOOM——高性能线上内存监控方案
美团也有一个用于快速定位线上OOM问题的组件—Probe,项目虽没有开源,但也分享了实现的思路:
Probe:Android线上OOM问题定位组件
应用是否占用了过多的内存,跟设备、系统和当时情况有关。不能根据具体的数值来决定性能。最理想的状态是,当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到“用时分配,及时释放”。
Bitmap
内存一般占应用总内存很大一部分,所以做内存优化没法避开的就是Bitmap
的优化。
随着Android
系统版本的演进,Bitmap
也被官方不断的折腾,对Bitmap
存放的位置做了好几次变换。
Android 3.0
之前,Bitmap
对象放在Java 堆
,而像素数据是放在Native 内存
中。如果不手动调用 recycle
,Bitmap Native
内存的回收完全依赖finalize
函数回调,这个时机不太可控的。Android 3.0-Android 7.0
将 Bitmap
对象和像素数据统一放到 Java 堆
中,这样就算不调用 recycle
,Bitmap
内存也会随着对象一起被回收。不过Bitmap
是内存消耗的大户,而手机分配给Java
堆内存也不多,即使手机内存还剩很多,依然会出现Java堆内存
不足出现OOM
。(Bitmap
放到 Java 堆的另外一个问题会引起大量的 GC
,对系统内存也没有完全利用起来。)NativeAllocationRegistry
是Android 8.0(API 27)
引入的一种辅助回收native
内存的机制。它会将 Bitmap
内存放到 Native
中,但可以做到和对象一起快速释放,同时 GC
的时候也能考虑这些内存防止被滥用。在 Android 3.0~Android 7.0,其实也是有方法将图片的内存放到 Native
中的:
将 Java Bitmap
的内容绘制到申请的空的 Native Bitmap
中。再将申请的Java Bitmap
释放,实现图片内存的“偷龙转凤”。(PS:虽然最终图片的内存的确是放到 Native
中了,不过与此同时带来了两个问题,一个是兼容性问题,另外一个是频繁申请释放Java Bitmap
容易导致内存抖动。)
// 步骤一:申请一张空的 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;
Android 8.0
重新将Bitmap
内存放回到Native
内存中,那么我们是不是就可以随心所欲地使用图片呢?
答案肯定是不行的。随心所欲的使用,也会很容易导致Native
内存不够,引发LMK
。还由于一些保活机制,违反了Android
规范,各个进程的互相拉起,导致system server
卡死。
把所有的Bitmap
都放到 Native 内存
,并不代表图片内存问题就完全解决了,这样做只是提升了系统内存利用率,减少了 GC 带来的一些问题而已。
图片内存优化的有个很关键步骤是收拢图片的调用,这样我们可以做整体的控制策略。
例如可以针对低端机统一使用 565 格式、更加严格的缩放算法。
还可以根据不同的情况使用 Glide
、Fresco
或者采取自研的图片框架,同时可以根据业务需要无痛的切换框架。
进一步的也要将所有Bitmap.createBitmap
、BitmapFactory
相关的接口也一并收拢。
即图片的大小不应该超过view
的大小。在把图片载入内存之前,我们需要先计算出一个合适的inSampleSize
缩放比例,避免不必要的大图载入。
千万不要去加载不需要的分辨率。在一个很小的ImageView
上显示一张高分辨率的图片不会带来任何视觉上的好处,但却会占用相当多宝贵的内存。需要注意的是,将一张图片解析成一个Bitmap
对象时所占用的内存并不是这个图片在硬盘中的大小,可能一张图片只有100k你觉得它并不大,但是读取到内存当中是按照像素点来算的,比如这张图片是1920x1080 像素,使用的ARGB_8888
颜色类型,那么每个像素点就会占用4个字节,总内存就是1920x1080x4字节,接近8M,这就相当浪费内存了。
如何加载的高效加载大型位图,关键还是计算采样率:
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
需要注意的是,先将 inJustDecodeBounds
设为 true
进行解码,传递选项,然后使用新的 inSampleSize
值并将 inJustDecodeBounds
设为 false
再次进行解码:
fun decodeSampledBitmapFromResource(
res: Resources,
resId: Int,
reqWidth: Int,
reqHeight: Int
): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeResource(res, resId, this)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeResource(res, resId, this)
}
}
我们可以使用 OnTrimMemory
回调,根据不同的状态决定释放哪些内存。一般情况以下资源可以按需释放:
缓存:缓存包括一些文件缓存,图片缓存等,当应用程序UI
不可见的时候,这些缓存就可以被清除以减少内存的使用,比如第三方图片库的缓存。
一些动态添加的View。这些动态生成和添加的View
且少数情况下才使用到的View
,这时候可以被释放,下次使用的时候再进行动态生成即可。比如原生桌面中,会在OnTrimMemory
的TRIM_MEMORY_MODERATE
等级中,释放所有AppsCustomizePagedView
的资源,来保证在低内存的时候,桌面不会轻易被杀掉。
@Override
public void onTrimMemory(int level) {
super.onTrimMemory(level);
if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
mAppsCustomizeTabHost.onTrimMemory();
}
}
关于OnTrimMemory
优化更多细节可以参考:
Android 代码内存优化建议 - OnTrimMemory 优化
为了让应用有更大的可使用内存,常常会另外开辟一个新的进程去处理业务。需要注意的是:一个空的进程也会占用 10MB 的内存。
当然,并不是说开新进程的方式不好,这里想说的还是要谨慎使用,如果使用不当,它会显著增加内存的使用,而不是减少。
减少应用启动的进程数、减少常驻进程对低端机内存优化是非常重要的。
使用类似device-year-class
的策略对设备分级,device-year-class
会根据手机的内存、CPU 核心数和频率等信息决定设备属于哪一个年份。
对于低端机用户可以关闭复杂的动画,或者是某些吃内存的功能,选择使用 565 格式的图片,使用更小的缓存内存等。
随着对性能优化的理解,发现优化的方法并不是重难点,关键是在于去主动、及时的发现问题所在。
要想实现主动和及时,代码采用优化--埋坑--优化--埋坑
的方式并不能帮我们做到。
发力点应该在于去建立一套合理的框架与监控体系,能及时的发现诸如bitmap
过大、像素浪费、内存占用过大、应用OOM
等问题。好在现在已经有很多大厂开源了它们的方案,我们可以使用它们的方案或借鉴学习它们的实现思路。
分析并优化 Android 应用内存占用
Android OOM案例分析
Android 代码内存优化建议 - OnTrimMemory 优化
如何加载的高效加载大型位图
Probe:Android线上OOM问题定位组件
android-performance-optimization