Android 内存管理

这篇具有很好参考价值的文章主要介绍了Android 内存管理。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

一、需求

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

  1. 了解内存的分配、回收方式
  2. 了解OOM、LMK的相关机制
  3. 了解Android系统内存相关调试方式
  4. 了解Android系统的性能优化方案

二、环境

  1. JDK 1.8
  2. Android 10

三、JVM

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

3.1 编译&执行过程

        Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
        Java文件必须先通过一个叫javac的编译器,将代码编译成class文件,然后通过JVM把class文件解释成各个平台可以识别的机器码,最终实现跨平台运行代码。

Android 内存管理

3.2 JVM内存模型

Android 内存管理

3.2.1 方法区

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

  1. 在Java8之前,HotSpot虚拟机使用 永久代 来实现方法区。
Android 内存管理
  1. 而Java8之后,HotSpot虚拟机使用 元空间 来实现方法区。
Android 内存管理
        元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存;永久代在虚拟机中。

        方法区存储的信息如下:

名称 内容
类型信息 (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;

Android 内存管理

对象内存分配过程如下:
        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堆,对象引用存储在栈中。

Android 内存管理

四、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 测试结果

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

4.3 OOM异常定位

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

@art\runtime\gc\heap.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 << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free
      << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM,"
      << " target footprint " << target_footprint_.load(std::memory_order_relaxed)
      << ", growth limit "
      << growth_limit_;
  ...
  self->ThrowOutOfMemoryError(oss.str().c_str());
}

AndroidStudio工具Profiler,可以查看设备实时内存:
相关用法:https://developer.android.google.cn/studio/profile/memory-profiler?hl=zh-cn

Android 内存管理

4.4 OOM常见场景

如下Android开发者比较常见的OOM场景:

类型 应用场景
资源对象没关闭造成的内存泄露 Cursor
调用registerReceiver后未调用unregisterReceiver()
未关闭InputStream/OutputStream
Bitmap使用后未调用recycle()
作用域不一样,导致对象不能被垃圾回收器回收 非静态内部类会隐式地持有外部类的引用,handler
Context泄露:
1、 不要保留对Context-Activity长时间的引用(对Activity的引用的时候,必须确保拥有和Activity一样的生命周期)
2、尝试使用Context-Application来替代Context-Activity
3、如果你不想控制内部类的生命周期,应避免在Activity中使用非静态的内部类,而应该使用静态的内部类,并在其中创建一个对Activity的弱引用。
内存压力过大 图片资源加载过多,超过内存使用空间,例如Bitmap 的使用
重复创建view

五、LMKD

        进程的启动分冷启动和热启动,当用户退出某一个进程的时候,并不会真正的将进程退出,而是将这个进程放到后台,以便下次启动的时候可以马上启动起来,这个过程名为热启动,这也是Android的设计理念之一。这个机制会带来一个问题,每个进程都有自己独立的内存地址空间,随着应用打开数量的增多,系统已使用的内存越来越大,就很有可能导致系统内存不足。
        Android 低内存终止守护程序 (Low Memory Killer Daemon) ,可监控运行中的 Android 系统的内存状态,并通过终止最不必要的进程来应对内存压力大的问题,使系统以可接受的性能水平运行。

5.1 LMKD 框架图

Android 内存管理

5.2 LMKD相关概念

5.2.1 LMKD错误信息

出现低内存异常杀死进程,一般会有lowmemorykiller的tag信息。

08-09 10:33:27.695   376   376 I lowmemorykiller: Kill 'com.android.deskclock' (14849), uid 1000, oom_adj 700 to free 6728kB; reason: low watermark is breached and swap is low (0kB < 108644kB), adjust critical adj.
08-09 10:33:27.695   376   376 D lowmemorykiller: handle notify_lmfs_process_killed done for 14849
08-09 10:33:27.720   383   383 D SurfaceFlinger: Setting power mode 0 on display 0
08-09 10:33:27.807   776  1101 D LmKillerTracker: doLmkForceStop pid=14849
08-09 10:33:27.827   328   328 I Zygote  : Process 14849 exited due to signal 9 (Killed)

