Android AOP拯救混乱的代码架构

Posted 壹条大懒虫

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android AOP拯救混乱的代码架构相关的知识,希望对你有一定的参考价值。

为什么要写这篇文章:

如今各大平台能搜xx框架如何使用的一大堆,但提及如何利用写出优雅的代码的文章却少之又少。所以本文主要提供一个思路来优化代码,也算抛砖引玉。若各位有不同看法或意见,可以在评论区提出,或者私信。博主看到会及时回复。

本文介绍:

  1. 拿过来直接可以运行

  1. 没有多余的废话,不会涉及到原理。先看到效果,用起来再去探究原理。

  1. 对小白友好

场景:

一个线上需要登录的App,许多函数在使用之前都要校验用户是否已登录。于是最直接的方案就是在这些函数中增加大量的if else来进行判断用户是登录......如下所示:

//需要验证的登录状态的函数
    private void sendMsg(String msg)
        //Manger.getInstance().getState() 获取登录状态
        switch (Manger.getInstance().getState())
            case -1://用户被强制下线
                //提醒用户 账号密码可能已泄漏
                //然后跳转到登录页面
                return;
            case 0://用户未登录
                //跳转登录页面
                return;
            case 1://用户已登录
                sendImMsg(msg);//验证通过发送消息
                break;
        
    

这种使用if else用来控制权限,着实不太优雅。且随着项目业务逐渐增多,管理登录状态的类(Manger)会跟多处代码耦合,浸入量极大。要解决这个问题,我们可以使用AOP思想来实现对登录模块的控制。(若不清楚AOP思想,可以先去了解一下大概意思)这里我们使用Aspectj这个框架来优化整段代码。

引入插件与依赖

  1. 在Project中的build.gradle中引入

buildscript 
    repositories 
        google()
        mavenCentral()
    
    dependencies 
        classpath "com.android.tools.build:gradle:7.0.2"
        
        //引入Aspectj插件 非常重要!!
        classpath 'org.aspectj:aspectjtools:1.8.9'
        classpath 'org.aspectj:aspectjweaver:1.8.9'
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    


task clean(type: Delete) 
    delete rootProject.buildDir

2.在Module中的build.gradle中引入

dependencies 
    //引入Aspectj框架
    implementation 'org.aspectj:aspectjrt:1.8.13'


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all  variant ->
    if (!variant.buildType.isDebuggable()) 
        log.debug("Skipping non-debuggable build type '$variant.buildType.name'.")
        return;
    
    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast 
        String[] args = ["-showWeaveInfo",
                         "-1.8",
                         "-inpath", javaCompile.destinationDir.toString(),
                         "-aspectpath", javaCompile.classpath.asPath,
                         "-d", javaCompile.destinationDir.toString(),
                         "-classpath", javaCompile.classpath.asPath,
                         "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)
        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) 
            switch (message.getKind()) 
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            
        
    

编写注解

每次调用函数时,我们需要先检查检查用户是否登录。如果用注解自动帮我们去检查,那么代码会优雅很多。这里我们定义一个注解,告诉Aspect 切入点在哪。

@CheckLogin 作用:加了@CheckLogin注解的函数运行之前都会自动去检查用户是否登录

//注意此处的包名 后面需要用,若你之间复制到自己项目中,包名改了,注解起不了作用
package com.chj.chjaj.loginmode.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/***
 * 作者:chj233
 * 时间:2023/3/17 23:16
 * 描述:用于检查登录
 */
@Target(ElementType.METHOD)//此注解只能写在函数上
@Retention(RetentionPolicy.RUNTIME)//注解生效 运行时
public @interface CheckLogin 

@LoginOut 作用:当校验未通过时(Manger.getInstance().getState() == -1),运行加了@LoginOut注解的函数

//注意此处的包名 后面需要用,若你之间复制到自己项目中,包名改了,注解起不了作用
package com.chj.chjaj.loginmode.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/***
 * 作者:chj233
 * 时间:2023/3/17 23:15
 * 描述:登录退出触发此注解的函数
 */
@Target(ElementType.METHOD)//此注解只能写在函数上
@Retention(RetentionPolicy.RUNTIME)//注解生效 运行时
public @interface LoginOut 

@LoginTo 作用:当校验为通过时(Manger.getInstance().getState() == 0),运行加了@LoginTo注解的函数

//注意此处的包名 后面需要用,若你之间复制到自己项目中,包名改了,注解起不了作用
package com.chj.chjaj.loginmode.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/***
 * 作者:chj233
 * 时间:2023/3/17 23:15
 * 描述:需要去登录
 */
