6、内存优化:内存抖动检测与Bitmap复用
内存优化这个话题,说实话我每次讲都觉得又爱又恨。爱的是它带来的性能提升立竿见影,恨的是排查起来有时候真能把人逼疯。今天咱们就聊聊内存抖动、Bitmap复用和Native内存泄漏这三个硬骨头。
6.1 内存抖动:那个让界面卡成PPT的元凶
内存抖动,说白了就是短时间内频繁创建和销毁对象。GC频繁触发,就像有人在后台不停地打扫卫生,每次打扫都要暂停你的主线程。你想想看,界面能不卡吗?
我在项目中遇到过最典型的场景:一个列表滑动页面,每帧都在创建新的对象。用户滑得越快,GC越频繁,最后卡得连手指都跟不上。嗯,这就是典型的内存抖动。
用Profiler抓内存抖动
Android Studio自带的Profiler其实挺好用的。我个人习惯这样操作:
- 打开Profiler,选择Memory面板
- 录制一段操作,比如快速滑动列表
- 观察内存曲线——如果出现锯齿状波形,基本就是内存抖动了
关键指标:GC频率超过每秒5次,或者单次GC耗时超过10ms,就需要警惕了。
用MAT做深度分析
Profiler只能告诉你「有问题」,但具体问题在哪,还得靠MAT。我记得有一次排查一个图片加载的抖动问题,Profiler上看到内存曲线像心电图一样,但就是定位不到具体代码。后来用MAT dump了堆快照,发现是某个工具类里每次调用都new了一个byte数组。
MAT的使用步骤其实不复杂:
- 在Profiler中点击「Dump Java Heap」
- 导出hprof文件,用Android Studio的转换工具转成标准格式
- 用MAT打开,重点看「Dominator Tree」和「Histogram」
小技巧:在Histogram中按Retained Heap排序,最大的那几个对象往往就是问题所在。我曾经靠这个找到过一个缓存了整张图片的HashMap,直接省了30%的内存。
6.2 Bitmap内存复用:别让图片吃光你的内存
Bitmap是Android内存消耗的大户。一张1920x1080的图片,加载到内存里就是1920*1080*4字节,差不多8MB。你想想看,如果列表里同时显示10张图,那就是80MB。这还没算上其他开销。
Bitmap复用池的设计
我个人习惯用LruCache配合复用池来做。核心思路是:不重复创建Bitmap,而是从池子里拿已经用过的。
public class BitmapPool {
private final LruCache<String, Bitmap> cache;
private final Set<Bitmap> reusableBitmaps;
public Bitmap get(int width, int height, Bitmap.Config config) {
// 先从复用池找
for (Bitmap b : reusableBitmaps) {
if (canReuse(b, width, height, config)) {
reusableBitmaps.remove(b);
b.eraseColor(Color.TRANSPARENT);
return b;
}
}
// 找不到就创建新的
return Bitmap.createBitmap(width, height, config);
}
private boolean canReuse(Bitmap b, int w, int h, Bitmap.Config c) {
return b.getWidth() >= w && b.getHeight() >= h
&& b.getConfig() == c;
}
}
注意:复用Bitmap时,一定要检查宽高和配置。我见过有人直接复用不同尺寸的Bitmap,结果图片拉伸变形,用户反馈说「这App是不是瞎了」。
inBitmap的妙用
Android 3.0之后提供了inBitmap选项,可以让Bitmap解码时复用已有的内存。说白了就是告诉系统:「别给我分配新内存了,用这块旧的就行」。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
options.inBitmap = pool.get(); // 从池子里拿一个可复用的
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),
R.drawable.image, options);
这里有个坑:inBitmap的尺寸必须大于等于要解码的图片。我曾经在这个问题上栽过跟头,解码一张小图时复用了大图的Bitmap,结果直接崩溃。嗯,从那以后我每次都会做尺寸校验。
6.3 Native内存泄漏:那个看不见的杀手
Java层的内存泄漏,MAT和Profiler都能抓到。但Native层的内存泄漏,说实话排查起来要麻烦得多。我遇到过最头疼的一次:一个视频播放器,播放半小时后内存涨了200MB,但Java堆只有50MB。剩下的150MB去哪了?答案就是Native堆。
排查Native内存泄漏的工具
我个人常用的工具链是这样的:
| 工具 | 用途 | 适用场景 |
|---|---|---|
| AddressSanitizer | 检测内存越界和泄漏 | Debug版本 |
| Malloc Debug | 跟踪malloc/free调用 | 定位具体泄漏点 |
| SimplePerf | 分析Native内存分配 | 性能分析 |
实战:用Malloc Debug定位泄漏
我记得有一次排查一个OpenGL纹理泄漏的问题。纹理对象在Native层分配,Java层根本看不到。用Malloc Debug的步骤是这样的:
- 在AndroidManifest中开启Malloc Debug:
android:debuggable="true" - 用adb命令启动:
adb shell setprop libc.debug.malloc 1 - 运行App,复现问题
- 抓取backtrace:
adb shell dumpsys meminfo
核心思路:Native内存泄漏的排查,关键是要找到「谁分配了内存但没释放」。Malloc Debug能记录每次malloc的调用栈,配合脚本分析,就能定位到具体的代码行。
避免Native泄漏的编码习惯
说实话,Native内存泄漏最好的解决方式就是预防。我总结了几个习惯:
- 所有Native资源都在Java层用finalize或Cleaner做兜底释放
- JNI调用中,每次NewGlobalRef都要对应一个DeleteGlobalRef
- OpenGL纹理、EGLSurface等资源,一定要在对应的生命周期回调中释放
个人经验:我习惯在Native代码里加一个引用计数。每次分配资源时计数+1,释放时-1。如果App退出时计数不为0,就说明有泄漏。这个方法虽然土,但真的管用。
6.4 知识体系总览
说了这么多,咱们用一张图来总结一下内存优化的核心脉络:
这张图其实就概括了我们今天聊的所有内容。内存优化没有银弹,但只要你掌握了这三个方向——抖动检测、Bitmap复用、Native泄漏排查——大部分问题都能迎刃而解。
最后说一句:内存优化这件事,工具只是辅助,真正重要的是编码时的意识。每次new一个对象之前,先问问自己:「这个对象能不能复用?」养成这个习惯,你的App内存表现不会差。