5.2.2 LMKD水位

我们可以观察到低内存出现后,会有如下错误原因: low watermark is breached,即到达低水位,那么Android设备的水位是怎么样的呢?
方式一:
adb shell cat /sys/module/lowmemorykiller/parameters/minfree
adb shell cat /sys/module/lowmemorykiller/parameters/adj
(通过kernel的lmk机制杀死进程,在Android P及以前版本中,采用该方式)
方式二:
adb shell getprop | grep sys.lmk.minfree_levels(在Android Q及之后,采用该方式)

Android 内存管理
进程优先级 内存水位(page) 内存水位(mb) 描述
0 18432 18432*4/1024=72mb 内存低于72mb,杀死进程等级大于0的进程
100 23040 23040*4/1024=90mb 内存低于90mb,杀死进程等级大于100的进程
200 27648 27648*4/1024=108mb 内存低于108mb,杀死进程等级大于200的进程
250 32256 32256*4/1024=126mb 内存低于126mb,杀死进程等级大于250的进程
900 36864 36864*4/1024=144mb 内存低于144mb,杀死进程等级大于900的进程
950 46080 46080*4/1024=180mb 内存低于180mb,杀死进程等级大于950的进程

ps: 绝大多数处理器上的内存页的默认大小都是 4KB,虽然部分处理器会使用 8KB、16KB 或者 64KB 作为默认的页面大小,但是 4KB 的页面仍然是操作系统默认内存页配置的主流,ProcessList有定义该值PAGE_SIZE=4KB;

5.2.3 进程优先级

        对于每一个运行中的进程,Linux 内核都通过 proc 文件系统暴露 /proc/[pid]/oom_score_adj 这样一个文件来允许其他程序修改指定进程的优先级,这个文件允许的值的范围是:-1000 ~ +1001之间。值越小,表示进程越重要。当内存非常紧张时,系统便会遍历所有进程,以确定哪个进程需要被杀死以回收内存,此时便会读取 oom_score_adj 这个文件的值。
        为了便于管理,ProcessList.java中预定义了oom_score_adj的可能取值,这里的预定义值也是对应用进程的一种分类。

@frameworks\base\services\core\java\com\android\server\am\ProcessList.java
    // 任何主要或次要adj字段的未初始化值
    static final int INVALID_ADJ = -10000;

    // 在某些我们还不知道的地方进行调整(通常这是将要被缓存的东西,但我们还不知道要分配的缓存范围内的确切值)。
    static final int UNKNOWN_ADJ = 1001;

    // 这是一个只托管不可见活动的进程,因此可以在不中断任何情况下终止它。
    static final int CACHED_APP_MAX_ADJ = 999;
    static final int CACHED_APP_MIN_ADJ = 900;

    // 这是我们允许先死的oom_adj级别。这不能等于CACHED_APP_MAX_ADJ,除非进程正在积极地被分配CACHED_APP_MAX_ADJ的oom_score_adj。
    static final int CACHED_APP_LMK_FIRST_ADJ = 950;

    // SERVICE_ADJ的B列表——这些是旧的和破旧的服务,不像A列表中的服务那么闪亮和有趣。
    static final int SERVICE_B_ADJ = 800;

    // 这是用户所在的前一个应用程序的进程。这个过程保持在其他事情之上,因为切换回上一个应用程序是非常常见的。这对于最近的任务切换(在两个最热门的最近应用程序之间切换)以及正常的UI流(例如单击电子邮件应用程序中的URI以在浏览器中查看,然后按回返回电子邮件)都很重要。
    static final int PREVIOUS_APP_ADJ = 700;

    // 这是一个包含主应用程序的进程——我们希望尽量避免杀死它,即使它通常在后台,因为用户与它交互太多了。
    static final int HOME_APP_ADJ = 600;

    // 这是一个包含应用程序服务的进程——就用户而言,杀死它不会有太大的影响。
    static final int SERVICE_ADJ = 500;

    // 这是一个重量级应用程序的过程。它在背景中,但我们要尽量避免杀死它。在system/rootdir/init中设置的值。Rc启动。
    static final int HEAVY_WEIGHT_APP_ADJ = 400;

    // 这是当前托管备份操作的进程。杀死它并不完全致命,但通常是个坏主意。
    static final int BACKUP_APP_ADJ = 300;

    // 这是一个受系统(或其他应用)约束的过程,它比服务更重要,但如果被杀死,它不会立即影响用户。
    static final int PERCEPTIBLE_LOW_APP_ADJ = 250;

    // 这是一个只承载用户可感知组件的进程,我们确实希望避免杀死它们,但它们不是立即可见的。背景音乐播放就是一个例子。
    static final int PERCEPTIBLE_APP_ADJ = 200;

    // 这是一个只承载用户可见的活动的进程,所以我们希望它们不要消失。
    static final int VISIBLE_APP_ADJ = 100;
    static final int VISIBLE_APP_LAYER_MAX = PERCEPTIBLE_APP_ADJ - VISIBLE_APP_ADJ - 1;

    // 这是一个最近被列为TOP的过程,并转移到了FGS。在一段时间内,继续把它当作前台应用来对待。
    static final int PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ = 50;

    // 这是运行当前前台应用程序的进程。我们真的不想杀死它!
    static final int FOREGROUND_APP_ADJ = 0;

    // 这是系统或持久进程绑定的进程,并表示它很重要。
    static final int PERSISTENT_SERVICE_ADJ = -700;

    // 这是一个系统持久进程,例如电话。我当然不想杀死它,但这样做也不是完全致命的。
    static final int PERSISTENT_PROC_ADJ = -800;

    // 系统进程以默认调整运行。
    static final int SYSTEM_ADJ = -900;

    // 不受系统管理的本机进程的特殊代码(因此没有系统分配的空间)。
    static final int NATIVE_ADJ = -1000;