@Target(ElementType.METHOD)//此注解只能写在函数上
@Retention(RetentionPolicy.RUNTIME)//注解生效 运行时
public @interface LoginTo 

AspectLogin 作用:这个类最为关键,注解是否按照我们编写好的执行,就看这个类

package com.chj.chjaj.loginmode;

import com.chj.chjaj.loginmode.annotation.LoginOut;
import com.chj.chjaj.loginmode.annotation.LoginTo;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/***
 * 作者:chj233
 * 时间:2023/3/17 23:13
 * 描述:注解控制
 */
@Aspect
public class AspectLogin 

    //切点 com.chj.chjaj.loginmode.annotation包下的CheckLogin注解为切点所有函数
    @Pointcut("execution(@com.chj.chjaj.loginmode.annotation.CheckLogin  * *(..))")
    public void loginCheck()
    

    //切面 loginCheck() 就是上面切点的函数名称 这个名称可以随便改 但两个地方需要保持一致
    @Around("loginCheck()")
    public void check(final ProceedingJoinPoint point) throws Throwable 
        switch (Manger.getInstance().getState())
            case 1://已登录验证通过
                point.proceed();//往下执行,若不执行point.proceed(),那么 加了切点注解的函数都不会运行
                break;
            case -1://已退出登录
                invokeAnnotion(point.getThis(), LoginOut.class); //point.getThis() 获取注解所在的类的对象
                break;
            case 0://被强制下线
                invokeAnnotion(point.getThis(), LoginTo.class); //point.getThis() 获取注解所在的类的对象
                break;
            default:
                break;
        
    

    //通过对象反射
    public static void invokeAnnotion(Object object, Class annotionClass) 
        Class<?> objectClass = object.getClass(); // 获取class对象
        // 遍历所有函数
        Method[] methods = objectClass.getDeclaredMethods(); // 得到所有的 对象中所有 公开 私有 函数
        for (Method method : methods) //循环这些函数
            method.setAccessible(true); // 让虚拟机不要去检测 private的函数

            // 判断是否被 annotionClass 注解过的函数
            boolean annotationPresent = method.isAnnotationPresent(annotionClass); //若是被注解的函数
            if (annotationPresent)  //那么之间执行
                // 当前函数 annotionClass 注解过的函数
                try 
                    method.invoke(object);//执行函数
                 catch (IllegalAccessException e) 
                    e.printStackTrace();
                 catch (InvocationTargetException e) 
                    e.printStackTrace();
                
            
        
    


MainActivity 作用:就是用来测试的

package com.chj.chjaj;

import android.os.Bundle;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;

import com.chj.chjaj.loginmode.Manger;
import com.chj.chjaj.loginmode.annotation.CheckLogin;
import com.chj.chjaj.loginmode.annotation.LoginOut;
import com.chj.chjaj.loginmode.annotation.LoginTo;

//简化业务层的代码
public class MainActivity extends AppCompatActivity 

    @Override
    protected void onCreate(Bundle savedInstanceState) 
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.denglu).setOnClickListener((view) ->
            Manger.getInstance().setState(1);
            toast("已登录");
        );
        findViewById(R.id.tuichu).setOnClickListener((view) ->
            Manger.getInstance().setState(-1);
            toast("已退出");
        );
        findViewById(R.id.xiaxian).setOnClickListener((view) ->
            Manger.getInstance().setState(0);
            toast("已下线");
        );
        findViewById(R.id.fasong).setOnClickListener((view) ->
            send();
        );
        findViewById(R.id.getinfo).setOnClickListener((view )-> 
            getInfo();
        );
    

    @CheckLogin//只需要增加@CheckLogin注解 即可去检查当前的登录状态
    protected void send()
        toast("发送消息");
    

    @CheckLogin//只需要增加@CheckLogin注解 即可去检查当前的登录状态
    protected void getInfo()
        toast("获取信息");
    

    @LoginOut//用户已退出 执行此函数
    public void logout()
        toast("登录已退出");
    

    @LoginTo//需要用户登录 执行此函数
    public void loginTo()
        toast("跳转登录.....");
    

    protected void toast(String msg)
        if (msg == null) return;
        Toast.makeText(this,msg,Toast.LENGTH_LONG).show();
    

总结一下:

此方案使用Aspect 通过反射的方式来执行注解标记的函数,所以在性能上会略低,所以对性能要求非常高的函数并不太适用。

拯救OOM 字节自研 Android 虚拟机内存管理优化黑科技 mSponge

作者:Android 平台架构

