线上报上来一个 Native Crash,它的堆栈如下所示:
Signal 16(SIGSTKFLT), Code -6(SI_TKILL)
#00 pc 00016db4 /system/lib/libc.so (write+12) [armeabi-v7a]
#01 pc 000884a5 /system/lib/libdvm.so (sysWriteFully(int, void const*, unsigned int, char const*)+28) [armeabi-v7a]
#02 pc 00088587 /system/lib/libdvm.so (sysCopyFileToFile(int, int, unsigned int)+114) [armeabi-v7a]
#03 pc 00050d41 /system/lib/libdvm.so (dvmRawDexFileOpen(char const*, char const*, RawDexFile**, bool)+392) [armeabi-v7a]
#04 pc 00064a41 /system/lib/libdvm.so [armeabi-v7a]
#05 pc 000276e0 /system/lib/libdvm.so [armeabi-v7a]
#06 pc 0002b5c4 /system/lib/libdvm.so (dvmInterpret(Thread*, Method const*, JValue*)+184) [armeabi-v7a]
#07 pc 0005fc79 /system/lib/libdvm.so (dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list)+272) [armeabi-v7a]
#08 pc 0005fca3 /system/lib/libdvm.so (dvmCallMethod(Thread*, Method const*, Object*, JValue*, …)+20) [armeabi-v7a]
#09 pc 0005481f /system/lib/libdvm.so [armeabi-v7a]
#10 pc 0000e3e8 /system/lib/libc.so (__thread_entry+72) [armeabi-v7a]
#11 pc 0000dad4 /system/lib/libc.so (pthread_create+160) [armeabi-v7a]
APP 收到 SIGSTKFLT 信号崩溃了,同时还输出了这样的日志:
06-25 15:10:53.821 7449 7450 E dalvikvm: threadid=2: stuck on threadid=135, giving up
06-25 15:10:53.821 7449 7450 D dalvikvm: threadid=2: sending two SIGSTKFLTs to threadid=135 (tid=8021) to cause debuggerd dump
SIGSTKFLT 是 Dalvik 虚拟机特有的一个信号。当虚拟机发生了 ANR 或者需要做 GC 的时候,就需要挂起所有 RUNNING 状态的线程,如果此时 Dalvik 虚拟机等待了足够长时间,线程仍旧无法被挂起,就会调用dvmNukeThread
函数发送 SIGSTKFLT 信号给相应线程,从而杀死 APP。
具体代码如下:
static void waitForThreadSuspend(Thread* self, Thread* thread)
{
constint kMaxRetries = 10;
… …
while (thread->status == THREAD_RUNNING) {
… …
if (retryCount++ == kMaxRetries) {
ALOGE(“Fatal spin-on-suspend, dumping threads”);
dvmDumpAllThreads(false);
/* log this after – long traces will scroll off log */
=> ALOGE(“threadid=%d: stuck on threadid=%d, giving up”,
self->threadId, thread->threadId);
/* try to get a debuggerd dump from the spinning thread */
=> dvmNukeThread(thread);
/* abort the VM */
dvmAbort();
… …
}
而从堆栈我们看出,杀死进程的时候,我们正调用DexFile.loadDex
,这个方法最后会调用到dvmRawDexFileOpen
里面,执行 write 操作。而这个 write 涉及 I/O 操作,是比较耗时的。所以,当线程在做 dexopt,长时间无法响应虚拟机的挂起请求时,就会触发这个问题。
一般来说,虚拟机在执行 Java 代码的时候,都会是 RUNNING 状态。而只要调用了 JNI 方法,在执行到 C/C++ 代码的时候,就会切换为 NATIVE 状态。而虚拟机只会在 RUNNING 状态下会挂起线程,如果是在 NATIVE 状态下,虚拟机是不会要求线程必须挂起的。
不过,这里有一个特殊之处。虽然DexFile.loadDex
方法最终也走到了 JNI 里面调用dvmRawDexFileOpen
函数,但由于DexFile
类是虚拟机的内部类,Dalvik 虚拟机不会在内部类执行 JNI 方法的时候将线程切换为 NATIVE 状态,仍然会保持原来的 RUNNING 状态。于是,在 RUNNING 状态下,做 OPT 的线程就会被要求挂起。而此时由于正在执行耗时的 write 操作,无法响应挂起请求,便出现了如上的崩溃。
当然,可能有人会想到在 Native 代码中,用CallStaticObjectMethod
来触发DexFile.loadDex
,不过这种方式是不可行的。因为CallStaticObjectMethod
调用 Java 方法DexFile.loadDex
时,会使得状态再次切换为 RUNNING。
具体来看下 CallStatciXXXMethod 方法的定义处:
static _ctype CallStatic##_jname##Method(JNIEnv* env, jclass jclazz, \
jmethodID methodID, …) \
{ \
UNUSED_PARAMETER(jclazz); \
ScopedJniThreadState ts(env); \
JValue result; \
va_list args; \
va_start(args, methodID); \
dvmCallMethodV(ts.self(), (Method*)methodID, NULL, true, &result, args);\
va_end(args); \
if (_isref && !dvmCheckException(ts.self())) \
result.l = (Object*)addLocalReference(ts.self(), result.l); \
return _retok; \
}
关键在于 ScopedJniThreadState:
explicit ScopedJniThreadState(JNIEnv* env) {
mSelf = ((JNIEnvExt*) env)->self;
… …
CHECK_STACK_SUM(mSelf);
dvmChangeStatus(mSelf, THREAD_RUNNING);
}
~ScopedJniThreadState() {
dvmChangeStatus(mSelf, THREAD_NATIVE);
COMPUTE_STACK_SUM(mSelf);
}
在使用dvmCallMethodV
调用 Java 方法前,会先切换状态为THREAD_RUNNING
,执行完毕后,ScopedJniThreadState
析构,再切换回THREAD_NATIVE
。这样,JNI 执行DexFile.loadDex
就和直接执行 Java 代码一样,状态会有问题。不只是CallStaticXXXMethod
,所有使用CallXXXMethod
函数在 Native 下调用 Java 方法的情况都是如此。
好在,我们想到了另一个办法:既然 Dalvik 不会对内部类的 JNI 调用做切换,我们就自己写一个 JNI 调用,使其走到 Native 代码中,这样线程就会变为 Native 状态,然后 直接调用虚拟机内部函数 做 dexopt 即可。这样在做 dexopt 的时候,始终会处于 NATIVE 的状态,不会切为 RUNNING,也不会被要求挂起,也就能避免这个问题。
这个虚拟机内部函数就是dvmRawDexFileOpen
,我们先来看下它的代码说明:
/*
-
Open a raw “.dex” file, optimize it, and load it.
-
On success, returns 0 and sets “*ppDexFile” to a newly-allocated DexFile.
-
On failure, returns a meaningful error code [currently just -1].
*/
int dvmRawDexFileOpen(const char* fileName, const char* odexOutputName,
RawDexFile** ppDexFile, bool isBootstrap);
这个函数可以用来打开原始 DEX 文件,并且对它做优化和加载。对应到 libdvm.so 中的符号是_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb
,我们只需要用 dlsym 在 libdvm.so 里面找到它,就可以直接调用了,完整代码如下:
using func = int ()(constchar fileName, constchar* odexOutputName, void* ppRawDexFile, bool isBootstrap);
void* handler = dlopen(“libdvm.so”, RTLD_NOW);
dvmRawDexFileOpen = (func) dlsym(handler, “_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb”);
dvmRawDexFileOpen(file_path, opt_file_path, &arg, false);
这样,我们自己写一个 JNI 调用,在 Native 状态下执行上述代码,就能达到完成 ODEX 的目的,从而根本上杜绝这个异常了。
另外,我们把 dexopt 操作放到了单独进程执行,由此可以避免 ODEX 操作对主进程造成其他性能影响。此外,由于设备情况多种多样,运行环境十分复杂,还可能会有一些厂商魔改,导致的 dlsym 找不到_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb
符号,虽然这种情况极为罕见,但理论上仍有可能发生。单独进程里面由于环境比较纯粹,基本很少发生 ANR 和 GC 事件,挂起的情况就很少,也能最大程度规避这个问题。
我们发现,相比于官方 MultiDex 加载 ZIP 形态的 DEX 文件,非 ZIP 方式的 DEX(也就是直接对 DEX 文件做 ODEX,而不用先把 DEX 压缩进 ZIP 里面)对于整体时间也有一定程度的优化,因为这种非 ZIP 方式避免了原先的两个耗时:
-
把原始 DEX 压缩为 ZIP 格式的时间;
-
ODEX 优化的时候从 ZIP 中解压出原始 DEX 的时间。
非 ZIP 的方式相比于 ZIP 方式,整体耗时会减少 40% 左右,但是 DEX 文件磁盘占用空间比原先 ZIP 文件的方式增加一倍多。因此我们可以只在磁盘空间充裕的时候,优先使用非 ZIP 方式加载。
而我们openDexFile_bytearray
加载 DEX 的方式,需要的只是原始 DEX 文件的字节数组(byte[])。这个字节数组我们在首次冷启动的时候是直接从 APK 里面解压提取得到的。我们可以在这次启动提取完成后,先把这些字节数组落地为 DEX 文件。这样如果再次启动 APP 的时候,ODEX 没做完,就可以直接使用前面保存的 DEX 文件来得到字节数组了,从而避免了从 APK 解压的时间。
总体来看,我们整套方案中一共存在四种形态的 DEX:
-
从 APK 文件里面解压得到的 DEX 字节数组;
-
从落地的 DEX 文件里面得到的 DEX 字节数组;
-
从 DEX 文件优化得到的 ODEX 文件;
-
从 ZIP 文件优化得到的 ODEX 文件。
生成各个产物的时序图如下所示:
我们依次说明每一步:
-
A. 从 APK 里面直接解压得到 DEX 字节数组;
-
B. 将 DEX 数组保存为文件;
-
C. 用 DEX 文件生成 ODEX 文件;
-
D. 用 DEX 数组生成 ZIP 文件以及它对应的 ODEX 文件。
正常情况下,我们会依次按 A -> B -> C 的时序依次产生各个文件,如果中间有中断的情况,我们下次启动后会继续按照当前已有产物做对应操作。我们仅在磁盘空间不够,且所在系统不支持直接加载字节数组的情况下才会走 ZIP&ODEX 方式的 D 路径。这里不支持的情况主要是一些特殊机型,比如 4.4 却采用了 ART 虚拟机的机型、阿里 Yun OS 机型等。
接下来我们继续看下加载流程图:
-
当 APP 首次启动的时候,如果会从 APK 里面解压 DEX 数组,因此会按照 a -> b 的路径执行;
-
当 APP 发现只有 DEX 文件,没有 ODEX 文件时,会把从 DEX 文件中取得 DEX 数组,按照 c -> b 路径执行;
-
当 APP 发现 DEX 文件和 ODEX 文件都存在的时候,会按照 ODEX 方式加载,按照 d 路径执行;
-
当 APP 发现有 ZIP 文件以及它所对应的 ODEX 的时候,会按照 e 路径执行。
这么一来,APP 就可以根据当前情况,选择最合适的方式执行加载 DEX 了。从而保证了任意时刻的最优性能。
前面提到,OPT 优化是在单独的进程里面执行的。单独进程除了可以减少前面的 SIGSTKFLT 问题,还能在做完 OPT 后及时终止后台进程,避免过多的资源占用。
然而,在单独进程处理 OPT 和其他进程执行 install 的时候,都涉及到 DEX 和 ODEX 文件的访问和生成,因此在这些进程之间涉及到文件访问和 OPT 时,都是加文件锁互斥执行的。这样可以避免加载的同时,另一个进程在操作 DEX 和 ODEX 文件导致的文件损坏。在官方的 MultiDex 中也是采用这种文件锁的方式来进行互斥访问的。
但这带来了另一个问题,如果 OPT 进程在长时间做 dexopt,而此时主进程(或者其他后台进程)需要再次启动,便会因为 OPT 进程持有互斥文件锁,而导致这些进程被阻塞住无法继续启动。可以看流程图来理解这一过程:
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
最后
最后为了帮助大家深刻理解Android相关知识点的原理以及面试相关知识,这里放上相关的我搜集整理的24套腾讯、字节跳动、阿里、百度2019-2021BAT 面试真题解析,我把大厂面试中常被问到的技术点整理成了视频和PDF(实际上比预期多花了不少精力),包知识脉络 + 诸多细节。
还有 高级架构技术进阶脑图 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
Android 基础知识点
Java 基础知识点
Android 源码相关分析
常见的一些原理性问题
希望大家在今年一切顺利,进到自己想进的公司,共勉!
转存中…(img-gmEvStou-1711917352516)]
最后
最后为了帮助大家深刻理解Android相关知识点的原理以及面试相关知识,这里放上相关的我搜集整理的24套腾讯、字节跳动、阿里、百度2019-2021BAT 面试真题解析,我把大厂面试中常被问到的技术点整理成了视频和PDF(实际上比预期多花了不少精力),包知识脉络 + 诸多细节。
还有 高级架构技术进阶脑图 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
Android 基础知识点
Java 基础知识点
Android 源码相关分析
常见的一些原理性问题
[外链图片转存中…(img-nNtQ5DD4-1711917352517)]
希望大家在今年一切顺利,进到自己想进的公司,共勉!