如下为测试应用在前台和后台切换,oom_score_adj的变化:

Android 内存管理
  1. 查看myproject应用的进程id:4262
  2. 打开应用,查看应用的adj值:0
  3. 按home键,应用回到后台,查看应用的adj值:700
  4. 打开其他应用,并按home键回到后台,此时查看myproject应用的adj值:800

ps:一般情况下,我们也会将进程分为:前台进程>可见进程>服务进程>后台进程>空进程。

5.2.4 系统内存实时查看

指令:adb shell cat proc/meminfo

5.3 LMKD源码分析

        源码分析是一个非常枯燥&无聊的事情,我们需要带着些问题去查阅代码,不然很容易被淹没在代码的海洋里!如下,是我们本次查看源码需要了解的逻辑:

  1. lowmemorykiller的异常信息是在哪打印的?如何杀死进程?
  2. oom_score_adj是怎么样发生变化的?

5.3.1 LMKD流程图

Android 内存管理

5.3.2 Framework层-AMS服务

5.3.2.1 进程adj变化

1. 进程杀掉后

Android 内存管理

2. 进程创建后

Android 内存管理

3. 进程回到后台

Android 内存管理

5.3.2.2 更新进程adj值

从5.3.1.1节,我们可以看到adj的更新,最终都会引用到setOomAdj().
step 1. 构建buf,写入指令id,进程id,adj值

@frameworks\base\services\core\java\com\android\server\am\ProcessList.java
    public static void setOomAdj(int pid, int uid, int amt) {
        ...
        long start = SystemClock.elapsedRealtime();
        ByteBuffer buf = ByteBuffer.allocate(4 * 4);
        buf.putInt(LMK_PROCPRIO);
        buf.putInt(pid);
        buf.putInt(uid);
        buf.putInt(amt);
        writeLmkd(buf, null);
        ...
    }

step 2. 打开lmkd的socket端口

    private static boolean openLmkdSocketLS() {
        try {
            sLmkdSocket = new LocalSocket(LocalSocket.SOCKET_SEQPACKET);
            sLmkdSocket.connect(
                new LocalSocketAddress("lmkd",
                        LocalSocketAddress.Namespace.RESERVED));
            sLmkdOutputStream = sLmkdSocket.getOutputStream();
            sLmkdInputStream = sLmkdSocket.getInputStream();
        } 
        ...
        return true;
    }

