遇到的问题
前段时间开发的时候,遇到一个问题,就是如何用 Java 实现 chdir
?网上搜索一番,发现了 JNR-POSIX
项目 [ stackoverflow ]。俗话说,好记性不如烂笔头。现在将涉及到的相关知识点总结成笔记。
其实针对 Java 实现 chdir
问题,官方 20 多年前就存在对应的 bug,即 JDK-4045688 'Add chdir or equivalent notion of changing working directory'。这个 bug 在 1997.04 创建,目前的状态是 Won't Fix
(不予解决),理由大致是,若实现与操作系统一样的进程级别的 chdir
,将影响 JVM 上的全部线程,这样引入了可变(mutable)的全局状态,这与 Java 的安全性优先原则冲突,现在添加全局可变的进程状态,已经太迟了,对不变性(immutability)的支持才是 Java 要实现的特性。
chdir
是平台相关的操作系统接口,POSIX 下对应的 API 为 int chdir(const char *path);
,而 Windows 下对应的 API 为 BOOL WINAPI SetCurrentDirectory(_In_ LPCTSTR lpPathName);
,另外 Windows 下也可以使用 MSVCRT 中 API 的 int _chdir(const char *dirname);
(MSVCRT 下内部实现其实就是调用 SetCurrentDirectory
[ reactos ] )。
Java 设计理念是跨平台,"write once, run anywhere"。很平台相关的 API,虽然各个平台都有自己的类似的实现,但存在会差异。除了多数常见功能,Java 并没有对全部操作系统接口提供完整支持,比如很多 POSIX API。除了 chdir
,另外一个典型的例子是,在 Java 9 以前 JDK 获取进程 id 一直没有简洁的方法 [ stackoverflow ],最新发布的 Java 9 中的 JEP 102(Process API Updates)才增强了进程 API。获取进程 id 可以使用以下方式 [ javadoc ]:
1 | long pid = ProcessHandle.current().pid(); |
相比其他语言,Pyhon 和 Ruby,对操作系统相关的接口都有更多的原生支持。Pyhon 和 Ruby 实现的相关 API 基本上都带有 POSIX 风格。比如上文提到,chdir
和 getpid
,在 Pyhon 和 Ruby 下对应的 API 为:Pyhon 的 os 模块 os.chdir(path) 和 os.getpid();Ruby 的 Dir 类的 Dir.chdir( [ string] ) 类方法和 Process 类的 Process.pid 类属性。Python 解释器的 chdir
对应源码为 posixmodule.c#L2611,Ruby 解释器的 chdir
对应源码为 dir.c#L848 和 win32.c#L6741。
JNI 实现 getpid
Java 下要想实现本地方法调用,需要通过 JNI。关于 JNI 的介绍,可以参阅“Java核心技术,卷II:高级特性,第9版2013”的“第12章 本地方法”,或者读当年 Sun 公司 JNI 设计者 Sheng Liang(梁胜)写的“Java Native Interface: Programmer's Guide and Specification”。本文只给出实现 getpid
的一个简单示例。
首先使用 Maven 创建一个简单的脚手架:
1 | mvn archetype:generate \ |
在 com.test
包下添加 GetPidJni
类:
1 | package com.test; |
用 javac
编译代码 GetPidJNI.java
,然后用 javah
生成 JNI 头文件:
1 | $ mkdir -p target/classes |
生成的 JNI 头文件 com_test_GetPidJni.h
,内容如下:
1 | /* DO NOT EDIT THIS FILE - it is machine generated */ |
现在有了头文件声明,但还没有实现,手动敲入 com_test_GetPidJni.c
:
1 |
|
编译 com_test_GetPidJni.c
,生成 libgetpidjni.dylib
:
1 | $ gcc -I $JAVA_HOME/include -I $JAVA_HOME/include/darwin -dynamiclib -o libgetpidjni.dylib com_test_GetPidJni.c |
生成的 libgetpidjni.dylib
,就是 GetPidJni.java
代码中的 System.loadLibrary("getpidjni");
,需要加载的 lib。
现在运行 GetPidJni
类,就能正确获取 pid:
1 | $ java -Djava.library.path=`pwd` -cp "target/classes" com.test.GetPidJni |
JNI 的问题是,胶水代码(黏合 Java 和 C 库的代码)需要程序员手动书写,对不熟悉 C/C++ 的同学是很大的挑战。
JNA 实现 getpid
JNA(Java Native Access, wiki, github, javadoc, mvn),提供了相对 JNI 更加简洁的调用本地方法的方式。除了 Java 代码外,不再需要额外的胶水代码。这个项目最早可以追溯到 Sun 公司 JNI 设计者 Sheng Liang 在 1999 年 JavaOne 上的分享。2006 年 11月,Todd Fast (也来自 Sun 公司) 首次将 JNA 发布到 dev.java.net 上。Todd Fast 在发布时提到,自己在这个项目上已经断断续续开发并完善了 6-7 年时间,项目刚刚在 JDK 5 上重构和重设计过,还可能有很多缺陷或缺点,希望其他人能浏览代码并参与进来。Timothy Wall 在 2007 年 2 月重启了这项目,引入了很多重要功能,添加了 Linux 和 OSX 支持(原本只在 Win32 上测试过),加强了 lib 的可用性(而非仅仅基本功能可用)[ ref ]。
看下示例代码:
1 | import com.sun.jna.Library; |
JNR 实现 getpid
最初,JRuby 的核心开发者 Charles Nutter 在实现 Ruby 的 POSIX 集成时就使用了 JNA [ ref ]。但过了一段时候后,开始开发 JNR(Java Native Runtime, github, mvn) 替代 JNA。Charles Nutter 在介绍 JNR 的 slides 中阐述了原因:
1 | Why Not JNA? |
即,(1) 预处理器的常量支持(通过 jnr-constants 解决);(2) 开箱即用的标准 API(作者实现了 jnr-posix, jnr-x86asm, jnr-enxio, jnr-unixsocket);(3) C 回调 callback 支持;(4) 性能(提升 8-10 倍)。
使用 JNR-FFI(github, mvn)实现 getpid
,示例代码:
1 | import jnr.ffi.LibraryLoader; |
使用 JNR-POSIX(github, mvn)实现 chdir
和 getpid
,示例代码:
1 | import jnr.posix.POSIX; |
JMH 性能比较
性能测试代码为 BenchmarkFFI.java
(github),测试结果如下:
1 | # JMH version: 1.19 |
即:JNI > JNR > JNA (Direct Mapping) > JNA (Interface Mapping)。相对 JNI 的实现性能,其他三种方式,从大到小的性能百分比依次为:74.8% (JNR), 13.2% (JnaDirect), 10.6% (JNA)。在博主电脑上测试,JNR 相比 JNA 将近快了 6-7 倍(JNR 作者 Charles Nutter 针对 getpid
的测试结果是 JNR 比 JNA 快 8-10 倍 [ twitter slides ])。
实现原理
JNA 源码简析
先来看下 JNA,JNA 官方文档 FunctionalDescription.md,对其实现原理有很好的阐述。这里将从源码角度分析实现的核心逻辑。
回顾下代码,我们现实定义了接口 LibC
,然后通过 Native.loadLibrary("c", LibC.class)
获取了接口实现。这一步是怎么做到的呢?翻下源码 Native.java#L547 就知道,其实是通过**动态代理(dynamic proxy)**实现的。使用动态代理需要实现 InvocationHandler 接口,这个接口的实现在 JNA 源码中是类 com.sun.jna.Library.Handler。示例中的 LibC
接口定义的全部方法,将全部分派到 Handler 的 invoke 方法下。
1 | public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; |
然后根据返回参数的不同,分派到 Native 类的,invokeXxx 本地方法:
1 | /** |
比如,long getpid()
会被分派到 invokeLong,而 int chmod(String filename, int mode)
会被分派到 invokeInt
。invokeXxx 本地方法参数:
- 参数
Function function
,记录了 lib 信息、函数名称、函数指针地址、调用惯例等元信息; - 参数
long fp
,即函数指针地址,函数指针地址通过 Native#findSymbol()获得(底层是 Linux API dlsym 或 Windows API GetProcAddress )。 - 参数
int callFlags
,即调用约定,对应 cdecl 或 stdcall。 - 参数
int callFlags
,即函数入参,若无参数,args 大小为 0,若有多个参数,原本的入参被从左到右依次保存到 args 数组中。
再来看下 invokeXxx
本地方法的实现 dispatch.c#L2122(invokeInt
或 invokeLong
实现源码类似):
1 | /* |
即,全部 invokeXxx
本地方法统一被分派到 dispatch
函数 dispatch.c#L439:
1 | static void |
这个 dispatch
函数是全部逻辑的核心,实现最终的本地函数调用。
我们知道,发起函数调用,需要构造一个栈帧(stack frame)。构造栈帧,涉及到参数压栈次序(参数从左到右压入还是从右到左压入)和清理栈帧(调用者清理还是被调用者清理)等实现细节问题。不同的编译器在不同的 CPU 架构下有不同的选择。构造栈帧的具体实现细节的选择,被称为调用惯例(calling convention)。按照调用惯例构造整个栈帧,这个过程由编译器在编译阶段完成的。比如要想发起 sum(2, 3)
这个函数调用,编译器可能会生成如下等价汇编代码:
1 | ; 调用者清理堆栈(caller clean-up),参数从右到左压入栈 |
dispatch
函数是,需要调用的函数指针地址、输入参数和返回参数,全部是运行时确定。要想完成这个函数调用逻辑,就要运行时构造栈帧,生成参数压栈和清理堆栈的工作。JNA 3.0 之前,实现运行时构造栈帧的逻辑的对应代码 dispatch_i386.c、dispatch_ppc.c 和 dispatch_sparc.s,分别实现 Intel x86、PowerPC 和 Sparc 三种 CPU 架构。
运行时函数调用,这个问题其实是一个一般性的通用问题。早在 1996 年 10 月,Cygnus Solutions 的工程师 Anthony Green 等人就开发了 libffi(home, wiki, github, doc),解决的正是这个问题。目前,libffi 几乎支持全部常见的 CPU 架构。于是,从 JNA 3.0 开始,摒弃了原先手动构造栈帧的做法,把 libffi 集成进了 JNA。
直接映射(Direct Mapping)
https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#RegisterNatives
http://www.chiark.greenend.org.uk/doc/libffi-dev/html/The-Closure-API.html
JNR 源码简析
JNR 底层同样也是依赖 libffi,参见 jffi。但 JNR 相比 JNA 性能更好,做了很有优化。比较重要的点是,JNA 使用动态代理生成实现类,而 JNR 使用 ASM 字节码操作库生成直接实现类,去除了每次调用本地方法时额外的动态代理的逻辑。使用 ASM 生成实现类,对应的代码为 AsmLibraryLoader.java。其他细节,限于文档不全,本人精力有限,不再展开。
Java 9 的 getpid 实现
Java 9 以前 JDK 获取进程 id 没有简洁的方法,最新发布的 Java 9 中的 JEP 102(Process API Updates)增强了进程 API。进程 id 可以使用以下方式 [ javadoc ]
1 | long pid = ProcessHandle.current().pid(); |
翻阅实现源码,可以看到对应的实现就是 JNI 调用:
jdk/src/java.base/share/classes/java/lang/ProcessHandleImpl [ src ]
1 | /** |
*nix 平台下实现为:
jdk/src/java.base/unix/native/libjava/ProcessHandleImpl_unix.c [ src ]
1 | /* |
Windows 平台下实现为:
jdk/src/java.base/windows/native/libjava/ProcessHandleImpl_win.c [ src ]
1 | /* |
参考资料
- Changing the current working directory in Java? https://stackoverflow.com/q/840190
- How can a Java program get its own process ID? http://stackoverflow.com/q/35842
- Java核心技术,卷II:高级特性,第9版2013:第12章 本地方法,豆瓣
- Java Native Interface: Programmer's Guide and Specification, Sheng Liang (wiki,linkedin,msa), 1999,豆瓣:作者梁胜,中国科技大学少年班83级,并拥有耶鲁大学计算机博士学位(1990-1996),目前 Rancher Labs 创始人兼 CEO [ ref ]
- 2013-07 Charles Nutter: Java Native Runtime http://www.oracle.com/technetwork/java/jvmls2013nutter-2013526.pdf
- JEP 191: Foreign Function Interface http://openjdk.java.net/jeps/191 作者是Charles Nutter
- 2014-03 Java 外部函数接口 http://www.infoq.com/cn/news/2014/03/java-foreign-function-interface
- 2005-08 Brian Goetz:用动态代理进行修饰 https://www.ibm.com/developerworks/cn/java/j-jtp08305.html