本文描述的虚拟机内存管理优化方案,是从应用侧视角对 Android 虚拟机内存管理进行改造,优化了虚拟机对 LargeObjectSpace 的内存管理策略,间接增加其它内存空间使用上限。改造后的方案,32 位运行环境 LargeObjectSpace 的内存使用上限可达到 2G 甚至更多(64 位环境使用上限理论上会趋于无限大)。通过本方案可以最大程度上从系统侧解决诸多应用都会遇到的内存瓶颈和 OOM 问题,一键接入,安全可靠。

1.背景

Java OOM对于 Android 开发者来说并不陌生,随着应用愈发复杂,部分业务在设计之初为了更好的产品体验,往往会考虑用空间换时间,长此以往,有限的内存资源将会不堪重负,尤其是在一些重大活动期间,内存挑战会更加严峻,稍有不慎就会暴雷。

为了应对这种情况,开发同学往往会从应用层面进行优化,减少缓存,但随着产品持续迭代,内存问题始终如达摩克利斯之剑,让应用处于崩溃的边缘。好在 Android 系统维护人员也意识到了这种问题,从 Android O 系统开始,对 Java 内存管理策略进行了调整,重点包括实例化 Bitmap 对象时,Bitmap 源数据的内存不再通过虚拟机申请,而是直接在 Native 层申请和管理,因此这部分内存不会纳入虚拟机 Heap 内存统计,以此来减少 LargeSpace 的占用,进而间接增加了其他内存空间的实际使用范围(此消彼长的关系),如下示意图:

通过调整 Bitmap 的内存管理策略以减少虚拟机整体内存使用,的确带来了立竿见影的效果。以字节公司内部诸多 App 为例,Android O 之后的移动设备,Java OOM 远远低于早期的系统版本。

2.思考与破局

对于上述 Android 内存管理策略优化,可以看到其实只有 Android N 以上版本受益,但是市面上仍有大量的低版本设备需要关注,为了带给用户更好的体验,我们开始思考,既然在 Android O 系统上可以通过调整 Bitmap 内存管理策略,以降低 Java 内存触顶压力,那么针对 Android 低版本是不是也可以考虑通过同样的方式去转移内存统计策略呢?

经过一番探索,最终找到了我们想要的答案:在应用侧,我们实现了对虚拟机内存管理策略的改造,将 LOS(Large Object Space)的整个内存使用,从虚拟机的内存统计之中进行移除,以保障其它内存 Space 可以更大范围地申请内存。

3.背景知识介绍

在正式介绍该方案之前,有必要先来简单了解一下 Android 虚拟机的内存空间管理、Java 大对象管理以及内存申请流程,以便于我们更加清晰地理解该方案的设计思想。

3.1 内存空间分类

众所周知,系统在创建 Zygote 过程中会初始化虚拟机配置,其中一个便是设置 Heap 内存。默认 HeapMax 是 256M、512M,后续应用进程通过 Zygote 进程孵化时,都会继承该配置且无法修改。但对于虚拟机来说,为了更好地管理内存并提升分配和回收性能,并没有将所有 Java 对象全部放在一块空间进行管理,而是按照不同的场景属性划分成若干个内存空间,这些内存空间将会共享虚拟机 512M 最大内存,因此它们之间是一个此消彼长的关系,如下图:

同时虚拟机在 GC 过程中,可以将部分 Object 对象进行移动以降低内存碎片,因此根据内存对象是否支持移动分为可移动对象、不可移动对象;按照连续性分为连续性空间(ContinuousMemMapAllocSpace)和非连续空间(DiscontinuousSpace);最终每个子类的 Space 都继承至 Space 和 ContinuousSpace/DisContinuousSpace,从上而下的继承关系,如下图:

这些内存空间的实例化对象存储在虚拟机 Heap 对象中,申请内存时根据内存属性选择不同的内存空间进行分配和管理。

针对此文,我们重点关注的是大内存管理,以 LargeObjectMapSpace 为例,从上图可以看到它继承至非连续内存空间,对于非连续内存的管理要简单得多,简单总结如下:虚拟机默认把大于 3 个物理页(12K)的原子性对象或 String 类型的对象通过该空间进行管理,该空间所有的对象都存储在一个 Map 容器中,每次 GC 时遍历这些对象是否被引用,如果没有被引用则直接释放即可。由于这些大对象之间是离散的,因此不会造成内存碎片问题,在 GC 时也就无需进行拷贝压缩以释放连续空间。下面我们再简单介绍一下 LargeObjectSpace 的角色和工作方式。

3.2 大对象内存管理

Java 大对象的内存管理比较简单,主要集中在 LargeObjectSpace 模块,主要负责 Java 大对象的内存申请和释放。

3.2.1 内存申请

结合源码,接下来简单介绍一下 Java 大对象申请流程,整理流程图如下。在此过程中,重点关注 Heap 已申请内存大小的更新过程和内存不足时抛出 OOM 的过程

