「性能优化系列」app内存优化理论与实践(代码片段)

涂程 涂程     2022-12-28     524

关键词:

好文推荐
作者:付十一

性能优化系列:

  • 启动优化
  • 内存优化
  • 布局优化
  • 卡顿优化
  • apk瘦身优化
  • 电量优化

项目地址: fuusy/FuPerformance

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

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

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

一、对象的创建和回收

1.1、对象的创建

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

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

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

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

    • 指针碰撞

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

  • 空闲列表

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

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

    • CAS算法+失败重试方式

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

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

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

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

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

  4. 执行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. 标记清除算法
    对存活的对象进行标记,在最后扫描整个空间时,没有被标记的对象就会被回收。整个过程如下图所示:

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

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

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

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

  2. 分代垃圾回收算法
    分代垃圾回收机制是根据不同对象的不同生命周期,采用不同的回收方法,提高回收效率。
    主要分为年轻代和老年代。
    年轻代: 用于存放新创建的对象,存活率较低的对象,经过多次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进行内存抖动的分析。 这里自定义了一个小球加载中的动画效果:

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

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

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

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

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

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

哦~~ ,原来是自定义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)

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

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
    

三、总结

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

spark系列(代码片段)

...《Java并发编程》,《Spring核心知识》《Docker教程》和《JVM性能优化》,都是多年面试总结。欢迎关注【后端精进之路】,轻松阅读全部文章。Java并发编程:Java并发编程系列-(1)并发编程基础Java并发编程系列-(2)线程的并发工具类Ja... 查看详情

spark系列(代码片段)

目前已经更新完《Java并发编程》,《JVM性能优化》,《Spring核心知识》《Docker教程》和《Spark基础知识》,都是多年面试总结。欢迎关注【后端精进之路】,轻松阅读全部文章。Java并发编程:Java并发编程系列-(1)并发编程基础Java并... 查看详情

抖音android性能优化系列:启动优化实践(代码片段)

组件,其初始化就是借助了一个叫ProcessLifecycleOwnerInitializer的ContentProvider进行初始化的。LifeCycle的初始化只是进行了Activity的LifecycleCallbacks的注册耗时不多,我们在逻辑层面上不需要做太多的优化。值得注意的是,如果这类用于... 查看详情

androidapp性能优化——内存cpu耗电量启动时间指标获取(代码片段)

最近公司要求对App进行性能优化。考虑到既然是优化性能,那首先要获取App的相关性能指标,比如:内存占用、CPU利用率、App启动时间、App耗电量等情况,获取以上各指标后,才能针对性分析确定是否存在优化的可能。最近公司... 查看详情

clickhouse极简教程-图文详解原理系列clickhouse主键索引的存储结构与查询性能优化...(代码片段)

概述这是AlexeyMilovidov(ClickHouse的创建者)给出的关于复合主键的答案的翻译。原文:https://groups.google.com/g/clickhouse/c/eUrsP30VtSU/m/p4-pxgdXAgAJ问题:主键可以有多少列?存储驱动器上的数据布局是什么?有任何理论/实践... 查看详情

dm中relaylog性能优化实践丨tidb工具分享(代码片段)

将转换后的binlogevent使用binlogwriter以relaylogfile的形式存储在本地的relaydirectory中。的机制,读取最近写入的文件并非通过磁盘,而是读取OS内存中的缓存,因此理论上影响有限。的存在,应用本身再增加一层缓存对latency的影响有... 查看详情

性能监控与优化(代码片段)

性能优化——CPU性能指标:吞吐、延时(应用负载视角)CPU、内存(资源视角)性能分析,其实就是找出应用或系统的瓶颈,并设法去避免或者缓解它们,从而更高效地利用系统资源处理更多的请... 查看详情

android—性能优化2—内存优化(代码片段)

文章目录性能优化:工具:memoryprofilerLeakCanaryarthookepic库java内存管理机制java内存回收机制Android内存管理机制Dalvik与Art区别LowMemoryKiller内存抖动解决内存泄漏解决第一个内存泄漏点内存很大的bitmap第一个地方生成二维码的时候每隔... 查看详情

android进阶——性能优化之bitmap位图内存管理及优化(代码片段)

引言位图一、Bitmap概述Bitmap直接继承Object并实现了Parcelable接口,是用于描述图片内部像素、像素类型、像素内部存储的编码格式、长、宽、颜色等一系列描述信息的对象,是Android中一切图形图像与硬件关联的重要对象&#x... 查看详情

android进阶——性能优化之bitmap位图内存管理及优化(代码片段)

引言位图一、Bitmap概述Bitmap直接继承Object并实现了Parcelable接口,是用于描述图片内部像素、像素类型、像素内部存储的编码格式、长、宽、颜色等一系列描述信息的对象,是Android中一切图形图像与硬件关联的重要对象&#x... 查看详情

电商网站https实践之路——性能优化篇(代码片段)

...服务端开销,又拖慢了客户端响应时间。因此,性能优化是必不可少的工作。很多文章都集中在服务端的性能优化上,但对于电商行业而言,大部分的用户流量源于App,因此客户端的性能优化配合服务端才能... 查看详情

一触即发——app启动优化最佳实践

一触即发App启动优化最佳实践文中的很多图都是Google性能优化指南第六季中的一些截图Google给出的优化指南来镇楼https://developer.android.com/topic/performance/launch-time.html闪屏定义Android官方的性能优化典范,从第六季开始,发起了一系... 查看详情

性能优化方法论系列三性能优化的核心思想(代码片段)

3.3提高资源利用率3.3.1空间换时间空间换时间是性能优化最常用的手段之一。其中缓存就是空间换时间的一种典型应用。CPU缓存、浏览器缓存、CDN缓存、DNS缓存、内存缓存、Redis缓存等,它们都是将数据缓存在离使用者更近的... 查看详情

程序设计基石与实践系列之编写高效的c程序与c代码优化

...文出处:codeproject:WritingEfficientCandCCodeOptimization虽然对于优化C代码有非常多有效的指导方针,可是对于彻底地了解编译器和你工作的机器依旧无法取代,通常。加快程序的速度也会加大代码量。这些添加的代码也会影响一个程序... 查看详情

cuda优化之layernorm性能优化实践(代码片段)

...姚迟、郑泽康、柳俊丞2020年末,OneFlow发布了《OneFlow性能优化分享:如何实现一个高效的SoftmaxCUDAkernel?》 ,其中介绍了OneFlow深度优化后的Softmax,尤其对很多框架没有考虑的half类型做了充分优化,使得... 查看详情

webpack性能优化系列-oneof(代码片段)

webpack系列文章:【Webpack性能优化系列(9)-多进程打包】极大的提升项目打包构建速度!!!【Webpack性能优化系列(8)-PWA】使用渐进式网络应用程序为我们的项目添加离线体验【Webpack性能优化系列(7)-懒加载和预加载... 查看详情

性能优化方法论系列三性能优化的核心思想(代码片段)

性能优化方法论系列目录《一、性能优化的本质》《二、性能优化方法论的思想源泉》《三、性能优化的核心思想(1)》《三、性能优化的核心思想(2)》《三、性能优化的核心思想(3)》《四、性能优... 查看详情

性能优化方法论系列三性能优化的核心思想(代码片段)

性能优化方法论系列目录《一、性能优化的本质》《二、性能优化方法论的思想源泉》《三、性能优化的核心思想(1)》《三、性能优化的核心思想(2)》《三、性能优化的核心思想(3)》《四、性能优... 查看详情