在介绍内存的基础知识的时分,咱们讲过在 32 位体系上虚拟内存只要 4G,由于有 1G 是给内核运用的,所以留给运用的只要 3G 了。3G 尽管看起来挺多,但仍然会由于不够用而导致运用溃散。为什么会这样呢?

咱们在学习 Java 堆的组成时就知道 MainSpace 会请求 512M 的虚拟内存,LargeObjectSpace 也会请求 512M 的虚拟内存,这就用掉了 1G 的虚拟内存,再加上其他 Space 和段映射请求的虚拟内存,如 bss 段、text 段以及各种 so 库文件的映射等,这样算下来,3G 的虚拟内存就没剩下多少了。

所以,虚拟内存的优化,在提高程序的稳定性上,是一种很重要的计划。虚拟内存的优化手法也有许多,这一章咱们主要介绍 3 种优化计划:

  1. 经过线程治理来优化虚拟内存;

  2. 经过多进程架构来优化虚拟内存;

  3. 经过一些“黑科技”手法来优化虚内存。

计划 1 和 2 相对简略但作用更佳,投入产出比最高,也是咱们最常用的。而计划 3 是经过多个“黑科技”的手法来完结虚拟内存的优化,这些手法尽管归于“黑科技”,但仍是会用到咱们学过的 Native Hook 等技能,所以你了解、吸收起来并不会很难。

那今日咱们先介绍 计划 1 和 计划 2 ,计划 3 会在下一章节单独介绍,下面就开始这一章的学习吧。

线程治理

首要,为什么治理线程能优化虚拟内存呢?实际上,即使是一个空线程也会请求 1M 的虚拟空间来作为栈空间巨细,咱们能够剖析 Thread 创立的源码来验证这一点。一起,对线程创立的剖析,也能让你能更好的了解后面的优化计划。

线程创立流程

当咱们运用线程履行使命时,通常会先调用 new Thread(Runnable runnable) 来创立一个 Thread.java 目标的实例,Thread 的结构函数中会将 stackSize 这个变量设置为 0,这个 stackSize 变量决定了线程栈巨细,接着咱们便会履行 Thread 实例提供的 start 办法运转这个线程,start 办法中会调用 nativeCreate 这个 Native 函数在体系层创立一个线程并运转。

Thread(ThreadGroup group, String name, int priority, boolean daemon) {
    ……
    this.stackSize = 0;
}
public synchronized void start() {
    if (started)
        throw new IllegalThreadStateException();
    group.add(this);
    started = false;
    try {
        nativeCreate(this, stackSize, daemon);
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
        }
    }
}

经过上面 Start 函数的源码能够看到,nativeCreate 会传入 stackSize。你或许想问,这个 stackSize 不是决定了线程栈空间的巨细吗?可是它现在的值为 0,那前面为什么说线程有 1M 巨细的栈空间呢?咱们接着往下看就能知道答案了。

咱们接着看 nativeCreate 的源码完成(),它的完成类是 java_lang_Thread.cc 。

static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size, jboolean daemon) {
  Runtime* runtime = Runtime::Current();
  if (runtime->IsZygote() && runtime->IsZygoteNoThreadSection()) {
    jclass internal_error = env->FindClass("java/lang/InternalError");
    CHECK(internal_error != nullptr);
    env->ThrowNew(internal_error, "Cannot create threads in zygote");
    return;
  }
  Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

nativeCreate 会履行 Thread::CreateNativeThread 函数,这个函数才是终究创立线程的当地,它的完成在 Thread.cc 这个目标中,而且在这个函数中会调用 FixStackSize 办法将 stack_size 调整为 1M,所以前面那个疑问在这儿就处理了,即使咱们将 stack_size 设置为 0,这儿仍然会被调整。咱们持续往下剖析,看看一个线程究竟是怎样被创立出来的?

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
  ……
  // 调整 stack_size,默认值为 1 M
  stack_size = FixStackSize(stack_size);
  ……
  if (child_jni_env_ext.get() != nullptr) {
    pthread_t new_pthread;
    pthread_attr_t attr;
    child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
    CHECK_PTHREAD_CALL(pthread_attr_init, (&attr), "new thread");
    CHECK_PTHREAD_CALL(pthread_attr_setdetachstate, (&attr, PTHREAD_CREATE_DETACHED),
                       "PTHREAD_CREATE_DETACHED");
    CHECK_PTHREAD_CALL(pthread_attr_setstacksize, (&attr, stack_size), stack_size);
    // 创立线程
    pthread_create_result = pthread_create(&new_pthread,
                                           &attr,
                                           Thread::CreateCallback,
                                           child_thread);
    CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");
    if (pthread_create_result == 0) {
      child_jni_env_ext.release();  // NOLINT pthreads API.
      return;
    }
  }
  ……
}

