作者:lds(lds2012@gmail.com)
日期:2017-04-07
前言
本文主要研究OpenJDK源码中涉及到加载native本地库的部分。主要目的是为了了解本地库是如何被加载到虚拟机,如果执行其中的本地方法,以及JNI的 JNI_OnLoad
和 JNI_OnUnLoad
是如何被调用的 。
1.载入本地库
使用JNI的第一步,往往是在Java代码里面加载本地库的so文件,例如:
public class Test {
static {
System.loadLibrary("my_native_library_name");
}
}
那么我们从这个方法作为入口来研究JDK的代码。
2. 寻找本地库文件
System.java
源码在 OpenJdk/jdk/src/share/classes/java/lang/System.java
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(getCallerClass(), libname);
}
Runtime.java
源码在 OpenJdk/jdk/src/share/classes/java/lang/Runtime.java
然后来看 java.lang.Runtime
类时如何来 loadLibrary 的:
synchronized void loadLibrary0(Class fromClass, String libname) {
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkLink(libname);
}
if (libname.indexOf((int)File.separatorChar) != -1) {
throw new UnsatisfiedLinkError(
"Directory separator should not appear in library name: " + libname);
}
ClassLoader.loadLibrary(fromClass, libname, false);
}
它首先做了一些安全性检查,然后使用 ClassLoader
来载入本地库的。
ClassLoader.java
源码在 OpenJdk/jdk/src/share/classes/java/lang/ClassLoader.java
接下来看 ClassLoader
是如何实现的具体的加载工作的:
首先根据 libname
参数找到本地库的文件路径,并访问该so库文件来载入,
在这里会在几个地方去找so库文件:
- Class Loader找到的绝对路径(class path)
-
java.library.path
定义的目录下(windows下的PATH,linux下的LD_LIBRARY_PATH) -
sun.boot.library.path
定义的目录下
usr_paths = initializePath("java.library.path");
sys_paths = initializePath("sun.boot.library.path");
// 首先从class loader的目录读取
String libfilename = loader.findLibrary(name);
// ...
File libfile = new File(libfilename);
// ...
if (loadLibrary0(fromClass, libfile)) {
return;
}
// 然后尝试从sys_paths目录下读取so文件
for (int i = 0 ; i < sys_paths.length ; i++) {
File libfile = new File(sys_paths[i], System.mapLibraryName(name));
if (loadLibrary0(fromClass, libfile)) {
return;
}
}
// 最后尝试从usr_paths目录下读取so文件
for (int i = 0 ; i < usr_paths.length ; i++) {
File libfile = new File(usr_paths[i], System.mapLibraryName(name));
if (loadLibrary0(fromClass, libfile)) {
return;
}
}
其中可以看到,对于传入给 System.loadLibrary(String libname)
的参数 libname
是通过调用 System.mapLibraryName
方法来将其映射为库文件的文件名。
这个方法是一个native方法,不同系统有不同的实现,具体的区别主要在于前缀和扩展名的不同,例如在 linux 平台下前缀和扩展名分为定义为:
#define JNI_LIB_PREFIX "lib"
#define JNI_LIB_SUFFIX ".so"
3. 维护本地库列表
对于找到so库文件以后,具体的加载工作是由 loadLibrary0
方法来完成的,
首先如果有 ClassLoader
则将本地库加载到该 ClassLoader
的本地库列表中,如果没有则加载到系统本地库列表中。
ClassLoader loader = (fromClass == null) ? null : fromClass.getClassLoader();
Vector<NativeLibrary> libs =
loader != null ? loader.nativeLibraries : systemNativeLibraries;
然后遍历已经加载的本地库列表,如果发现这个本地库已经被system或这个classLoader加载过了,则不再执行加载工作,直接返回true。这里也防止了我们重复的去调用 System.loadLibrary
去加载同一个库。
这里需要注意的是一个本地库只能被同一个 ClassLoader
(或线程)加载,一旦被某个 ClassLoader
(或线程)加载过了,再使用另一个 ClassLoader
(或线程)去加载它,则会抛出异常。
然后的本地库都会被封装成 NativeLibrary
对象,并存入 ClassLoader 的静态Stack里面。然后调用它的 load
方法来完成加载功能。
这里需要先了解一下,本地库被谁加载,加载以后存在哪里:
首先系统类去维护一个本地库列表,其中保存了由系统加载的本地库名称。
// Native libraries belonging to system classes.
private static Vector<NativeLibrary> systemNativeLibraries = new Vector<>();
然后每个 ClassLoader 实例都必须去维护一个列表,其中保存了所有由它加载过的本地库名称。
// Native libraries associated with the class loader.
private Vector<NativeLibrary> nativeLibraries = new Vector<>();
最有所有的被加载过的本地库名称列表,以静态变量的形式保存起来。
// All native library names we've loaded.
private static Vector<String> loadedLibraryNames = new Vector<>();
然后所有的本地库在加载后,都被以 NativeLibrary
类型保存在 ClassLoader 的静态Stack里。
NativeLibrary
源码在 OpenJdk/jdk/src/share/classes/java/lang/ClassLoader.java
NativeLibrary是ClassLoader的静态内部类,用于封装已经加载过的本地库信息。每个NativeLibrary对象都需要有一个JNI的版本号。这个版本号是虚拟机在载入本地库的时候获取并设置的。
它有主要的三个方法,并且它们都是native方法,依次是:
native void load(String name);
native long find(String name);
native void unload();
load
方法用于加载本地库。
find
方法用于找到本地库的指针地址。
unload
方法用于卸载本地库。
另外在其 finalize
方法里,将其从 ClassLoader 中保存的已加载本地库列表中移除。
4. 加载和卸载本地库
ClassLoader.c
源码在:OpenJDK/jdk/src/share/native/java/lang/ClassLoader.c
在此主要关注java层的NativeLibrary类其中的三个native方法,来了解具体是如何加载和卸载本地库的。
NativeLibrary_load
首先来看本地代码是如何加载一个本地库的。
JNIEXPORT void JNICALL
Java_java_lang_ClassLoader_00024NativeLibrary_load
(JNIEnv *env, jobject this, jstring name)
注意:这里的
_00024
表示的是$
符号,用来在java中表示内部类。
这里需要说明的是最后一个参数 name
,它是在构建一个 NativeLibrary
对象时传进来的,是本地库文件的完整路径,其是调用 Java 中的 File.getCanonicalPath()
方法来获取的。
Step 1: 先加载本地库文件
其中最关键的在于根据传入的这个 name (会将jstring类型转换成char*类型),来加载本地库:
handle = JVM_LoadLibrary(cname);
Step 2: 再执行JNI_OnLoad函数
在其加载成功后,会去寻找 JNI_OnLoad
函数,并执行它, JNI_OnLoad
函数返回的其使用的JNI版本号的值,如果没有找到该方法,则默认使用 JNI 1_1 作为版本号。
如果返回的是一个不支持的版本号,则会抛出 UnsatisfiedLinkError
异常。
其中 JVM_LoadLibrary
函数定义为:
OpenJdk/jdk/src/share/javavm/export 目录下的 jvm.h
文件中:
JNIEXPORT void * JNICALL JVM_LoadLibrary(const char *name);
具体的实现由虚拟机在实现,例如 hotspot 的实现在
OpenJdk/hotspot/src/share/vm/prims 目录下的 jvm.cpp
文件:
JVM_ENTRY_NO_ENV(void*, JVM_LoadLibrary(const char* name))
//%note jvm_ct
JVMWrapper2("JVM_LoadLibrary (%s)", name);
char ebuf[1024];
void *load_result;
{
ThreadToNativeFromVM ttnfvm(thread);
load_result = os::dll_load(name, ebuf, sizeof ebuf);
}
if (load_result == NULL) {
char msg[1024];
jio_snprintf(msg, sizeof msg, "%s: %s", name, ebuf);
// Since 'ebuf' may contain a string encoded using
// platform encoding scheme, we need to pass
// Exceptions::unsafe_to_utf8 to the new_exception method
// as the last argument. See bug 6367357.
Handle h_exception =
Exceptions::new_exception(thread,
vmSymbols::java_lang_UnsatisfiedLinkError(),
msg, Exceptions::unsafe_to_utf8);
THROW_HANDLE_0(h_exception);
}
return load_result;
JVM_END
其中能看到重要的在于 os::dll_load
函数,它是根据系统不同而由不同的实现的。
linux实现
例如在 linux 系统下的实现在 openjdk/hotspot/src/os/linux/vm/os_linux.cpp
文件中。
它其中主要做了两件事情,一个是使用 linux 的 dlopen
来打开这个so本地库文件,再则检查了这个so本地库文件是否和当前运行虚拟机的CPU架构是否相同。
dlopen函数定义在 dlfcn.h
,原型为:
void * dlopen( const char * pathname, int mode);
其中第二个参数使用的是 RTLD_LAZY: 异常绑定。
windows实现
windows的实现是使用 LoadLibrary
函数来加载 dll 本地库。
NativeLibrary_unload
Step1: 先执行JNI_OnUnLoad方法
虚拟机在卸载本地库文件之前,会先回调本地库文件中的 JNI_OnUnLoad
函数,可以在该函数中执行一些清理工作,例如清理全局变量等。
Step2: 再卸载本地库文件
JVM_UnloadLibrary
和 ``JVM_loadLibrary` 函数一样,具体根据平台不同而实现:
在linux平台上,使用 dlopen
函数来 load so文件, 使用 dlclose
函数来 unload.
在windows平台上,使用 LoadLibrary
函数来load dll文件,来 FreeLibrary
函数来 unload.
NativeLibrary_find
寻找本地库里的某个方法或全局变量的内存地址。
在不同平台上的实现不一样:
在linux平台上,使用dlsym
函数来获取某个方法的内存地址。
在windows平台上,使用 GetProcAddress
函数来获取某个方法的内存地址。
注意:在 NativeLibrary_load
和 NativeLibrary_unload
两个函数内,不是调用了so库里面的 JNI_OnLoad
和 JNI_OnUnLoad
函数嘛,其就是使用 NativeLibrary_find
函数来找到这两个函数地址,并执行它们了。
handle = jlong_to_ptr((*env)->GetLongField(env, this, handleID));
JNI_OnUnload = (JNI_OnUnload_t )
JVM_FindLibraryEntry(handle, onUnloadSymbols[i]);
if (JNI_OnUnload) {
JavaVM *jvm;
(*env)->GetJavaVM(env, &jvm);
(*JNI_OnUnload)(jvm, NULL);
}
这里面有一个比较重要的变量就是 handleID
,这个handleID是从哪里来,存在哪里都比较关键。
首先我们来看这个handleID来至哪里,它其实是 JVM_LoadLibrary
返回的值,即 dlopen
返回的值,这个比较简单,它是在打开本地库时返回的句柄,然后这个句柄并没有保存在native层,而是将其保存在了Java层。
在调用 NativeLibrary_load
函数里,将这个 handleID
保存到了这个 NativeLibrary
Java对象的 long handle
成员域里。每次需要使用 handleID
的时候都从这个Java对象里面的成员域去取。
5. 加载流程小结
从整个加载本地库的流程来看,基本上还是调用和平台有关的函数来完成的,并在加载和卸载的时候分别调用了两个生命周期回调函数 JNI_OnLoad
和 JNI_OnUnLoad
。
以linux平台为例,简单总结一下整个so库的加载流程:
- 首先
System.loadLibrary()
被调用,开始整个加载过程。 - 其中调用
ClassLoader
对象来完成主要工作,将每个本地库封装成NativeLibrary
对象,并以静态变量存到已经加载过的栈中。 - 执行
NativeLibrary
类的load
native方法,来交给native层去指向具体的加载工作。 - native层
ClassLoader.c
中的Java_java_lang_ClassLoader_00024NativeLibrary_load
函数被调用。 - 在native load函数中首先使用
dlopen
来加载so本地库文件,并将返回的handle保存到NativeLibrary
对象中。 - 接着查找已经加载的so本地库中的
JNI_OnLoad
函数,并执行它。 - 整个so本地库的加载流程完毕。
只有在 NativeLibrary
对象被GC回收的时候,其 finalize
方法被调用了,对应加载的本地库才会被 unload 。这种情况一般来说并不会发生,因为 NativeLibrary
对象是以静态变量的形式被保存的,而静态变量是 GC roots,一般来说都不会被回收掉的。
TODO: 那请问
JNI_OnUnLoad
函数什么情况下会被调用?虚拟机关闭的时候?一个本地库被load后,是否能手动的unload?什么情况下才可能被unload?
结语
参考资料:
- OpenJdk/jdk/src/share/classes/java/lang/System.java
- OpenJdk/jdk/src/share/classes/java/lang/Runtime.java
- OpenJdk/jdk/src/share/classes/java/lang/ClassLoader.java
- OpenJDK/jdk/src/share/native/java/lang/ClassLoader.c