深入内存优化

前言

内存问题很常见 而且经常会因为内存问题引起卡顿问题 在接下来的卡顿分析中 内存也是一个很重要的方向

内存抖动

内存抖动是由频繁gc导致产生 由于内存空间的不足 回导致频繁gc 在Profile中查看是锯齿状

内存抖动实战

我们可以通过Profile来分析memory Profile的优势大概就是图表非常直观 我们一般可以配合使用mat来解决内存泄漏的问题

 @SuppressLint("HandlerLeak")
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(@NonNull Message msg) {
            super.handleMessage(msg);
            for (int i = 0; i < 100; i++) {
                String args[] = new String[100000];
            }
            mHandler.sendEmptyMessageDelayed(0, 30);
        }
    };

我们通过频繁申请大对象来模拟内存抖动 我们来观察Profile

WechatIMG3.png

emmmm 现在的手机还是很厉害啊 没有锯齿状 但是可以看到gc非常频繁 这时候我们就可以dump heap
然后看一下内存的主要消耗


WechatIMG6.jpeg

我们主要看Shallow Size 和Retained Siz

Jvm内存分配

JVM内存分配主要分为以下几个部分

  • 方法区 (常量 静态变量 编译之后代码)
  • 程序计数器 (计算当前线程的当前方法执行到多少行)
  • 虚拟机栈 (java对象引用)
  • 本地方法栈 (native对象引用)
  • 堆 (生成的对象)

工具选择

Profile

Profile是Android Studio自带的工具 我们可以用来查看Cpu Memory Network Energy的消耗
我们可以使用Profile来定位内存抖动问题 因为可以很明显的看到锯齿状或者频繁gc的情况
也可以用Cpu Profile 或者 Memory Profile来查看内存泄漏问题
Profile提供了Fragment/Activity的监测 还可以通过包名等方式 来查看内存中的泄漏问题
可以定位查看 内存中是否存在不合理的对象

MAT

MAT全称Memory Anlyzer Tools ,是一款可以分析Hprof文件的可视化工具 我们可以使用Mat工具
查找定位我们预设的怀疑点 通过exclude weak soft等引用 获取到gc到对象的引用路径 帮助我们解决问题

LeakCanary源码解析

在2.0之前的版本 需要我们手动调用Install方法 在2.0之后 LeakCanary注册了ContentProvider 不需要手动的调用Install

LeakCanary分为两部分 监控和分析

监控

查看LeakCanary源码 发现AppWatcherInstaller类,继承ContentProvider
在Oncreate方法中调用了AppWatcher.manualInstall(application)
然后AppWatcher中调用了InternalAppWatcher.install(application)

查看InternalAppWatcher.install方法

fun install(application: Application) {
    checkMainThread()
    if (this::application.isInitialized) {
      return
    }
    SharkLog.logger = DefaultCanaryLog()
    InternalAppWatcher.application = application

    val configProvider = { AppWatcher.config }
    //监控Activity 这里传递了ObjectWatcher用来监控Object对象
    ActivityDestroyWatcher.install(application, objectWatcher, configProvider)
    //监控Fragment
    FragmentDestroyWatcher.install(application, objectWatcher, configProvider)
    onAppWatcherInstalled(application)
  }

我们已经察觉到了 关键代码就在ActivityDestroyWatcher.install里面了 让我们跟上

internal class ActivityDestroyWatcher private constructor(
  private val objectWatcher: ObjectWatcher,
  private val configProvider: () -> Config
) {

  private val lifecycleCallbacks =
    object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
      override fun onActivityDestroyed(activity: Activity) {
        if (configProvider().watchActivities) {
        //使用我们上面提到的objectWatcher来观察activity
          objectWatcher.watch(
              activity, "${activity::class.java.name} received Activity#onDestroy() callback"
          )
        }
      }
    }

  companion object {
    fun install(
      application: Application,
      objectWatcher: ObjectWatcher,
      configProvider: () -> Config
    ) {
      val activityDestroyWatcher =
        ActivityDestroyWatcher(objectWatcher, configProvider)
        //注册生命周期
application.registerActivityLifecycleCallbacks(activityDestroyWatcher.lifecycleCallbacks)
    }
  }
}

