布景

公司项目一直以来存在一个Firebase Push的溃散问题,如下图所示

Android 线程栈紧缩计划
Android 线程栈紧缩计划
创立线程数 1357 个,问题的原因在于运用离线,Firebase Message 积压,导致当运用启动时,一次性倒灌过来,Firebase 内部的 CloudMessagingReceiver 创立多个名称为 firebase-iid-executor 的线程,导致 pthread_create 内存溢出,需要推送平台处理一下过期时刻等等。因而对线程管理提上议程。

线程创立流程

Android 线程栈紧缩计划

上述创立流程在流程图中很清晰了,就不过多阐述了。

不过内部线程栈巨细的设置流程,咱们需要快速过一下代码。

线程栈空间巨细设置流程

咱们在 Java 侧创立线程,终究会经过 JNI 调用到 thread.cc 中的 CreateNativeThread 函数。

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
  CHECK(java_peer != nullptr);
  Thread* self = static_cast<JNIEnvExt*>(env)->GetSelf();
  if (VLOG_IS_ON(threads)) {
    ScopedObjectAccess soa(env);
    ArtField* f = WellKnownClasses::java_lang_Thread_name;
    ObjPtr<mirror::String> java_name =
        f->GetObject(soa.Decode<mirror::Object>(java_peer))->AsString();
    std::string thread_name;
    if (java_name != nullptr) {
      thread_name = java_name->ToModifiedUtf8();
    } else {
      thread_name = "(Unnamed)";
    }
    VLOG(threads) << "Creating native thread for " << thread_name;
    self->Dump(LOG_STREAM(INFO));
  }
  Runtime* runtime = Runtime::Current();
  bool thread_start_during_shutdown = false;
  {
    MutexLock mu(self, *Locks::runtime_shutdown_lock_);
    if (runtime->IsShuttingDownLocked()) {
      thread_start_during_shutdown = true;
    } else {
      runtime->StartThreadBirth();
    }
  }
  if (thread_start_during_shutdown) {
    ScopedLocalRef<jclass> error_class(env, env->FindClass("java/lang/InternalError"));
    env->ThrowNew(error_class.get(), "Thread starting during runtime shutdown");
    return;
  }
  Thread* child_thread = new Thread(is_daemon);
  child_thread->tlsPtr_.jpeer = env->NewGlobalRef(java_peer);
  // 代码 1...
  stack_size = FixStackSize(stack_size);
  SetNativePeer(env, java_peer, child_thread);
  std::string error_msg;
  std::unique_ptr<JNIEnvExt> child_jni_env_ext(
      JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM(), &error_msg));
  int pthread_create_result = 0;
  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);
    // 代码 2...
    pthread_create_result = pthread_create(&new_pthread,
                                           &attr,
                                           gUseUserfaultfd ? Thread::CreateCallbackWithUffdGc
                                                           : Thread::CreateCallback,
                                           child_thread);
    CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");
    if (pthread_create_result == 0) {
      child_jni_env_ext.release();
      return;
    }
  }
  {
    MutexLock mu(self, *Locks::runtime_shutdown_lock_);
    runtime->EndThreadBirth();
  }
  child_thread->DeleteJPeer(env);
  delete child_thread;
  child_thread = nullptr;
  SetNativePeer(env, java_peer, nullptr);
  {
    std::string msg(child_jni_env_ext.get() == nullptr ?
        StringPrintf("Could not allocate JNI Env: %s", error_msg.c_str()) :
        StringPrintf("pthread_create (%s stack) failed: %s",
                                 PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
    ScopedObjectAccess soa(env);
    soa.Self()->ThrowOutOfMemoryError(msg.c_str());
  }
}

上述代码是CreateNativeThread函数实现细节,主要看代码 1 与代码 2 处。因为咱们在 Java 构建线程时,多数情况下很少指定线程栈巨细,所以未指定的情况下,调用这个函数时,入参中的 stack_size = 0。 那么经过代码看,对 stack_size 设置值的当地在代码 1处。

static size_t FixStackSize(size_t stack_size) {
  if (stack_size == 0) {
    // 这儿的 GetDefaultStackSize 是能够经过对虚拟机的配置进行外部设置,
    // 比方经过 -Xss= 去配置,可是针对 Android 并没有特别设置,所以这儿的赋值为0。
    stack_size = Runtime::Current()->GetDefaultStackSize();
  }
  // 设置为1M。
  stack_size += 1 * MB;
  if (kMemoryToolIsAvailable) {
    // ignore...
    stack_size = std::max(2 * MB, stack_size);
  }
  if (stack_size < PTHREAD_STACK_MIN) {
    // PTHREAD_STACK_MIN是一个常量,在 Linux中这个值为 16k。
    stack_size = PTHREAD_STACK_MIN;
  }
  // 下面的这个方法见名之意,为了处理 StackOverFlow 的查看逻辑,这儿默许给一个栈空间专门处理这个查看逻辑。经过测验,这个空间为 16kb。
  if (Runtime::Current()->GetImplicitStackOverflowChecks()) {
    stack_size += Thread::kStackOverflowImplicitCheckSize +
        GetStackOverflowReservedBytes(kRuntimeISA);
  } else {
    stack_size += GetStackOverflowReservedBytes(kRuntimeISA);
  }
  stack_size = RoundUp(stack_size, gPageSize);
  return stack_size;
}

经过代码注释的剖析,拓荒线程池巨细为 1M + StackOverFlow处理的专属空间。 所以咱们 push 创立的 1357 个线程 至少拓荒了 1357 * 1 * 1024 * 1024 byte,也便是1357M

ok,到这儿咱们知道了线程栈空间分配,后续会履行pthread_create函数去创立一个线程。

实际上咱们日常开发,线程栈空间占用实际上不会占用太多,一般咱们常见的递归操作假如操控合理能够递归好几轮,所以Android默许分配 1M 的巨细,为了提前确保满足的空间用于逻辑处理,否则假设默许分配 512KB,那么StackOverFlow的或许就比较大了,所以考虑功能和兼容性考虑终究挑选了1M。 不过我也问了一下 GPT4, 它的答复是:

为什么默许栈巨细是1M?
  1. 平衡功能与资源耗费:线程栈巨细决定了线程能够运用的内存量。较大的栈巨细能够让线程履行更深层次的函数调用和拥有更多的局部变量,但一起也会添加每个线程的内存耗费。在多线程运用中,过大的线程栈会显著添加全体的内存占用。1M的默许巨细是在答应满足的函数调用深度和局部变量空间的一起,尽量削减每个线程的内存耗费,为多线程并发留出空间。
  2. 兼容性和通用性:设置一个中等巨细的默许值能够确保在多种不同的运用场景中都能有较好的表现,不需要针对特定运用进行大幅调整。1M的栈巨细对于大多数运用来说都是满足的,一起也不会因为过大而浪费资源。
  3. 线程安全:线程栈巨细与线程安全直接关系不大,但合理的栈巨细能够间接影响程序的稳定性和过错处理才能。过小的栈或许会导致栈溢出,尤其是在深层次递归调用或大量局部变量运用的情况下。恰当的栈巨细能够削减这类问题的发生。
与线程安全和功能的平衡
  • 线程安全:经过确保每个线程有满足的栈空间来履行其任务,削减了因栈溢出而导致的程序溃散危险,从而间接提高了线程安全。此外,合理的栈巨细还能够防止在多线程环境下因内存不足而导致的竞态条件和其他线程安全问题。
  • 功能:合理的默许栈巨细能够提高运用的功能。过小的栈或许导致频繁的栈溢出异常,影响功能;而过大的栈会耗费过多内存资源,降低系统的并发才能。因而,1M的默许值是在确保单个线程功能的一起,也优化了全体的内存运用,使得运用能够在有限的资源下运转更多的线程,从而提高并发功能。

当然咱们也能够优化他,咱们能够去设置这个栈空间巨细。

栈空间设置

线程 Thread 创立时设置

long stackSize = -512 * 1024; // -512KB 
Thread thread = new Thread(null, new ThreadTask(), "DemoThread", stackSize);

可是因为咱们运用一般会接入二方三方库,内部也存在线程的分配,无法做到一致处理,除非现已对线程进行了收拢,否则很难做到一致处理。 所以咱们只能挑选 hook 的计划。

关于 PLT & Inline

针对 Native hook,有两种方法 PLT HookInline Hook, 上述两个技能点不具体说明晰,比较深邃,需要花很多时刻去研究。 有兴趣能够看下这篇赵子健大佬的PLT Hook从入门到实战。 本文选用 Inline hook 去处理这个技能任务,因为公司的项目运用的是字节的ShadowHook,对其进行了脱敏,所以就直接选用它了。

寻觅 hook 点

经过上述 Thread Native 创立流程,咱们其实现已能够知道能够挑选两个 hook 的点。

pthread_create()

Hook pthread_create()函数将愈加底层,假如咱们 hook 这个函数,将无视FixStackSize函数的逻辑,当然是能够无视的,不过为了不必要的危险,要保存这个FixStackSize中的逻辑咱们最终挑选了第二个 hook 点。

当然假如有满足的测验,其实 pthread_create()能够直接运用 PLT hook,功能更好, 别忘记 16kbStackOverFlow 的栈空间。

示例代码

ThreadHook.cpp
#include <jni.h>
#include <string>
#include <shadowhook.h>
#include <android/log.h>
#include <pthread.h>
#include "thread_hook.h"
#include "thread_compressor.h"
#define LOG_TAG "thread_hook"
#define TARGET_LIB "libc.so"
#define TARGET_FUNC "pthread_create"
extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_threadHook(JNIEnv *env,jobject /* this */) {
    thread_hook::thread_hook();
}
extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_setStackSize(JNIEnv *env,jobject /* this */, jint size) {
    thread_compressor::thread_stack_size(size);
}
extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_threadUnhook(JNIEnv *env,jobject /* this */, jint size) {
    thread_hook::thread_unhook();
}
namespace thread_hook {
    void *orig = NULL;
    void *stub = NULL;
    typedef void (*type_t)(pthread_t *pthread_ptr, pthread_attr_t const *attr,void *(*start_routine)(void *), void *args);
    void proxy(pthread_t *pthread_ptr, pthread_attr_t const *attr, void *(*start_routine)(void *),void *args) {
        __android_log_print(ANDROID_LOG_INFO, LOG_TAG, "proxy pthread_create called.");
        thread_compressor::compress(attr);
        ((type_t) orig)(pthread_ptr, attr, start_routine, args);
    }
    void bind_proxy() {
        stub = shadowhook_hook_sym_name(
                TARGET_LIB,
                TARGET_FUNC,
                (void *) proxy,
                (void **) &orig);
        if (stub == NULL) {
            int err_num = shadowhook_get_errno();
            const char *err_msg = shadowhook_to_errmsg(err_num);
        }
    }
    void thread_hook() {
        bind_proxy();
    }
    void thread_unhook() {
        shadowhook_unhook(stub);
        stub = NULL;
        orig = NULL;
    }
}
ThreadStackCompressed.cpp
#include "jni.h"
#include <string>
#include <android/log.h>
#include <pthread.h>
#include <cerrno>
#include <sys/prctl.h>
// Logging levels
#define LOG_TAG "thread_hook"
#define LOG_ERROR ANDROID_LOG_ERROR
#define LOG_DEBUG ANDROID_LOG_DEBUG
// Logging macros for convenience
#define LOGE(TAG, fmt, ...) __android_log_print(LOG_ERROR, TAG, fmt, ##__VA_ARGS__)
#define LOGD(TAG, fmt, ...) __android_log_print(LOG_DEBUG, TAG, fmt, ##__VA_ARGS__)
// Branch prediction hints
#ifndef LIKELY
#define LIKELY(cond) (__builtin_expect(!!(cond), 1))
#endif
#ifndef UNLIKELY
#define UNLIKELY(cond) (__builtin_expect(!!(cond), 0))
#endif
// Size constants for stack adjustments
#define SIZE_16K (16 * 1024)
#define SIZE_1M (1 * 1024 * 1024)
// Thread name size
#define THREAD_NAME_SIZE 16
// Error codes for clarity
#define ERROR_GET_STACK_SIZE_FAILED -1
#define ERROR_ADJUST_STACK_SIZE_FAILED -2
#define ERROR_IGNORE_COMPRESS -3
#define ERROR_INVALID_STACK_SIZE -4
#define ERROR_STACK_SIZE_TOO_SMALL -5
namespace thread_compressor {
    long stack_size = SIZE_1M;
    // Checks if the given size is within an acceptable range for stack size adjustment
    bool IsSizeValid(size_t originSize) {
        constexpr size_t STACK_SIZE_OFFSET = SIZE_16K;
        constexpr size_t ONE_MB = SIZE_1M;
        return ONE_MB - STACK_SIZE_OFFSET <= originSize && originSize <= ONE_MB + STACK_SIZE_OFFSET;
    }
    static void currentThreadName();
    // Adjusts the stack size for a thread, if possible and necessary
    static int AdjustStackSize(pthread_attr_t *attr) {
        size_t origStackSize = 0;
        int ret = pthread_attr_getstacksize(attr, &origStackSize);
        if (UNLIKELY(ret != 0)) {
            LOGE(LOG_TAG, "Fail to call pthread_attr_getstacksize, ret: %d", ret);
            return ERROR_GET_STACK_SIZE_FAILED;
        }
        if (!IsSizeValid(origStackSize)) {
            LOGE(LOG_TAG, "Origin Stack size %u, give up adjusting.", origStackSize);
            return ERROR_INVALID_STACK_SIZE;
        }
        if (origStackSize < 2 * PTHREAD_STACK_MIN) {
            LOGE(LOG_TAG, "Stack size is too small to reduce, give up adjusting.");
            return ERROR_STACK_SIZE_TOO_SMALL;
        }
        if (stack_size > origStackSize){
            stack_size = origStackSize >> 1U;
        }
        size_t final_stack_size = stack_size;
        ret = pthread_attr_setstacksize(attr, final_stack_size);
        if (LIKELY(ret == 0)) {
            LOGE(LOG_TAG, "min size is %d", final_stack_size);
            currentThreadName();
        } else {
            LOGE(LOG_TAG, "Fail to call pthread_attr_setstacksize, ret: %d", ret);
            return ERROR_ADJUST_STACK_SIZE_FAILED;
        }
        return ret;
    }
    static void currentThreadName() {
        char threadName[THREAD_NAME_SIZE];
        if (prctl(PR_GET_NAME, (unsigned long)threadName, 0, 0, 0) != 0) {
            LOGD(LOG_TAG, "Acquire current thread name failed.");
            return;
        }
        LOGD(LOG_TAG, "Shrink thread stack size successfully, thread name: %s", threadName);
    }
    // Public interface to attempt stack size compression on a thread
    int compress(pthread_attr_t const *attr) {
        if (attr == nullptr) {
            LOGD(LOG_TAG, "attr is null, skip adjusting.");
            return ERROR_IGNORE_COMPRESS; // Using a defined error code here might improve clarity
        }
        return AdjustStackSize(const_cast<pthread_attr_t *>(attr));
    }
    void thread_stack_size(long size) {
        stack_size = size;
    }
    long get_thread_stack_size() {
        return stack_size;
    }
}

Thread::CreateNativeThread()

这个函数是存在于 libart.so 中,因为 thread.cc是 C++ 代码,因而它存在一个函数名改编(mangling)的动作,因而咱们能够运用 adb 指令将设备中的 libart.so 文件拉出来,然后运用readelf工具找到对应的函数签名。

readelf -Ws /path/to/your/file | grep CreateNativeThread

履行上述指令以后会得到下述函数改编值

_ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectmb

当然遇到其他类似的改编值,咱们也能够反解析出来。

c++filt _ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectmb
art::Thread::CreateNativeThread(_JNIEnv*, _jobject*, unsigned long, bool)

ok,咱们拿到了对应的改编值,能够开搞了。

示例代码

thread_hook.cpp
#include <jni.h>
#include <string>
#include <shadowhook.h>
#include <android/log.h>
#include <pthread.h>
#include <linux/prctl.h>
#include <sys/prctl.h>
#include "com_deliverysdk_thread_hook.h"
#include "thread_stack.h"
#define LOG_TAG "thread_hook"
#define TARGET_ART_LIB "libart.so"
#define THREAD_NAME_SIZE 16
#if defined(__arm__) // ARMv7 32-bit
#define TARGET_CREATE_NATIVE_THREAD "_ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectjb"
#elif defined(__aarch64__) // ARMv8 64-bit
#define TARGET_CREATE_NATIVE_THREAD "_ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectmb"
#endif
#define SIZE_1M_BYTE (1 * 1024 * 1024)
#define SIZE_1KB_BYTE (1 * 1024)
namespace thread_hook {
    jobject callbackObj = nullptr;
    void *originalFunction = nullptr;
    void *stubFunction = nullptr;
    typedef void (*ThreadCreateFunc)(JNIEnv *env, jobject java_peer, size_t stack_size,
                                     bool is_daemon);
    bool currentThreadName(char *name) {
        return prctl(PR_GET_NAME, (unsigned long) name, 0, 0, 0) == 0;
    }
    void createNativeThreadProxy(JNIEnv *env, jobject java_peer, size_t stack_size, bool is_daemon) {
        char threadName[THREAD_NAME_SIZE];
        if (stack_size == 0) {
            long adjustment_size = thread_stack::get_thread_stack_size();
            long default_size = SIZE_1M_BYTE;
            long final_size = default_size - adjustment_size;
            __android_log_print(ANDROID_LOG_INFO,
                                LOG_TAG,
                                "Adjusting thread size, target adjustment: %ld, thread name %s",
                                -final_size,
                                currentThreadName(threadName) ? threadName : "N/A");
            ((ThreadCreateFunc) originalFunction)(env, java_peer, -final_size, is_daemon);
        } else {
            ((ThreadCreateFunc) originalFunction)(env, java_peer, stack_size, is_daemon);
        }
    }
    void setNativeThreadStackFailed(JNIEnv *pEnv, const char *errMsg) {
        jclass jThreadHookClass = pEnv->FindClass("com/sample/thread_hook/ThreadSizeCallback");
        if (jThreadHookClass == nullptr) {
            return;
        }
        jmethodID jMethodId = pEnv->GetMethodID(jThreadHookClass, "setNativeThreadStackFailed",
                                                "(Ljava/lang/String;)V");
        if (jMethodId != nullptr) {
            pEnv->CallVoidMethod(callbackObj, jMethodId, pEnv->NewStringUTF(errMsg));
        }
    }
    void hook_create_native_thread(JNIEnv *pEnv) {
#if defined(__arm__) || defined(__aarch64__)
        stubFunction = shadowhook_hook_sym_name(TARGET_ART_LIB, TARGET_CREATE_NATIVE_THREAD,
                                                (void *) createNativeThreadProxy,
                                                (void **) &originalFunction);
        if (stubFunction == nullptr) {
            const int err_num = shadowhook_get_errno();
            const char *errMsg = shadowhook_to_errmsg(err_num);
            if (errMsg == nullptr || callbackObj == nullptr) {
                return;
            }
            setNativeThreadStackFailed(pEnv, errMsg);
            delete errMsg;
        }
        // this hook exist in the APP's whole lifecycle, so we do not need to release 'stubFunction' pointer.
#else
        setNativeThreadStackFailed(pEnv, "Unsupported architecture.");
#endif
    }
} // namespace thread_hook
extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_setNativeThreadStackSize(JNIEnv *env,
                                                                      jobject /* this */,
                                                                      jlong stackSizeKb,
                                                                      jobject callback) {
    long const target_size = stackSizeKb * SIZE_1KB_BYTE;
    thread_stack::set_thread_stack_size(target_size);
    if (target_size > 0 && target_size < SIZE_1M_BYTE) {
        thread_hook::callbackObj = env->NewGlobalRef(callback);
        thread_hook::hook_create_native_thread(env);
    }
}

其间 15-19 行宏界说请注意,针对32 位 & 64 位CPU-Arch,对应的函数签名不相同,区别是 stack_size 的类型对应是 int & long,所以函数签名也发生了改变。

除此之外便是一些小的case,比方设置的stack_size是 大于0M或许小于1M才进行 hook,其他的就丢弃掉了好了。 还有便是最终传入给原始 CreateNativeThread()函数中的 stack_size必定要是一个负值哦。

Demo 测验

将线程栈极限紧缩到512kb

栈紧缩前

Android 线程栈紧缩计划

创立了1136个线程,发生了OOM

栈紧缩后

Android 线程栈紧缩计划
创立了2087个线程,发生了OOM

危险操控

咱们项目的危险操控能够选用 Firebase长途开关进行操控冷启动的时候是否开启大局Hook, 之前想要运用大局 JavaCrashHandler 捕获一切的StackOverFlowError的过错,一旦发生了1例的过错,立即关闭 hook,可是在测验的过程中发现并不能被大局捕获,其实它类似于 OOM error,是猜测性过错,在或许发生类似的 Error 的代码处进行 Try… 才能够捕获,可是大局性的捕获因为现已是 error 了即便是大局捕获,其实也阻挡不了系统的不稳定性溃散。其次或许是多线程的过错,存在异步问题,因而也很难兜住。

怎么测验

先设置一个最大期望值,比方 512Kb,直接砍一半,然后丢给 QA 测验。

其次是对项目中的事务进行整理,哪些是杂乱的事务逻辑,比方地图导航流程,杂乱的接单流程,或许一些二方三方的 sdk 事务流程,这些流程必定要具体测验。

结合 Monkey 测验脚本,去跑对应的核心流程。假如核心流程没有任何问题,在上线时 能够制定 900kb -> 800kb -> … 顺次递减,直到找到一个最小阈值。

主张

最好在做这个技能需求时能够先去做完线程管理,究竟 hook 的计划是有危险的,除非收益很大才会做。 当然假如做完了线程管理,其实能够在线程池工厂中自界说一个线程栈巨细,这样危险更小。

项目收益(TODO)

还没有上线,等上线后会弥补这部分内容。

感谢

感谢字节供给的稳定的Inline Hook 计划!以及字节的技能共享!获益很多!