在上图我们可以看到,在内存实际申请过程,会首先检测当前对象是否满足大对象,具体条件是:申请对象类型必须是原子类型或者 String 类型,并且要大于 3 个物理页

inline bool Heap::ShouldAllocLargeObject(ObjPtr<mirror::Class> c, size_t byte_count) const 

  return byte_count >= large_object_threshold_ && (c->IsPrimitiveArray() || c->IsStringClass());


如果满足以上条件,则会执行大对象申请流程,从 LargeObjectMapSpace 进行申请。但是在正式申请之前,会再次判断是否内存触顶,计算规则是将 Heap 中已经申请的内存和将要申请的内存进行相加,判断是否超过虚拟机的内存上限(growth_limit_),如果没有超过则说明不会触顶,然后就会直接从 LargeObjectMapSpace 申请一块内存;如果大于 growth_limit_,则说明可能会触顶,此时要先进行 GC,争取释放一些内存,如果多轮 GC 之后仍然满足不了,则抛出 OOM

在通过以上检测之后,将会通过LargeObjectMapSpace::Alloc 实例化一个 Object 对象,如下图:

结合上图,可以看到 LargeObjectMapSpace 在内存申请过程中如要完成以下工作:

  1. 根据申请的内存大小,利用 mem_map 映射与之对应的一块内存;
  2. 将映射的内存转换为 mirror::Object 对象;
  3. 将该 Object 对象与 mem_map 实例进行关联,并存储到 large_objects 集合;
  4. 更新 largeObjectMapSpace 当前内存占用和对象数量,以及累计占用大小和对象数量;

3.2.2 内存释放

内存释放逻辑:根据传入的对象,先检查是否在 large_objects*_*集合中,如果不存在则抛出异常;否则同步更新(释放)当前内存状态,并从该集合移除该对象。

在介绍完虚拟机内存空间管理以及 Large Object Space 内存管理方式的相关背景知识后,接下来我们回归到正题,看看我们针对虚拟机是如何改造 Large Object Space 的内存管理策略的。

4. mSponge 实现原理

4.1 方案简介

为了便于更好地理解,我们将整个方案分为 2 个部分进行介绍。

一期方案:主要介绍在 Java 大对象通过 LargeObjectSpace 的内存申请和释放过程中,如何在内存申请和释放过程对其进行改造,以脱离虚拟机对这些对象的内存管理,最后实现 LargeObjectSpace 占用的内存完全脱离虚拟机内存统计。

二期方案:针对一期方案需要在应用运行过程中提前开启,但是线上 99%以上运行过程中可能不会发生 OOM,因此一期方案对系统的侵入有点高。为了优化这个现象,二期方案主要通过监听应用是否发生 OOM,如监测到 OOM 则拦截并同开启一期方案“释放更多可用内存”,然后重试内存申请,以挽救本次 OOM;如果没有发生 OOM,则说明内存状态良好,该方案就不需要开启。显然,这种智能式的开启方式和最大化的内存保障效果将是更加极致的解决方案。

4.2 命名由来

在运行过程中,该方案会随着 LargeObjectSpace 的使用情况动态“吸收”和“释放”虚拟机 Heap 统计内存——“吸收”不希望被虚拟机统计的 LargeObjectSpace 的内存,“释放”已经通过 GC 回收的 Large Object 内存。整个运行过程犹如海绵吸水,故将该方案命名为:Memory Sponge,寓意:内存海绵,简称mSponge

4.3 mSponge 一期

从上面的大内存申请流程图中可以看到,如果当前内存申请满足 Java 大对象的条件(大于 12K),并在内存申请过程检测是否内存触顶时,“一直”返回 False,则可以通过 LargeObjectMapSpace 直接申请并返回对象实例,则可以绕过这里的内存触顶 OOM 问题。

同时,我们知道LargeObjectMapSpace 内部管理的对象是离散的,不支持虚拟机 GC 过程中连续内存空间的拷贝压缩的特性,因此即使是该空间内存占用过多,导致总内存超过了上限(512M?),但是其它连续内存 Space 的内存阈值仍然保持正常范围,因此不会影响到其他内存空间 GC 同构拷贝压缩能力,也就不会破坏虚拟机的内存管理。

4.3.1 方案思路

那么如何才能在大对象内存触顶检测过程中绕开现有的检测机制呢?通过调研发现判断内存触顶的关键条件在于虚拟机中管理当前已申请内存 Heap::num_bytes_allocated_对象,即每次内存申请成功和 GC 释放时,都会同步更新该值:

inline mirror::Object* Heap::AllocObjectWithAllocator(Thread* self,
ObjPtr<mirror::Class> klass, size*t byte_count, AllocatorType allocator, const PreFenceVisitor& pre_fence_visitor) 
......  
 //实际申请内存过程
......  
 if (bytes_tl_bulk_allocated > 0) 
size_t num_bytes_allocated_before =
//成功申请之后,需要同步更新虚拟机整体 Heap 内存使用
num_bytes_allocated* . fetch_add ( bytes_tl_bulk_allocated , std :: memory_order_relaxed );
......


GC 过程中,当每个 Space 释放一定对象和内存之后,会进一步同步到虚拟机的 Heap 对象,同步更新虚拟机整体内存使用,接口如下:

void Heap::RecordFree(uint64_t freed_objects, int64_t freed_bytes) 
  ......
  // Note: This relies on 2s complement for handling negative freed_bytes.
  //释放之后,需要同步更新虚拟机整体Heap内存使用
  num_bytes_allocated_  . fetch_sub (static_cast< ssize_t >( freed_bytes ), std :: memory_order_relaxed );
  ......

通过上面这个接口我们可以看到,每个 Space 在内存回收后都会更新虚拟机更新整体内存使用情况,那么我们是不是可以在合适的时机人为主动调用该接口,减去 LargeObjectMapSpace 管理的内存值,那么 Heap::num_bytes_allocated_统计的就全部是其他内存 Space 的内存使用了;换而言之通过 LargeObjectMapSpace 申请的内存将会“脱离虚拟机”Heap::num_bytes_allocated_的统计,纳入 Native 层的内存管理。但是这些对象的引用和内存回收机制仍然由虚拟机管理,因此并不会存在内存泄漏的隐患。

4.3.2 流程示意

LargeObjectMapSpace申请的内存,直接通过 Map 映射到虚拟内存,因此对于 32 位环境应用空间可映射内存在 3G 左右,但虚拟机本身会抢先占用 1G+的地址空间用于管理 Java 内存,因此应用侧实际使用范围在 2G 左右,极端情况下调整后的虚拟机内存理论范围将在 512M~2.5G,至于下限为何是 512M?理论上如果发生 OOM 时虚拟机没有任何大对象,这种情况下,则虚拟机可用内存范围将保持不变,因为我们改变的 Java 大对象的内存可用空间;示意图如下:

4.3.3 关键实现

上面介绍了该方案的背景知识和实现思路,接下来就要从技术层面考虑如何去实现了。如果在系统层面,直接从源码层面定制,相关改动会轻松很多,但是对应用侧来说,要想兼容不同 Android 版本,只有一条路可走——通过 InlineHook 代理相关接口,在执行过程中魔改相关参数以达到目的。在解决完接口代理问题之后,接下来还有下面几件事情要解决:

  • 虚拟机并没有对外暴露获取 LargeObjectMapSpace 内存的接口,如何才能实时获取当前 Space 已申请的内存大小?
  • 如何在合适的时机同步 Heap::num_bytes_allocated_内存统计,以便于让 LargeObjectMapSpace 的内存"脱离"虚拟机的统计?
  • 如何"跳过"虚拟机在内存释放过程对内存大小一致性校验的问题?
4.3.3.1 获取 LargeObjectMapSpace 当前内存

针对第一个问题,尽管 LargeObjectSpace 中提供了获取当前内存大小的接口(LargeObjectSpace::GetBytesAllocated),但是这个接口并没有对外暴露,因此需要通过解析 Libart.so 中的"GetBytesAllocated"符号,以 Android Q 为例,该函数签名符号为:_ZN3art2gc5space16LargeObjectSpace17GetBytesAllocatedEv;并在运行过程中动态获取该符号在内存中的地址。

由于 GetBytesAllocated 是非静态函数,因此在实际调用该接口时,需要知道当前对象的实例化对象,然后通过实例化对象调用该接口即可,在这里,我们通过 inlineHook 代理"LargeObjectMapSpace::Alloc"获取 LargeObjectMapSpace 的实例,LargeObjectMapSpace::Alloc 接口部分源码如下:

mirror::Object* LargeObjectMapSpace::Alloc(Thread* self, size_t num_bytes, size_t* bytes_allocated, size_t* usable_size, size_t* bytes_tl_bulk_allocated) 

  std::string error_msg;

  MemMap mem_map = MemMap::MapAnonymous("large object space allocation",
                                        num_bytes,
                                        PROT_READ | PROT_WRITE,
                                        /*low_4gb=*/ true,
                                        &error_msg);
  ......
  //申请成功后将当前内存占用+ allocation_size
  num_bytes_allocated_  += allocation_size ;
  total_bytes_allocated_ += allocation_size;
  ++ num_objects_allocated_  ;  //申请成功后将当前内存数量+1
  ++total_objects_allocated_;
  return obj;

