「性能优化系列」APP内存优化理论与实践

3,325

本文已参与掘金创作者训练营第三期,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

性能优化系列:

  • 启动优化
  • 内存优化
  • 布局优化
  • 卡顿优化
  •  apk瘦身优化
  • 电量优化 项目地址:  fuusy/FuPerformance

当一个应用同时运行越来越多的任务以及复杂的业务,Android系统的内存管理机制已经无法满足内存的释放与回收,为了应用的稳定性与性能,去控制内存的创建和回收就成为了一个重要的命题。

本篇文章主要涉及内容如下:

  • 对象的创建与回收;
  • 分配内存的方式,对象在JVM中的生命周期;
  • 判断对象是否需要被回收,垃圾回收算法;
  • 内存抖动、内存泄漏的监控;
  • Bitmap的大小、重复监控方案;
  • 设备分级方案。

一、对象的创建和回收

1.1、对象的创建

在java中对象的创建基本上就是一个new,但new的背后在内存中做了些什么?并且对象对内存有哪些影响?又是如何被回收的?......先了解这些基本知识点对后面内存性能优化有很大的帮助。

对象的创建基本上有以下几点:

  1. 判断对象对应的类是否加载、链接和初始化;
    例如我们利用new来创建一个User对象时,JVM虚拟机在收到new的指令时,会去方法区查询该User类是否被引用,并检查User类是否已经被加载,链接和初始化过,如果没有,就需要先去执行类的加载过程。

  2. 为对象分配内存;
    在Java堆中划分一块内存分配给对象,分配内存会根据Java堆是否规整,分为两种方式,指针碰撞和空闲列表

    • 指针碰撞

      使用指针碰撞的前提是Java堆的内存属于规整模型,所谓指针碰撞,指的是利用一个指针将内存空间分为已被占用内存空闲内存,当为一个对象进行内存分配时,指针就向空闲内存一侧移动,移动的距离与对象大小相等。如下图所示:

    掘金-付十一.png

    • 空闲列表

      空闲列表是在Java堆内存不完整的情况下使用的方式,已使用内存与空闲内存无规则,并且JVM另外维护了一张空闲内存的表,当有新对象需要分配内存时,就从空闲列表中查找一块足够该对象的内存。

    掘金-付十一.png

  3. 处理并发安全问题;
    当对象创建很频繁时,就需要去解决并发的问题,也就是线程安全。比如程序中多线程创建m和n两个对象,给m对象分配内存的同时也会给n对象分配,如果这时候两个对象分配的是同一块内存,必然就出现了冲突。 为了解决这个并发的问题,JVM提供了两种方式。

    • CAS算法+失败重试方式

      CAS是项乐观锁技术,当多个线程尝试同时更新一个变量时,只有其中一个线程能够更新变量的值,而其他的线程都是失败的,但失败的线程都不会被挂起,可以再次尝试,直到成功为止。

    • 本地线程分配缓存区-TLAB

      所谓本地线程分配缓存区,就是当线程开启时,就为每个线程在Eden区分配一块内存,然后当线程内部创建对象时,就从自己的内存空间分配。若自己的内存不足或者用尽时,就开始从堆内存中分配,这个时候就是采用CAS的方式。

  4. 初始化内存空间;
    将分配到的内存,除对象头以外都初始化为零值。这也是为什么对象的实例在Java代码中不赋初始值就可以直接使用的原因,访问的都是对象的零值。

  5. 设置对象的对象头;
    将对象的所属类,对象的HashCode以及对象的GC分代等数据存储到对象的对象头中。

  6. 执行init方法进行初始化
    执行init方法,初始化对象的成员变量,调用类的构造方法,到这里,一个对象就被创建了。

