美文网首页
Andoird性能优化 - 死锁监控与其背后的小知识

Andoird性能优化 - 死锁监控与其背后的小知识

作者: _Jun | 来源:发表于2022-10-31 13:48 被阅读0次

    前言

    “死锁”,这个从接触程序开发的时候就会经常听到的词,它其实也可以被称为一种“艺术”,即互斥资源访问循环的艺术,在Android中,如果主线程产生死锁,那么通常会以ANR结束app的生命周期,如果是两个子线程的死锁,那么就会白白浪费cpu的调度资源,同时也不那么容易被发现,就像一颗“肿瘤”,永远藏在app中。当然,本篇介绍的是业内常见的死锁监控手段,同时也希望通过死锁,去挖掘更加底层的知识,同时让我们更加了解一些常用的监控手段。

    我们很容易模拟一个死锁操作,比如

    val lock1 = Object()
    val lock2 = Object()
    
    Thread ({
        synchronized(lock1){
            Thread.sleep(2000)
    
            synchronized(lock2){
    
            }
        }
    },"thread222").start()
    
    Thread ({
        synchronized(lock2) {
            Thread.sleep(1000)
    
            synchronized(lock1) {
    
            }
        }
    },"thread111").start()
    

    因为thread111跟thread222都同时持有着对方想要的临界资源(互斥资源),因此这两个线程都处在互相等待对方的状态。

    死锁检测

    我们怎么判断死锁:是否存在一个线程所持有的锁被另一个线程所持有,同时另一个线程也持有该线程所需要的锁,因此我们需要知道以下信息才能进行死锁分析:

    1. 线程所要获取的锁是什么
    2. 该锁被什么线程所持有
    3. 是否产生循环依赖的限制(本篇就不涉及了,因为我们知道了前两个就可以自行分析了)

    线程Block状态

    通过我们对synchronized的了解,当线程多次获取不到锁的时候,此时线程就会进入悲观锁状态,因此线程就会尝试进入阻塞状态,避免进一步的cpu资源消耗,因此此时两个线程都会处于block 阻塞的状态,我们就能知道,处于被block状态的线程就有可能产生死锁(只是有可能),我们可以通过遍历所有线程,查看是否处于block状态,来进行死锁判断的第一步

    val threads = getAllThread()
    threads.forEach {
        if(it?.isAlive == true && it.state == Thread.State.BLOCKED){
           进入死锁判断
        }
    }
    

    获取所有线程

    private fun getAllThread():Array<Thread?>{
        val threadGroup = Thread.currentThread().threadGroup;
        val total = Thread.activeCount()
        val array = arrayOfNulls<Thread>(total)
        threadGroup?.enumerate(array)
        return array
    
    }
    

    通过对线程的判断,我们能够排除大部分非死锁的线程,那么下一步我们要怎么做呢?如果线程发生了死锁,那么一定拥有一个已经持有的互斥资源并且不释放才有可能造成死锁对不对!那么我们下一步,就是要检测当前线程所持有的锁,如果两个线程同时持有对方所需要的锁,那么就会产生死锁

    获取当前线程所请求的锁

    虽然我们在java层没有相关的api提供给我们获取线程当前想要请求的锁,但是在我们的native层,却可以轻松做到,因为它在art中得到更多的支持。

    ObjPtr<mirror::Object> Monitor::GetContendedMonitor(Thread* thread) {
        // This is used to implement JDWP's ThreadReference.CurrentContendedMonitor, and has a bizarre
        // definition of contended that includes a monitor a thread is trying to enter...
        ObjPtr<mirror::Object> result = thread->GetMonitorEnterObject();
        if (result == nullptr) {
            // ...but also a monitor that the thread is waiting on.
            MutexLock mu(Thread::Current(), *thread->GetWaitMutex());
            Monitor* monitor = thread->GetWaitMonitor();
            if (monitor != nullptr) {
                result = monitor->GetObject();
            }
        }
        return result;
    }
    

    其中第一步尝试着通过thread->GetMonitorEnterObject()去拿

    mirror::Object* GetMonitorEnterObject() const REQUIRES_SHARED(Locks::mutator_lock_) {
            return tlsPtr_.monitor_enter_object;
    }
    

    其中tlsPtr_ 其实就是art虚拟机中对于线程ThreadLocal的代表,即代表着只属于线程的本地对象,会先尝试从这里拿,拿不到的话通过Thread类中的wait_mutex_对象去拿

    Mutex* GetWaitMutex() const LOCK_RETURNED(wait_mutex_) {
            return wait_mutex_;
    }
    

    GetContendedMonitor 提供了一个方法查询当前线程想要的锁对象,这个锁对象以ObjPtrmirror::Object对象表示,其中mirror::Object类型是art中相对应于java层的Object类的代表,我们了解一下即可。看到这里我们可能还有一个疑问,这个Thread* thread的入参是什么呢?(其实是nativePeer,下文我们会了解)

    我们有办法能够查询到线程当前请求的锁,那么这个锁被谁持有呢?只有解决这两个问题,我们才能进行死锁的判断对不对,我们继续往下

    通过锁获取当前持有的线程

    我们还记得上文中返回的锁对象是以ObjPtrmirror::Object表示的,当然,art中同样提供了方法,让我们通过这个锁对象去查询当前是哪个线程持有

    uint32_t Monitor::GetLockOwnerThreadId(ObjPtr<mirror::Object> obj) {
        DCHECK(obj != nullptr);
        LockWord lock_word = obj->GetLockWord(true);
        switch (lock_word.GetState()) {
            case LockWord::kHashCode:
                // Fall-through.
            case LockWord::kUnlocked:
                return ThreadList::kInvalidThreadId;
            case LockWord::kThinLocked:
                return lock_word.ThinLockOwner();
            case LockWord::kFatLocked: {
                Monitor* mon = lock_word.FatLockMonitor();
                return mon->GetOwnerThreadId();
            }
            default: {
                LOG(FATAL) << "Unreachable";
                UNREACHABLE();
            }
        }
    }
    

    这里函数比较简单,如果当前调用正常,那么执行的就是LockWord::kFatLocked,返回的是native层的Thread的tid,最终是以uint32_t类型表示

    注意这里GetLockOwnerThreadId中返回的Thread id千万不要跟Java层的Thread对象的tid混淆,这里的tid才是真正的线程id标识

    线程启动

    我们来看一下native层主线程的启动,它随着art虚拟机的启动随即启动,我们都知道java层的线程其实在没有跟操作系统的线程绑定的时候,它只能算是一块内存!只要经过与native线程绑定后,这时的Thread才能真正具备线程调度的能力,下面我们以主线程启动举例子:

    thread.cc
    void Thread::FinishStartup() {
    Runtime* runtime = Runtime::Current();
    CHECK(runtime->IsStarted());
    
    // Finish attaching the main thread.
    ScopedObjectAccess soa(Thread::Current());
    // 这里是关键,为什么主线程称为“main线程”的原因
    soa.Self()->CreatePeer("main", false, runtime->GetMainThreadGroup());
    soa.Self()->AssertNoPendingException();
    
    runtime->RunRootClinits(soa.Self());
    soa.Self()->NotifyThreadGroup(soa, runtime->GetMainThreadGroup());
    soa.Self()->AssertNoPendingException();
    }
    

    可以看到,为什么主线程被称为“主线程”,是因为在art虚拟机启动的时候,通过CreatePeer函数,创建的名称是“main”,CreatePeer是native线程中非常重要的存在,所有线程创建都经过它,这个函数有点长,笔者这里做了删减

    
    void Thread::CreatePeer(const char* name, bool as_daemon, jobject thread_group) {
        Runtime* runtime = Runtime::Current();
        CHECK(runtime->IsStarted());
        JNIEnv* env = tlsPtr_.jni_env;
    
        if (thread_group == nullptr) {
            thread_group = runtime->GetMainThreadGroup();
        }
        // 设置了线程名字
        ScopedLocalRef<jobject> thread_name(env, env->NewStringUTF(name));
        // Add missing null check in case of OOM b/18297817
        if (name != nullptr && thread_name.get() == nullptr) {
            CHECK(IsExceptionPending());
            return;
        }
        // 设置Thread的各种属性
        jint thread_priority = GetNativePriority();
        jboolean thread_is_daemon = as_daemon;
        // 创建了一个java层的Thread对象,名字叫做peer
        ScopedLocalRef<jobject> peer(env, env->AllocObject(WellKnownClasses::java_lang_Thread));
        if (peer.get() == nullptr) {
            CHECK(IsExceptionPending());
            return;
        }
        {
            ScopedObjectAccess soa(this);
            tlsPtr_.opeer = soa.Decode<mirror::Object>(peer.get()).Ptr();
        }
        env->CallNonvirtualVoidMethod(peer.get(),
                                      WellKnownClasses::java_lang_Thread,
                                      WellKnownClasses::java_lang_Thread_init,
                                      thread_group, thread_name.get(), thread_priority, thread_is_daemon);
        if (IsExceptionPending()) {
            return;
        }
        // 看到这里,非常关键,self 指向了当前native Thread对象 self->Thread
        Thread* self = this;
        DCHECK_EQ(self, Thread::Current());
        env->SetLongField(peer.get(),
                          WellKnownClasses::java_lang_Thread_nativePeer,
                          reinterpret_cast64<jlong>(self));
    
        ScopedObjectAccess soa(self);
        StackHandleScope<1> hs(self);
       ....
    }
    
    

    这里其实就是一次jni调用,把java中的Thread 的nativePeer 进行了赋值,而赋值的内容,正是通过了这个调用SetLongField

    env->SetLongField(peer.get(),
                          WellKnownClasses::java_lang_Thread_nativePeer,
                          reinterpret_cast64<jlong>(self));
    

    这里我们简单了解一下SetLongField,如果进行过jni开发的同学应该能过明白,其实就是把peer.get()得到的对象(其实就是java层的Thread对象)的nativePeer属性,赋值为了self(native层的Thread对象的指针),并强转换为了jlong类型。我们接下来回到java层

    Thread.java
    private volatile long nativePeer;
    

    说了一大堆,那么这个nativePeer究竟是个什么?通过上面的代码分析,我们能够明白了,Thread.java中的nativePeer就是一个指针,它所指向的内容正是native层中的Thread

    nativePeer 与 native Thread tid 与java Thread tid

    经过了上面一段落,我们了解了nativePeer,那么我们继续对比一下java层Thread tid 与native层Thread tid。我们通过在kotlin/java中,调用Thread对象的id属性,其实得到的是这个

    private long tid;
    

    它的生成方法如下

    /* Set thread ID */
    tid = nextThreadID();
    
    
    private static synchronized long nextThreadID() {
        return ++threadSeqNumber;
    }
    

    可以看到,虽然它的确能代表一个java层中Thread的标识,但是生成其实可以看到,他也仅仅是一个普通的累积id生成,同时也并没有在native层中被当作唯一标识进行使用。

    而native Thread 的 tid属性,才是真正的线程id

    在art中,通过GetTid获取

    pid_t GetTid() const {
        return tls32_.tid;
    }
    

    同时我们也可以注意到,tid 是保存在 tls32_结构体中,并且其位于Thread对象的开头,从内存分布上看,tid位于state_and_flagssuspend_countthink_lock_thread_id之后,还记得我们上面说过的nativePeer嘛?我们一直强调native是Thread的指针对象

    因此我们可以通过指针的偏移,从而算出nativePeer到tid的换算公式,即nativePeer指针向下偏移三位就找到了tid(因为state_and_flags,state_and_flags,think_lock_thread_id都是int类型,那么对应的指针也就是int * )这里有点绕,因为涉及指针的内容

    int *pInt = reinterpret_cast<int *>(native_peer);
    //地址 +3,得到tid
    pInt = pInt + 3;
    return *pInt;
    

    nativePeer对象因为就在java层,我们很容易通过反射就能拿到

    val nativePeer = Thread::class.java.getDeclaredField("nativePeer")
    nativePeer.isAccessible = true
    val currentNativePeer = nativePeer.get(it)
    

    这里我们通过nativePeer换算成tid可以写成一个jni方法

    external fun nativePeer2Threadid(nativePeer:Long):Int
    

    实现就是

    extern "C"
    JNIEXPORT jint JNICALL
    Java_com_example_signal_MainActivity_nativePeer2Threadid(JNIEnv *env, jobject thiz,
                                                             jlong native_peer) {
        if (native_peer != 0) {
                //long 强转 int
                int *pInt = reinterpret_cast<int *>(native_peer);
                //地址 +3,得到 native id
                pInt = pInt + 3;
                return *pInt;
            }
        }
    }
    

    dlsym与调用

    我们上面终于把死锁能涉及到的点都讲完,比如如何获取线程所请求的锁,当前锁又被那个线程持有,如何通过nativePeer获取Thread id 做了分析,但是还有一个点我们还没能解决,就是如何调用这些函数。我们需要调用的是GetContendedMonitor,GetLockOwnerThreadId,这个时候dlsym系统调用就出来了,我们可以通过dlsym 进行调用我们想要调用的函数

    void* dlsym(void* __handle, const char* __symbol);
    

    这里的symbol是什么呢?其实我们所有的elf(so也是一种elf文件)的所有调用函数都会生成一个符号,代表着这个函数,它在elf的.text中。而我们android中,就会通过加载so的方式加载系统库,加载的系统库libart.so里面就包含着我们想要调用的函数GetContendedMonitor,GetLockOwnerThreadId的符号

    我们可以通过objdump -t libart.so 查看符号

    这里我们直接给出来各个符号,读者可以直接用objdump查看符号

    GetContendedMonitor 对应的符号是

    _ZN3art7Monitor19GetContendedMonitorEPNS_6ThreadE
    

    GetLockOwnerThreadId 对应的符号

    sdk <= 29
    _ZN3art7Monitor20GetLockOwnerThreadIdEPNS_6mirror6ObjectE
    
    >29是这个
    _ZN3art7Monitor20GetLockOwnerThreadIdENS_6ObjPtrINS_6mirror6ObjectEEE
    

    系统限制

    然后到这里,我们还是没能完成调用,因为dlsym等dl系列的系统调用,因为从Android 7.0开始,Android系统开始阻止App中直接使用dlopen(), dlsym()等函数打开系统动态库,好家伙!谷歌大兄弟为了安全的考虑,做了很多限制。但是这个防君子不防程序员,业内依旧有很多绕过系统的限制的方法,我们看一下dlsym

    __attribute__((__weak__))
    void* dlsym(void* handle, const char* symbol) {
        const void* caller_addr = __builtin_return_address(0);
        return __loader_dlsym(handle, symbol, caller_addr);
    }
    

    __builtin_return_address是Linux一个内建函数(通常由编译器添加),__builtin_return_address(0)用于返回当前函数的返回地址。

    在__loader_dlsym 会进行返回地址的校验,如果此时返回地址不是属于系统库的地址,那么调用就不成功,这也是art虚拟机保护手段,因此我们很容易就得出一个想法,我们是不是可以用系统的某个函数去调用dlsym,然后把结果给到我们自己的函数消费就可以了?是的,业内已经有很多这个方案了,比如ndk_dlopen

    我们拿arm架构进行分析,arm架构中LR寄存器就是保存了当前函数的返回地址,那么我们是不是在调用dlsym时可以通过汇编代码直接修改LR寄存器的地址为某个系统库的函数地址就可以了?嗯!是的,但是我们还需要把原来的LR地址给保存起来,不然就没办法还原原来的调用了。

    这里我们拿ndk_dlopen的实现举例子

    if (SDK_INT <= 0) {
        char sdk[PROP_VALUE_MAX];
        __system_property_get("ro.build.version.sdk", sdk);
        SDK_INT = atoi(sdk);
        LOGI("SDK_INT = %d", SDK_INT);
        if (SDK_INT >= 24) {
            static __attribute__((__aligned__(PAGE_SIZE))) uint8_t __insns[PAGE_SIZE];
            STUBS.generic_stub = __insns;
            mprotect(__insns, sizeof(__insns), PROT_READ | PROT_WRITE | PROT_EXEC);
    
            // we are currently hijacking "FatalError" as a fake system-call trampoline
            uintptr_t pv = (uintptr_t)(*env)->FatalError;
            uintptr_t pu = (pv | (PAGE_SIZE - 1)) + 1u;
            uintptr_t pd = (pv & ~(PAGE_SIZE - 1));
            mprotect((void *)pd, pv + 8u >= pu ? PAGE_SIZE * 2u : PAGE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC);
            quick_on_stack_back = (void *)pv;
      // arm架构汇编实现
    #elif defined(__arm__)
                // r0~r3
                /*
                 0x0000000000000000:     08 E0 2D E5     str lr, [sp, #-8]!
                 0x0000000000000004:     02 E0 A0 E1     mov lr, r2
                 0x0000000000000008:     13 FF 2F E1     bx r3
                */
                memcpy(__insns, "\x08\xE0\x2D\xE5\x02\xE0\xA0\xE1\x13\xFF\x2F\xE1", 12);
                if ((pv & 1u) != 0u) { // Thumb
                    /*
                     0x0000000000000000:     0C BC   pop {r2, r3}
                     0x0000000000000002:     10 47   bx r2
                    */
                    memcpy((void *)(pv - 1), "\x0C\xBC\x10\x47", 4);
                } else {
                    /*
                     0x0000000000000000:     0C 00 BD E8     pop {r2, r3}
                     0x0000000000000004:     12 FF 2F E1     bx r2
                    */
                    memcpy(quick_on_stack_back, "\x0C\x00\xBD\xE8\x12\xFF\x2F\xE1", 8);
                } //if
    
    

    其中我们拿(*env)->FatalError作为了混淆系统调用的stub,我们参照着流程图去理解上述代码:

    • 02 E0 A0 E1 mov lr, r2 把r2寄存器的内容放到了lr寄存器,这个r2存的东西就是FatalError的地址
    • 0x0000000000000008: 13 FF 2F E1 bx r3 ,通过bx指令调转,就可以正常执行我们的dlsym了,r3就是我们自己的dlsym的地址
    • 0x0000000000000000: 0C 00 BD E8 pop {r2, r3} 调用完r3寄存器的方法把r2寄存器放到调用栈下,提供给后面的执行进行消费
    • 0x0000000000000004: 12 FF 2F E1 bx r2 ,最后就回到了我们的r2,完成了一次调用

    总之,我们想要做到dl系列的调用,就是想尽方法去修改对应架构的函数返回地址的数值。

    死锁检测所有代码

    
    const char *get_lock_owner_symbol_name() {
        if (SDK_INT <= 29) {
            return "_ZN3art7Monitor20GetLockOwnerThreadIdEPNS_6mirror6ObjectE";
        } else {
            return "_ZN3art7Monitor20GetLockOwnerThreadIdENS_6ObjPtrINS_6mirror6ObjectEEE";
        }
    }
    
    extern "C"
    JNIEXPORT jint JNICALL
    Java_com_example_signal_MyHandler_deadLockMonitor(JNIEnv *env, jobject thiz,
                                                      jlong native_thread) {
        //1、初始化
        ndk_init(env);
    
        //2、打开动态库libart.so
        void *so_addr = ndk_dlopen("libart.so", RTLD_NOLOAD);
    
        void * get_contended_monitor = ndk_dlsym(so_addr, "_ZN3art7Monitor19GetContendedMonitorEPNS_6ThreadE");
        void * get_lock_owner_thread = ndk_dlsym(so_addr, get_lock_owner_symbol_name());
    
        int monitor_thread_id = 0;
        if (get_contended_monitor != nullptr && get_lock_owner_thread != nullptr) {
            //1、调用一下获取monitor的函数,返回当前线程想要竞争的monitor
            int monitorObj = ((int (*)(long)) get_contended_monitor)(native_thread);
            if (monitorObj != 0) {
                // 2、获取这个monitor被哪个线程持有,返回该线程id
                monitor_thread_id = ((int (*)(int)) get_lock_owner_thread)(monitorObj);
            } else {
                monitor_thread_id = 0;
            }
        }
        return monitor_thread_id;
    }
    extern "C"
    JNIEXPORT jint JNICALL
    Java_com_example_signal_MainActivity_nativePeer2Threadid(JNIEnv *env, jobject thiz,
                                                             jlong native_peer) {
        if (native_peer != 0) {
            if (SDK_INT > 20) {
                //long 强转 int
                int *pInt = reinterpret_cast<int *>(native_peer);
                //地址 +3,得到 native id
                pInt = pInt + 3;
                return *pInt;
            }
        }
    }
    
    extern "C" jint JNI_OnLoad(JavaVM *vm, void *reserved) {
        char sdk[PROP_VALUE_MAX];
        __system_property_get("ro.build.version.sdk", sdk);
        SDK_INT = atoi(sdk);
        return JNI_VERSION_1_4;
    }
    

    对应java层

    external fun deadLockMonitor(nativeThread:Long):Int
    
    private fun getAllThread():Array<Thread?>{
        val threadGroup = Thread.currentThread().threadGroup;
        val total = Thread.activeCount()
        val array = arrayOfNulls<Thread>(total)
        threadGroup?.enumerate(array)
    
        return array
    
    }
    external fun nativePeer2Threadid(nativePeer:Long):Int
    

    总结

    我们通过死锁这个例子,去了解了native层Thread的相关方法,同时也了解了如何使用dlsym打开函数符号并调用。本篇Android性能优化就到此结束,感谢观看!

    作者:Pika
    链接:https://juejin.cn/post/7159784805293359141

    相关文章

      网友评论

          本文标题:Andoird性能优化 - 死锁监控与其背后的小知识

          本文链接:https://www.haomeiwen.com/subject/qdxltdtx.html