Colección avanzada de Android: ajuste de GC y ART

En el artículo anterior # Colección avanzada de Android: área de datos de tiempo de ejecución de JVM , cuando el espacio interno de la nueva generación o la generación anterior es insuficiente, se activará el GC. Entonces, ¿por qué agregar este mecanismo de GC en Java? ¿Se puede hacer sin GC? Obviamente no, se puede decir que la GC es una condición necesaria para garantizar el funcionamiento normal del programa. A medida que se ejecuta el programa, la memoria disponible está destinada a disminuir, la GC es inevitable y la fragmentación de la memoria debe gestionarse al mismo tiempo.

1 algoritmos relacionados con GC

Al realizar GC, el recolector de elementos no utilizados necesita saber qué objetos deben reciclarse y cómo organizar la memoria después del reciclaje. Esto involucra muchos algoritmos centrales, que se describen en detalle aquí.

1.1 Algoritmo de confirmación de spam

Algoritmo de confirmación de basura, el propósito es marcar objetos que se pueden reciclar, hay dos tipos principales: algoritmo de conteo de referencia y algoritmo de análisis de accesibilidad GcRoot

1.1.1 Algoritmo de conteo de referencia

El algoritmo de conteo de referencia es un algoritmo relativamente primitivo. La lógica central usa el método de contador. Cuando se hace referencia a un objeto, el conteo de referencia es +1, y después de que la referencia no es válida, el conteo de referencia es -1. Cuando el conteo de referencia del objeto es 0, representa que los objetos se pueden reciclar.

Entonces, hay dos razones principales por las que se abandona este algoritmo de conteo de referencias:

(1) El contador de referencia debe usarse para almacenar el conteo y se debe abrir memoria adicional;
(2) El mayor problema es que el problema de las referencias circulares no se puede resolver, lo que hará que el conteo de referencia nunca se vuelva 0, pero los dos objetos de referencia ya no son utilizados por otros objetos. .

Por lo tanto, la aparición del algoritmo de análisis de accesibilidad puede resolver este problema.

1.1.2 Algoritmo de análisis de accesibilidad

El algoritmo de análisis de accesibilidad comienza desde el conjunto de nodos raíz, que en realidad es el conjunto de GcRoots, y luego atraviesa los objetos a los que hace referencia cada GcRoot. Los objetos conectados directa o indirectamente a GcRoot son todos objetos sobrevivientes, y otros objetos se marcarán como basura. . .

imagen.png

Entonces, ¿qué tipo de objetos se seleccionarán como GcRoot?

(1) Objetos en la tabla de variables locales de la pila de la máquina virtual

Esta es en realidad una mejor explicación, es decir, la ejecución de un método definitivamente requiere este objeto, si se recicla casualmente, el método no se puede ejecutar.

(2) Variables estáticas en el
área de métodos (3) Constantes en el área de métodos

Estos son objetos con un ciclo de vida largo y también se pueden usar como GcRoot

(4)本地方法栈中JNI本地方法的引用对象。

我们能够看到,GcRoot对象的共同点都是不易于被垃圾回收器回收

1.2 垃圾清除算法

前面我们通过标记算法标记了可以被回收的对象,接下来通过垃圾清除算法就可以将垃圾回收

1.2.1 标记清除算法

imagen.png

其中打上标记的,就是需要被清除的垃圾对象,那么垃圾回收之后

imagen.png

这种算法存在的的问题:

(1)效率差; 需要遍历全部对象查找被标记的对象
(2)在GC的时候需要STW,影响用户体验
(3)核心问题会产生内存碎片,这种算法不能重新整理内存,例如需要申请4内存空间,会发现没有连续的4块内存,只能再次发起GC

1.2.2 复制算法

这部分跟新生代的survivor区域有些类似,复制算法是将内存区域1分为2,每次只使用1块区域,当发起GC的时候,先把活的对象全部复制到另一块区域,然后把当前区域的对象全部删除。

imagen.png

在分配内存时,只是使用左半边区域,发起GC后:

imagen.png

我们发现,复制算法会整理内存,这里就不会再有内存碎片了。

这种方式存在的弊端:因为涉及到内存整理,因此需要维护对象的引用关系,时间开销大。

1.3.3 标记整理算法

其实看名字,就应该知道这个算法是集多家之所长,在清除的同时还能去整理内存,避免内存碎片。

imagen.png

首先跟标记清除算法一样,先将死的对象全部清楚,然后通过算法内部逻辑移动内存碎片,使其成为一块连续的内存

imagen.png

其实3种算法比较来看,复制算法效率最快,但是内存开销大;相对来说,标记整理更加平滑一些,但是也不是最优解,而且凡是移动内存的操作,全部都会STW,影响用户体验。

1.3.4 分代收集算法

这个方式在上一篇文章开题就已经介绍过了,将堆区分为新生代和老年代,因为大部分对象一开始都会存储在Eden区,因此新生代会是垃圾回收最活跃的,因此在新生代就使用了复制算法,将新生代按照8(Eden):2(survivor)的比例分成,速度最快,减少因为STW带来的体验问题