1.2、对象在JVM中的生命周期

  1. 创建阶段,上面已经详细给出对象的创建过程;
  2. 应用阶段,当对象创建完成后,并分配给变量复制,状态切换到应用阶段;
  3. 不可见阶段,在程序中找不到对象的任何强引用;
  4. 不可达阶段,在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达;
  5. 收集阶段,垃圾收集器发现对象不可达,并且垃圾收集器已经准备好对该对象的内存空间进行重新进行分配;
  6. 终结阶段,垃圾收集器回收该对象的空间。

1.3、对象的回收

1.3.1、判断对象是否需要被回收

在对对象进行回收前,需要知道该对象是否是垃圾。而判断一个对象是否需要被回收,有两种方式,引用计数法和可达性分析算法:

  1. 引用计数法
    所谓引用计数法,指的是对象会维护一个引用计数器,计算被引用的次数,如果被引用一次,计数器就+1,如果不在引用,则-1,知道计数器为0时,就说明该对象可以被回收了。
    如果堆中有两个对象相互引用,那他们的计数器都为1,就不会被回收,会造成内存泄漏,这也是引用计数法的缺点。

  2. 可达性分析算法
    该算法基本思路就是GC Roots作为起点,从这个节点开始向下扫描,扫描到的对象即存活对象,未被扫描到的即需要被回收。
    GC Roots可以理解为由堆外指向堆内的引用, 一般而言,GC Roots包括(但不限于)以下几种:

    1. Java 方法栈桢中的局部变量;
    2. 已加载类的静态变量;
    3. JNI handles;
    4. 已启动且未停止的 Java 线程。

了解了对象是否可回收,接下来就开始了解垃圾的回收算法。

1.3.2、垃圾回收算法

  1. 标记清除算法
    对存活的对象进行标记,在最后扫描整个空间时,没有被标记的对象就会被回收。整个过程如下图所示:

    掘金-付十一.png

    该算法的缺点从上图就可看到,清除垃圾对象后,会产生不连续的内存碎片,当后面需要分配较大的对象时,会因为无法找到足够的连续内存空间,而触发垃圾回收,如果内存还是不足,则会异常。
    标记压缩算法就解决了这个问题。

  2. 标记压缩算法
    对存活的对象进行标记,在最后扫描整个空间时,没有被标记的对象就会被回收,并且进行内存碎片整理。整个过程如下图所示:

    掘金-付十一.png

    虽然标记压缩算法解决了标记清除算法内存不规整的问题,但又存在新的问题。比如说,最后对内存空间的整理需要花费时间,且指针也需要不断的重新移动,时间消耗会随堆内存越来越大。

  3. 复制算法
    复制算法是为了解决对碎片的垃圾回收,该算法一开始把堆一分为二,分为对象面和空闲面,程序在对象面为对象分配空间,当对象面满了,就将每个存活对象复制到空闲面,这样空闲面变成了对象面,对象面变成了空闲面。
    该算法的执行效率比标记整理和标记清除的效率都高,但是每次只能利用50%的内存空间。

  4. 分代垃圾回收算法
    分代垃圾回收机制是根据不同对象的不同生命周期,采用不同的回收方法,提高回收效率。
    主要分为年轻代和老年代。
    年轻代: 用于存放新创建的对象,存活率较低的对象,经过多次GC后,该对象仍然存活,那么就会放入老年代。常用复制算法
    老年代: 用于存放存活时间长的对象,常用标记清除或标记整理算法

    算法细节:

    • 对象新建,将存放在新生代的Eden区域,注意Suvivor区又分为两块区域,FromSuv和ToSuv;
    • 当年轻代Eden满时,将会触发Minor GC,如果对象仍然存活,对象将会被移动到Fromsuvivor空间,对象还是在新生代;
    • 再次发生minor GC,对象还存活,那么将会采用复制算法,将对象移动到ToSuv区域,此时对象的年龄+1;
    • 再次发生minor GC,对象仍然存活,此时Survivor中跟对象Object同龄的对象还没有达到Surivivor区的一半,所以还是会继续采用复制算法,将fromSuv和ToSuv的区域进行互换;
    • 当多次发生monorGC后,对象实例仍然存活,且此时,此时Survivor中跟对象Object同龄的对象达到Surivivor区的一半,那么对象实例将会移动到老年代区域,或者对象经过多次的回收,年龄达到了15岁,那么也会迁移到老年代。

