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


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

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

  1. 判断对象对应的类是否加载、链接和初始化;

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

  1. 为对象分配内存;

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

  • 指针碰撞

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

  • 空闲列表

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

  1. 处理并发安全问题;

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

  • CAS算法+失败重试方式

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

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

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

  1. 初始化内存空间;

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

  1. 设置对象的对象头;

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

  1. 执行init方法进行初始化

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

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


  1. 创建阶段,上面已经详细给出对象的创建过程;

  2. 应用阶段,当对象创建完成后,并分配给变量复制,状态切换到应用阶段;

  3. 不可见阶段,在程序中找不到对象的任何强引用;

  4. 不可达阶段,在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达;

  5. 收集阶段,垃圾收集器发现对象不可达,并且垃圾收集器已经准备好对该对象的内存空间进行重新进行分配;

  6. 终结阶段,垃圾收集器回收该对象的空间。

1.3、对象的回收


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

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

  1. 引用计数法

所谓引用计数法,指的是对象会维护一个引用计数器,计算被引用的次数,如果被引用一次,计数器就+1,如果不在引用,则-1,知道计数器为0时,就说明该对象可以被回收了。

如果堆中有两个对象相互引用,那他们的计数器都为1,就不会被回收,会造成内存泄漏,这也是引用计数法的缺点。

  1. 可达性分析算法

该算法基本思路就是GC Roots作为起点,从这个节点开始向下扫描,扫描到的对象即存活对象,未被扫描到的即需要被回收。

GC Roots可以理解为由堆外指向堆内的引用, 一般而言,GC Roots包括(但不限于)以下几种:

  1. Java 方法栈桢中的局部变量;

  2. 已加载类的静态变量;

  3. JNI handles;

  4. 已启动且未停止的 Java 线程。

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

1.3.2、垃圾回收算法

  1. 标记清除算法

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

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

标记压缩算法就解决了这个问题。

  1. 标记压缩算法

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

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

  1. 复制算法

复制算法是为了解决对碎片的垃圾回收,该算法一开始把堆一分为二,分为对象面和空闲面,程序在对象面为对象分配空间,当对象面满了,就将每个存活对象复制到空闲面,这样空闲面变成了对象面,对象面变成了空闲面。

该算法的执行效率比标记整理和标记清除的效率都高,但是每次只能利用50%的内存空间。

  1. 分代垃圾回收算法

分代垃圾回收机制是根据不同对象的不同生命周期,采用不同的回收方法,提高回收效率。

主要分为年轻代和老年代。

年轻代: 用于存放新创建的对象,存活率较低的对象,经过多次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(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 bitmapClasses = snapshot.findClasses(“android.graphics.Bitmap”);

//获取堆数据,这里包括项目app、系统、default heap的信息,需要进行过滤

Collection 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> map = new HashMap<>();

重要知识点

下面是有几位Android行业大佬对应上方技术点整理的一些进阶资料。

高级进阶篇——高级UI,自定义View(部分展示)

UI这块知识是现今使用者最多的。当年火爆一时的Android入门培训,学会这小块知识就能随便找到不错的工作了。不过很显然现在远远不够了,拒绝无休止的CV,亲自去项目实战,读源码,研究原理吧!

  • 面试题部分合集

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
uals(“default”)) {

continue;

}

Tools.print(“HeapName:” + heap.getName());

Map<Integer, List> map = new HashMap<>();

重要知识点

下面是有几位Android行业大佬对应上方技术点整理的一些进阶资料。

[外链图片转存中…(img-pNW9oa1S-1714972216988)]

高级进阶篇——高级UI,自定义View(部分展示)

UI这块知识是现今使用者最多的。当年火爆一时的Android入门培训,学会这小块知识就能随便找到不错的工作了。不过很显然现在远远不够了,拒绝无休止的CV,亲自去项目实战,读源码,研究原理吧!

[外链图片转存中…(img-wdmpLkBo-1714972216989)]

  • 面试题部分合集
    [外链图片转存中…(img-bGq5WLh7-1714972216990)]

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

  • 13
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值