虚拟内存优化:线程+多进程优化
在介绍内存的基础知识的时候,我们讲过在 32 位系统上虚拟内存只有 4G,因为有 1G 是给内核使用的,所以留给应用的只有 3G 了。3G 虽然看起来挺多,但依然会因为不够用而导致应用崩溃。为什么会这样呢?
我们在学习 Java 堆的组成时就知道 MainSpace 会申请 512M 的虚拟内存,LargeObjectSpace 也会申请 512M 的虚拟内存,这就用掉了 1G 的虚拟内存,再加上其他 Space 和段映射申请的虚拟内存,如 bss 段、text 段以及各种 so 库文件的映射等,这样算下来,3G 的虚拟内存就没剩下多少了。
所以,虚拟内存的优化,在提升程序的稳定性上,是一种很重要的方案。虚拟内存的优化手段也有很多,这一章我们主要介绍 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 种手段:
在应用中使用统一的线程池;
将应用中的野线程及野线程池进行收敛。
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 堆内存优化中,我们已经讲到了可以通过多进程优化,那对于虚拟内存,我们依然可以通过多进程的架构来优化。比如说,下面这些业务我都建议你放在独立的进程中:
WebView 相关的业务
小程序相关的业务
Flutter 相关的业务
RN 相关的业务
这些业务都是虚拟内存占用的大户,用独立的进程来承载,会减少很多虚拟内存的占用,也会减少相应的异常情况。并且,将这些业务放在子进程中也很简单,只需要在承载这些业务的 activity 的 mainfest 配置文件中添加 android:process = "子进程名" 即可。需要注意的是,如果我们把业务放在子进程,就没法直接和主进程通信了,需要借助 Binder 跨进程通信的方式来完成。
当然,你还可能会担心把这些业务放在独立进程后,会影响这些业务的启动速度,其实这都可以通过各种优化方案来解决,比如预启动子进程等。在后面速度提升优化的章节中,我们会进行详细讲解。
小结
这一节课我们介绍了两种虚拟内存优化方案,如下图:
这两种优化方案相对简单,容易落地,投入产出比高。对于一个中小型应用来说,这两个方案几乎能保证 32 位手机上有足够可用的虚拟内存了。如果这两个方案落地后,还是会有因虚拟内存不足导致的应用崩溃问题,我们就需要接着用“黑科技”手段来进行优化了,所以在下一篇文章中,会接着带大家看看有哪些“黑科技”可以用在虚拟内存优化上,它们又能带来什么样的效果!
来源:juejin.cn/post/7209306358582853688