在获取 LargeObjectMapSpace 的实例化对象之后,再通过该对象直接调用 GetBytesAllocated即可实时获取当前 LargeObjectMapSpace 的内存大小

4.3.3.2"移除"LargeObjectSpace 内存

当我们可以实时获取 LargeObjectSpace 的内存使用之后,接下来便是如何从虚拟机 Heap 中“移除”LargeObjectSpace 实际占用的内存了,通过调研发现可以通过解析"Heap::RecordFree"函数符号,并调用Heap::RecordFree 的函数接口,增加或减少 Heap 中我们想要更新的内存大小

void Heap::RecordFree(uint64_t freed_objects, int64_t freed_bytes) 
  ......
  // Note: This relies on 2s complement for handling negative freed_bytes.
  num_bytes_allocated_.fetch_sub (static_cast< ssize_t >( freed_bytes ), std :: memory_order_relaxed );
  ......

当上述条件满足我们可以灵活更新虚拟机 Heap 内存之后,接下来要处理的就是选择一个合适的时机直接“移除”LargeObjectSpace 的内存,并且需要更新记录当前 Heap“移除”的内存大小,经过调研并考虑到及时性和精准性,最终选择了在 LargeObjectSpace 的内存申请和回收过程,对虚拟机 Heap 内存进行动强制更新,以移除虚拟机对 LargeObjectSpace 的内存统计。

在 Large Object 申请过程,如果内存申请成功,则在该 Object 实例化对象返回之前,先强制从虚拟机内存统计中减去该部分内存,接下来虚拟机内部会在返回实例化对象之后,并统计本次新增内存,在这里我们通过先减后加的方式,维持了整个内存水位不变,从而间接地实现了虚拟机“忽略”了本次内存开销。

如果后续 GC 过程中释放了 LargeObjectSpace 中的部分或者全部对象,正常情况下释放的内存会同步同步到 Heap,以便于更新整体使用内存及可用内存,但是从上面的分析中我们知道,其实 LargeObjectSpace 的内存已经不在 Heap 统计之中了,如果从 Heap 中减去释放的这些内存,那么将会导致 Heap 统计的内存偏少,因此需要主动将该部分释放的内存"补偿"回来,避免统计错乱。

通过上述步骤实现了在内存回收过程中对大对象内存管理的改造,改造之后 Heap 统计的内存将不再包含 LargeObjectSpace 管理的内存,从而间接地扩大了其他内存 Space 使用上限;对于 LargeObjectSpace 来说,虚拟机统计到的该内存 Space 一直为 0,但是 LargeObjectSpace 内部并没有内存限制,异常该 Space 的内存使用上限将会显著提升,针对 Android O 以下系统来说,这部分内存 Space 不仅包含 Bitmap,还包含其他大对象。

4.3.3.3 内存校验

在适配过程中,发现 Android L 版本之后,虚拟机会在 GC 过程中对释放内存和使用内存进行一次校验。如果发现当前使用内存加上释放内存小于 GC 之前的内存,则会抛出“断言”异常,相关源码如下:

void Heap::GrowForUtilization(collector::GarbageCollector* collector_ran,

                              uint64_t bytes_allocated_before_gc) 

   //GC结束后,再次获取当前虚拟机内存大小

  const uint64_t bytes_allocated = GetBytesAllocated();
  ......
 if (!ignore_max_footprint_) 
        const uint64_t freed_bytes = current_gc_iteration_.GetFreedBytes() +
          current_gc_iteration_.GetFreedLargeObjectBytes() +
          current_gc_iteration_.GetFreedRevokeBytes();
    //GC之后虚拟机已使用内存加上本次GC释放内存理论上要大于等于GC之前虚拟机使用的内存,如果不满足,则抛出Fatel异常!!!
  CHECK_GE ( bytes_allocated + freed_bytes , bytes_allocated_before_gc );
 
  ......


因为我们在内存 GC 过程中,动态调整了 Heap 当前使用内存大小,这可能会导致 gc 结束后再次获取的 Heap 当前使用内存小于实际值,为了不影响校验逻辑,需要代理 Heap::GrowForUtilization 接口,强制将bytes_allocated_before_gc 参数设置为 0,以保证校验恒成立(针对该处调整从后续逻辑和实际测试来看,对后续内存 GC 并无明显影响)

4.3.4 小结