磨刀不误砍柴工,首先基本上了解了一些内存方面的知识点,那接下来就开始内存优化实践。

二、内存优化实践

内存优化主要分为几个大方向,Bitmap优化内存泄漏内存抖动设备分级等。

2.1、内存抖动

内存抖动指的是内存频繁分配和回收导致内存不稳定,频繁GC,会导致卡顿,甚至会OOM。至于为什么会造成卡顿?是因为在GC时,会触发STW(stop the world)机制,也就是在执行垃圾收集算法时,应用程序的其他所有线程都被挂起(除了垃圾收集器之外),这个时候也就不会处理用户的操作事件,从而出现卡顿。

下面将模拟一个内存抖动情况,并使用Memory profile进行内存抖动的分析。 这里自定义了一个小球加载中的动画效果:

fushiyi.gif

在开始小球动画后,我们打开打开AS自带的Memory,从下图可以看到,内存的走势是在上下起伏,并且内存也在不断的增加,这就代表着内存在不断的分配和回收,这就是内存抖动。

image.png

那内存抖动的具体问题出现在哪里?

为了分析内存抖动的问题所在,我们先选取一段内存抖动的地方,可以看到在我们选取的这段时间里,AS的profile 都显示了每个对象的内存分配情况,如下所示。

image.png

从上面图片中的红色框里就可以了解到,App产生内存抖动的原因主要就是app heap的前几项,而要匹配到项目中的代码就需要一个一个查看占用内存多的模块。

image.png 我们先选择其中一个,在Allocation Call Stack模块中清楚的看到具体所分配的堆栈,同时也找到了造成堆中实例对象多的源代码。

image.png

频繁创建对象实例的原代码在com.fuusy.fuperformance.memory.view.WaveView的136行。

image.png

哦~~ ,原来是自定义View时,在onDraw中频繁创建Paint所致,解决的办法就是将paint作为全局变量,在外部创建。

这个案例只是内存抖动中一个小小的缩影,当项目越来越大时,排查的工作难度也随之增加,这就要我们在平时开发时,就需要注意代码细节问题,尽可能在coding的过程中就减少内存问题。

内存抖动的注意事项:

  1. 避免在循环和频繁调用的方法中创建对象;
  2. 使用对象池,如Handler、Glide中的对象池。

2.2、内存泄漏

内存泄漏指的是程序中已分配的内存由于某种原因未释放或者无法释放,造成系统内存的浪费。

造成内存泄漏的原因有很多,比如:

  • 长生命周期对象持有短生命周期对象的强引用,从而导致短生命周期对象无法被回收;
  • 异步线程持有短生命周期对象,如Handler、网络请求或者其他工作线程持有短生命周期对象;
  • 资源未及时关闭,如BroadcastReceiver、File、Cursor等;
  • 大量使用静态变量;
  • ......

当然,在实际项目中查找内存泄漏的原因的方式也有很多,比如主流工具LeakCanary、 MAT等。

2.3、Bitmap优化

Bitmap作为程序中内存占用的大户,是必须优化的对象,之前写过一篇关于Bitmap优化的文章「性能优化系列」不使用第三方库,Bitmap的优化策略,可参考查看。

Bitmap除了基本优化外,其实还需要在coding的过程中,就将Bitmap内存问题扼杀在摇篮里,本篇文章就将从图片大小监控,重复图片监控两个方向进行阐述。

2.3.1、Bitmap大小监控方案

Bitmap有一种从其尺寸上优化的手段,即当装载图片的容器例如ImageView只有100 * 100,而图片的分辨率为1800 * 800,这个时候将图片直接放置在容器上,很容易OOM,同时也是对图片和内存资源的一种浪费。当容器的宽高都很小于图片的宽高,其实就需要对图片进行尺寸上的压缩.