step 3. 往lmkd写入buf数据

   private static boolean writeLmkdCommandLS(ByteBuffer buf) {
        try {
            sLmkdOutputStream.write(buf.array(), 0, buf.position());
        } catch (IOException ex) {
            Slog.w(TAG, "Error writing to lowmemorykiller socket");
            IoUtils.closeQuietly(sLmkdSocket);
            sLmkdSocket = null;
            return false;
        }
        return true;
    }

step 4. 从lmkd读取buf数据

    private static boolean readLmkdReplyLS(ByteBuffer buf) {
        int len;
        try {
            len = sLmkdInputStream.read(buf.array(), 0, buf.array().length);
            if (len == buf.array().length) {
                return true;
            }
        } 
        ...
    }

5.3.3 Native层-LMKD进程

5.3.3.1 lmkd进程启动

@system/core/lmkd/lmkd.rc
service lmkd /system/bin/lmkd
    class core
    user lmkd
    group lmkd system readproc
    capabilities DAC_OVERRIDE KILL IPC_LOCK SYS_NICE SYS_RESOURCE NET_ADMIN
    critical
    socket lmkd seqpacket 0660 system system
    socket lmfs stream 0660 root system
    socket vmpressure stream 0666 root system
    writepid /dev/cpuset/system-background/tasks

5.3.3.2 lmkd->main():入口函数

LMKD进程的入口main函数,主要初始化该模块相关参数、消息事件

@\system\core\lmkd\lmkd.c
int main(int argc __unused, char **argv __unused) {
    ...
    level_oomadj[VMPRESS_LEVEL_LOW] =
        property_get_int32("ro.lmk.low", OOM_SCORE_ADJ_MAX + 1);//初始化属性配置
    ...
    if (!init()) {//初始化消息事件处理
        if (!use_inkernel_interface) {//不使用驱动LMK杀进程方案
            ...
            if (mlockall(MCL_CURRENT | MCL_FUTURE | MCL_ONFAULT) && (errno != EINVAL)) {//虚拟空间上锁,防止内存交换
                ALOGW("mlockall failed %s", strerror(errno));
            }
            ...
        }
        ...
        mainloop();//轮询监听消息事件
    }
    ...
    return 0;
}

5.3.3.3 lmkd->init():初始化

step 1. 创建epoll
创建了一个epoll实例,整个lmkd的消息处理都是依赖epoll 机制来管理。其相当于是,创建一个池子,一个监控和管理句柄 fd 的池子,有点像java的线程池;

    epollfd = epoll_create(MAX_EPOLL_EVENTS);
    if (epollfd == -1) {
        ALOGE("epoll_create failed (errno=%d)", errno);
        return -1;
    }

step 2. 初始化socket lmkd
该部分对lmkd端的socket通信进行初始化,其对端AMS.mProcessList会通过/dev/socket/lmkd节点与lmkd进行通信,socket连接成功后,响应事件处理由ctrl_connect_handler函数处理。

    ctrl_sock.sock = android_get_control_socket("lmkd");//设置监听的socket名称
    if (ctrl_sock.sock < 0) {
        ALOGE("get lmkd control socket failed");
        return -1;
    }

    ret = listen(ctrl_sock.sock, MAX_DATA_CONN);//监听socket消息
    if (ret < 0) {
        ALOGE("lmkd control socket listen failed (errno=%d)", errno);
        return -1;
    }

    epev.events = EPOLLIN;//只有当对端有数据写入时才会触发,所以触发一次后需要不断读取所有数据直到读完EAGAIN为止
    ctrl_sock.handler_info.handler = ctrl_connect_handler;//socket连接成功的响应处理事件
    epev.data.ptr = (void *)&(ctrl_sock.handler_info);
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, ctrl_sock.sock, &epev) == -1) {//将socket句柄添加到epoll的池子,并设置epev的监听事件类型
        ALOGE("epoll_ctl for lmkd control socket failed (errno=%d)", errno);
        return -1;
    }

