Android 性能优化:内存优化(实践篇)

1. 前言

前一篇文章Android性能优化:内存优化 (思路篇) 大概梳理了Android 内存原理和优化的必要性及应该如何优化,输出了一套短期和长期内存优化治理的SOP方案。

那么这一篇文章就总结下我最近在做内存优化如何实践的,本篇文章有参考了很多其他大佬的文章,站在巨人肩膀上确实更加省力,感谢~ ,这里会对大部分内存优化相关的操作都罗列进来,但是部分内容笔者研究有限,仅用于笔记记录和总结,有不对的地方可以指出,望海涵...

2. 获取内存信息

这里还是要先插入下Android App的内存构成

您在内存分析器顶部看到的数字,基于您的应用提交的所有专用内存页面(此数据由 Android 系统根据其记录提供)。此计数不包含与系统或其他应用共享的页面。

内存计数中的类别如下:

  • Java:从 Java 或 Kotlin 代码分配的对象的内存。
  • Native:从 C 或 C++ 代码分配的对象的内存。

即使您的应用中不使用 C++,您也可能会看到此处使用了一些原生内存,因为即使您编写的代码采用 Java 或 Kotlin 语言,Android 框架仍使用原生内存代表您处理各种任务,如处理图像资源和其他图形。

  • Graphics:图形缓冲区队列为向屏幕显示像素(包括 GL 表面、GL 纹理等等)所使用的内存。(请注意,这是与 CPU 共享的内存,不是 GPU 专用内存。)
  • Stack:您的应用中的原生堆栈和 Java 堆栈使用的内存。这通常与您的应用运行多少线程有关。
  • Code:您的应用用于处理代码和资源(如 dex 字节码、经过优化或编译的 dex 代码、.so 库和字体)的内存。
  • Others:您的应用使用的系统不确定如何分类的内存。
  • Allocated:您的应用分配的 Java/Kotlin 对象数。此数字没有计入 C 或 C++ 中分配的对象。

3. 内存检测工具

常用的就是以下三种内存检测方式,这里简单罗列下优缺点

3.1 LeakCanary

线下使用,目前大厂内存检测的思路开辟者,集成简单 ,自动化内存泄漏检测神器。主要用于线下集成,虽然使用了idleHandler与多进程,但是 dumphprof 的 SuspendAll Thread 的特性依然会导致应用卡顿,不过只能够自动检测 Activity、Fragment 和其他常见组件的内存泄漏,但某些复杂情况下的泄漏可能无法被自动检测到,需要开发者手动检查和分析。

3.2 Memory Profiler

Android studio中内置的一个强大的工具,其中主要包括NetWork ,cpu 和 Memory Profiler部分,如果是Mac M2以上的开发,建议深度使用,使用起来还是比较流畅的。

Memory Profiler 显示堆内存的实时使用情况,包括 Java 堆、Native 堆和其他内存占用。你可以通过图表查看应用内存使用的波动情况,点击Capture heap dump可以输出当前堆的内存快照,也可以进行Java/Kotlin 或者 Native allocations 。

看下图,Memory Profiler 会展示出类的列表。对于每个类,Allocations 这一列显示的是它的实例数量。后边依次是 Native Size、Shallow Size 和 Retained Size:

Shallow Size对象本身消耗的内存大小,即为红色节点自身所占内存:

Native Size 它是类对象所引用的 Native 对象 (蓝色节点) 所消耗的内存大小:

Retained Size 它是下图中所有橙色节点的大小,由于一旦删除红色节点,其余的橙色节点都将无法被访问,所以橙色节点是被红色节点所持有的,因此被命名为 Retained Size

3.3 Memory Analyzer

(MAT)是一个强大的分析工具,用于查找 Java 应用程序中的内存泄漏并分析内存使用情况。可以使用命令行或者 Android studio 中生成堆转储 (Heap Dump),可以在Leak Suspects Report中查看内存泄露点,Dominator Tree中查看哪些对象占用了大内存,并根据调用链向上排查。

如果使用Android studio profiler Memory视图中 export 生成的 .hprof文件,需要通过hprof工具进行类型转换,避免MAT 打开文件提示文件类型错误。

因为Android Studio保存的是Android Dalvik/ART格式的.hprof文件,所以需要转换成J2SE HPROF格式才能被MAT识别和分析。Android SDK自带了一个转换工具在SDK的platform-tools下,其中转换语句为

shell 复制代码
hprof-conv <input>memory-20241231T151336.hprof <output>memory1.hprof

在MAT窗口上,OverView是一个总体概览,显示总体的内存消耗情况和疑似问题。MAT提供了多种分析维度,其中Histogram、Dominator Tree、Top Consumers和Leak Suspects的分析维度是不同的。

Histogram 列出内存中的所有实例类型对象和其个数以及大小 ,并在顶部的regex区域支持正则表达式查找