我们可能发现了LeakCanary的原理 就是监听Activity的生命周期回调 在OnDestroy之后 使用objectWatcher去观察Activity是否有被回收 如果没有回收 就表示泄漏了

Follow Me,胜利就在前面了!!!

接着查看objectWatcherwatch方法

 @Synchronized fun watch(
    watchedObject: Any,
    description: String
  ) {
    if (!isEnabled()) {
      return
    }
    //移除一些弱引用对象
    removeWeaklyReachableObjects()
    //这里使用了随机数生成key
    val key = UUID.randomUUID()
        .toString()
    val watchUptimeMillis = clock.uptimeMillis()
    //将观察对象用WeakRefrence引用 并且使用RefrenceQueue来接收销毁的对象
    val reference =
      KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
    SharkLog.d {
      "Watching " +
          (if (watchedObject is Class<*>) watchedObject.toString() else "instance of ${watchedObject.javaClass.name}") +
          (if (description.isNotEmpty()) " ($description)" else "") +
          " with key $key"
    }

    watchedObjects[key] = reference
    checkRetainedExecutor.execute {
    //接着判断
      moveToRetained(key)
    }
  }
  
   @Synchronized private fun moveToRetained(key: String) {
   //删除不可达的对象
    removeWeaklyReachableObjects()
    val retainedRef = watchedObjects[key]
    if (retainedRef != null) {
      retainedRef.retainedUptimeMillis = clock.uptimeMillis()
      //分析内存泄漏
      onObjectRetainedListeners.forEach { it.onObjectRetained() }
    }
  }
  
   private fun removeWeaklyReachableObjects() {
    // WeakReferences are enqueued as soon as the object to which they point to becomes weakly
    // reachable. This is before finalization or garbage collection has actually happened.
    var ref: KeyedWeakReference?
    //queue中存在的对象是已经被回收的对象
    do {
      ref = queue.poll() as KeyedWeakReference?
      if (ref != null) {
        watchedObjects.remove(ref.key)
      }
    } while (ref != null)
  }

上面的方法也很简单 就是将我们要观察的对象 用WeakRefrence和RefrenceQueue对象来进行包装
如果gc之后 对象被回收 那么会将回收的对象放入RefrenceQueue中

如果retainedRef不为null 那么开始分析HProf

监控总结

我们发现 LeakCanary的监控原理其实也比较简单 就是在OnDestroy之后用WeakRefrence来检查Activity/Fragment是否泄漏

分析

接着分析泄漏
刚最后调用了onObjectRetainedListeners.forEach { it.onObjectRetained() }
我们发现AppWatcher继承了onObjectRetainedListener

忽略一些简单方法 最后会调用到

private fun checkRetainedObjects(reason: String) {
    val config = configProvider()
    // A tick will be rescheduled when this is turned back on.
    if (!config.dumpHeap) {
      SharkLog.d { "Ignoring check for retained objects scheduled because $reason: LeakCanary.Config.dumpHeap is false" }
      return
    }

    var retainedReferenceCount = objectWatcher.retainedObjectCount

    if (retainedReferenceCount > 0) {
        //再gc一次
      gcTrigger.runGc()
      retainedReferenceCount = objectWatcher.retainedObjectCount
    }

    //检查保留数量
    if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return

    if (!config.dumpHeapWhenDebugging && DebuggerControl.isDebuggerAttached) {
      onRetainInstanceListener.onEvent(DebuggerIsAttached)
      //显示通知弹窗
      showRetainedCountNotification(
          objectCount = retainedReferenceCount,
          contentText = application.getString(
              R.string.leak_canary_notification_retained_debugger_attached
          )
      )
      scheduleRetainedObjectCheck(
          reason = "debugger is attached",
          rescheduling = true,
          delayMillis = WAIT_FOR_DEBUG_MILLIS
      )
      return
    }

    val now = SystemClock.uptimeMillis()
    val elapsedSinceLastDumpMillis = now - lastHeapDumpUptimeMillis
    if (elapsedSinceLastDumpMillis < WAIT_BETWEEN_HEAP_DUMPS_MILLIS) {
      onRetainInstanceListener.onEvent(DumpHappenedRecently)
      showRetainedCountNotification(
          objectCount = retainedReferenceCount,
          contentText = application.getString(R.string.leak_canary_notification_retained_dump_wait)
      )
      scheduleRetainedObjectCheck(
          reason = "previous heap dump was ${elapsedSinceLastDumpMillis}ms ago (< ${WAIT_BETWEEN_HEAP_DUMPS_MILLIS}ms)",
          rescheduling = true,
          delayMillis = WAIT_BETWEEN_HEAP_DUMPS_MILLIS - elapsedSinceLastDumpMillis
      )
      return
    }

    SharkLog.d { "Check for retained objects found $retainedReferenceCount objects, dumping the heap" }
    //取消通知弹窗
    dismissRetainedCountNotification()
    //Dump Heap 堆转储
    dumpHeap(retainedReferenceCount, retry = true)
  }