step 3. 确定是否用LMK 驱动程序
        过去,Android 使用内存LMK 驱动程序来监控系统内存的压力,这是一种依赖于硬编码值的硬件机制。从Kernel 4.12开始,LMK驱动程序从上游内核中移除,由应用空间的 lmkd 执行内存监控和进程终止任务。
        通过函数access 确认旧的节点是否还存在,用以确认kernel 是否还在用LMK 驱动程序。之所以有这样的处理,应该是Android 为了兼容旧版本kernel。目前Android10上,该节点已不存在。

#define INKERNEL_MINFREE_PATH "/sys/module/lowmemorykiller/parameters/minfree"

has_inkernel_module = !access(INKERNEL_MINFREE_PATH, W_OK);
use_inkernel_interface = has_inkernel_module;

step 4. 选择系统内存监控策略
        LMKD进程通过使用内核生成的 vmpressure 事件或PSI监视器,获取内存压力等级的通知。但是由于vmpressure信号会存在大量误报的情况,造成不必要的系统开销。因此,Android 10 以及更高版本,使用 PSI 监视器来检测内存压力,且当前Android为了对旧版本的支持,依然保留了vmpressure策略。
        PSI (Pressure Stall Information) 压力失速信息,PSI统计数据为即将发生的资源短缺提供了预警功能,因而实现更主动、更细致、更准确的响应。当然PSI统计数据不仅包含了Memory,它同时涵盖了Memory、CPU、IO三大资源的pressure指标,来帮助工程师们及时管控系统资源短缺的情况。

/* Try to use psi monitor first if kernel has it */
    use_psi_monitors = property_get_bool("ro.lmk.use_psi", true) &&
        init_psi_monitors();//使用psi策略

    if (use_psi_vmpressure && use_psi_monitors) {
        if (!init_mp_common(VMPRESS_LEVEL_MEDIUM_EXT)) {//使用vmpressure策略
            ALOGE("Kernel does no support memory pressure events.use psi only.");
        }
    }

        本文我们将分析新策略,即PSI策略,来进一步分析lmkd。LMKD是支持新旧策略同时执行的。
step 5. 初始化PSI策略相关行为
        确认是使用PSI策略还是vmpressure策略,同时对于不同的策略,初始化相关操作。

static bool init_psi_monitors() {
    ...
    bool use_new_strategy =
        property_get_bool("ro.lmk.use_new_strategy", low_ram_device || !use_minfree_levels);//确认是使用PSI 策略还是vmpressure

    /* In default PSI mode override stall amounts using system properties */
    if (use_new_strategy) {
        /* Do not use low pressure level */
        psi_thresholds[VMPRESS_LEVEL_LOW].threshold_ms = 0;
        psi_thresholds[VMPRESS_LEVEL_MEDIUM].threshold_ms = psi_partial_stall_ms;//70ms,部分 PSI 失速阈值(以毫秒为单位),用于触发内存不足通知。如果设备收到内存压力通知的时间太晚,可以降低此值以在较早的时间触发通知。
        psi_thresholds[VMPRESS_LEVEL_CRITICAL].threshold_ms = psi_complete_stall_ms;//700ms,完全 PSI 失速阈值(以毫秒为单位),用于触发关键内存通知。如果设备收到关键内存压力通知的时间太晚,可以降低该值以在较早的时间触发通知。
    }
    //初始化PSI相关行为
    if (!init_mp_psi(VMPRESS_LEVEL_LOW, use_new_strategy)) {
        return false;
    }
    ...
    return true;
}

step 6. PSI内存压力监听&响应

#define PSI_MON_FILE_MEMORY "/proc/pressure/memory"
static bool init_mp_psi(enum vmpressure_level level, bool use_new_strategy) {
    ...
    fd = init_psi_monitor(psi_thresholds[level].stall_type,
        psi_thresholds[level].threshold_ms * US_PER_MS,
        PSI_WINDOW_SIZE_MS * US_PER_MS);//获取/proc/pressure/memory节点
    ...
    vmpressure_hinfo[level].handler = use_new_strategy ? mp_event_psi_psi : mp_event_common;//内存压力消息响应处理事件
    vmpressure_hinfo[level].data = level;
    if (register_psi_monitor(epollfd, fd, &vmpressure_hinfo[level]) < 0) {//注册监听器,监听psi内存压力
        destroy_psi_monitor(fd);
        return false;
    }
    ...
    return true;
}