Dominator Tree 列出最大的对象及其依赖存活的Object。相比Histogram直方图,能更方便地看出引用关系

Top Consumers 是通过图像列出最大的 Object

Leak Suspects 则是自动分析内存泄露的原因的整体报告

分析内存最常用的是HistogramDominator Tree这两个视图,视图中一共有四列:

  • Class Name:类名
  • Objects:对象实例个数
  • Shallow Heap :对象自身占用的内存大小,不包括它引用的对象 。非数组的常规对象的Shallow Heap Size由其成员变量的数量和类型决定,数组的Shallow Heap Size由数组元素的类型(对象类型、基本类型)和数组长度决定。真正的内存都在堆上,看起来是一堆原生的byte[]、char[]、int[],对象本身的内存都很小。因此Shallow Heap对分析内存泄漏意义不是很大
  • Retained Heap :是当前对象大小与当前对象可直接或间接引用到的对象的大小总和,包括被递归释放的。即:Retained Size就是当前对象被GC后,从Heap上总共能释放掉的内存大小。

相比于MAT的查找内存泄露,个人更喜欢使用 Android studio中的 profiler,查看更加直接,点击泄露对象,查看References 排查链路中可能得泄漏点。

4. OOM常见问题

其实笔者是在优化OOM问题过程中,针对内存优化做了优化,先从技术或代码维度优化写法,减少内存占用,但是最后发现是业务的内容太多,所以最后也落点到了业务层面。

导致App 发生OOM和内存占用过高主要分为以下几个方面 :

  1. Bitmap内存占用:大尺寸图片资源,未合理的对图片进行裁剪缩放和缓存,导致内存占用高
  2. 内存泄露 :内存中有对象的引用无法被释放,导致这些对象无法正常被回收,最终导致内存耗尽(导致OOM)
  3. 内存溢出 :Android 应用存在固定内存限制,超过一定数量会导致程序崩溃 (导致OOM)
  4. 内存抖动 :内存频繁分配和回收导致内存出现锯齿抖动的现象 (导致OOM)
  5. 过多线程开辟 :大量创建线程,或线程池资源未正确管理,可能会占用大量内存(导致OOM)

4.1 Bitmap内存占用

其实这里主要说的就是 Bitmap Native 内存占用,因为新版本模拟器 Bitmap 内存都会放在 Native 中,并且对于直播多媒体App,Bitmap在App的占比肯定是最高的,所以这里我把Bitmap内存占用放在第一位。

App端主要有两种形式加载 Bitmap 图片:本地图片和网络图片,本地图片推荐使用webp格式,缩小图片大小,尽量保证图片尺寸和展示View大小符合。网络图片则需要动态加载好bitmap大小,获取宽高进行截取,所以推荐直接使用GlidePicasso,省心省力,但是还是大概说下不用工具的话,部分写法。

如果项目中没有使用 GlidePicasso 等支持图片裁切加载和多级缓存的图片仓库的话,需要自己根据View展示大小,手动压缩图片大小和图片质量。

java 复制代码
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.drawable.large_image, options);
int outWidth = options.outWidth;
int outHeight = options.outHeight;
int inSampleSize = calculateInSampleSize(outWidth, outHeight, reqWidth, reqHeight);
options.inSampleSize = inSampleSize;
options.inJustDecodeBounds = false;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.large_image, options);

如果手写缓存的话,可以使用 LruCache 或 磁盘缓存 DiskLruCache

java 复制代码
private static final int MAX_MEMORY = (int) (Runtime.getRuntime().maxMemory() / 1024);
private static final int CACHE_SIZE = MAX_MEMORY / 8;
LruCache<String, Bitmap> mMemoryCache = new LruCache<String, Bitmap>(CACHE_SIZE) {
    @Override
    protected int sizeOf(String key, Bitmap bitmap) {
        // 计算 Bitmap 所占内存大小
        return bitmap.getRowBytes() * bitmap.getHeight();
    }
};

使用 GlidePicasso 的话这里就不过多赘述两个图片加载工具在加载,异常处理异步执行和 多级缓存等原理了,在项目中碰到的使用了 Picasso 依然可能存在对于图片拉伸计算错误导致的实际 bitmap截取后比View大小稍大的问题,所以引出我们下边要说的 Bitmap大小监控

4.2 Bitmap图片大小监控

想到的第一个办法就是实现一个自定义 ImageView (AppCompatImageView 也继承于 ImageView) ,在View中区判断要加载的图片和实际View大小在进行尺寸上的压缩和优化,但是在实际多人并发开发中很难实现和代码收口。这里列举 业志陈 文章代码。

复制代码
open class MonitorImageView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : android.widget.ImageView(context, attrs, defStyleAttr), MessageQueue.IdleHandler {

    //...

