Jython,Jep,GraalVM,你最想要的Java调Python方法是哪种?

调用第三方库

当我们打算在Java调用python程序的时候,需要考虑什么?

  • python实现的功能能否用Java实现?
    • 如果是机器学习部分,也许可以考虑使用Deeplearning4j
    • 如果涉及较多科学计算,python科学计算常用库比如numpy和pandas底层都用C进行了优化,速度不是劣势。
  • 是否将python部分封装为服务?

连接Java和Python的工具有很多,下面列举的只是heatao知道的一部分:

Jython

Website: https://www.jython.org/index

Source: https://github.com/jython

Jython是比较有名的连接Java和Python的桥梁(不应该认为是桥本身),Jython是用Java编写的Python解释器。

Jython最大的劣势在于,目前(2020.10)只支持Python2.7,无法访问庞大的CPython生态系统,不支持如NumPy或SciPy之类的库,而很多机器学习的工具都依赖这些库。

Jython的优势在于灵活,可以直接将python代码嵌入Java代码中,也允许在Python中直接写Java代码:

1
2
3
4
5
6
7
8
9
import org.python.util.PythonInterpreter;

public class JythonHelloWorld {
public static void main(String[] args) {
try(PythonInterpreter pyInterp = new PythonInterpreter()) {
pyInterp.exec("print('Hello Python World!')");
}
}
}
1
2
3
4
from java.lang import System # Java import

print('Running on Java version: ' + System.getProperty('java.version'))
print('Unix time from Java: ' + str(System.currentTimeMillis()))

Jpy

Website: https://github.com/bcdev/jpy/

Documentation: https://jpy.readthedocs.io/en/latest/

Source: https://github.com/bcdev/jpy

PyPI: https://pypi.org/project/jpy/

jpy是一个双向的Python-Java桥,可以使用它在Python程序中嵌入Java代码,或者反过来使用。它是专门针对两种语言之间的最大数据传输速度而设计的。

jpy的最初开发是由于需要为一个用Java编程的已建立的科学成像应用程序编写Python扩展,即SNAP toolbox,由欧洲航天局(ESA)资助的项目。(jpy与SNAP发行版捆绑在一起。)

安装:

clone the repository

1
2
3
export JDK_HOME=<your-jdk-dir>
export JAVA_HOME=$JDK_HOME
python3 setup.py build maven bdist_wheel

在Python中使用:

1
2
3
4
5
6
7
8
9
10
jpy.create_jvm(["-Djava.class.path=" + jar_path])
StringBuilder = jpy.get_type("java.lang.StringBuilder")
sb = StringBuilder()
sb.append("Demonstration of ")
sb.append("StringBuilder")
print(sb.toString())

Main = jpy.get_type("net.talvi.pythonjavabridgedemos.Main")
main = Main("Bob")
print(main.greet("Wotcha"))
  • 需要很多设置才能使用,不是很推荐

Jep(推荐)

Website: https://github.com/ninia/jep

Documentation: https://github.com/ninia/jep/wiki

Source: https://github.com/ninia/jep

PyPI: https://pypi.org/project/jep/

在其首页介绍是通过 ==JNI== 来将CPython嵌入Java

试验

setup.py build install命令进行编译安装。

Install完成之后,Python对应的site-package路径也会有对应的jar包。

🌰:

invoke_args.py

1
2
3
4
5
6
7
8
import numpy as np
import pandas as pd

df2 = pd.DataFrame(np.array([[1, 2, 3], [4,5, 6], [7, 8, 9]]))

def invokeNoArgs():
   print("hello")
print(df2)

Java调用Python代码如下,注意把JEP添加到Java的Library Path中去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package first;

import jep.Interpreter;
import jep.JepConfig;
import jep.JepException;
import jep.SubInterpreter;

public class invoke2 {
   public static void main(String[] args) throws JepException {
       JepConfig config = new JepConfig();
       config.addIncludePaths("C:\\Users\\IdeaProjects\\DailyLearning\\src\\python");
       try (Interpreter interp = new SubInterpreter(config)){
           interp.eval("from invoke_args import *");
           // test a basic invoke with no args
           Object result = interp.invoke("invokeNoArgs");
           if (result!=null){
                throw newIllegalStateException(
                        "Received " +result + "but expected null");
           }
       }
    }
}

优点:

  • 支持CPython扩展和模块,比如numpy等库,但不能保证在所有情况下都可正常工作
  • 支持python3.x

缺点:

  • 不支持从Python中调用Java

Javabridge

支持在Python代码中启动JVM虚拟机,方便与Java的API和JVM交互。使用人数不多,可以作为实验或学习使用。

安装

1
pip3 install javabridge

🌰:

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
javabridge.start_vm(run_headless=True, class_path=javabridge.JARS +
[jar_path])
try:
# Bind a Java variable and run a script that uses it.
print(javabridge.run_script(
'java.lang.String.format("Hello, %s!", greetee);',
dict(greetee="world")))