在上面简化后的代码中咱们能够看到,CreateNativeThread 的源码完成终究调用的是 pthread_create 函数,它是一个 Linux 函数,而 pthread_create 函数终究会调用 clone 这个内核函数。clone 函数会依据传入的 stack 巨细,经过 mmap 函数请求一块对应巨细的虚拟内存,而且创立一个进程。

int clone(int (*fn)(void * arg), void *stack, int flags, void *arg);

所以,关于 Linux 体系来说,一个线程实际是一个精简的进程。咱们创立线程时,终究会履行 clone 这个内核函数去创立一个进程,经过检查官方文档也能看到,Clone 函数实际上会创立一个新的进程(These system calls create a new (“child”) process, in a manner similar to fork)。

虚拟内存优化:线程+多进程优化

这儿我就不持续深入介绍 Linux 中线程的原理了,假如你有爱好能够参阅这篇文章 《掌握 Android 和 Java 线程原理》。

除了经过线程的创立流程能够证明一个线程需求占用 1M 巨细的虚拟内存,咱们还能在 maps 文件中证明这一点,仍是拿前面华章提到的“设置”这个体系运用的 maps 文件为例,也能发现 anno:stack_and_tls 也便是线程的虚拟内存,巨细为 1M 左右。

虚拟内存优化:线程+多进程优化

了解了一个线程会占用 1M 巨细的虚拟内存,咱们自然而然也能想到经过削减线程的数量和削减每个线程所占用的虚拟内存巨细来进行优化。接下来,咱们就具体了解一下怎样完成这两种计划。

削减线程数量

首要是削减线程的数量,咱们主要有 2 种手法:

  1. 在运用中运用一致的线程池

  2. 将运用中的野线程及野线程池进行收敛。

Java 开发者应该都知道线程池,但有的人认知或许不深。实际上,线程池是十分重要的知识点,需求咱们了解并能娴熟运用的。线程池对运用的功能提高有很大的协助,它能够协助咱们更高效和更合理地运用线程,提高运用的功能。但这儿就不具体介绍线程池的运用了,在后面的章节中咱们会深入来讲线程池的运用。假如你不了解线程池,那我主张你尽快了解起来,这儿主要针对怎样削减线程数这个方向,介绍一下线程池中线程数量的最优设置。

关于线程池,咱们需求手动设置中心线程数和最大线程数。中心线程是不会退出的线程,被线程池创立之后会一直存在。最大线程数是该线程池最大能到达的线程数量,当到达最大线程数后,线程池处理新的使命便作为反常,放在兜底逻辑中处理。那么,这两个线程数设置成多少比较合适呢?这个问题也经常作为面试题,需求引起留意。

线程池能够分为 CPU 线程池和 IO 线程池,CPU 线程池用来处理 CPU 类型的使命,如核算,逻辑等操作,需求能够迅速呼应,但使命耗时又不能太久。那些耗时较久的使命,如读写文件、网络请求等 IO 操作便用 IO 线程池来处理,IO 线程池专门处理耗时久,呼应又不需求很迅速的使命。因此,关于 CPU 的线程池,咱们会将中心线程数设置为该手机的 CPU 核数,理想状态下每一个核能够运转一个线程,这样能削减 CPU 线程池的调度损耗又能充分发挥 CPU 功能。

至于 CPU 线程池的最大线程数,和中心线程数保持一致即可。 由于当最大线程数超过了中心线程数时,反倒会降低 CPU 的利用率,由于此时会把更多的 CPU 资源用于线程调度上,假如 CPU 核数的线程数量无法满足咱们的事务运用,很大或许便是咱们对 CPU 线程池的运用上出了问题,比如在 CPU 线程中履行了 IO 堵塞的使命。

