Android 内存管理

2023年 9月 16日 34.6k 0

一、需求

我司存在内存为1G RAM的设备,属于低内存设备,经常会出现内存很紧张的场景,也容易因此导致一系列七七八八的边际问题,故有必要了解Android系统的内存相关知识:

  • 了解内存的分配、回收方式
  • 了解OOM、LMK的相关机制
  • 了解Android系统内存相关调试方式
  • 了解Android系统的性能优化方案
  • 二、环境

  • JDK 1.8
  • Android 10
  • 三、JVM

    JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一个虚构出来的计算机,有着自己完善的硬件架构,如处理器、堆栈等。

    3.1 编译&执行过程

    Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

    Java文件必须先通过一个叫javac的编译器,将代码编译成class文件,然后通过JVM把class文件解释成各个平台可以识别的机器码,最终实现跨平台运行代码。

    3.2 JVM内存模型

    3.2.1 方法区

    方法区是《Java虚拟机规范》中规定的一个内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。方法区是一个规范,它的实现取决于不同的虚拟机:

  • 在Java8之前,HotSpot虚拟机使用 永久代 来实现方法区。
  • 而Java8之后,HotSpot虚拟机使用 元空间 来实现方法区。

  • 元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存;永久代在虚拟机中。
    方法区存储的信息如下:

    名称 内容
    类型信息 (1)是类class、接口interface、枚举enum、注解annotation中哪一种
    (2)完整有效名称(包名.类名)
    (3)直接父类的完整名称(接口和java.lang.Object没有父类)
    (4)类型的修饰符(public、abstract、final等)
    (5)类型直接接口的有序列表(实现的接口构成列表)
    域(Field、属性)信息 (1)保存类型所有域(属性)的相关信息和声明顺序
    (2)相关信息包含:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient等)
    方法(method)信息:按顺序保存 (1)方法名称
    (2)返回类型(含Void)
    (3)方法参数和类型(按顺序)
    (4)方法的修饰符(public、private、protected、static、final、synchronized、native、abstract)
    (5)方法的字节码、操作数栈、局部变量表及其大小(abstract和native方法除外)
    (6)异常表abstract和native方法除外),每个异常处理开始、结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引等
    Non-final类变量:(static修饰的变量,静态变量) (1)逻辑上是类数据一部分
    (2)在类的加载过程中链接的准备阶段设置默认初始值,初始化阶段赋予真实值
    (3)类变量(non-final)被所有实例共享,没有实例化类对象也可访问,(全局常量,static和final一起修饰)
    (4)与final修饰的类变量不同,每个全局常量在编译时就分配了
    Class文件常量池 (1)一个有效的字节码文件除了包含类的版本信息、字段、方法以及接口描述信息外,还包含一个常量池表。常量池表中包含字面量、域和方法的符号引用。
    (2)字面量就是int i=5;String=”Hello World!”中的5和”Hello World!”
    (3)一个JAVA源原文件中的类、接口,编译后生成字节码文件,Java中的字节码需要数据,但是这些数据很多很大,不能直接存到内存中,可以将其存到常量池中,字节码中包含了指向常量池的引用。
    (4)常量池中包含:数量值、字符串引用、类引用、字段引用、方法引用
    运行时常量池 (1)运行时常量池是方法区的一部分
    (2)常量池表示Class中的一部分,用于存放编译器生成的各种字面量和符号引用,在加载类和接口到虚拟机后,就创建相应的运行时常量池
    (3)JVM为每个加载的类或接口维护一个运行时常量池,池中数据类似数组项,通过索引访问
    (4)运行时常量池中含多种不同常量,包含编译器就明确的数值字面量,也包含运行期的方法或者字段引用,此时不再是常量池中的符号地址,而是真实地址。
    (5)运行时常量池,相对于Class文件中的常量池,还有一个特征就是具备动态性,可以动态添加数据到运行时常量池
    (6)当创建运行时常量池时,如果所需内存空间大于方法区能提供的最大值,那么JVM抛出OutOfMemoryError异常

    3.2.2 堆

    堆是java内存管理中最大的一块内存,也是所有线程共享的一块内存,在虚拟机启动时创建。堆中主要存放的是对象实例、数组。几乎所有的对象实例、数组都在这一块内存中分配。

    堆也是GC垃圾回收的主要区域。垃圾回收现在主要采取的是分代垃圾回收算法。为了方便垃圾回收,java堆还进行了细分:新生代(YoungGen)、和老年代(oldGen),默认占比为:1:2;其中新生代还可以划分为Eden空间、survivor0空间、survivor1空间,默认占比为:8:1:1;

    对象内存分配过程如下:
    1.new一个对象value,value先放于新生代->Eden区;
    2.当Eden区空间填满后,我们需要再创建value2对象,JVM会对Eden区继续垃圾回收(Minor GC);
    3.Eden区触发GC后,Eden区会被清空,同时Eden区幸存对象会移动到S0幸存区。此时,Eden区和S1区未存放对象;
    4.如果Eden区再次被填满,再次触发GC,此时会对Eden区和S0区进行垃圾回收,存活对象移动至S1幸存区。此时Eden区和S0区未存放对象;
    5.在eden区发生gc后剩余对象内存大于s区时,直接进入老年代。
    6.如果再次经历垃圾回收,此时幸存对象会重新放回S0区,如此反复,幸存区会永远存在一个区为空对象;
    7. 当我们的对象时长超过一定年龄时(默认15,可以通过参数设置),将会把对象放入老生代,当然大的对象会直接进入老生代。老生代采用的回收算法是标记整理算法。
    8. 当老年代内存满了或者发生young GC后要转移至老年代的对象内存大于老年代剩余内存时,触发Full GC(Full GC会触发STW(stop the world))。

    3.2.3 程序计数器

    程序计数器是一块较小的内存空间,可看作是当前线程所执行字节码的行号指示器。字节码解释器根据这个计数器来获取当前线程需要执行的下一条指令,分支、循环、跳转、异常、线程恢复等功能都需要依赖程序计数器来完成。

    此外,在线程争夺CPU时间片的时候,需要线程切换,这时候,就需要这个计数器来帮助线程恢复到正确执行的位置,每一条线程有自己的程序计数器,所以才能够保证当前程序能够正确恢复到上次执行的步骤。

    ps:程序计数器是唯一一个不会出现OOM错误的内存区域,它的生命周期伴随线程的创建而创建,随程序的消亡而消亡。

    3.2.4 虚拟机栈

    虚拟机栈是线程私有的。虚拟机栈跟线程的生命周期相同,它描述的是java方法执行的内存模型,每次java方法调用的数据,都是通过栈传递的。

    java内存可以粗糙的分为 堆内存(heap)和 栈内存(stack) ,其中栈内存就是指的虚拟机栈,或者说是虚拟机栈中局部变量表中的部分。实际上,虚拟机栈就是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息:

    名称 内容
    局部变量表 主要存放的是编译期间可知的各种数据类型(八大基本数据类型)、对象引用(Reference类型,不同于对象,可能是指向对象地址的指针或者与此对象位置相关的信息)
    操作数栈 主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间
    动态链接 对运行常量池的引用,类加载机制过程中解析那一步的作用是将常量池中的符号引用替换成直接引用,这叫静态链接,而这里的动态连接的意思是在运行过程中转换成直接引用
    方法出口 无论是程序正常返回或者是异常调用完成返回,都必须回到最初方法被调用时的位置。

    虚拟机栈可能抛出两种错误:StackOverflowError 、OutOfMemoryError。

    3.2.5 本地方法栈

    本地方法栈的工作原理跟虚拟机栈并无区别,唯一的区别就是本地方法栈面向的不是.class字节码,而是Native修饰的本地方法。

    本地方法的执行过程,也是本地方法栈中栈帧的出栈过程。

    同虚拟机栈一样,本地方法栈也是会抛出 StackOverflowError 、OutOfMemoryError 两种异常。

    3.2.6 直接内存

    直接内存有一种叫法,堆外内存。

    直接内存(堆外内存)指的是Java应用程序通过直接方式从操作系统中申请的内存。这个的差别与之前的堆,栈,方法区不同。那些内存都是经过了虚拟化的内存

    3.2.7 方法区、堆、栈之间的关系

    栈中的类字节码存储在方法区(也就是存类),实例化对象存储在Java堆,对象引用存储在栈中。

    四、OOM

    OOM(Out of Memory)即内存溢出,是因为应用所需要分配的内存超过系统对应用内存的阈值,而抛出的 java.lang.OutOfMemoryError错误。 OOM的根本原因是开发者对内存使用不当造成的。

    Android的每个应用程序都会使用一个专有的Dalvik虚拟机实例来运行,也就是说每个应用程序都是在属于自己的进程中运行的。如果程序内存溢出,Android系统只会kill掉该进程,而不会影响其他进程的使用(如果是system_process等系统进程出问题的话,则会引起系统重启)。

    4.1 OOM阈值

    Android系统JVM对应用所分配的内存阈值:

    sl8541e_1h10_32b:/ # getprop | grep dalvik.vm.heap
    [dalvik.vm.heapgrowthlimit]: [80m] //单个应用程序最大内存限制,超过将被Kill
    [dalvik.vm.heapsize]: [256m] //所有情况下(包括设置android:largeHeap="true"的情形)的最大堆内存值,超过直接oom
    [dalvik.vm.heapstartsize]: [6m] //单个应用程序分配的初始内存
    
    

    4.2 OOM演示

    4.2.1 测试代码

    void testForOutMemory(){
        ActivityManager mActivityManager = (ActivityManager) getApplication().getSystemService(Context.ACTIVITY_SERVICE);
        int largeMemoryClass = mActivityManager.getLargeMemoryClass();
        int memoryClass = mActivityManager.getMemoryClass();
        int currentMemory = (int) Runtime.getRuntime().maxMemory() /1024 /1024;
        Log.d("LZQ","[show memory] largeMemoryClass = " + largeMemoryClass + " | memoryClass = " + memoryClass + " | currentMemory = " + currentMemory);
    
        List list = new ArrayList();
        int count = 0;
        while (true) {
            Log.d("LZQ","[allocate memory] count = " + count);
            byte[] test = new byte[20 * 1024 * 1024];//20M数据
            list.add(test);
            count++;
        }
    }
    
    

    4.2.2 测试结果

  • 当前应用未设置largeHeap,故当前设备的应用最大内存为80MB;
  • 往系统申请20MB的内存,仅申请了3*20=60MB的内存,当申请第4块内存时,系统发生OOM,当前的应用内仅剩18MB,不足以继续分配;
  • 4.3 OOM异常定位

    OOM异常在log上还是相对明显,有OOM标识:java.lang.OutOfMemoryError。
    堆内存分配失败,对应的代码如下(以下流程涉及JVM的内存分配流程,没有进一步展开分析,详细代码可自行阅读):

    @artruntimegcheap.cc
    void Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type) {
      // If we're in a stack overflow, do not create a new exception. It would require running the
      // constructor, which will of course still be in a stack overflow.
      ...
      std::ostringstream oss;
      size_t total_bytes_free = GetFreeMemory();
      oss handler(handler_info->data, evt->events, &poll_params);//执行handler
                    ...
                }
            }
        }
    }
    
    

    epoll主要监听了9个event,不同的fd 对应不同的handler处理逻辑,这些handler大致分为:

  • 一个socket listener fd 监听,主要是/dev/socket/lmkd,在init() 中添加到epoll;
  • 三个客户端socket data fd 的数据通信,在ctrl_connect_handler() 中添加到epoll;
  • 三个presurre 状态的监听,在init_psi_monitors() -> init_mp_psi() 中添加到epoll;(或者init_mp_common 的旧策略)
  • 一个是LMK event kpoll_fd 监听,在init() 中添加到epoll,目前新的lmkd 不再使用这个监听;
  • 一个是wait 进程death 的pid fd 监听,在 start_wait_for_proc_kill() 中添加到epoll;
  • 5.3.3.5 lmkd->ctrl_command_handler():处理AMS下发事件

    AMS下发事件主要有如下,其他的事件处理雷同:

  • 更新OomLevels水位,将minfree和oom_adj_score进行保存&组装,然后将组装的字符串存入到prop sys.lmk.minfree_levels。后续会根据minfree和oom_adj_score,来决定进程的查杀。
  • 更新oom_adj_score,将AMS 中传下来的进程的oom_score_adj 写入到节点 /proc/pid/oom_score_adj;
  • //根据指令id进行事件下发
    static void ctrl_command_handler(int dsock_idx) {
        ...
        switch(cmd) {
        case LMK_TARGET://更新OomLevels水位时,触发
            ...
            cmd_target(targets, packet);
            break;
        case LMK_PROCPRIO://oom_adj_score更新时,触发
            ...
            cmd_procprio(packet);
            break;
        case LMK_PROCREMOVE://进程退出时,移除相关信息,触发
            ...
            cmd_procremove(packet);
            break;
        case LMK_PROCPURGE://socket连接成功后,触发
            ...
            cmd_procpurge();
            break;
        ...
        }
        ...
    }
    
    //更新OomLevels水位
    static void cmd_target(int ntargets, LMKD_CTRL_PACKET packet) {
        ...
        for (i = 0; i < ntargets; i++) {
            lmkd_pack_get_target(packet, i, &target);
            lowmem_minfree[i] = target.minfree;//内存阈值数组
            lowmem_adj[i] = target.oom_adj_score;//adj等级数组
            pstr += snprintf(pstr, pend - pstr, "%d:%d,", target.minfree,
                target.oom_adj_score);
            ...
        }
        pstr[-1] = '';
        property_set("sys.lmk.minfree_levels", minfree_str);//重新写入水位属性
        ...
    }
    
    //更新oom_adj_score
    static void cmd_procprio(LMKD_CTRL_PACKET packet) {
        ...
        snprintf(path, sizeof(path), "/proc/%d/oom_score_adj", params.pid);
        snprintf(val, sizeof(val), "%d", params.oomadj);
        if (!writefilestring(path, val, false)) {
            ALOGW("Failed to open %s; errno=%d: process %d might have been killed",
                  path, errno, params.pid);
            /* If this file does not exist the process is dead. */
            return;
        }
        ...
    }
    
    

    5.3.3.6 lmkd->mp_event_psi():进程查杀

    step 1. 解析/proc/vmstat和/proc/meminfo节点

        if (vmstat_parse(&vs) < 0) {
            ALOGE("Failed to parse vmstat!");
            return;
        }
    
        if (meminfo_parse(&mi) < 0) {
            ALOGE("Failed to parse meminfo!");
            return;
        }
    
    

    step 2. 根据vmstat节点的状态,计算工作集refault值占据file-backed页面缓存的抖动百分比。
    vmstat(Virtual Memory Statistics),用于报告虚拟内存状态的统计信息,不仅可以监测虚拟内存,也可监测进程、物理内存、内存分页、磁盘和 CPU 等的活动信,是对系统的整体情况进行统计

        if (!in_reclaim) {
            /* Record file-backed pagecache size when entering reclaim cycle */
            base_file_lru = vs.field.nr_inactive_file + vs.field.nr_active_file;
            init_ws_refault = vs.field.workingset_refault;
            thrashing_limit = thrashing_limit_pct;
        } else {
            /* Calculate what % of the file-backed pagecache refaulted so far */
            thrashing = (vs.field.workingset_refault - init_ws_refault) * 100 / base_file_lru;
        }
        in_reclaim = true;
    
    

    step 3. 间隔60s,解析/proc/zoneinfo,并计算min/low/hight水位线

        if (watermarks.high_wmark == 0 || get_time_diff_ms(&wmark_update_tm, &curr_tm) > 60000) {
            struct zoneinfo zi;
    
            if (zoneinfo_parse(&zi) < 0) {
                ALOGE("Failed to parse zoneinfo!");
                return;
            }
    
            calc_zone_watermarks(&zi, &watermarks);
            wmark_update_tm = curr_tm;
         }
    
    

    step 4. 根据mi,判断当前所处的水位线

    enum zone_watermark {
        WMARK_MIN = 0,
        WMARK_LOW,
        WMARK_HIGH,
        WMARK_NONE
    };
    
    /* Find out which watermark is breached if any */
    wmark = get_lowest_watermark(&mi, &watermarks);
    
    

    step 5. 根据水位线、thrashing值、压力值、swap_low值等数据,添加不同的kill原因

       if (cycle_after_kill && wmark  thrashing_limit_pct) {//swap 空间已经超过底线,且内存抖动占比也超过限制
            /* Page cache is thrashing while swap is low */
            kill_reason = LOW_SWAP_AND_THRASHING;
            snprintf(kill_desc, sizeof(kill_desc), "device is low on swap (%" PRId64
                "kB < %" PRId64 "kB) and thrashing (%" PRId64 "%%)",
                mi.field.free_swap * page_k, swap_low_threshold * page_k, thrashing);
        } else if (swap_is_low && wmark < WMARK_HIGH) {//swap 空间已经超过底线,且处于低水位
            /* Both free memory and swap are low */
            kill_reason = LOW_MEM_AND_SWAP;
            snprintf(kill_desc, sizeof(kill_desc), "%s watermark is breached and swap is low (%"
                PRId64 "kB  WMARK_LOW ? "min" : "low",
                mi.field.free_swap * page_k, swap_low_threshold * page_k);
        } else if (wmark  thrashing_limit) {//标记此时处于低水位并抖动状态异常
            /* Page cache is thrashing while memory is low */
            kill_reason = LOW_MEM_AND_THRASHING;
            snprintf(kill_desc, sizeof(kill_desc), "%s watermark is breached and thrashing (%"
                PRId64 "%%)", wmark > WMARK_LOW ? "min" : "low", thrashing);
            cut_thrashing_limit = true;
            /* Do not kill perceptible apps because of thrashing */
            min_score_adj = PERCEPTIBLE_APP_ADJ;
        } else if (reclaim == DIRECT_RECLAIM && thrashing > thrashing_limit) {//kswap 进入reclaim状态,并且抖动状态异常
            /* Page cache is thrashing while in direct reclaim (mostly happens on lowram devices) */
            kill_reason = DIRECT_RECL_AND_THRASHING;
            snprintf(kill_desc, sizeof(kill_desc), "device is in direct reclaim and thrashing (%"
                PRId64 "%%)", thrashing);
            cut_thrashing_limit = true;
            /* Do not kill perceptible apps because of thrashing */
            min_score_adj = PERCEPTIBLE_APP_ADJ;
        }
    
    

    step 6. 根据当前zone的信息,以及通过lowmem_adj和lowmem_minfree水位线,重新生成min_score_adj,用于决定需要杀死的进程等级

     if (other_free proc_adj_lru_skip()->adjslot_skip()->lmkd_skip_kill()->lmkd_config_skip_kill()

    @systemcorelmkdlmkconfig.c
    /* For CONFIG_LMKD_SKIP_PROCESS_LIST */
    #define LMKD_PARAMETER_NAME             "/vendor/etc/lmkd_param.conf"
    
    bool lmkd_config_skip_kill(char *task_name)
    {
        PARAM_INFO param_info;
        int count = 0;
        int number = 0;
    
        memset(&param_info, 0, sizeof(param_info));
        if (get_param_info(&param_info, CONFIG_LMKD_SKIP_PROCESS_LIST) == true) {
            count = param_info.proc_count;
            while(count) {
                number = count - 1;
                if (!memcmp(param_info.proc_info[number].task_info, task_name, strlen(param_info.proc_info[number].task_info) -1))
                    return true;
                    count --;
            }
        }
        return false;
    }
    
    

    5.4 LMKD小结

  • 在kernel4.12之前,采用的是linux内核的lmk机制查杀进程;
  • 在kernel4.12之后,Android 9采用用户空间lmkd的vmpressure策略,来查杀进程;
  • Android 10之后采用lmkd的Psi策略,查杀进程;
  • framework层与Lmkd是通过socket实现ipc通信;
  • lmkd的socket通信通过epoll机制管理;
  • lmkd可以通过配置白名单,避免被查杀;
  • 👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论