    override fun setImageBitmap(bm: Bitmap?) {
        super.setImageBitmap(bm)
        monitor()
    }

    private fun checkDrawable() {
        val mDrawable = drawable ?: return

        val drawableWidth = mDrawable.intrinsicWidth
        val drawableHeight = mDrawable.intrinsicHeight
        val viewWidth = measuredWidth
        val viewHeight = measuredHeight

        val imageSize = calculateImageSize(mDrawable)

        if (imageSize > MAX_ALARM_IMAGE_SIZE) {
            log(log = "图片大小超标 -> $imageSize")
        }

        if (drawableWidth > viewWidth || drawableHeight > viewHeight) {
            log(log = "图片尺寸超标 -> drawable:$drawableWidth x $drawableHeight view:$viewWidth x $viewHeight")
        }
    }
    
    // other methods and logic go here...
}

如果项目中使用 Glide,那第二种办法就是加载成功后添加检测 Bitmap 尺寸是否过大,但是也存在一些弊端,如果更换图片加载库,必须要每次主动调用 onResourceReady 方法获取信息。

kotlin 复制代码
Glide.with(context)
    .asBitmap()
    .load(url)
    .listener(object : RequestListener<Bitmap> {
        override fun onLoadFailed(
            e: GlideException?,
            model: Any?,
            target: Target<Bitmap>?,
            isFirstResource: Boolean
        ): Boolean {
            // 图片加载失败处理
            // ...
            return false
        }

        override fun onResourceReady(
            resource: Bitmap?,
            model: Any?,
            target: Target<Bitmap>?,
            dataSource: DataSource?,
            isFirstResource: Boolean
        ): Boolean {
            // 图片加载成功,检查是否为大图
            resource?.let {
                val imageSize = calculateImageSize(it)
                if (imageSize > LARGE_IMAGE_THRESHOLD) {
                    // 处理大图逻辑,如压缩、裁剪或异步加载
                    handleLargeImage(it)
                }
            }
            return false
        }
    })
    .into(target)

简单列举了没有使用图片加载库和 使用了图片加载库 Glide几种简单检测大图方式。下面列举下目前比较流行的几种监控Bitmap大小的方案:ASM字节码插桩和Native Hoook

4.2.1 ASM字节码插桩

ASM 是一种操作 Java 字节码的工具,允许在编译时或运行时动态修改 Java 类的字节码。通过 ASM,我们可以在编译阶段插入额外的逻辑代码,例如在 ImageView.setImageBitmap() 方法中插入检测逻辑。

kotlin 复制代码
class LegalBitmapTransform(private val config: LegalBitmapConfig) : BaseTransform() {

    companion object {
        private const val ImageViewClass = "android/widget/ImageView"
    }

    override fun modifyClass(byteArray: ByteArray): ByteArray {
        val classReader = ClassReader(byteArray)
        val className = classReader.className
        val superName = classReader.superName

        Log.log("className: $className superName: $superName")

        // 判断是否是 ImageView 子类
        return if (className != config.formatMonitorImageViewClass && superName == ImageViewClass) {
            val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
            val classVisitor = object : ClassVisitor(Opcodes.ASM6, classWriter) {
                override fun visit(
                    version: Int,
                    access: Int,
                    name: String?,
                    signature: String?,
                    superName: String?,
                    interfaces: Array<out String>?
                ) {
                    // 保留类信息,但修改父类为自定义监控类(如果提供)
                    super.visit(
                        version,
                        access,
                        name,
                        signature,
                        config.formatMonitorImageViewClass ?: superName,
                        interfaces
                    )
                }

                override fun visitMethod(
                    access: Int,
                    name: String?,
                    descriptor: String?,
                    signature: String?,
                    exceptions: Array<out String>?
                ): MethodVisitor {
                    val mv = super.visitMethod(access, name, descriptor, signature, exceptions)
                    // 判断目标方法 setImageBitmap(Bitmap)
                    if ("setImageBitmap" == name && "(Landroid/graphics/Bitmap;)V" == descriptor) {
                        Log.log("Hooking method: setImageBitmap in $className")
                        // 返回自定义 MethodVisitor
                        return object : MethodVisitor(Opcodes.ASM6, mv) {
                            override fun visitCode() {
                                // 方法开始时插入逻辑
                                super.visitCode()

                                // 1. 加载 this(即 ImageView 实例)
                                mv.visitVarInsn(Opcodes.ALOAD, 0)

                                // 2. 加载 Bitmap 参数
                                mv.visitVarInsn(Opcodes.ALOAD, 1)

                                // 3. 调用静态方法进行监控
                                mv.visitMethodInsn(
                                    Opcodes.INVOKESTATIC,
                                    "com/example/BitmapValidator", // 静态工具类路径
                                    "checkBitmapSize",           // 静态方法名称
                                    "(Landroid/widget/ImageView;Landroid/graphics/Bitmap;)V", // 方法签名
                                    false
                                )
                            }
                        }
                    }
                    return mv
                }
            }
            classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
            classWriter.toByteArray()
        } else {
            // 如果不匹配目标类,则返回原始字节码
            byteArray
        }
    }

}
4.2.2 Java Hook 和 Inline-Hook