关于 IO 线程池,咱们通常会将中心线程数设置为 0 个,而且 IO 线程池并不需求呼应的及时性,所以将常驻线程设置为 0 能够削减该运用的线程数量。但并不是说这儿一定要设置为 0 个,假如咱们的事务 IO 使命比较多,这儿也能够设置为不大于 3 个数量。关于 IO 线程池的最大线程数,则能够依据运用的复杂度来设置,假如是中小型运用且事务较简略设置 64 个即可,假如是大型运用,事务多且复杂,能够设置成 128 个

能够看到,假如事务中所有的线程都运用公共线程池,那即使咱们将线程的数量设置得十分宽裕,所有线程加起来所占用的虚拟内存也不会超过 200 M。但实际情况下是,运用中总会有许多当地不恪守标准,单独创立线程或许线程池,咱们称之为野线程或许野线程池。那怎样才干收敛野线程和野线程池呢?

关于简略的运用,咱们一个个排查即可,经过大局查找 new Thread() 线程创立代码,以及大局查找 newFixedThreadPool 线程池创立代码,然后将不合标准的代码,进行修正收敛进公共线程池即可。

但假如是一个中大型运用,还许多运用了二方库、三方库和 aar 包等,那大局查找也不管用了,这个时分就需求咱们运用字节码操作的办法了,技能计划仍是前面文章介绍过的 Lancet,经过 hook 住 newFixedThreadPool 创立线程池的函数,并在函数中将线程池的创立替换成咱们公共的线程池,就能完结对线程池的收敛。

public class ThreadPoolLancet {
    @TargetClass("java.util.concurrent.Executors")
    @Proxy(value = "newFixedThreadPool")
    public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
        // 替换并回来咱们的公共线程池
        ……
    }
    @TargetClass("java.util.concurrent.Executors")
    @Proxy(value = "newFixedThreadPool")
    public static ExecutorService newFixedThreadPool(int nThreads) {
       // 替换并回来咱们的公共线程池
        ……
    }
}

收敛完了野线程池,那直接运用 new Thread() 创立的野线程又该怎样收敛呢? 关于三方库中的野线程,咱们没有太好的收敛手法,由于即使 Thread 的结构函数被 hook 住了,也不能将其收敛到公共线程池中。好在咱们运用的三方库大都现已很老练并经过许多用户验证过,直接运用野线程的当地会很少。咱们能够选用 hook 住 Thread 的结构函数并打印堆栈的办法,来确认这个线程是不是经过线程池创立出来的,假如三方库中确实有许多的野线程,那么咱们只能将源码下载下来之后手动修正了。

削减线程占用的虚拟内存

在刚才解说 CreateNativeThread 源码的时分咱们讲过,该函数会履行 FixStackSize 办法将 stack_size 调整为 1M。那结合前面各种 hook 的事例,咱们很简略就能想到,经过 hook FixStackSize 这个函数,是不是能够将 stack_size 的从 1M 削减到 512 KB 了呢? 其时是能够的,可是这个时分咱们没法经过 PLT Hook 的计划来完成了,而是要经过 Inline Hook 计划完成,由于 FixStackSize 是 so 库内部函数的调用,所以只要 FixStackSize 才干完成。

那假如咱们想用 PLT Hook 计划来完成能够做到么?其实也能够。CreateNativeThread 是坐落 libart.so 中的函数,可是 CreateNativeThread 实际是调用 pthread_create 来创立线程的,而 pthread_create 是坐落 libc.so 库中的函数,假如在 CreateNativeThread 中调用 pthread_create ,相同需求经过走 plt 表和 got 表查询地址的办法,所以咱们经过 bhook 工具 hook 住 libc.so 库中的 pthread_create 函数,将入参 &attr 中的 stack_size 直接设置成 512KB 即可,完成起来也十分简略,一行代码即可。

static int AdjustStackSize(pthread_attr_t const* attr) {
    pthread_attr_setstacksize(attr, 512 * 1024);
}

至于怎样 hook 住 pthread_create 这个函数的办法也十分简略,经过 bhook 也是一行代码就能完成,前面的华章现已讲过怎样运用了,所以这个计划剩下的部分就留给你自己去实践啦。