而比较重要的点就是如何判断Bitmap的尺寸符合图片容器?

想到的第一种方法就是可以自定义一个ImageView,在View中去判断图片以及容器的大小,如果图片太大,则进行尺寸上的压缩或优化。这种方式简单实用,确实也解决了我们的问题,但在实际开发中,除了代码侵入性强外,如果想要开发团队中的每个人加载ImageView时都使用这个控件,也是一件很难展开的事情。

为了更加解耦性和减少代码侵入性,这里介绍一种Bitmap大小的监控方案-ARTHook

ARTHook通俗来讲就是统一添加代码修改原有逻辑。基于ARTHook的框架有很多,这里介绍一个常用框架-Epic。 Epic就是ART上的 Dexposed(支持 Android 5.0 ~ 11)。它可以拦截本进程内部几乎任意的 Java 方法调用,可用于实现 AOP 编程、运行时插桩、性能分析等。

下面就基于Epic框架进行Bitmap大小监控的编写。

添加依赖

dependencies {
     implementation 'me.weishu:epic:0.11.0'
}

新建一个Hook类用来编写图片大小读取,比较,继承XC_MethodHook,并实现其afterHookedMethod,在其方法内实现需要监控的对象,具体监控的方法调用后将会调用该方法。

class BitmapARTHook : XC_MethodHook() {
    @Throws(Throwable::class)

    override fun afterHookedMethod(param: MethodHookParam) {
        super.afterHookedMethod(param)
   
        val imageView = param.thisObject as ImageView
        checkBitmap(imageView, (param.thisObject as ImageView).drawable)
    }
}

在afterHookedMethod方法里实现我们需要的逻辑,这里需要判断图片大小并给出警示。那就加上图片大小的监测方法。

if (bitmap.width >= width shl 1 && bitmap.height >= height shl 1) {
    warn(
        bitmap.width,
        bitmap.height,
        width,
        height,
        RuntimeException("Bitmap size too large")
    )
}

详细代码请参考fuusy/FuPerformance

写个测试来看看最终的效果。在xml中新建一个ImageView,宽高都设置为100dp,在Activity中将一张分辨率为1300* 500的图片设置到ImageView中。

val imageView = findViewById<ImageView>(R.id.iv_bitmap)
BitmapFactory.decodeResource(resources, R.mipmap.bitmap1).apply {
    imageView.setImageBitmap(this)
}

运行后在终端可以看到图片大小的提示信息。

掘金-付十一.png

ce5bca928aaf34c53b0cdb3e28746b4.jpg

2.3.2、重复图片监控

张邵文在高手课中提及重复图片指的是 Bitmap 的像素数据完全一致,但是有多个不同的对象存在。给出的方案是使用HAHA 库快速判断内存中是否存在重复的图片,并且将这些重复图片的 PNG、堆栈等信息输出。