目前Native Hook 大部分都是使用 Epic等框架实现,基于 Android ART/Java 虚拟机的运行时方法替换技术,它工作在 Java 层ART 层 ,通常利用 ART 虚拟机的 ArtMethod 结构,通过修改 Java 方法的指针或方法表来实现 Hook,以 Hook Bitmap为例,Epic Hook主要是修改Java层的 Bitmap.createBitmap()方法

项目中 Debug环境使用的 Inline-Hook 的方式,也是看了喜马同事 世欣 GitHub 的文章 才知道原来还可以这么玩。inline-hook 是基于 机器指令级别 的 Hook 技术,直接修改目标函数的指令、地址或者二进制代码,实现对函数的拦截或行为替换。它工作在 Native 层,主要通过重写函数的入口指令或跳转地址来实现。

cpp 复制代码
jint do_hook_bitmap(long bitmap_recycle_check_interval,
                        long get_stack_threshold,
                        long restore_image_threshold,
                        const char *restore_image_dir,
                        bool notify_check_local_image_size) {

    g_recycle_check_interval_second = bitmap_recycle_check_interval;
    g_get_stack_threshold = get_stack_threshold;
    g_restore_image_threshold = restore_image_threshold;
    g_restore_image_dir = restore_image_dir;
    g_notify_check_local_image_size = notify_check_local_image_size;

    int api_level = get_api_level();

    if (api_level > 33) {
        return -2;
    }

    LOGI("hookBitmapNative called,  printStackThreshold: %ld, restore_image_threshold: %ld, api_level: %d",
         get_stack_threshold, restore_image_threshold, api_level);

    // 根据Android 不同版本 Bitmap.nativeCreate所在的so文件名称
    auto so = api_level > API_LEVEL_10_0 ? BITMAP_CREATE_SYMBOL_SO_RUNTIME_AFTER_10 : BITMAP_CREATE_SYMBOL_SO_RUNTIME;
    // 获取 nativeCreate方法名
    auto symbol = api_level >= API_LEVEL_8_0 ?  BITMAP_CREATE_SYMBOL_RUNTIME : BITMAP_CREATE_SYMBOL_BEFORE_8;
    // 借助字节的 shadowhook 进行 bitmap hook
    auto stub = shadowhook_hook_sym_name(so, symbol, (void *) create_bitmap_proxy,nullptr);

    if (stub != nullptr) {
        g_ctx.open_hook = true;
        g_ctx.shadowhook_stub = stub;
        JNIEnv *jni_env;
        if (g_ctx.java_vm->AttachCurrentThread(&jni_env, nullptr) == JNI_OK) {
            jclass bitmap_java_class = jni_env->FindClass("android/graphics/Bitmap");
            g_ctx.bitmap_recycled_method = jni_env->GetMethodID(bitmap_java_class, "isRecycled",
                                                               "()Z");

            jclass bitmap_info_jobject = jni_env->FindClass(
                    "com/xmly/ting/android/xmbitmapmonitor/BitmapMonitorData");
            g_ctx.bitmap_info_jclass = static_cast<jclass>(jni_env->NewGlobalRef(
                    bitmap_info_jobject));

            g_ctx.report_bitmap_data_method = jni_env->GetStaticMethodID(g_ctx.bitmap_monitor_jclass,
                                                                        "reportBitmapInfo",
                                                                        "(Lcom/xmly/ting/android/xmbitmapmonitor/BitmapMonitorData;)V");
            g_ctx.report_bitmap_file_method = jni_env->GetStaticMethodID(g_ctx.bitmap_monitor_jclass,
                                                                         "reportBitmapFile",
                                                                         "(Ljava/lang/String;)V");

        }

        //hook 成功后,开启一个线程,定时轮训当前保存的数据,如果发现有被 recycle 的,移出去,更新总体数据 (世欣 SDK 逻辑)
        start_loop_check_recycle_thread();

        return 0;
    }

    g_ctx.open_hook = false;
    g_ctx.shadowhook_stub = nullptr;
    return -1;
}

相比Epic Hook 直接拦截底层实现函数,通过修改jni函数入口指令,达到替换逻辑,几乎没有额外性能开销,不需要担心 ART动态运行时环境的兼容, 不过相比Epic ,inline-hook需要对C++和底层逻辑更加熟悉,入门门槛比较高。