那么在老年代显然是GC不活跃的区域,而且在这个区域中不能有内存碎片,防止大对象无法分配内存,因此采用的是标记整理算法,始终是连续的内存区域。

2 垃圾回收器

2.1 垃圾回收的并行与串行

imagen.png 从上图中,我们可以看出,只有一个GC线程在执行垃圾回收操作,这个时候垃圾回收就是串行执行的

imagen.png

在上图中,我们可以看到有多个GC线程在同时工作,这个时候垃圾回收就是并行的

其实在多线程中有两个概念:并行和并发。

其中,并行就是上述GC线程,在同一时间段执行,但是线程之间并无竞争关系而是独立运行的,这就是并行执行;而并发同样也是多个线程在同一时间点执行,只不过他们之间存在竞争关系,例如抢占锁,就涉及到了并发安全的问题。

2.2 垃圾回收器分类

关于垃圾回收器的分类,我们从新生代和老年代两个大方向来看:

imagen.png

我们可以看到,在新生代的垃圾回收器,都是采用的复制算法,目的就是为了提效;而在老年代而是采用标记整理算法居多,前面的像Serial、ParNew这些垃圾回收器采用的复制算法我们都明白是什么流程,接下来介绍下CMS垃圾回收器的并发标记清除算法思想。

2.2.1 CMS垃圾回收器

CMS垃圾回收器,是JDK1.5之后发布的第一款真正意义上的并发垃圾回收器。它采用的思想是并发标记 - 清除 - 整理,真正去优化因为STW带来的性能问题

这里先看下CMS的具体工作原理
(1)标记GCROOT对象;这个过程时间短,会STW;
(2)标记整个GCROOT引用链;这个过程耗时久,采用并发标记的方式,与用户线程混用,不会STW,因为耗时比较久,在此期间可能会产生新的对象;
(3)重新标记;因为第二步可能产生新的对象,因此需要重新标记数据变动的地方,这个过程时间短,会STW;
(4)并发清理;将标记死亡的对象全部清除,这个过程不会STW;

看到上面的主要过程后,可能会问,整理内存并没有做,那么是什么时候完成的内存整理呢?其实CMS内存整理并不是伴随着每次GC完成的,而是开启定时,在空闲的时间完成内存整理,因为内存整理会导致STW,这样就不会影响到用户体验。

3 ART虚拟机调优

前面我们介绍的都是JVM,而Android开发使用的又不是JVM,那么为什么要学习JVM呢,其实不然,因为不管是ART还是Dalvik,都是依赖JVM的规范做的衍生产物,所以两者是相通的。

3.1 Dalvik和ART与Hotspot的区别

首先Android中使用的ART虚拟机,在Android 5.0以前是Dalvik虚拟机,这两种虚拟机与Hotspot基本是一样的,差别在于两者执行的指令集是不一样的,Android中指令集是基于寄存器的,而Hotspot是基于堆栈的;还有就是Android虚拟机不能执行class文件,而是执行dex文件。

接下来我们通过对比DVM和JVM运行时数据区的差异

3.1.1 栈区别

我们知道,在JVM中执行方法时,每个方法对应一个栈帧,每个栈帧中的数据结构如下:

imagen.png

而ART/Dalvik中同样存在栈帧,但是跟Hotspot的差别比较大,因为Android中指令集是基于寄存器的,所以将局部变量表和操作数栈移除了,取而代之的是寄存器的形式。

imagen.png

因为在字节码指令中指明了操作数的地址,因此CPU可以直接获取到操作数,例如累加操作,通过CPU的ALU计算单元直接计算,然后赋值给另一块内存地址,相较于JVM不断入栈出栈,这种响应速度更快,尤其对于Android来说,速度大于一切。

所以DVM的栈内存相较于JVM,少了操作数栈的概念,而是采用了寄存器的多地址模式,速度更快。

3.1.2 堆内存

imagen.png

ART的堆内存跟JVM的堆内存几乎是完全不一样的,主要是分为4块:

(1)Image Space:这块区域用于存储预加载的类,在类加载之前自动加载

这部分首先要从Dalvik虚拟机开始说起,在Android 2.2之后,Dalvik引入了JIT(即时编译技术),它会对于执行过的代码做dex优化,不需要每次都编译dex文件,提高了执行的速度,但是这个是在运行时做的处理,dex转为机器码需要时间。

因此在Android 5.0之后,Dalvik被废弃,取而代之的是ART虚拟机,从而引进了全新的编译方式AOT,就是在安装app的过程中,将dex文件全部编译为本地机器码,运行时就直接拿机器码执行,提高了执行速度,但是也存在很多问题,安装app的时候特别慢,造成资源浪费。