需要注意的是需要使用8.0以下的机器,因为8.0以后Bitmap中的buffer已经放到native内存中。 核心代码与思路如下:

            //打开hprof文件
            HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
            HprofParser parser = new HprofParser(buffer);
            //解析获得快照
            com.squareup.haha.perflib.Snapshot snapshot = parser.parse();
            snapshot.computeDominators();

            //获得Bitmap Class
            Collection<ClassObj> bitmapClasses = snapshot.findClasses("android.graphics.Bitmap");
            //获取堆数据,这里包括项目app、系统、default heap的信息,需要进行过滤
            Collection<Heap> heaps = snapshot.getHeaps();

            long startTime = System.currentTimeMillis();
            Tools.print("---------------------- 开始 ----------------------- ");
            for (Heap heap : heaps) {
                // 只需要分析app和default heap即可
                if (!heap.getName().equals("app") && !heap.getName().equals("default")) {
                    continue;
                }
                Tools.print("HeapName:" + heap.getName());

                Map<Integer, List<AnalyzerResult>> map = new HashMap<>();
                
                for (ClassObj clazz : bitmapClasses) {
                    //从heap中获得所有的Bitmap实例
                    List<Instance> instances = clazz.getHeapInstances(heap.getId());

                    for (int i = 0; i < instances.size(); i++) {
                        //从GcRoot开始遍历搜索,Integer.MAX_VALUE代表无法被搜索到,说明对象没被引用可以被回收
                        if (instances.get(i).getDistanceToGcRoot() == Integer.MAX_VALUE) {
                            continue;
                        }
                        List<AnalyzerResult> analyzerResults;
                        int curHashCode = Tools.getHashCodeByInstance(instances.get(i));
                        AnalyzerResult result = Tools.getAnalyzerResult(instances.get(i));
                        result.setInstance(instances.get(i));
                        if (map.get(curHashCode) == null){
                            analyzerResults = new ArrayList<>();
                        }else {
                            analyzerResults = map.get(curHashCode);
                        }
                        analyzerResults.add(result);
                        map.put(curHashCode, analyzerResults);
                    }
                }

                if (map.isEmpty()){
                    Tools.print("当前head暂无bitmap对象");
                }

                for (Map.Entry<Integer, List<AnalyzerResult>> entry : map.entrySet()){
                    List<AnalyzerResult> analyzerResults = entry.getValue();
                    //去除size小于2的,剩余的为重复图片。
                    if (analyzerResults.size() < 2){
                        continue;
                    }
                }
              
            }

具体方案可参考高手课Demo github.com/simplezhli/… 以及fuusy/FuPerformance

2.4、设备分级

所谓设备分级,指的是根据不同设备环境来考虑不同的内存优化策略。目前市场上手机层出不穷,几乎每一年都会对手机性能进行提升,但是对于性能较差的手机,app应用的运行状况就会较差。

对于低端机用户可以关闭复杂的动画,或者是某些功能;使用 565 格式的图片,使用更小的缓存内存等。在现实环境下,不是每个用户的设备都跟我们的测试机一样高端,在开发过程我们要学会思考功能要不要对低端机开启、在系统资源吃紧的时候能不能做降级。- 张邵文

那如何进行设备分级?

Facebook其实开发了一个 设备年份类库,它使用简单的算法将设备的 RAM、CPU 内核和时钟速度与这些特性被认为是高端的年份相匹配。使得我们能够根据手机的硬件功能编写不同的逻辑。

RAMconditionYear Class
768MB1 core2009
2+ cores2010
1GB<1.3GHz2011
1.3GHz+2012
1.5GB<1.8GHz2012
1.8GHz+2013
2GB2013
3GB2014
5GB2015
more2016

而针对设备性能,我们能做的优化就如上面张邵文所说。

  • 是否关闭动画;
  • 图片质量分级;
  • 为低性能设备设计简版应用。

设备分级实践

添加设备年份库的依赖

implementation 'com.facebook.device.yearclass:yearclass:2.1.0'

获取设备年限以及进行设备分级。

val year = YearClass.get(applicationContext)
Log.d(TAG, "Year: $year")
when {
    year >= 2013 -> {
        // Do advanced animation
    }
    year > 2010 -> {
        // Do simple animation
    }
    else -> {
        // Phone too slow, don't do any animations
    }
}

image.png

三、总结

内存优化是一个很大的命题,从内存在虚拟机中的创建与回收,到LeakCanary、MAT分析工具的使用,再到内存泄漏、内存抖动以及Bitmap的监控和优化,再分细一点,线上线下的监控方案、优化手段以及如何一直保持内存的稳定,这些都是内存优化需要关注的问题。

感谢阅读,这是一个性能优化系列,请持续关注。

项目地址: fuusy/FuPerformance

参考资料:

国内Top团队大牛带你玩转Android性能分析与优化
Android开发高手课

推荐阅读:

「性能优化系列」不使用第三方库,Bitmap的优化策略
「性能优化系列」APP启动优化理论与实践(上)
「性能优化系列」APP启动优化理论与实践(下)