项目中基于 AndroidBitmpMonitor ,编入lib模块,方便后续修改,测试环境通过打开开关,可观察 Native 内存中 bitmap的占用大小和 图片大小占比,方便我们定位Top问题。

4.2.3 重复Bitmap监控

上面讲到的AndroidBitmpMonitor ,获取到bitmap信息后,也输出了图片调用地址,如果是网络图片加载,大多调用链会指向图片加载工具类,如果是本地图片可以查看具体名称,如果想要再深入的做,可以将重复Bitmap监控落地,本人大体思路是获取到 bitmap后,可以比较Bitmap的像素数据,但是这种必须要求图片一模一样,在项目中这种可能性很低,大多还是图片尺寸或压缩比不一样,另外如果项目中图片数据量很大,Md5比较也是很慢的 (原理上来看),另外一种办法就是 哈希感知。

可以借助opencv库实现,大概思路如下:

复制代码
#include <opencv2/opencv.hpp>
#include <cmath>
#include <string>

// 获取图片的感知哈希值
std::string calc_perceptual_hash(const cv::Mat &img) {
    if (img.empty()) return "";

    // 1. 转换为灰度图像
    cv::Mat gray;
    cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);

    // 2. 缩放到 8x8
    cv::Mat resized;
    cv::resize(gray, resized, cv::Size(8, 8), 0, 0, cv::INTER_AREA);

    // 3. 转换为 float 类型
    resized.convertTo(resized, CV_32F);

    // 4. 计算 DCT(离散余弦变换)
    cv::Mat dctImage;
    cv::dct(resized, dctImage);

    // 5. 从左上角取8x8的低频分量
    cv::Mat dctLow = dctImage(cv::Rect(0, 0, 8, 8)).clone();

    // 6. 计算均值
    float meanValue = cv::mean(dctLow)[0];

    // 7. 生成感知哈希值
    std::string hash;
    for (int i = 0; i < 8; i++) {
        for (int j = 0; j < 8; j++) {
            hash += (dctLow.at<float>(i, j) > meanValue) ? "1" : "0";
        }
    }

    // 确保 hash 长度为 64 位
    return hash;
}

// 比较感知哈希值之间的海明距离(Hamming Distance)
int hamming_distance(const std::string &hash1, const std::string &hash2) {
    if (hash1.size() != hash2.size()) return -1;

    int dist = 0;
    for (size_t i = 0; i < hash1.size(); ++i) {
        if (hash1[i] != hash2[i]) {
            dist++;
        }
    }
    return dist;
}

// 示例:比较两张图片是否重复
bool is_duplicate_image(const cv::Mat &img1, const cv::Mat &img2, int threshold = 5) {
    auto hash1 = calc_perceptual_hash(img1);
    auto hash2 = calc_perceptual_hash(img2);

    if (!hash1.empty() && !hash2.empty()) {
        int dist = hamming_distance(hash1, hash2);
        return dist >= 0 && dist <= threshold;
    }

    // 图片不合法
    return false;
}

关于图片的优化这里收个尾,这一块其实东西很多,网上不同的检测方式也很多,这里仅做部分方案记录

关于图片内存使用注意事项:

  1. 本地图片尽量压缩到极致,推荐webp格式,切图尺寸尽量和ui尺寸一致避免浪费
  2. 多项目组同步开发,避免引入多套图片加载工具库,基建层对通用工具进行收口和规范制定
  3. 合理规划缓存池大小,在OnTrimMemory / LowMemory后调中,根据系统状态去释放对应的缓存和内存
  4. 线下使用大图检测工具,超过尺寸图片应该提供堆栈和负责人信息,

4.3 内存泄露、内存抖动和内存溢出

还是啰嗦的说下内存泄露为什么会导致OOM,内存泄漏 就是 在当前应用周期内不再使用的对象被GC Roots引用,导致不能回收,使实际可使用内存变小,直至最后没有更多内存可以分配,产生OOM。

关于内存溢出,Android设备出厂以后,java虚拟机对单个应用的最大内存分配就确定下来了,超出这个值就会OOM,所以长期的内存泄露也会导致内存溢出,不过如果是一次性开辟太大的数组或者加载过大的文件图片也容易导致OOM。

内存抖动,主要是内存波动图类似锯齿状,代表存在频繁地内存开辟和销毁,容易导致内存碎片,频繁的GC也容易导致卡顿,不过在最新的ART虚拟机上,针对内存管理和回收策略做了优化,所以除非代码中存在轮训添加Bitmap又频繁移除的场景,个人感觉很少遇到内存抖动问题了。

文章开篇讲了MAT 和 Android studio Profiler的使用,这两个工具都可以用来查看内存泄露问题,通过查看Profiler的 Memory 运行曲线,也可以发现内存抖动的趋势。上面说了内存泄露、内存抖动和内存溢出的原因,那么也列举下常见的内存代码优化方式:

4.3.1 内存抖动优化