至此,通过上述思路和技术方案完成了 Android 虚拟机内存统计策略的改造,该方案不仅间接提升了虚拟机其它内存空间运行时的使用上限,也将 LargeObjectSpace 的内存使用上限完全脱离了虚拟机的限制,完全等同于 Native 内存属性进行管理。因此该方案相比 Android 系统 Bitmap 内存管理改造更加彻底,给应用的内存环境带来了极大的改善。

4.4 mSponge 方案二期

在上文,对内存统计策略改造之后可以很大程度释放 LargeObjectSpace 内存空间以优化 Java OOM 问题,但是进一步思考之后,发现一期方案并不是最优解,因为应用运行过程中很大概率不会发生 OOM,如果能监听到 OOM 时,再启动优化方案,同时再救活本次 OOM,那么这种智能化的按需开启将是极致化的解决方案

4.4.1 方案思路

针对上述思考,基于 mSponge 方案一期的设计,决定采用“OOM 探测+按需开启”的策略来完成对内存的按需扩展。即:对内存申请过程进行定向监控,当监听到内存不足即将抛出 OOM 异常时,进行拦截,并激活 mSponge 方案一期内存优化方案(从 Heap 内存统计中移除当前 LargeObjectSpace 使用内存);然后再触发一次内存申请,以保证内存成功申请。按照上述思路,整理方案优化前后对比示意图:

4.4.2 流程示意

基于上面的思路,我们需要在虚拟机内部监听并拦截 OOM,当监听到第一次 OOM 时主动将 LargeObjectSpace 的内存从 Heap 统计中移除,以增加空闲内存,同时再开启 mSponge 一期优化策略,以保证后续 LargeObjectSpace 的内存变化不会影响 Heap 内存统计;按照这种思路,整体二期方案示意图如下:

4.4.3 关键实现

二期技术实现主要涉及下面几个流程:监听并判断是否需要拦截 OOM 异常;监听内存分配结果;重试内存申请。具体如下:

  • 监听 OOM 异常:代理 Heap::ThrowOutOfMemoryError,监听内存分配失败时抛出 OOM 的过程,并判断是否需要拦截,如果可拦截。
  • 监听内存分配结果:代理 Heap::AllocateInternalWithGc ,监听本次内存申请过程中,是否发生了 OOM 并被拦截,如果发生 OOM 并被拦截,则再次触发内存申请,以保证内存申请成功。
  • 重试内存申请:通过 AllocateInternalWithGc 再次触发一次内存申请,并在此之前禁止拦截本次内存申请过程中可能抛出的 OOM,如果成功申请,则返回该对象。
4.4.3.1 监听 ThrowOutOfMemoryError

通过 inlineHook 代理"Heap::ThrowOutOfMemoryError"并监听该接口,如果该接口被调用则说明,当前内存不足或者没有连续内存,无法满足本次内存需求;并根据调用 AllocateInternalWithGc 代理接口设置的标识"sAllowsSkipThrowOutOfMemoryError",判断是否拦截本次 OOM 异常;实现如下:

void ThrowOutOfMemoryErrorProxy(void* heap, void* self, size_t byte_count, AllocatorType allocator_type)
    if(isAllowWork()) 
        sFindThrowOutOfMemoryError = true;
    if (sAllowsSkipThrowOutOfMemoryError
 && !sForceAllocateInternalWithGc) 
             //拦截并跳过本次OutOfMemory,并置标记位
            sSkipThrowOutOfMemoryError = true;

            //TODO:将LargeObjectSpace内存从Heap中移除
            return;
        
        sSkipThrowOutOfMemoryError = false;

         //如果不允许拦截,则直接调用原函数,抛出OOM异常
        ThrowOutOfMemoryErrorOrigin(heap, self, byte_count, allocator_type);
     else
        ThrowOutOfMemoryErrorOrigin(heap, self, byte_count, allocator_type);
    

4.4.3.2 代理 AllocateInternalWithGc

通过 inlineHook 代理"Heap::AllocateInternalWithGc"代理并监听该接口,因为在该接口执行过程中会触发一次或多次 GC,如果依然满足不了本次内存申请,则会抛出 OOM 并返回 NULL;因此可以通过代理该接口可以知道本次内存申请是否成功,以及本次申请过程中是否抛出 OOM 异常;如果返回对象为 NULL,并拦截了 OOM 异常,则设置禁止拦截 OOM 标记之后,调用 Heap::AllocateInternalWithGc 原接口再次进行内存申请,以保证成功申请内存。

原则上通过 mSpnge 方案将 LargeObjectSpace 的内存从 Heap 移除之后,理论上虚拟机可用内存会增加很多,基本能保证本次内存成功申请(极端情况仍会出现内存不足,正常抛出 OOM 即可)。

