最近公司某部门的一款音乐产品反馈在某些用户的手机上启动有时候会出现崩溃,因为该产品接入了我们部门的云捕SDK,所以可以从后台看到崩溃后上传的日志,是加载某个so的时候出现了UnsatisfiedLinkError(强烈推荐开发者接入使用网易云捕SDK,http://crash.163.com/),然后我就在网上搜索了下UnsatisfiedLinkError相关的信息,发现网上包含了大量关于此异常的文章,在了解这个异常的过程中我意识到这个异常是一个对于上线产品而言出现概率较小(因为上线产品肯定是经过测试的,因此出现这类错误的某些情况在上线前就已经被解决了),但是基于安卓数亿终端的数量和安卓机型碎片化导致的复杂性,这类错误基本上一定会在某些用户手机上出现,因此让我产生了好奇,打算从so加载的过程来详细了解下会出现UnsatisfiedLinkError的几种情况以及原因。
当我们要使用c++层的函数时首先会在java层通过System.loadLibrary()来加载so,因此我们首先来看下这个函数的定义,这个函数在安卓源码的libcore/luni/src/main/java/java/lang/System.java路径下,以下是其安卓源码4.4.4的实现(本博文所有的源码分析部分都基于安卓源码4.4.4)
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Loads and links the library with the specified name. The mapping of the * specified library name to the full path for loading the library is * implementation-dependent. * * @param libName * the name of the library to load. * @throws UnsatisfiedLinkError * if the library could not be loaded. */ public static void loadLibrary(String libName) { Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader()); } |
从注释可以看到该函数如果加载so库失败会抛出UnsatisfiedLinkError,说明我们分析的入口是正确的,另外可以看到该函数实际上是调用了Runtime类的loadLibary,因此接下来要分析Runtime类的LoadLibary函数,Runtime类位于libcore/luni/src/main/java/java/lang/Runtime.java路径下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
void loadLibrary(String libraryName, ClassLoader loader) { if (loader != null) { String filename = loader.findLibrary(libraryName); if (filename == null) { throw new UnsatisfiedLinkError("Couldn't load " + libraryName + " from loader " + loader + ": findLibrary returned null"); } String error = doLoad(filename, loader); if (error != null) { throw new UnsatisfiedLinkError(error); } return; } String filename = System.mapLibraryName(libraryName); List<String> candidates = new ArrayList<String>(); String lastError = null; for (String directory : mLibPaths) { String candidate = directory + filename; candidates.add(candidate); if (IoUtils.canOpenReadOnly(candidate)) { String error = doLoad(candidate, loader); if (error == null) { return; // We successfully loaded the library. Job done. } lastError = error; } } if (lastError != null) { throw new UnsatisfiedLinkError(lastError); } throw new UnsatisfiedLinkError("Library " + libraryName + " not found; tried " + candidates); } |
从源码可以看到该函数分为了2种情况进行处理,一种是传进来的ClassLoader对象不为空的情况,一种是为空的情况,不管是否为空,每种情况的处理都可以提炼出2步核心逻辑,首先通过so库的名称获取so库的路径,然后调用doLoad函数进行加载。首先来看不为空的情况:
ClassLoader不为空时查找so策略
通过ClassLoader的findLibary函数来查找so库的路径,在也谈安卓dex的动态加载与MultiDex和热修复中我们分析过安卓中的ClassLoader本质上是BaseDexClassLoader(实际上是PathClassLoader,但PathClassLoader仅仅只是提供了个构造函数,核心逻辑来自其父类BaseDexClassLoader),而且BaseDexClassLoader中类的查找与加载的逻辑实际上是由DexPathList这个类来完成的,不清楚的可以回过头看看也谈安卓dex的动态加载与MultiDex和热修复 ,因此看下DexPathList类中的findLibary是如何实现的,源码如下:
1 2 3 4 5 6 7 8 9 10 |
public String findLibrary(String libraryName) { String fileName = System.mapLibraryName(libraryName); for (File directory : nativeLibraryDirectories) { String path = new File(directory, fileName).getPath(); if (IoUtils.canOpenReadOnly(path)) { return path; } } return null; } |
可以看到首先通过库名获取文件名,比如我们在System.loadLibrary(“netease”)中指定so名为去掉了前缀lib和后缀.so的名称,则经过System.mapLibraryName之后的文件名为libnetease.so,然后从nativeLibraryDirectories这个File数组中查找这个so,而这个nativeLibraryDirectories是在DexPathList类的构造函数中赋值的,代码如下:
1 |
this.nativeLibraryDirectories = splitLibraryPath(libraryPath); |
因此我们需要跟进到splitLibraryPath函数中看下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
/** * Splits the given library directory path string into elements * using the path separator ({@code File.pathSeparator}, which * defaults to {@code ":"} on Android, appending on the elements * from the system library path, and pruning out any elements that * do not refer to existing and readable directories. */ private static File[] splitLibraryPath(String path) { // Native libraries may exist in both the system and // application library paths, and we use this search order: // // 1. this class loader's library path for application libraries // 2. the VM's library path from the system property for system libraries // // This order was reversed prior to Gingerbread; see http://b/2933456. ArrayList<File> result = splitPaths(path, System.getProperty("java.library.path"), true); return result.toArray(new File[result.size()]); } private static ArrayList<File> splitPaths(String path1, String path2, boolean wantDirectories) { ArrayList<File> result = new ArrayList<File>(); splitAndAdd(path1, wantDirectories, result); splitAndAdd(path2, wantDirectories, result); return result; } |
从注释可以看到splitLibraryPath做了2件事,添加application libaries路径,一般而言是data/data/app-package(对应自己app的包名)/lib/,添加system libaries路径,通过测试发现是/vendor/lib/和/system/lib/目录(不同cpu架构的路径可能不一样,比如64位cpu架构目录为/vendor/lib64/和/system/lib64/),到这里当ClassLoader不为空的情况下查找so库的逻辑我们就弄清楚了,即会从以下3个路径下查找so:
- data/data/app-package(对应自己app的包名)/lib/
- /vendor/lib/
- /system/lib/
然后回过头来看Runtime类LoadLibary的代码,如果从这3个路径下都没查找到这个so将会抛出UnsatisfiedLinkError异常,此时对应的情况为findLibrary returned null,或者couldn’t find(5.0的源码上是throw new UnsatisfiedLinkError(loader + ” couldn’t find \”” +System.mapLibraryName(libraryName) + “\””);)即查找so库失败,也就是说磁盘上这3个路径下根本不存在要查找的so文件。此时具体crash信息一般类似这样:
如果在这3个路径下的任意一个位置找到了该so,则会调用doLoad函数来加载这so,因为doLoad加载so的这部分逻辑和ClassLoader为空的情况一样,因此我们把doLoad的部分单独拿出来在后面统一分析,先看看ClassLoader为空时查找so的逻辑。
ClassLoader为空时查找so策略
再来看看Runtime类LoadLibary逻辑中ClassLoader为空时的逻辑部分,发现会从mLibPaths这个字符串数组表示的路径下查找so,其赋值的逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private final String[] mLibPaths = initLibPaths(); private static String[] initLibPaths() { String javaLibraryPath = System.getProperty("java.library.path"); if (javaLibraryPath == null) { return EmptyArray.STRING; } String[] paths = javaLibraryPath.split(":"); // Add a '/' to the end of each directory so we don't have to do it every time. for (int i = 0; i < paths.length; ++i) { if (!paths[i].endsWith("/")) { paths[i] += "/"; } } return paths; } |
可以看到会从系统默认路径System.getProperty(“java.library.path”)下查找so,经过前面的分析我们知道这个路径实际上就是/vendor/lib/和/system/lib/目录,如果查找失败,将会抛出UnsatisfiedLinkError,此时对应的情况为Library xx(表示so库的名称) not found; tried xx_path(表示默认的so库查找路径),一般而言不会出现ClassLoader为空的情况。
nativeLoad加载so的策略
前面说过不管ClassLoader是否为空,如果so库查找成功,都会调用doLoad函数加载so,而doLoad内部就是调用了native函数nativeLoad来加载so的。其native层的函数签名为static void Dalvik_java_lang_Runtime_nativeLoad(const u4* args,JValue* pResult),位于dalvik/vm/native/java_lang_Runtime.cpp路径下,而该函数实际上是调用了dvmLoadNativeCode函数来完成so加载的,这个函数位于dalvik/vm/Native.cpp路径下,这个函数的核心逻辑可以归纳为调用dlopen打开so文件,调用dlsym()函数查找到JNI_OnLoad()函数地址,执行JNI_OnLoad函数。因为这个函数比较长,所以我省略了若干和UnsatisfiedLinkError异常不相关的细节代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
/* * Load native code from the specified absolute pathname. Per the spec, * if we've already loaded a library with the specified pathname, we * return without doing anything. * 省略若干注释... * The library will be associated with the specified class loader. The JNI * spec says we can't load the same library into more than one class loader. * * Returns "true" on success. On failure, sets *detail to a * human-readable description of the error or NULL if no detail is * available; ownership of the string is transferred to the caller. */ bool dvmLoadNativeCode(const char* pathName, Object* classLoader, char** detail) { SharedLib* pEntry; void* handle; bool verbose; /* reduce noise by not chattering about system libraries */ verbose = !!strncmp(pathName, "/system", sizeof("/system")-1); verbose = verbose && !!strncmp(pathName, "/vendor", sizeof("/vendor")-1); if (verbose) ALOGD("Trying to load lib %s %p", pathName, classLoader); *detail = NULL; //省略若干细节代码... handle = dlopen(pathName, RTLD_LAZY); dvmChangeStatus(self, oldStatus); if (handle == NULL) { *detail = strdup(dlerror()); ALOGE("dlopen(\"%s\") failed: %s", pathName, *detail); return false; } //省略若干调用JNI_OnLoad的细节代码... if (version == JNI_ERR) { *detail = strdup(StringPrintf("JNI_ERR returned from JNI_OnLoad in \"%s\"", pathName).c_str()); } else if (dvmIsBadJniVersion(version)) { *detail = strdup(StringPrintf("Bad JNI version returned from JNI_OnLoad in \"%s\": %d", pathName, version).c_str()); //省略若干细节代码... } |
从注释中可以看到如果该函数执行失败会将*detail的内容设置为错误原因,也就是会返回给java层
1 |
String error = doLoad(filename, loader); |
中的error字符串的内容,从这段逻辑可以看到如果调用dlopen函数打开so文件失败,会将dlerror()的内容作为失败的详细原因返回给java层,native层返回false,java层会抛出UnsatisfiedLinkError,此时对应的情况为dlopen(xx) failed:dlerror的详细原因,此时具体crash信息一般类似这样:
比如上面是因为I/O error导致的。
另外如果在加载so进入JNI_OnLoad函数时如果失败也会抛出相应异常,比如JNI_ERR returned from JNI_OnLoad和Bad JNI version returned from JNI_OnLoad。
总结
在安卓中出现UnsatisfiedLinkError一般会存在以下几大类情况:
- 查找的so不存在,包含2大类情况ClassLoader为空和ClassLoader不为空,当失败日志中查找路径不包括app自己的应用路径的时候说明ClassLoader为空,这种情况一般很少出现,另外如果app自己的应用路径下的so文件权限为不可读则会导致查找so路径失败
- so存在加载so失败,比如当加载的so X中又使用了其他so Y,则被引用的so Y的加载要放在加载so X的前面,即先调用System.loadLibary(“Y”),在调用System.loadLibary(“X”),否则加载so X会失败
- so存在,加载so成功,但是执行JNI_OnLoad失败
知道了包含这3大类error,则当出现这种异常时我们就知道了该从哪个方向去排查问题,另外这3大类UnsatisfiedLinkError下实际上包含了很多种小情况,后面会专门搞个so加载失败案例的集锦来进行总结。