容易频繁使用的对象,使用缓存池,减少对象频繁创建和销毁 。

减少不合理对象的创建,特别是嵌套for循环,注意对象创建的位置,尽量在for结构外侧。如果是Gson解析避免多处的重复创建。

使用合理的数据结构,SparseArray类,在清楚Map的个数的时候,可以手动设置大小个数,初始化个数都在16个,减少过多开辟

4.3.2 内存泄露优化

减少内类间接持有context或Fragment对象 ,比如匿名内部类和 普通的 Hanlder 都存在 this$0 间接引用 的问题, 这类问题可以使用 static和 WeakReference的方式,在外类销毁的时候调用移除方法

动画也可能导致内存泄露,比如启动了属性动画(ObjectAnimator),但是在销毁的时候,没有调用cancle方法,虽然我们看不到动画了,但是这个动画依然会不断地播放下去,动画引用所在的控件,所在的控件引用Activity,这就造成Activity无法正常释放。因此同样要在Activity销毁的时候cancel掉属性动画,避免发生内存泄漏。

注册对象未注销。如BraodcastReceiver、EventBus未注销造成的内存泄漏,要在Activity/Fragment销毁时及时注销。

WebView内存泄漏的问题,在应用中只要使用一次WebView,内存就不会被释放掉。 腾讯X5的思路是WebView开启一个独立的进程,使用AIDL与应用的主进程进行通信,WebView所在的进程可以根据业务的需要选择合适的时机进行销毁,达到正常释放内存的目的。

类的静态变量导致泄露。静态变量存储在方法区,它的生命周期从类加载开始,到整个进程结束。一旦静态变量初始化后,它所持有的引用只有等到进程结束才会释放

java 复制代码
public class MainActivity extends AppCompatActivity { 
    public static Info sInfo; 
  
    ...
  
    class Info { 
        private Context mContext; 
        public Info(Context context) { 
            this.mContext = context; 
        } 
    } 
} 

4.4 过多线程开辟

OOM发生也可能是线程数超标导致的,Android 中每个线程默认分配 1MB 内存,如果存在大量线程,且并没有很好的利用,也容易导致内存不足,且多线程之间也可能出现竞争和互锁问题。

常见的关于线程优化方式:

  1. 全局使用统一线程池 ,提高线程复用,避免线程的重复创建和销毁,提高性能。不过在真实使用中,也不会只有一个线程池,线程池的知识面博大精深,这里不能再展开了,实际开发中一般分为 CPU线程池和IO线程池,根据子任务调用的频次和占用耗时,CPU线程池处理更加迅速的任务,一般都是核心线程,避免最大线程数超过核心线程数。IO线程池使用频次较低,就可以把核心线程数设置低一点 一般1就足够了,最大线程数可以大一点。

  2. 如果是Kotlin语言,有官方提供的 Kotlin Coroutines ,通过线程分区实现虚拟化线程,更加轻量

    GlobalScope.launch(Dispatchers.IO) {
    val result = performBackgroundTask()

    复制代码
     withContext(Dispatchers.Main) {
         // 更新到主线程
         textView.text = result
     }

    }

    suspend fun performBackgroundTask(): String {
    // 耗时操作
    delay(1000)
    return "Task Completed"
    }

  3. 避免并发任务过多,根据业务特点,分批处理多线程任务,避免爆发式并发问题。

  4. 没有实践,大项目风险太高 ,搞不好提桶)Android默认创建线程开辟1MB内存,32位的话,微信有黑科技是利用PLT hook需改了创建线程时的栈空间大小。但是喜马目前32位系统的设备占比很低很低,可以忽略不计,也可以使用 赵子健 的文章方案,在创建Thread的时候 Thread 的构造函数中会将 stackSize 这个变量设置为 0,这个 stackSize 变量决定了线程栈大小

    Thread(ThreadGroup group, String name, int priority, boolean daemon) {
    ......
    this.stackSize = 0;
    }

    public synchronized void start() {
    if (started)
    throw new IllegalThreadStateException();
    group.add(this);
    started = false;
    try {
    nativeCreate(this, stackSize, daemon);
    started = true;
    } finally {
    try {
    if (!started) {
    group.threadStartFailed(this);
    }
    } catch (Throwable ignore) {

    复制代码
         }
     }

    }

看大佬是直接在线程工厂创建的地方收口

不过本人在真实项目处理线程收敛的时候,还是比较困难,第一 部分线程是业务绑定的倒计时任务 ,没办法去掉,毕竟业务才是根本。 第二就是第二方和第三方的线程滥用问题,因为代码或逻辑不在本地,需要推动其他同学优化,如果App中还有多渠道的广告SDK,那么恭喜你这几个广告SDK 基本要有二十个线程开辟数了,再加上APM和日志上报,OkHttp、Glide和Bugly等工具库的引入,线程数其实优化空间很小,比较难拿结果。