除了 Native Hook 计划,咱们还能在 Java 层经过字节码操作的办法来完成该计划。stack_size 不便是经过 Java 层传递到 Native 层嘛,那咱们直接在 Java 层调整 stack_size 的巨细就能够了,但在这之前之前,要先看看在 FixStackSize 函数中是怎样调整 stack_size 巨细的。

static size_t FixStackSize(size_t stack_size) {
  if (stack_size == 0) {
    stack_size = Runtime::Current()->GetDefaultStackSize();
  }
  stack_size += 1 * MB;
  ……
  return stack_size;
}

FixStackSize 函数的源码完成很简略,便是经过 stack_size += 1 * MB 来设置 stack_size 的:假如咱们传入的 stack_size 为 0 时,默认巨细便是 1 M ;假如咱们传入的 stack_size 为 -512KB 时,stack_size 就会变成 512KB(1M – 512KB)。那咱们是不是只用带有 stackSize 入参的结构函数去创立线程,而且设置 stackSize 为 -512KB 就行了呢?

public Thread(ThreadGroup group, Runnable target, String name,
                  long stackSize) {
    this(group, target, name, stackSize, null, true);
}

是的,可是由于运用中创立线程的当地太多很难逐个修正,而且咱们实际不需求这样去修正。前面咱们现已将运用中的线程全部收敛到公共线程池中去创立了,所以只需求修正公共线程池中创立的线程办法就能够了,而且线程池刚好也能够让咱们自己创立线程,那只需求传入自定义的 ThreadFactory 就能完成需求。

虚拟内存优化:线程+多进程优化
虚拟内存优化:线程+多进程优化

在咱们自定义的 ThreadFactory 中,创立 stack_size 为 – 512 kb 的线程,这么一个简略的操作就能削减线程所占用的虚拟内存。

虚拟内存优化:线程+多进程优化

当咱们将运用中线程栈的巨细全改成 512 kb 后,或许会导致一些使命比较重的线程出现栈溢出,此时咱们能够经过埋点收聚会栈溢出的线程,不修正这部分线程的巨细即可。总的来说,这是一个简略落地且投入产出比高的计划。

经过上面的计划介绍,咱们也能够看到,削减一个线程所占用的虚拟内存的计划许多,能够经过 Native Hook,也能够经过 Java 代码直接修正。咱们在做事务或许功能相关的作业时,往往都有多个完成计划,可是咱们在敲定终究计划时,始终要挑选最简略、最稳定且投入产出比最高的计划。

多进程架构优化

在 Java 堆内存优化中,咱们现已讲到了能够经过多进程优化,那关于虚拟内存,咱们仍然能够经过多进程的架构来优化。比如说,下面这些事务我都主张你放在独立的进程中:

  1. WebView 相关的事务

  2. 小程序相关的事务

  3. Flutter 相关的事务

  4. RN 相关的事务

这些事务都是虚拟内存占用的大户,用独立的进程来承载,会削减许多虚拟内存的占用,也会削减相应的反常情况。而且,将这些事务放在子进程中也很简略,只需求在承载这些事务的 activity 的 mainfest 配置文件中添加 android:process = “子进程名” 即可。需求留意的是,假如咱们把事务放在子进程,就没法直接和主进程通讯了,需求凭借 Binder 跨进程通讯的办法来完结。

当然,你还或许会担心把这些事务放在独立进程后,会影响这些事务的发动速度,其实这都能够经过各种优化计划来处理,比如预发动子进程等。在后面速度提高优化的章节中,咱们会进行具体解说。

小结

这一节课咱们介绍了两种虚拟内存优化计划,如下图:

虚拟内存优化:线程+多进程优化

这两种优化计划相对简略,简略落地,投入产出比高。关于一个中小型运用来说,这两个计划几乎能保证 32 位手机上有足够可用的虚拟内存了。假如这两个计划落地后,仍是会有因虚拟内存不足导致的运用溃散问题,咱们就需求接着用“黑科技”手法来进行优化了,所以在下一篇文章中,会接着带大家看看有哪些“黑科技”能够用在虚拟内存优化上,它们又能带来什么样的作用!