# Wrap a Java object and call some of its methods.
array_list = javabridge.JWrapper(javabridge.make_instance(
"java/util/ArrayList", "()V"))
array_list.add("ArrayList item 1")
array_list.add("ArrayList item 2")
print("ArrayList size:", array_list.size())
print("First ArrayList item:", array_list.get(0))

# Wrap a Java object from our jar and call a method.
main1 = javabridge.JWrapper(javabridge.make_instance(
"net/talvi/pythonjavabridgedemos/Main",
"(Ljava/lang/String;)V", "Alice"))
print(main1.greet("Hello"))

# Wrap a Java object using JClassWrapper (no signature required)
main2 = javabridge.JClassWrapper(
"net.talvi.pythonjavabridgedemos.Main")("Bob")
print(main2.greet("Hi there"))
print("2 + 2 =", main2.add(2, 2))

finally:
javabridge.kill_vm()

其他的Java与Python交互工具

Py4j

  • Website: https://www.py4j.org/

  • 允许在Python解释器中运行的Python程序动态访问Java虚拟机中的Java对象

  • 允许Java程序会调Python对象

JPype

  • Website: https://github.com/jpype-project/jpype/

  • 是一个Python库,让Python可以使用Java的库

  • 并非像Jython那样实现了解释器,而是通过两个虚拟机的本机接口实现

  • 同时提供对CPython和Java库的访问

PyJNIus

  • Website: https://github.com/kivy/pyjnius
  • Python库,不支持Java调Python
  • 允许Python通过JNI来获得Java的类,从Github star来看是上述几种方式中最多的
  • 适用场景:极个别的加密算法等内容,用python不方便实现或者实现较耗时,可基于Pyjnius把java类当做python库使用

通过Runtime调用

在java中,调用系统命令,可以使用:

1
RunTime.getRuntime().exec()

该方法会生成一个新的进程去运行调用的程序,这种方式相当于直接对命令行进行操作,可以打开软件,自然也可以调用Python脚本。

🌰:

1
2
3
4
5
def add(a,b):
   return a + b
res = add(3,4)

print(res)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package first;


import java.io.*;

public class invoke {
   public static void main(String[] args) throws IOException,InterruptedException {
       String executer = "python";
       String file_path = "D:\\add_test.py";// python绝对路径
       String[] command_line = new String[]{executer, file_path};
       Process process = Runtime.getRuntime().exec(command_line);
       BufferedReader in = new BufferedReader(newInputStreamReader(process.getInputStream()));
       String line;
       while ((line = in.readLine()) != null) {
           System.out.println(line);
       }
       in.close();
       process.waitFor();
    }
}

通过runtime调用的缺点:

  • 对于复杂数据的返回值,调用交互较为麻烦
  • 直接调用没有状态保持,每次调用都是全新的开始
  • 跨机远程调用不支持

如果你打算使用Runtime.exec()的方式,需要注意一点Java安全编码喔,见参考:

https://developer.aliyun.com/article/175227

Python转jar包(不是那么实用)

该方法来自==轩辕之风==大佬的文章,和其他第三方库使用JVM虚拟机的方式不同,这里从JNI入手。

Source: https://juejin.im/post/6844903972596088846#heading-18

使用HTTP接口是一种耦合性较低的方式,也方便Java部分和Python部分单独开发,但问题在于当服务器不够多但访问量较大时,并发网络访问会成为速度的瓶颈。

对于 Java 来说,能够本地调用的有两种:

  • Java 代码包
  • Native 代码模块

像Jython这种在JVM中使用Python的方式有诸多局限,还有一条路就是,把 Python 代码转换成 Native 代码块,Java 通过 JNI 的接口形式调用。

整体思路

先将 Python 源代码转换成 C 代码,之后用 GCC 编译 C 代码为二进制模块 so/dll,接着进行一次 Java Native 接口封装,使用 Jar 打包命令转换成 Jar 包,然后 Java 便可以直接调用。

其实这个思路很想Jep,如果不想自己开发可以直接考虑Jep的方式。

python2native

Python 代码如何转换成 C 代码?

  • Cython

请注意,这里的Cython和前面提到的CPython不是一回事。CPython 狭义上是指 C 语言编写的 Python 解释器,是 Windows、Linux 下我们默认的 Python 脚本解释器。

而 Cython 是 Python 的一个第三方库,你可以通过pip install Cython进行安装。

官方介绍 Cython 是一个 Python 语言规范的超集,它可以将 Python+C 混合编码的.pyx 脚本转换为 C 代码,主要用于优化 Python 脚本性能或 Python 调用 C 函数库。

听上去有点复杂,也有点绕,不过没关系,get 一个核心点即可:Cython 能够把 Python 脚本转换成 C 代码