5. 业务优化

在以上技术维度进行内存优化外,可结合业务优化,多抓手形成组合拳帮助解决内存问题

5.1 设备分级

设备分级最早应该是 FaceBook 的轻量工具库 Device Year Class,主要是根据设备CPU 内存等硬件信息,通过计算划分一个版本年代,项目中也是按照这个思想,但是更新了最新的参数

参考了国外早期的案例,也可以看下目前市面上的 安兔兔的跑分策略,数据主要来自四个方面,分别为 :内存、CPU、GPU和IO速度。 不过这里我仅仅使用了内存和CPU ,并且自己添加上了实时的网络监控和电量监控。简单贴一部分代码

java 复制代码
public class LiveDeviceLevel {
    public static final int DEVICE_LEVEL_HIGH = 3;
    public static final int DEVICE_LEVEL_MID = 2;
    public static final int DEVICE_LEVEL_LOW = 1;
    public static final int DEVICE_LEVEL_UNKNOWN = -1;

    /**
     * Level judgement based on current memory and CPU.
     * @param context - Context object.
     * @return int  设备等级
     */
    public static int judgeDeviceLevel(Context context) {
        int level = DEVICE_LEVEL_UNKNOWN;
        int ramLevel = judgeMemory(context);
        int cpuLevel = judgeCPU();
        // 内存小于等于6G CPU刷新率小于等于2G 低端机
        if (ramLevel == 1 || cpuLevel == 1) {
            level = DEVICE_LEVEL_LOW;
        } else if (ramLevel == 2 && (cpuLevel >= 2)) {
            // 内存等于8G CPU刷新率大于等于2GHz 中端机
            level = DEVICE_LEVEL_MID;
        } else if (ramLevel > 2) {
            // 内存大于8G CPU刷新率大于等于2.5GHz 高端机
            if (cpuLevel > 2) {
                level = DEVICE_LEVEL_HIGH;
            } else {
                level = DEVICE_LEVEL_MID;
            }
        }
        return level;
    }

    /**
     * 评定内存的等级.
     * @return
     */
    private static int judgeMemory(Context context) {
        long ramMB = LiveDeviceInfo.getTotalMemory(context) / (1024 * 1024);
        int level = 1;
        if (ramMB <= 6000) { //低端机
            level = 1;
        } else if (ramMB <= 8000) { // 中端机
            level = 2;
        } else { //8G以上 高端机
            level = 3;
        }
        return level;
    }

    /**
     * 评定CPU等级.(按频率和厂商型号综合判断)
     * @return
     */
    private static int judgeCPU() {
        int level = 1;
        int freqMHz = LiveDeviceInfo.getCPUMaxFreqKHz() / 1000;

        if (freqMHz <= 2000) { //2GHz 低端
            level = 1;
        } else if (freqMHz <= 2500) { //2GHz - 2.5GHz 中端
            level = 2;
        } else { //高端
            level = 3;
        }
        return level;
    }

}

上述代码中缺少的 获取内存和获取最大CPU刷新数 部分方法块代码

复制代码
// 获取内存总量
public static long getTotalMemory(Context c) {
    if (sTotalMemory > 0) {
        return sTotalMemory;
    }

    // memInfo.totalMem not supported in pre-Jelly Bean APIs.
    ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
    ActivityManager am = (ActivityManager) c.getSystemService(Context.ACTIVITY_SERVICE);
    am.getMemoryInfo(memInfo);
    sTotalMemory = memInfo.totalMem;
    return memInfo.totalMem;
}

// 获取cpu最大刷新数
public static int getCPUMaxFreqKHz() {
    if (sCPUMaxFreqKHz > 0) {
        return sCPUMaxFreqKHz;
    }
    int maxFreq = DEVICEINFO_UNKNOWN;
    try {
        for (int i = 0; i < getNumberOfCPUCores(); i++) {
            String filename =
                    "/sys/devices/system/cpu/cpu" + i + "/cpufreq/cpuinfo_max_freq";
            File cpuInfoMaxFreqFile = new File(filename);
            if (cpuInfoMaxFreqFile.exists() && cpuInfoMaxFreqFile.canRead()) {
                byte[] buffer = new byte[128];
                FileInputStream stream = new FileInputStream(cpuInfoMaxFreqFile);
                try {
                    stream.read(buffer);
                    int endIndex = 0;
                    //Trim the first number out of the byte buffer.
                    while (Character.isDigit(buffer[endIndex]) && endIndex < buffer.length) {
                        endIndex++;
                    }
                    String str = new String(buffer, 0, endIndex);
                    int freqBound = Integer.parseInt(str);
                    if (freqBound > maxFreq) {
                        maxFreq = freqBound;
                    }
                } catch (NumberFormatException e) {
                    //Fall through and use /proc/cpuinfo.
                } finally {
                    stream.close();
                }
            }
        }
        if (maxFreq == DEVICEINFO_UNKNOWN) {
            FileInputStream stream = new FileInputStream("/proc/cpuinfo");
            try {
                int freqBound = parseFileForValue("cpu MHz", stream);
                freqBound *= 1000; //MHz -> kHz
                if (freqBound > maxFreq) maxFreq = freqBound;
            } finally {
                stream.close();
            }
        }
    } catch (IOException e) {
        maxFreq = DEVICEINFO_UNKNOWN; //Fall through and return unknown.
    }
    sCPUMaxFreqKHz = maxFreq;
    return maxFreq;
}

