Android-性能优化-02-内存优化-内存抖动

484 阅读7分钟

Android 内存抖动(Memory Churn)是指在短时间内频繁地分配和释放小块内存,导致垃圾回收(Garbage Collection,GC)频繁触发,从而影响应用的性能。内存抖动可能导致以下问题:

  1. GC频繁触发:由于短时间内产生大量的临时对象,会导致 GC 不断运行,增加 CPU 使用率和应用卡顿的可能性。
  2. 性能下降:频繁的 GC 会导致主线程被暂停,直接影响用户体验,特别是在需要流畅动画的场景下。
  3. 内存碎片化:频繁分配和释放内存可能导致堆内存碎片化,使得内存的使用效率降低。

内存抖动的常见原因

  1. 频繁创建临时对象

    • 在循环或高频调用中创建大量短生命周期对象,例如 String 的拼接、包装类(如 IntegerDouble)的频繁使用。
  2. 重复分配大块对象

    • 某些复杂的计算中反复分配大块内存但不复用,导致 GC 的 Full GC 触发。
  3. 集合操作不当

    • 频繁扩展集合容量或操作大规模集合导致内存分配增加。
  4. 不当使用第三方库

    • 某些库可能在实现中存在过多的临时对象创建,造成内存抖动。
  5. 绘图或动画频繁创建对象

    • 自定义控件的 onDraw 方法中频繁分配对象。

内存抖动的影响

image.png

内存抖动导致卡顿的主要原因是频繁的垃圾回收(GC) ,而垃圾回收过程中可能会暂停应用线程,尤其是主线程,从而引起明显的卡顿。

1. 内存抖动与GC的关系

内存抖动的本质

  • 内存抖动指在短时间内频繁地分配和释放对象,这些对象往往是临时的、小块的,并且生命周期极短。
  • 这些临时对象被分配到堆内存的年轻代(Young Generation,通常是 Eden 区域)。
  • 当年轻代内存被填满时,系统会触发 Minor GC 来清理这些对象。

GC的影响

  • 每次 GC 都需要暂停应用的部分线程(称为 STW:Stop-the-World)。
  • 如果内存抖动频繁发生,GC 会被频繁触发,增加了 CPU 的负担,并导致线程频繁暂停。
  • 对于主线程,这种暂停会阻塞 UI 绘制和事件处理,直接导致卡顿现象。

2. 卡顿的主要原因

(1) GC 暂停(Stop-the-World)

  • Young GC:GC 会暂停线程,清理年轻代中的短生命周期对象。

    • 时间消耗:通常为几毫秒,但如果内存抖动非常频繁,每帧都可能会触发,累积的暂停时间会导致明显卡顿。
  • Full GC:当老年代(Old Generation)对象较多且需要整理时触发,暂停时间更长,甚至达到几十毫秒或更多。

(2) 主线程被阻塞

  • Android 的主线程负责处理 UI 绘制、动画、用户输入等任务。如果主线程被 GC 暂停,UI 帧无法按时渲染,就会导致掉帧或卡顿。
  • 在 60 FPS 的目标下,每帧的渲染时间不能超过 16.67ms。一旦 GC 导致线程暂停超过这个时间,用户就会感受到明显卡顿。

(3) CPU 资源被占用

  • 内存抖动引发频繁的对象分配和回收,大量的 CPU 时间被消耗在内存管理上,导致应用的其他任务(如逻辑计算、事件响应)得不到足够的资源支持。

(4) 内存碎片化

  • 如果对象的分配和释放频率极高,可能导致堆内存碎片化,使得后续大块内存分配困难,从而进一步增加内存分配时间和 GC 开销。

3. 为什么会感到卡顿?

(1) 人眼对帧率变化的敏感性

  • 人眼对帧率变化非常敏感,尤其是在高动态场景(如滚动、动画)下。
  • Android 设备通常设定为 60 FPS(每帧时间为 16.67ms)。如果内存抖动导致 GC 超过这一时间,就会掉帧,产生卡顿感。

(2) GC 频率过高

  • 如果内存抖动每秒触发 10 次以上的 GC,会导致应用无法保持连贯的帧率,UI 和交互明显变得不流畅。

4. 示例:内存抖动如何导致卡顿?

问题代码:

kotlin
复制代码
override fun onDraw(canvas: Canvas) {
    for (i in 0..100) {
        val paint = Paint() // 每次调用都创建新的 Paint 对象
        canvas.drawCircle(100f, 100f, 50f, paint)
    }
}

优化代码:

kotlin
复制代码
private val paint = Paint() // 将 Paint 对象复用

override fun onDraw(canvas: Canvas) {
    for (i in 0..100) {
        canvas.drawCircle(100f, 100f, 50f, paint)
    }
}

对比分析:

  • 问题代码:每次调用 onDraw 方法都会创建 100 个 Paint 对象,触发频繁的 GC。
  • 优化代码:复用 Paint 对象,减少临时对象的分配,降低 GC 频率,避免主线程被频繁暂停。

5. 如何缓解内存抖动导致的卡顿?

(1) 减少临时对象分配

  • 在高频调用的场景中(如动画、绘制),尽量避免临时对象创建。
  • 复用可变对象(如 PaintBitmapPath 等)。

(2) 使用对象池

  • 对需要频繁创建和销毁的对象(如自定义消息、绘图元素)使用对象池进行复用。

    kotlin
    复制代码
    val pool = Pools.SynchronizedPool<MyObject>(10)
    val obj = pool.acquire() ?: MyObject()
    pool.release(obj)
    

(3) 优化集合操作

  • 提前设置集合的初始容量,避免频繁扩容导致的内存分配。

    kotlin
    复制代码
    val list = ArrayList<String>(100)
    

(4) 避免频繁触发 GC

  • 降低内存分配频率,通过优化算法、复用资源减少内存占用。

如何检测内存抖动

  1. 使用 Android Profiler

    • 在 Android Studio 中,通过 Memory Profiler 可以实时观察内存分配情况,包括对象分配频率和 GC 次数。
    • 如果看到频繁的内存分配和回收,可能是内存抖动的征兆。
  2. Debug API

    • 使用 Debug.startAllocCounting()Debug.getAllocCount() 查看分配次数。
  3. Logcat 分析

    • 查看 GC 日志,如果频繁出现 GC_FOR_ALLOCGC_CONCURRENT,可能有内存抖动问题。
  4. 使用工具库

    • 借助工具库如 LeakCanary 和 MAT(Memory Analyzer Tool),分析对象分配和生命周期。

优化和解决方法

  1. 避免频繁创建临时对象

    • 场景:字符串拼接
      优化:使用 StringBuilderStringBuffer 替代 String 的直接拼接。
    • 场景:包装类频繁创建
      优化:优先使用基本数据类型代替包装类。
  2. 对象复用

    • 场景:频繁使用类似对象
      优化:通过对象池(如 SparseArrayObjectPool)实现复用。
  3. 减少集合操作的内存分配

    • 提前设置集合的初始容量,避免扩容导致的内存抖动:

      kotlin
      复制代码
      val list = ArrayList<Int>(expectedSize)
      
  4. 优化自定义控件绘制

    • onDraw() 方法中避免分配对象,提前缓存所需资源,例如 PaintPath
  5. 使用更高效的数据结构

    • 根据场景选择合适的数据结构。例如,用 SparseArray 替代 HashMap,减少内存开销。
  6. 优化线程和任务调度

    • 避免短时间内过多线程频繁启动和销毁。
  7. 减少重复分配和大块内存使用

    • 对需要频繁使用的大块内存,可通过缓存策略避免反复分配。

问题代码

kotlin
复制代码
fun concatenateStrings(strings: List<String>): String {
    var result = ""
    for (str in strings) {
        result += str // 每次拼接都会创建一个新的 String 对象
    }
    return result
}

优化后代码

kotlin
复制代码
fun concatenateStrings(strings: List<String>): String {
    val builder = StringBuilder()
    for (str in strings) {
        builder.append(str) // 使用 StringBuilder 减少对象分配
    }
    return builder.toString()
}

抖动实战

业务逻辑

image.png

基于链表的对象池设计

image.png

LRU算法

LRU(Least recently used,最近最少使用)“如果数据最近被访问过,那么将来被访问的几率也更高”。 image.png

基于LRU的对象池

如果对象池中不存在需要大小的byte数组怎么办? image.png