在dumpHeap方法中 我们先看一下如何生成HProf文件(省略了一些代码)

override fun dumpHeap(): File? {
    val heapDumpFile = leakDirectoryProvider.newHeapDumpFile() ?: return null

   ......

    return try {
    //生成HProf文件
      Debug.dumpHprofData(heapDumpFile.absolutePath)
      if (heapDumpFile.length() == 0L) {
        SharkLog.d { "Dumped heap file is 0 byte length" }
        null
      } else {
        heapDumpFile
      }
    } catch (e: Exception) {
      SharkLog.d(e) { "Could not dump heap" }
      // Abort heap dump
      null
    } finally {
      cancelToast(toast)
      notificationManager.cancel(R.id.leak_canary_notification_dumping_heap)
    }
  }

然后调用HeapAnalyzerService.runAnalysis(application, heapDumpFile)开启分AnalyzerService

HeapAnalyzerService这部分我们在线上LeakCanary里 其实可以阉割掉 只需要想办法把Hprof删除保留有效数据 并传回服务端就好了

AnalyzerService会调用analyzerHeap来分析内存泄漏并生成图表


private fun analyzeHeap(
    heapDumpFile: File,
    config: Config
  ): HeapAnalysis {
    val heapAnalyzer = HeapAnalyzer(this)

    val proguardMappingReader = try {
      ProguardMappingReader(assets.open(PROGUARD_MAPPING_FILE_NAME))
    } catch (e: IOException) {
      null
    }
    return heapAnalyzer.analyze(
        heapDumpFile = heapDumpFile,
        leakingObjectFinder = config.leakingObjectFinder,
        referenceMatchers = config.referenceMatchers,
        computeRetainedHeapSize = config.computeRetainedHeapSize,
        objectInspectors = config.objectInspectors,
        metadataExtractor = config.metadataExtractor,
        proguardMapping = proguardMappingReader?.readProguardMapping()
    )
  }
  

最后会调用

 Hprof.open(heapDumpFile)
          .use { hprof ->
            val graph = HprofHeapGraph.indexHprof(hprof, proguardMapping)
            val helpers =
              FindLeakInput(graph, referenceMatchers, computeRetainedHeapSize, objectInspectors)
            helpers.analyzeGraph(
                metadataExtractor, leakingObjectFinder, heapDumpFile, analysisStartNanoTime
            )
          }

生成图表

ARTHook监控不合理图片

内存优化的过程中 Bitmap优化肯定是其中之一 我们可能需要监测大图 或者监测重复图 现在一张图的内存可能就占用1M 解决一张重复的 就可以省下1M内存

优化方法

  • 使用统一接口

    我们可以使用统一接口来设置图片 在接口层 我们可以监控大图或者重复图片

    弊端:程序员可能会忘记使用统一接口导致监控遗漏

  • 使用ART Hook

    我们可以使用Epic框架,Epic 是一个在虚拟机层面、以 Java Method 为粒度的 运行时 AOP Hook 框架。简单来说,Epic 就是 ART 上的 Dexposed(支持 Android 4.0 ~ 10.0)。它可以拦截本进程内部几乎任意的 Java 方法调用,可用于实现 AOP 编程、运行时插桩、性能分析、安全审计等。

    但是在使用Epic的过程中 也遇到很多奇葩无解问题 等待作者解决

Epic使用方法

我们这边使用Epic来Hook所有setImageBitmap来监控Bitmap是否过大