void* AllocateInternalWithGcProxy(void* heap, void* thread,
                                  AllocatorType allocator,
                                  bool instrumented,
                                  size_t alloc_size,
                                  size_t* bytes_allocated,
                                  size_t* usable_size,
                                  size_t* bytes_tl_bulk_allocated,
                                  void* klass)
    if(isAllowWork()) 
         //设置标记位,允许拦截本次内存申请过程的OOM
        sAllocateInternalWithGc = true;

        sForceAllocateInternalWithGc = false;

        //调用原始接口,并判断返回object是否未NULL
        void *object = AllocateInternalWithGcOrigin(heap, thread, allocator, instrumented, alloc_size, bytes_allocated, usable_size, bytes_tl_bulk_allocated, klass);
        sAllocateInternalWithGc = false;
          //如果返回object为NULl,并且在此过程中抛出了OOM异常,则说明内存不足导致申请失败,则开启虚拟机内存统计策略优化方案,释放LargeObjectSpace内存
        if (object == NULL && sAllowsSkipThrowOutOfMemoryError && sSkipThrowOutOfMemoryError) 

             //设置标记位,不允许拦截本次内存申请过程的OOM
            sForceAllocateInternalWithGc = true;

             //再次调用内存申请,争取成功申请内存
            object = AllocateInternalWithGcOrigin(heap, thread, allocator, instrumented, alloc_size, bytes_allocated, usable_size, bytes_tl_bulk_allocated, klass);

            sForceAllocateInternalWithGc = false;
        
        return object;
     else

        return AllocateInternalWithGcOrigin(heap, thread, allocator, instrumented, alloc_size,
                                     bytes_allocated, usable_size,
                                     bytes_tl_bulk_allocated, klass);
    

4.4.4 小结

至此,通过上述思路和技术方案完成了应用内存申请过程中的 OOM 监测,并在虚拟机内存抛出 OOM 的过程对 Heap 的内存管理进行了改造,移除了 LargeObjectSpace 的内存占用,间接增加了虚拟机可用内存之后,再次触发内存申请,以拯救本次 OOM;通过这种按需开启的方式,体现了以最小化的侵入成本换来最大化的内存保障。

5.方案收益

以今日头条 32 位测试环境为例,通过该方案对虚拟机大对象内存扩展了 500M(实际使用可根据产品自身特点设置扩展内存大小),通过测试用例连续占用 500M 内存后,虚拟机内存分布如下图:

从上图可以清晰看到虚拟机其它内存空间(main space)仍然是 1G 大小,但是large space 内存大小接近 600M,内存可用范围得到明显增加

5.1 统一 Large Object 管理策略

通过该优化方案,实现了在应用侧对 Android 各版本虚拟机内存管理策略的统一,弥补了 Android 系统优化向下兼容性不足的缺陷,很大程度降低了产品设计和 RD 开发过程,需要对低版本内存管理差异性进行降级或兼容的成本。

5.2 OOM 收益

统计近半年头条线上 OOM Case(集中发生在 Android O 以下版本,因为低版本 Bitmap 内存纳入虚拟机管理和统计,更加容易导致内存触顶),都发生在部分图片缓存过高,或者各种节日运营导致 Bitmap 缓存不合理;由于该方案将虚拟机大内存的统计进行了优化,相当于“扩展”了虚拟机可用内存,因此对于上述场景导致的 Java OOM 问题,起到很好的容错能力,挽救 90%以上的 OOM 问题。

6.总结

通过上文,我们以 32 位运行环境为例,介绍了Android 虚拟机内存管理策略的改造思路,改造之后的内存管理策略,统一了 Android 系统碎片化的内存管理差异,进一步优化应用的运行环境,显然更加符合应用侧对 Java 内存的使用诉求,更好地应对和保障了应用运行时的稳定性,带给用户更好体验。

7.后续

在移动互联网快速迭代的背景下,各类应用也在快速迭代,如何更好地保障线上质量,将是一种长期需要应对和探索的方向,除了在常规视角进行优化之外,如何进行更深层次的系统探索,也是我们日常工作的主要方向,后续我们将会分享更多关于系统层面的相关实践。

以上是关于Android AOP拯救混乱的代码架构的主要内容,如果未能解决你的问题,请参考以下文章

Android 逆向Android 进程注入工具开发 ( 远程调用 | x86 架构的返回值获取 | arm 架构远程调用 )

拯救OOM 字节自研 Android 虚拟机内存管理优化黑科技 mSponge

记一次擦窗机器人项目的拯救

本地志愿者项目介绍 | 独家 AOP Templet 大放送!

字节出品 Android Compose 强化实战手册,包你一个月学会Compose,拯救代码屎山

Google 官方Android MVP架构实践