因此在Android N(Android 7.0)之后,引入了混编技术(JIT + 解释 + AOT)。在安装应用的时候不再全量转换,那么安装速度变快了;而是在运行时将经常执行的方法进行JIT,并将这些信息保存在Profile文件中,那么在手机空闲或者充电的时候,后台有一个BackgroundDexOptService会从Profile文件中拿到这些方法,看哪些没有编译成机器码进行AOT,然后存储在base.art文件中

那么base.art文件就是存储在Image Space中的,这个区域不会发生GC。

(2)Zygote Space:用于存储Zygote进程启动之后,预加载的类和创建的对象;\ (3)Allocation Space:用于存储用户数据,我们自己写的代码创建的对象,类似于JVM中堆的新生代
(4)LargeObject Space:用于存储超过12K(3页)的大对象,类似于JVM堆中的老年代

3.1.3 对象分配

在ART中存在3种GC策略,内部采用的垃圾回收器是CMS

(1)浮游GC:这次GC只会回收上次GC到本次GC中间申请的内存空间;
(2)局部GC:除了Image Space和Zygote Space之外的内存区域做一次内存回收;
(3)全量GC:除了Image Space之外,全部的内存做一次内存回收。

所以在ART分配对象的时候,会从第一个策略开始依次判断是否有足够空间分配内存,如果不够就继续往下走;如果全量GC都无法分配内存,那么就判断是否能够扩容堆内存。

3.2 线上内存问题定位

回到 # Android进阶宝典 -- JVM运行时数据区开头说的场景

(1)App莫名其妙地产生卡顿;
(2)线下测试好好的,到了线上就出现OOM;
(3)自己写的代码质量不高;

其实我们在线下开发的过程中,如果不注意内存问题其实很难会发现,因为我们每次修改都会run一次应用,相当于应用做了一次重置,类似于OOM或者内存溢出很难察觉,但是一到线上,用户使用时间久了就会出问题,下面就用一个线上案例配合JVM内存分配查找问题原因。

当时的场景,我们需要自定义一个View,这个View在旋转的时候需要做颜色的渐变,我们先看下出问题的代码。

class MyFadeView : View {

    constructor(context: Context) : super(context) {
        initView()
    }

    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
        initView()
    }

    private fun initView() {
        initTimer()
    }

    private val colors = mutableListOf("#CF1B1B", "#009988", "#000000")
    private var currentColor = colors[0]

    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        Log.e("TAG", "onDraw")
        val borderPaint = Paint()
        borderPaint.color = Color.parseColor(currentColor)
        borderPaint.isAntiAlias = true
        borderPaint.strokeWidth =
            context.resources.getDimension(androidx.constraintlayout.widget.R.dimen.abc_action_bar_content_inset_material)

        val path = Path()
        path.moveTo(0f, 0f)
        path.lineTo(0f, 100f)
        path.lineTo(100f, 100f)
        path.lineTo(100f, 0f)
        path.lineTo(0f, 0f)


        canvas?.let {
            it.drawPath(path, borderPaint)
        }
    }

    private var FadeRunnable: Runnable = Runnable {
        currentColor = colors[(0..2).random()]
        postInvalidate()
    }

    private fun initTimer() {

        val timer = object : CountDownTimer(1000, 2000) {
            override fun onTick(millisUntilFinished: Long) {
                Handler().post(FadeRunnable)
                initTimer()
            }

            override fun onFinish() {
            }

        }
        timer.start()
    }
}
复制代码

这里我们先做一个简单的自定义View,然后我们可以看下内存Profiler

imagen.png

内存曲线还是比较平滑的,看下对象分配

imagen.png

其中Paint还有Path创建的对象比较多,为什么呢?伙伴们应该都知道,每次调用postInvalidate方法,都会走onDraw方法,频繁地调用onDraw方法,导致Paint和Path被创建了多次。

在之前JVM的学习中,我们知道当一个方法结束之后,栈内的对象也会被回收,因此这样就会造成频繁地创建和销毁对象,如果当前内存紧张便会频繁地GC,导致内存抖动,因此创建对象不能在频繁调用的方法中执行,需要在initView中做初始化。

imagen.png 还有就是,伙伴们有用过直接使用Color.parseColor去加载一种颜色,这种方法也不能在频繁调用的方法中执行,看下源码,在这个方法中调用了substring方法,每次都会创建一个String对象。

那么有个问题,内存抖动是造成App卡顿的真凶吗?其实不然,即便是产生了内存抖动,在方法执行结束之后,对象也都被回收掉了不会存在于内存中,JVM还是很强大的,在内存充足的时候还是没有太大的影响的。

如果是产生了卡顿,那么一定伴随着内存泄漏,因为内存泄漏导致内存不断减少,从而导致了GC的提前到来,又加上频繁地创建和销毁对象,导致频繁地GC,从而产生了卡顿。

En el artículo #Optimización del rendimiento de Android: optimización de la memoria , hay usos específicos de las herramientas de optimización de la memoria y los socios interesados ​​pueden echarle un vistazo.

Supongo que te gusta

Origin juejin.im/post/7154929465749929997
Recomendado
Clasificación