12.4 Java原生接口
如果想编写尽可能快的代码,要避免使用 JNI。
在现行的 JVM 版本上,编写得好的 Java 代码至少会与相应的 C 或 C++ 代码跑得一样快(现在可不是 1996 年了)。语言纯粹主义者会继续争论 Java 和其他语言的相对性能指标,当然肯定能找到相应的例子,证明用其他语言编写的应用比用 Java 编写的相同应用快(不过这类例子中往往会包含写得很差的 Java 代码)。然而,这类争论并非本节的要领,这里要说的是:如果某个应用已经是用 Java 编写的,那出于性能原因调用原生代码几乎总是一个坏主意。
JNI 有时仍然非常有用。Java 平台提供了不同操作系统的很多公共特性,但如果需要访问一个特殊的、特定于操作系统的函数,那 JNI 就派上用场了。如果有现成的商用原生代码,那为什么还要构建自己的执行操作的库呢?在这种情况和其他一些情况下,问题就变成了如何编写最高效的 JNI 代码。
答案是尽可能避免从 Java 调用 C。跨 JNI 边界(边界是描述跨语言调用的术语)成本非常高,这是因为,调用一个现有的 C 库首先需要一些胶水代码,需要花时间通过胶水代码创建新的、粗粒度的接口,一下子要多次进入 C 库。
有趣的是,反过来就未必如此了:从 C 代码调用回 Java 不会有很大的性能损失(与所用的参数有关)。比如,考虑下面的代码:
public void main() {calculateError();}public void calculateError() {for (int i = 0; i < numberOfTrials; i++) {error += 50 - calc(numberOfIterations);}}public double calc(int n) {double sum = 0;for (int i = 0; i < n; i++) {int r = random(100); // 返回1至100之间的一个随机值sum += r;}return sum / n;}
这段(完全没有实际意义的)代码有两个主循环:内层循环多次调用生成随机数的代码,外层循环重复调用内层循环,看看所得的随机数与预期值(这里是 50)的接近程度。通过 JNI,可以用 C 实现 calculateError()、calc() 和 random() 这些方法中的任何一个或多个。表 12-4 展示了不同组合情况下的性能,其中 numberOfTrials 为 10 000。
表12-4:计算随机方法的error的时间
calculateError
|
Calc
|
Random
| JNI转移 | 总时间(秒) |
|---|---|---|---|---|
| Java | Java | Java | 0 | 12.4 |
| Java | Java | C | 10 000 000 | 32.1 |
| Java | C | C | 10 000 | 24.4 |
| C | Java | Java | 10 000 | 12.4 |
| C | C | C | 0 | 12.4 |
仅用 JNI 调用实现最内层方法,跨 JNI 边界的次数最多(numberOfTrials * numberOfloops,1 千万次)。将跨边界次数减少到 numberOfTrials(即 10 000)可以大幅减少开销,而将其减到 0,性能会最好——至少从 JNI 角度看是这样,尽管纯 Java 实现和完全使用原生代码一样快。
如果所用的参数不是简单的基本类型,JNI 代码性能会更糟。这一开销涉及两个方面。第一,对于简单的引用,需要地址转换。这也是为什么在上面的例子中,从 Java 调用 C 比从 C 调用 Java 开销更大:从 Java 调用 C,会隐式地把问题中的对象(this)传递给 C 函数,从 C 调用 Java 则无需传递任何对象。
第二,对于基于数组的数据,其中的操作在原生代码中会进行特殊处理。这包括 String 对象,因为字符串数据本质上是一个字符数组。要访问这类数组中的单个元素,必须调用一个特殊的方法,将该对象固定在内存中(对于 String 对象,要将其从 Java 的 UTF-16 编码转换成 UTF-8)。当不再需要数组时,必须在 JNI 代码中显式地释放。当有数组被固定在内存中时,垃圾收集器就无法运行——所以 JNI 代码中代价最高的错误之一就是在长期运行的代码中固定了一个字符串或数组。这会阻碍垃圾收集器运行,实际上也阻塞了所有应用线程,直到 JNI 代码完成。对于会固定数组的临界区,尽可能缩短固定时间极为重要。
有时,后面这个目标会与减少跨 JNI 边界调用这个目标冲突 3。这种情况下,后一个目标更重要:即使这意味着要多次跨 JNI 边界,也要让固定数组和字符串的代码区尽可能短。
3这里是指,本来一次 JNI 调用可以完成的事情,因为要缩短固定数组的时间,所以可能要分成几次,以防影响垃圾收集器工作。——译者注
快速小结
1. JNI 并不能解决性能问题。Java 代码几乎总是比调用原生代码跑得快。
2. 当使用 JNI 时,应该限制从 Java 到 C 的调用次数;跨 JNI 边界的调用成本很高。
3. 使用数组或字符串的 JNI 代码必须固定这些对象;为避免影响垃圾收集器,应该限制固定对象的时间。
快速小结