5.3.3.4 lmkd->mainloop:epoll消息处理

mainloop主要是通过epoll_wait阻塞线程,有消息响应后,再分发消息给对应的handler处理对应逻辑。

static void mainloop(void) {
    ...
    while (1) {
        ...
        if (poll_params.poll_handler) {
            ...
            /* Wait for events until the next polling timeout */
            nevents = epoll_wait(epollfd, events, maxevents, delay);//阻塞等待epoll响应
            ...
        }
        ...
        /* Second pass to handle all other events */
        for (i = 0, evt = &events[0]; i < nevents; ++i, evt++) {
            ...
            if (evt->data.ptr) {
                handler_info = (struct event_handler_info*)evt->data.ptr;
                /* Set input params for the call */
                handler_info->handler(handler_info->data, evt->events, &poll_params);//执行handler
                ...
            }
        }
    }
}

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

  1. 一个socket listener fd 监听,主要是/dev/socket/lmkd,在init() 中添加到epoll;
  2. 三个客户端socket data fd 的数据通信,在ctrl_connect_handler() 中添加到epoll;
  3. 三个presurre 状态的监听,在init_psi_monitors() -> init_mp_psi() 中添加到epoll;(或者init_mp_common 的旧策略)
  4. 一个是LMK event kpoll_fd 监听,在init() 中添加到epoll,目前新的lmkd 不再使用这个监听;
  5. 一个是wait 进程death 的pid fd 监听,在 start_wait_for_proc_kill() 中添加到epoll;

5.3.3.5 lmkd->ctrl_command_handler():处理AMS下发事件

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

  1. 更新OomLevels水位,将minfree和oom_adj_score进行保存&组装,然后将组装的字符串存入到prop sys.lmk.minfree_levels。后续会根据minfree和oom_adj_score,来决定进程的查杀。
  2. 更新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] = '\0';
    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 < WMARK_LOW) {//cycle_after_kill 为true 表明此时还处于killing 状态,并且水位已经低于low 水位
        kill_reason = PRESSURE_AFTER_KILL;
        strncpy(kill_desc, "min watermark is breached even after kill", sizeof(kill_desc));
    } else if (level == VMPRESS_LEVEL_CRITICAL && events != 0) {//内存压力过大
        kill_reason = NOT_RESPONDING;
        do_multi_kill = low_ram_device ? true : false;
        strncpy(kill_desc, "device is not responding", sizeof(kill_desc));
    } else if (fast_kill_enabled) {
        kill_reason = DO_FAST_KILL;
        strncpy(kill_desc, "do fast kill", sizeof(kill_desc));
        min_score_adj = low_ram_device ? CACHED_APP_MIN_ADJ : PERCEPTIBLE_LOW_APP_ADJ;
        min_score_adj = swap_is_low ? PERCEPTIBLE_LOW_APP_ADJ : min_score_adj;
    } else if (swap_is_low && thrashing > 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 < %" PRId64 "kB)", wmark > WMARK_LOW ? "min" : "low",
            mi.field.free_swap * page_k, swap_low_threshold * page_k);
    } else if (wmark < WMARK_HIGH && thrashing > 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 <= minfree && other_file <= minfree) {
            min_score_adj_adjust = lowmem_adj[0];
            strcat(kill_desc, ", adjust adj0.");
        } else if (other_free <= lowmem_minfree[1] && other_file <= lowmem_minfree[1]) {
            min_score_adj_adjust = lowmem_adj[1];
            strcat(kill_desc, ", adjust adj1.");
        }

        if (min_score_adj_adjust == -1 && !min_score_adj) {
            min_score_adj = VISIBLE_APP_ADJ;
            strcat(kill_desc, ", adjust critical adj.");
        } else if (min_score_adj_adjust != -1) {
            min_score_adj = min_score_adj_adjust;
        }