真实业务中也会对网络情况和电量进行判断,如果App物理内存占用超过多少占比或者电量低于阈值,就会提醒用户打开 流畅模式。

5.2 业务降级

设备分级其实每家大厂应该都有自己的衡量方式,重要的还是根据自己的业务特色 进行不同的设备等级定制化处理,以下只简单列举下自己业务中的部分降级场景

  1. 如果是低端机进入直播场景,默认打开直播流畅看播功能,屏蔽他人进房POP条和小心心互动动画等非主态的交互动画,但是商业化红包和礼物不做降级处理
  2. 如果用户手机是中低端机,且内存阈值超过上限或者剩余电量超过最低阈值,也提醒用户打开流畅模式,尝试减少APP功耗
  3. 针对不同设备分级,如图片的加载格式进行相应的降低,如资源的清晰度也可以动态调整等等

6. 线上监控和告警体系搭建

线下监控前面大概列举了一些工具,比如 MAT 、Android studio Profiler和 LeakCanary(GC会引起STW 导致卡顿只推荐线下,且内存泄露只能检测Activity级别。 dump内存快照,生成hprof文件也比较耗时),线上的方案和告警依然重要,我这里比较推荐 Koom 的线上内存泄漏检测方案。

Java Heap 泄漏监控

  • koom-java-leak 模块用于 Java Heap 泄漏监控:它利用 Copy-on-write 机制 fork 子进程 dump Java Heap,解决了 dump 过程中 app 长时间冻结的问题,详情参考 这里

Native Heap 泄漏监控

  • koom-native-leak 模块用于 Native Heap 泄漏监控:它利用 Tracing garbage collection 机制分析整个 Native Heap,直接输出泄漏内存信息「大小、分配堆栈等』;极大的降低了业务同学分析、解决内存泄漏的成本。详情可以参考 这里

Thread 泄漏监控

  • koom-thread-leak 模块用于 Thread 泄漏监控:它会 hook 线程的生命周期函数,周期性的上报泄漏线程信息。详情参考 这里

以上是简单贴了Koom的官网原理介绍,但是源码并没有深究,有了APM工具,就可以依据Koom的结果进行上报,根据上报日志,定制不同业务域的 内存泄露告警了。

总结

由于24年的kpi是关于OOM和内存优化治理,结果来看OOM降低了 70%,但是内存优化结果不太理想,线程数有一定控制,但是内存水位依然稳定,流畅看播的业务专项,在技术层面可一定程度遏制内存增高,但是对业务影响正相关性还未验证成功,实验组和对照组数据彼此纠缠。

由于很多笔记都是平时开发过程中临时笔记,最近是年关有空进行整理,部分段落可能比较跳跃,海涵,采百家花,酿自家蜜,稳重很多工具和优化手段都是站在前人肩膀上做了定制化落地,本篇文章更多是记录工作所学,温故知新,欢迎大家评论区讨论学习,大家蛇年快乐...

参考文章

# 深入探索 Android 内存优化

# 扒一扒抖音是如何做线程优化的

相关推荐
Kapaseker18 小时前
三分钟搞懂 Kotlin Flow 中的背压
android·kotlin
柯南二号18 小时前
【大前端】【Android】把 Activity 重构成 MVVM 的对比示例
android·状态模式
某空m19 小时前
【Android】Glide的缓存机制
android·缓存·glide
某空m19 小时前
【Android】Glide的使用
android·glide
QING61819 小时前
Jetpack Compose 中的 ViewModel 作用域管理 —— 新手指南
android·kotlin·android jetpack
郝学胜-神的一滴19 小时前
Linux C++会话编程:从基础到实践
linux·运维·服务器·开发语言·c++·程序人生·性能优化
鹏多多19 小时前
flutter-使用EventBus实现组件间数据通信
android·前端·flutter
Evan芙19 小时前
总结常见fastcgi相关的性能优化参数
性能优化
ShayneLee820 小时前
Nginx修改请求头响应头
android·运维·nginx
廋到被风吹走20 小时前
【数据库】【MySQL】高可用与扩展方案深度解析
android·数据库·mysql