实验

准备 Python 源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
# FileName: Test.py
# 示例代码:将输入的字符串转变为大写
def logic(param):
print('this is a logic function')
print('param is [%s]' % param)
return param.upper()

# 接口函数,导出给Java Native的接口
def JNI_API_TestFunction(param):
print("enter JNI_API_test_function")
result = logic(param)
print("leave JNI_API_test_function")
return result

注意1:这里在 python 源码中使用一种约定:以JNI_API_为前缀开头的函数表示为Python代码模块要导出对外调用的接口函数,这样做的目的是为了让我们的 Python 一键转 Jar 包系统能自动化识别提取哪些接口作为导出函数。

注意2:这一类接口函数的输入是一个 python 的 str 类型字符串,输出亦然,如此可便于移植以往通过JSON形式作为参数的 RESTful 接口。使用JSON的好处是可以对参数进行封装,支持多种复杂的参数形式,而不用重载出不同的接口函数对外调用。

注意3:还有一点需要说明的是,在接口函数前缀JNI_API_的后面,函数命名不能以 python 惯有的下划线命名法,而要使用驼峰命名法,注意这不是建议,而是要求,原因后续会提到。

准备一个 main.c 文件

这个文件的作用是对 Cython 转换生成的代码进行一次封装,封装成 Java JNI 接口形式的风格,以备下一步 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
#include <Python.h>
#include <stdio.h>

#ifndef _Included_main
#define _Included_main
#ifdef __cplusplus
extern "C" {
#endif

#if PY_MAJOR_VERSION < 3
# define MODINIT(name) init ## name
#else
# define MODINIT(name) PyInit_ ## name
#endif
PyMODINIT_FUNC MODINIT(Test)(void);

JNIEXPORT void JNICALL Java_Test_initModule
(JNIEnv *env, jobject obj) {
PyImport_AppendInittab("Test", MODINIT(Test));
Py_Initialize();

PyRun_SimpleString("import os");
PyRun_SimpleString("__name__ = \"__main__\"");
PyRun_SimpleString("import sys");
PyRun_SimpleString("sys.path.append('./')");

PyObject* m = PyInit_Test_Test();
if (!PyModule_Check(m)) {
PyModuleDef *mdef = (PyModuleDef *) m;
PyObject *modname = PyUnicode_FromString("__main__");
m = NULL;
if (modname) {
m = PyModule_NewObject(modname);
Py_DECREF(modname);
if (m) PyModule_ExecDef(m, mdef);
}
}
PyEval_InitThreads();
}


JNIEXPORT void JNICALL Java_Test_uninitModule
(JNIEnv *env, jobject obj) {
Py_Finalize();
}

JNIEXPORT jstring JNICALL Java_Test_testFunction
(JNIEnv *env, jobject obj, jstring string)
{
const char* param = (char*)(*env)->GetStringUTFChars(env, string, NULL);
static PyObject *s_pmodule = NULL;
static PyObject *s_pfunc = NULL;
if (!s_pmodule || !s_pfunc) {
s_pmodule = PyImport_ImportModule("Test");
s_pfunc = PyObject_GetAttrString(s_pmodule, "JNI_API_testFunction");
}
PyObject *pyRet = PyObject_CallFunction(s_pfunc, "s", param);
(*env)->ReleaseStringUTFChars(env, string, param);
if (pyRet) {
jstring retJstring = (*env)->NewStringUTF(env, PyUnicode_AsUTF8(pyRet));
Py_DECREF(pyRet);
return retJstring;
} else {
PyErr_Print();
return (*env)->NewStringUTF(env, "error");
}
}
#ifdef __cplusplus
}
#endif
#endif

这个文件中一共有3个函数:

  • Java_Test_initModule: python初始化工作
  • Java_Test_uninitModule: python反初始化工作
  • Java_Test_testFunction: 真正的业务接口,封装了对原来Python中定义对JNI_API_testFuncion函数的调用,同时要负责JNI层面的参数jstring类型的转换。

根据 JNI 接口规范,native 层面的 C 函数命名需要符合如下的形式:

1
2
3
4
5
// QualifiedClassName: 全类名
// MethodName: JNI接口函数名
void
JNICALL
Java_QualifiedClassName_MethodName(JNIEnv*, jobject);

所以在main.c文件中对定义需要向上面这样命名,这也是为什么前面强调python接口函数命名不能用下划线,这会导致JNI接口找不到对应的native函数。

存在的问题

import问题

上面演示的案例只是一个单独的 py 文件,而实际工作中,我们的项目通常是具有多个 py 文件,并且这些文件通常是构成了复杂的目录层级,互相之间各种 import 关系,错综复杂。

Cython 这个工具有一个最大的坑在于:经过其处理的文件代码中会丢失代码文件的目录层级信息,如下图所示,C.py 转换后的代码和 m/C.py 生成的代码没有任何区别。