step 7. 根据min_score_adj水位线,查找并杀死对应的进程

        if (do_multi_kill) {
            do_multi_kill = false;
            if (NOT_RESPONDING == kill_reason) {
                min_score_adj = swap_is_low ? 0 : min_score_adj;
                strcat(kill_desc, " <kill all processes above>");
                pages_freed = find_and_kill_multi_processes(min_score_adj, kill_desc, true);
            } else if (swap_is_low) {
                strcat(kill_desc, " <kill all processes above>");
                pages_freed = find_and_kill_multi_processes(min_score_adj, kill_desc, true);
            } else {
                strcat(kill_desc, " <kill group processes>");
                pages_freed = find_and_kill_multi_processes(min_score_adj, kill_desc, false);
            }
        } else if (fast_kill_enabled) {
            fast_kill_enabled = false;
            pages_freed = find_and_kill_multi_processes(min_score_adj, kill_desc, true);
        } else
            pages_freed = find_and_kill_process(min_score_adj, kill_desc);

step 8. lmkd白名单,避免被杀死
调用栈:find_and_kill_process()->proc_adj_lru_skip()->adjslot_skip()->lmkd_skip_kill()->lmkd_config_skip_kill()

@system\core\lmkd\lmkconfig.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小结

  1. 在kernel4.12之前,采用的是linux内核的lmk机制查杀进程;
  2. 在kernel4.12之后,Android 9采用用户空间lmkd的vmpressure策略,来查杀进程;
  3. Android 10之后采用lmkd的Psi策略,查杀进程;
  4. framework层与Lmkd是通过socket实现ipc通信;
  5. lmkd的socket通信通过epoll机制管理;
  6. lmkd可以通过配置白名单,避免被查杀;

5.5 遗留问题

  1. 从业务代码上看,PSI策略并没有完全遵循sys.lmk.minfree_levels水位的查杀,vmpressure策略相对来说更加遵循一点,有点意外,需要再确认此问题?
  2. 内存管理实际是基于如下节点:/proc/vmstat、/proc/meminfo和/proc/zoneinfo,需要了解其含义以及由来?
  3. /proc/pressure/memory内存压力的实现逻辑?

参考资料

JVM相关:
https://blog.csdn.net/Park33/article/details/129558206
https://www.cnblogs.com/xing901022/p/5243657.html
https://www.javaclub.cn/java/41736.html
https://blog.csdn.net/qq_36370187/article/details/113093764
https://segmentfault.com/a/1190000041118595

JAVA编译相关:
https://blog.csdn.net/weixin_45987569/article/details/127848443

OOM相关:
https://blog.csdn.net/boyupeng/article/details/47726765
https://blog.csdn.net/baidu_40389775/article/details/130861616

LMK相关:
https://www.jianshu.com/p/4dbe9bbe0449
https://blog.csdn.net/Eqiqi/article/details/131538782
https://blog.csdn.net/shift_wwx/article/details/121593698
https://justinwei.blog.csdn.net/article/details/122268437

LINUX-EPOLL相关:
https://mp.weixin.qq.com/s?__biz=MzU0OTE4MzYzMw==&mid=2247515011&idx=2&sn=3812f80dd80bb27340d5849df8d1cec0&chksm=fbb1327dccc6bb6bfd5ab7f9da23220ade44e88e2f8d2506b7e0868bb84665a95f026eddb82d&scene=27文章来源地址https://www.toymoban.com/news/detail-668217.html