public class ImageHook extends XC_MethodHook {
    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        ImageView imageView = (ImageView) param.thisObject;
        checkBitmap(imageView, ((ImageView) param.thisObject).getDrawable());
    }

    private void checkBitmap(final ImageView imageView, Drawable drawable) {
        if (imageView != null && drawable != null) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            if (bitmap != null) {
                int height = imageView.getLayoutParams().height;
                int width = imageView.getLayoutParams().width;
                if (height > 0 && width > 0) {
                    if (bitmap.getHeight() >= height << 1
                            && bitmap.getWidth() >= width << 1) {
                        warn(bitmap.getWidth(), bitmap.getHeight(), width, height, new RuntimeException("Bitmap size too large"));
                    }
                } else {
                    final Throwable stackTrace = new RuntimeException();
                    //还咩有初始化完成
                    imageView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
                        @Override
                        public boolean onPreDraw() {
                            int w = imageView.getWidth();
                            int h = imageView.getHeight();
                            if (w > 0 && h > 0) {
                                if (bitmap.getWidth() >= (w << 1)
                                        && bitmap.getHeight() >= (h << 1)) {
                                    warn(bitmap.getWidth(), bitmap.getHeight(), w, h, stackTrace);
                                }
                                imageView.getViewTreeObserver().removeOnPreDrawListener(this);
                            }
                            return true;
                        }
                    });
                }
            }
        }
    }

    private static void warn(int bitmapWidth, int bitmapHeight, int viewWidth, int viewHeight, Throwable t) {
        String warnInfo = new StringBuilder("Bitmap size too large: ")
                .append("\n real size: (").append(bitmapWidth).append(',').append(bitmapHeight).append(')')
                .append("\n desired size: (").append(viewWidth).append(',').append(viewHeight).append(')')
                .append("\n call stack trace: \n").append(Log.getStackTraceString(t)).append('\n')
                .toString();

        LogUtils.i(warnInfo);
    }
}

代码很简单 就是获取Bitmap和ImageViewwidthheight 然后对比是否超过两倍大

内存必解Bitmap

其他内存优化点

  1. 设备分级

    我们可以对设备性能进行分级 4G内存的手机和1G内存的手机运行肯定是不一样的
    比如一些动画我们可以在13年之后的手机开启 10年之前的手机不开启任何动画
    可以参考device-year-class

  2. 缓存管理

    我们需要一套统一的缓存管理机制 当遇到LMK时 果断释放占有内存 减小被杀几率 我们可以使用OnTrimMemory回调 根据不同的状态决定释放不同的内存

  3. 进程模型

    减少应用启动的进程数,常驻的进程数 有节操的保活 对低端机优化很有效

  4. 安装包大小

    安装包的代码 资源 图片以及so都跟占有内存有很大的关系 所以我们可以针对低端机型退出Lite版本

  5. 统一图片库

    收拢图片库的使用 统一使用自研库或者Glide,Fresco等 低端机使用565,更严格的缩放策略
    而且可以进一步的将Bitmap.createBitmap,BitmapFactory相关接口收拢 方便监控

  6. 统一监控

    可以采用接口的方式 也可以采用ARTHook的方式 不过ART Hook在实验室环境没什么关系 在实验室环境,遇到内存不合理或者图片合理 可以立即弹窗提醒开发人员解决 但是在线上环境 我们要更多的考虑稳定性和容错性

线上监控方案

  • java内存泄漏

    我们可以简历类似LeakCanary的线上方案 裁剪大部分图片对应的byte数组 再使用压缩 进一步提高文件上传的成功率

  • native内存泄漏

    native内存泄漏往往很难采集 可以参考 《微信Android终端内存优化实践》

  • 采集方式

    用户在前台时 我们可以每五分钟采集一次PSS,JAVA堆,图片总内存,建议按照用户抽样 而不是按照次抽样

容灾方案参考

我们可以在一些特殊的时间点 重启应用 释放一些已经泄漏的内存 可以更好的提高用户体验 下面参考自微信:

  • 微信是否在主界面退到后台 且 位于后台的时间超过 30 分钟

  • 当前时间为凌晨 2~5 点

  • 不存在前台服务(存在通知栏,音乐播放栏等情况)

  • java heap 必须大于当前进程最大可分配的 85% || native 内存大于 800M || vmsize 超过了 4G(微信 32bit)的 85%

  • 非大量的流量消耗(每分钟不超过 1M) && 进程无大量 CPU 调度情况

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260