这就带来一个非常大的问题:A.py 或 B.py 代码中如果有引用 m 目录下的 C.py 模块,目录信息的丢失将导致二者在执行 import m.C 时报错,找不到对应的模块!

幸运的是,经过实验表明,在上面的图中,如果 A、B、C 三个模块处于同一级目录下时,import 能够正确执行。

Python GIL 问题

众所周知,限于历史原因,Python 诞生于上世纪九十年代,彼时多线程的概念还远远没有像今天这样深入人心过,Python 作为这个时代的产物一诞生就是一个单线程的产品。

虽然 Python 也有多线程库,允许创建多个线程,但由于 C 语言版本的解释器在内存管理上并非线程安全,所以在解释器内部有一个非常重要的锁在制约着 Python 的多线程,所以所谓多线程实际上也只是大家轮流来占坑。

原来 GIL 是由解释器在进行调度管理,如今被转成了 C 代码后,谁来负责管理多线程的安全呢?

获取 GIL 锁:

获取GIL锁

释放 GIL 锁:

释放GIL锁

在 JNI 调用入口需要获得 GIL 锁,接口退出时需要释放 GIL 锁。

加入 GIL 锁的控制后,烦人的 Crash 问题终于得以解决!

测试效果

准备两份一模一样的 py 文件,同样的一个算法函数,一个通过 Flask Web 接口访问,(Web 服务部署于本地 127.0.0.1,尽可能减少网络延时),另一个通过上述过程转换成 Jar 包。

在 Java 服务中,分别调用两个接口 100 次,整个测试工作进行 10 次,统计执行耗时:

170cd5f122c10d32-20201030205700539

上述测试中,为进一步区分网络带来的延迟和代码执行本身的延迟,在算法函数的入口和出口做了计时,在 Java 执行接口调用前和获得结果的地方也做了计时,这样可以计算出算法执行本身的时间在整个接口调用过程中的占比。

  • 从结果可以看出,通过 Web API 执行的接口访问,算法本身执行的时间只占到了 30%+,大部分的时间用在了网络开销(数据包的收发、Flask 框架的调度处理等等)。
  • 而通过 JNI 接口本地调用,算法的执行时间占到了整个接口执行时间的 80%以上,而 Java JNI 的接口转换过程只占用 10%+的时间,有效提升了效率,减少额外时间的浪费。
  • 除此之外,单看算法本身的执行部分,同一份代码,转换成 Native 代码后的执行时间在 300500μs,而 CPython 解释执行的时间则在 20004000μs,同样也是相差悬殊。

缺点

  • 如果调用较多,需要事先编写Python转Jar包脚本,并且对于函数的命名等方式有固定要求。
  • 在python中,import的形式多种多样,如果全部要求以平坦的结构处理所有情况…

使用GraalVM

Website: https://www.graalvm.org/

Documentation: https://www.graalvm.org/docs/

Source: https://github.com/oracle/graal

GraalVM并非某个库或者工具,而且一个JVM虚拟机,但目前仍处于实验性质,其目标可以用雄心勃勃来形容,引用周志明老师的话:

Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成 的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、 Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支 持其他像JavaScript、Ruby、Python和R语言等。Graal VM可以无额外开销地混合使用这些编程语言, 支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。

Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式 (例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation, IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程 序特化(Specialized,也常被称为Partial Evaluation)。Graal VM提供了Truffle工具集来快速构建面向一 种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。

参考《深入理解Java虚拟机》周志明

下面这个回答对GraalVM有进一步的介绍:

如何评价 GraalVM 这个项目? - kelthuzadx的回答 - 知乎 https://www.zhihu.com/question/274042223/answer/1270829173

个人看法:

  • 目前GraalVM还在实验阶段,无法保证其能兼容Python的现有库,如果希望在工程上同时使用Java和Python,可能尚不适合
  • 如果使用Jython可以尝试迁移到GraalVM

我的建议

  • 首先需要明白使用的场景
    • 如果本地简单实用,那么直接用runtime新开进程就可以
  • 尽量减少程序耦合
    • 推荐使用RESTFUL,网络传输是其速度的瓶颈
  • 使用支持Python3的方式
    • 第一个排除Jython,如果希望调用第三方工具,我推荐jep,但并不能保证所有python的库都可以使用
  • 如果你希望在Python中使用Java的方法,推荐Pyjnius

参考

https://www.infoworld.com/article/3322898/machine-learning-with-python-an-introduction.html?page=2

https://py.processing.org/tutorials/python-jython-java/

https://talvi.net/a-brief-overview-of-python-java-bridges-in-2020.html

https://juejin.im/post/6844903972596088846#heading-18

https://blog.csdn.net/weixin_42348333/article/details/106169841

https://juejin.im/post/6844903972596088846#heading-18