到了这里,关于Android 内存管理的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 未充分考虑物联网设备的安全需求

    1.1 安全漏洞与安全隐患的识别和发现 为了充分考虑到物联网设备的安全需求,我们需要首先了解和发现其可能面临的安全漏洞及隐患。这些安全漏洞可能包括: - 密码泄露:物联网设备通常需要设置复杂且独特的密码来确保数据安全和防止黑客攻击。 - 恶意代码:物联网设

    2024年02月03日
    浏览(39)
  • 嵌入式设备应用开发(发现需求和提升价值)

    【 声明:版权所有,欢迎转载,请勿用于商业用途。 联系信箱:feixiaoxing @163.com】         很多做技术的同学,都会陷入到技术的窠臼之中。对于如何做具体的产品、实现具体的技术,他们可能很感兴趣。但是做出来的东西做什么用,或者说是有没有竞争力,事实上他们不

    2024年02月11日
    浏览(49)
  • PBA.客户需求分析 & 需求管理

    大部分人认为,产品满足不了客户需要,是因为客户告知的需求是错误的,这听起来有一些道理,却没有任何意义。不同角色对于需求的理解是不一样的。在客户的需求和厂家的需求之间必然有一定差距,甚至可以说是鸿沟。如果客户提的需求非常专业,反而是一件比较糟糕

    2024年02月07日
    浏览(69)
  • 软件工程(十) 需求工程之需求开发与管理

    前面我们学习到了需求工程的概念与分类,我们知道了需求工程主要分为需求开发和需求管理,但是没有说明到底该如何开发需求,有哪些方法去开发需求。到底该如何进行需求管理,又有哪些进行需求管理的方式。具体是如何去做的。下面我们将会详细进行描述。 前面提到

    2024年02月11日
    浏览(63)
  • 【第28例】IPD体系进阶 | 需求管理:需求实现过程

    目录 简介 内容详解 CSDN学院相关推荐 作者简介 继续 IPD 体系中的需求管理相关的专题。 先来看看整个需求管理涉及的过程内容: 需求管理流程主要包含五个阶段: 需求收集;

    2024年02月06日
    浏览(58)
  • 安全需求规范和管理指南

    安全需求规范 安全需求的标记法 对应于2.2章节中的安全需求特性,通过如下组合来定义安全需求: 自然语言(适应于较高层面的安全需求,如功能和技术安全需求); 表1中所列举方法(适应于较低层面的安全需求,如软件和硬件安全需求); 表1 定义安全需求 方法 ASIL

    2024年02月05日
    浏览(45)
  • 【DEVOPS】需求跟踪管理全面落地

    1. 现状/背景 近期又被领导问到\\\"如何对项目过程中的需求进行量化和跟踪管理\\\"。这真是一个狗皮膏药似的问题,反反复复地,隔一段时间就得被再次提及。 究其原因,除了过往在传统软件行业中技术团队的发展(现状篇),【DEVOPS】基于禅道 - 重构研发协作流程分别进行过总结

    2024年02月10日
    浏览(36)
  • Python+Selenium+Pytest+Allure自动化测试框架实战实例(示例为我司网盘产品)

    应公司要求,组织员工培训自动化测试,所以也趁此机会把我所学习的自动化框架整理一下,虽说不是很完美,但也有所收获。 序号 库、插件、工具 版本号 1 Python 3.11 2 Pycharm 22.2.3 3 pytest 7.2.0 4 pywin32 305 5 selenium3 4.6.0 6 openpyxl 3.0.10 7 Chromedriver 与当前浏览器版本对应即可 8 al

    2023年04月16日
    浏览(106)
  • 电网管理信息系统安全运行需求分析

    传统的电网企业信息系统存在着许多的问题,信息系统的数据安全不能得到 保障,容易丢失;对于庞大分散的客户端不能进行有效地管理,并且需要耗费大 量的人力和时间去维护;硬件的价格高且使用周期短,这就给企业带来了高成本 的压力;用户对于应用系统的升级比较

    2024年02月07日
    浏览(58)
  • 字符设备驱动(内核态用户态内存交互)

    内核驱动:运行在内核态的动态模块,遵循内核模块框架接口,更倾向于插件。 应用程序:运行在用户态的进程。 应用程序与内核驱动交互通过既定接口,内核态和用户态访问依然遵循内核既定接口。 系统:openEuler-20.03-LTS-SP3 char_module.c Makefile 驱动构建 驱动信息确认 应用程

    2024年02月11日
    浏览(44)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包