Espresso - Java 与 .NET 原生互操作 - 第一部分






4.52/5 (12投票s)
本文描述了一种在 Java 平台和 .NET Framework 之间实现高性能互操作的解决方案。所提出的解决方案并非取代 Java 虚拟机或 .NET Framework 运行时,而是将您的 JVM 或 .NET 分别托管在对方的运行时环境中。
前言
本文是系列文章的第一篇,该系列文章将展示如何完全集成 Java 和 .NET。
文章如下:
- 第一部分:Java 与 .NET 互操作简介及所提解决方案
- 第二部分:实现 .NET 代理以访问 Java 类
- 第三部分:使用特性扩展 Java API 解决方案
- 第四部分:Java 调用 .NET API
- 第五部分:实现 Java 代理以访问 .NET 类
- 第六部分:添加注解以扩展 .NET API 解决方案
引言
Java 问世已十余年,在系统、商业、互联网和教育编程领域取得了惊人的成就。近几年来,微软推出了一项新的竞争技术,即如今为人所知的 .NET Framework。本文无意深入探讨这两个平台各自的细节,而是旨在展示我在让 C# 与 Java 原生共存以及反之亦然方面的经验。
本文基于一个代号为 Espresso 的项目,该项目由 Reflective Software 开发。在其网站上,您可以找到该项目的更新信息和构建版本。
背景
如前所述,Java 和 .NET 已经存在多年。在此期间,有许多文章和解决方案试图解决这两个框架之间的互操作性问题。这些解决方案的主要问题在于它们试图通过外部通信来集成这两个框架。这意味着每个框架同时在同一台机器上作为一个独立进程运行,或者在不同机器上作为进程运行。所使用的通信采用了多种技术,如 Web 服务、.NET Remoting 等。这类通信的根本问题在于其速度非常慢且依赖于网络。
所提出的解决方案将展示这两个框架如何在同一个进程中协同工作并无缝地进行通信。
本文描述了一种在 Java 平台和 .NET Framework 之间实现高性能互操作的解决方案。所提出的解决方案并非取代 Java 虚拟机或 .NET Framework 运行时,而是将您的 JVM 或 .NET 分别托管在对方的运行时环境中,从而确保供应商特定的 VM 优化得以保留。
"Espresso" 的目标是:
- 在任意组合的 .NET 运行时(1.0+)和 Java 虚拟机(1.2+)上运行。
- 允许在 .NET 环境中完全重用任何 Java 库,反之亦然,仅在 API 层面工作,避免对实际实现进行字节码翻译。
- 提供最佳性能,让 JVM 和 .NET 在同一进程下运行,避免网络或 IPC 成本。
初始代码来自 Caffeine 开源项目,它实现了从 .NET 到 Java 的单向 API 调用。本文及后续文章将介绍一个完整的解决方案,允许从 .NET 到 Java 的 API 调用以及反之,并实现两个平台之间的无缝集成。
在我们为我的公司 Reflective Software 开发的项目之一中,我们有一个 Java 和 .NET 之间的互操作需求。我们尝试过使用 Web 服务,但效果不够好。我们使用了 Caffeine 并对其进行了扩展,以便能够实现 Java 调用 .NET 以及 .NET 调用 Java。这些文章包含了完成该项目所需的各个阶段以及源代码。
第一部分
在第一篇文章中,我将解释 Espresso 解决方案,该方案将作为后续文章的基础。
第一部分的大部分信息都基于 Caffeine 项目,因此如果您熟悉这个问题,可以直接跳到下一部分,该部分将介绍如何将 Caffeine 项目扩展到新的水平。
运行时架构
Java Native Interface (JNI) 是所有 Java 虚拟机实现者都必须提供的公共接口。JNI 是一组包含在 JVM 原生库(根据架构不同为 *jvm.dll* 或 *libjvm.so*)中的原生函数,它允许从 Java 调用原生函数,也允许原生代码创建 JVM 并调用 Java 类。
Espresso 采用 JNI 技术在 .NET 运行时下托管 Java 虚拟机 (JVM)。由于 JVM 与 .NET 运行时在同一个操作系统进程中运行,因此该解决方案没有 IPC 成本。下图说明了 Espresso 的运行时架构。灰色块由 Espresso 提供,黄色块由 JVM 提供。

bridge.dll 是 C++ 代码,它公开了将由 *JNI.NET.Bridge.dll* 使用的函数。*JNI.NET.Bridge.dll* 是一组类,它使 .NET Framework 能够通过 P/Invoke 调用 Java API。.NET 类是一些包装 Caffeine
API 的类,以便为其提供更具吸引力和面向对象的外观。在这些文章中,我们不会展示这个库,因为我采用了不同的方法,即代理(proxy)方法,这与 Caffeine
的方法不同。
bridge.dll
如果您了解 JNI,您会知道 `JNIEnv` 接口指针是其核心。`JNIEnv` 包含一个 JNI 函数表,该表作为参数传递给每个原生方法。`JNIEnv` 接口指针设计的主要优点是 JNI 实现不需要链接到特定版本的 Java 虚拟机。二进制兼容性是 JNI 的主要设计标准,而 `JNIEnv` 则遵循了这一点。
尽管可以从 C# 访问接口指针,但这非常麻烦。因此,Espresso 提供了 bridge 库。桥接库的另一个原因是 `JNIEnv` 使用线程本地存储,这意味着每个原生线程都必须附加到 Java 线程才能确保正确性。Espresso 桥接库会扁平化 `JNIEnv` 接口,并确保每个原生线程都正确地附加到 Java 线程。
`FindClass` 函数如下所示:
jclass
FindClass(const char *name)
{
JNIEnv *env = GetEnv();
return (*env)->FindClass(env, name);
}
对于了解 JNI C++ 绑定的人来说,上面的结构会很熟悉。我们正在扁平化结构接口,从函数签名中移除 `JNIEnv`。相反,`FindClass` 函数的第一行调用 `GetEnv()` 函数。`GetEnv()` 返回附加到当前线程的 `JNIEnv` 接口指针,如果当前原生线程未附加到线程本地存储,则它会附加该线程并返回一个 `JNIEnv` 接口指针。第二行通过 `JNIEnv` 接口指针实际调用 JNI `FindClass` 函数。
桥接库包含了 `JNIEnv` 接口指针结构中所有函数的 1:1 扁平化版本。此外,桥接库还包含上面描述的 `GetEnv()` 函数,以及创建和销毁 Java VM 的函数。
创建 JVM
桥接库提供了两个方便的函数来创建 Java VM:
int CreateJavaVMDLL(const char *dllName,
JavaVMOption * options,
int nOptions);
int CreateJavaVMAnon(JavaVMOption * options,
int nOptions);
`CreateJavaVMDLL` 允许指定在运行时加载的包含 Java VM 的共享库。桥接库不链接到任何特定的 Java VM 实现。相反,它根据传递给 `CreateJavaVMDLL` 函数的 `dllName` 使用动态库定位和加载(`LoadLibrary`/`GetProcAddress`)。`CreateJavaVMAnon` 不接受 `dllName`,并在 Win32 上默认将 `dllName` 设置为 *jvm.dll*。这似乎是 90% 设置的有效默认值。大多数应用程序不需要指定 JVM 库,但如果默认设置不起作用,`JNI.NET.Bridge` 提供了配置包含 JVM 的 DLL 名称的能力(参见“配置”部分)。
传递参数
大多数 `JNIEnv` 函数有三种形式:
jchar (JNICALL *CallNonvirtualCharMethod)
(JNIEnv *env, jobject obj, jclass clazz, jmethodID methodID, ...);
jchar (JNICALL *CallNonvirtualCharMethodV)
(JNIEnv *env, jobject obj, jclass clazz, jmethodID methodID,
va_list args);
jchar (JNICALL *CallNonvirtualCharMethodA)
(JNIEnv *env, jobject obj, jclass clazz, jmethodID methodID,
jvalue *args);
`JNIEnv` 函数可以接受省略号、可变参数列表或 `jvalue` 联合。P/Invoke 不允许省略号或可变参数列表,因为无法进行类型封送,因此桥接库仅提供使用 `jvalue` 联合的函数形式。
JNI.NET.Bridge.dll
此库是 *bridge.dll* 的 .NET 对应项。在此库中,您会找到一个以更面向对象的方式封装 JNI 调用的类,并使 .NET 开发人员能够以比直接调用 JNI API 更优雅的方式使用 Java API。
数据绑定。
所有 JNI 类型都有一个 .NET 对应项:
JObject
,封装jobject
,并且是所有 .NET 类型的基类型。JClass
,封装JNI jclass
类型。JMethod
,封装jmethodID
。JField
,封装jfieldID
。JArray
、JBooleanArray
、JByteArray
、JCharArray
、JDoubleArray
、JFloatArray
、JIntArray
、JLongArray
、JObjectArray
和JShortArray
,它们分别封装jarray
、jbooleanarray
、jbytearray
、jchararray
、jdoublearray
、jfloatarray
、jintarray
、jlongarray
、jobjectarray
和jshortarray
。JThrowable
,封装jthrowable
。JString
,封装jstring
。
JNI.NET.Bridge
提供了一些额外的类型,以简化绑定使用。
JMember
,JConstructor
、JMethod
和JField
的基类。JConstructor
,JMethod
的特殊版本。
所有 `JNIEnv` 函数都已封装在相应的 .NET 类型中。例如,桥接函数
jint MonitorExit(jobject obj);
被封装为:
public class JObject
{
// ...
[DllImport(JNIEnv.DLL_JAVA)]
static extern int MonitorExit (IntPtr obj);
public void MonitorExit () { // ... }
// ...
}
传递参数
如前所述,桥接库仅公开使用 `jvalue` 联合的函数,而不公开使用可变参数列表或省略参数的函数。`JValue` 是一个 C# 结构,可以使用 `FieldOffset` 属性将其封送到 C 联合。在 *jni.h* 中定义的原始 `jvalue` 联合如下所示:
typedef union jvalue {
jboolean z;
jbyte b;
jchar c;
jshort s;
jint i;
jlong j;
jfloat f;
jdouble d;
jobject l;
} jvalue;
模拟此联合的 C# 结构如下所示:
[StructLayout (LayoutKind.Explicit)]
public struct JValue
{
[FieldOffset (0)] bool z;
[FieldOffset (0)] byte b;
[FieldOffset (0)] char c;
[FieldOffset (0)] short s;
[FieldOffset (0)] int i;
[FieldOffset (0)] long j;
[FieldOffset (0)] float f;
[FieldOffset (0)] double d;
[FieldOffset (0)] IntPtr l;
// ...
}
实例化 Java 对象
所有 Java 对象都由 JObject
包装。JObject
是一个 .NET 类型,它维护对 Java 对象的引用(`Handle` 属性)。换句话说,要实例化一个 Java 对象,我们必须创建一个 JObject
的实例。
有两种方法可以创建 JObject
的实例:调用 JObject
构造函数并提供一个 JConstructor
,或者调用 JClass
中的实例方法 `NewInstance`。让我们专注于第一种方法,这是创建实例的通用方法。我们必须遵循的步骤是:
- 获取与我们感兴趣的 Java 类型对应的
JClass
对象的实例。 - 从
JClass
实例中,找到一个合适的构造函数,并获取JConstructor
的实例。 - 最后,调用
JObject
的构造函数,并将之前从JClass
获取的JConstructor
实例传递进去。
为了查找类,我们调用 JClass
中的 static
方法 `ForName`,提供我们希望加载的 Java 类型的完全限定名。在后台,`ForName` 调用 JNIEnv
接口指针中的 `FindClass` 函数。
一旦我们有了 JClass
对象(对应于一个 Java 类型),我们就必须获取该类型的构造函数。我们通过在 JClass
中调用 `GetConstructor(string sig)` 方法来实现这一点。请注意,签名遵循 JNI 命名约定(您可以使用 Java 工具 `javap` 来获取签名)。使用示例为:
JClass clazz = JClass.ForName ("Prog");
JConstructor ctr = clazz.GetConstructor ("()V");
在此示例中,我们获取 Java 类 `Prog` 的无参构造函数。对于无参构造函数,我们也可以调用方便的方法 `GetConstructor()`,等同于调用 `GetConstructor("()V");`。
一旦我们有了构造函数,我们就可以调用 JObject
构造函数:
JObject obj = new JObject (ctr);
您应该知道 JObject
暴露了三个构造函数,但没有一个是 public
的。第一个构造函数是我们刚刚展示的,它由 JObject
的派生类使用。例如,如果我们正在编写 *Prog.cs*(一个 Java 类 *Prog.class* 的 C# 包装器),我们将使用该构造函数。此构造函数的签名是:
protected JObject (JConstructor ctr, params object[] args);
此构造函数接受可变数量的 `System.Object` 参数(这就是 C# `params` 关键字的作用)。这允许派生类在 static
类构造函数中获取构造函数(JConstructor
),并使用指向 JConstructor
的引用以及传递给它的参数来调用基类构造函数。
调用方法
JConstructor
只是 JMember
的一种特殊类型。我们可以查询 JClass
以获取任何 JMember
,无论是字段、构造函数还是方法。调用方法或访问字段与创建新对象实例的差异不大。我们仍然需要一个 JClass
,从中我们将使用其签名获取 JMethod
或 JField
。
回到我们的 `Prog` 示例,如果我们有一个 Java 方法:
public class Prog {
public int max (int a, int b) {
return (a > b) ? a : b;
}
}
我们可以通过在 JClass
中调用 `GetMethod()` 从 JNI.NET.Bridge
获取方法:
JClass clazz = JClass.ForName ("Prog");
JMethod _max = clazz.GetMethod ("max", "(II)I");
GetMethod
接受两个参数:方法名称和内部方法签名。要获取内部方法签名,您可以学习规范,并使用 Sun 的 Java SDK `javap` 工具。
一旦我们获取了方法,我们就可以调用它。在我们的 `Prog` 示例中,我们将调用:
int result = _max.CallInt (obj, a, b);
`CallInt` 用于调用返回 int
的方法。基于返回类型,`JMethod` 中还有其他变体可以调用 Java 方法:
CallBoolean
、CallByte
、CallChar
、CallShort
、CallInt
、CallLong
、CallFloat
、CallDouble
、CallVoid
,分别用于调用返回boolean
、byte
、char
、short
、int
、long
、float
、double
和无返回的方法。CallObject
,用于调用返回对象或数组(无论是对象数组还是基本类型数组)的方法。
所有 CallXXX 方法都有两个版本:一个接受 JValue
数组,另一个接受可变数量的参数。例如,对于 `CallInt`:
public int CallInt (JObject obj, JValue[] args);
public int CallInt (JObject obj, params object[] args);
在可能的情况下,出于性能原因,应优先选择 JValue[]
版本。当使用可变参数列表时,会产生装箱/拆箱开销以及转换路由开销。
此外,`JMethod` 提供了一个方便的方法 `Invoke`,该方法避免了必须知道使用哪个 CallXXX 版本。`Invoke` 的签名是:
public object Invoke (JObject obj, JValue[] args);
public object Invoke (JObject obj, params object[] args);
`Invoke` 将根据创建 `JMethod` 实例时提供的方法签名来确定要调用的正确 CallXXX。`Invoke` 返回一个对象,利用 .NET 的装箱。因此请注意,尽管方便,但此方法提供的性能不如直接调用正确的 CallXXX 版本,因为后者没有装箱。
CallXXX 的所有版本以及 `Invoke`,都将对 `JObject` 实例的引用作为它们的第一个参数。显然,对象引用仅对实例方法是必需的,对于类(static
)方法,第一个参数将被忽略。因此,将 `null` 引用传递给类(static
)方法是合法的。
数组
Java(和 .NET)中的所有数组都是对象。JNI 将所有数组暴露为 jarray
,在 JNI.NET.Bridge
中将其封装为 JArray
。JNI 还有 jarray
的专用版本,如 jintarray
,在 JNI.NET.Bridge
中将其封装为 JIntArray
。
JNI.NET JArray
的专用版本,如 JIntArray
或 JObjectArray
,提供三个构造函数:
public JObjectArray (JObject other);
复制构造函数。public JObjectArray (int length, JClass clazz);
创建一个具有指定长度的JObjectArray
,并使用由JClass
实例引用的 Java 类。public JObjectArray (JObject[] source, JClass clazz);
从JObject
数组创建JObjectArray
,使用由JClass
指定的类型。
一个例子将阐明何时使用每个构造函数。如果我们正在调用 Java 方法 `fooB`:
public int fooB (Prog[] arg);
方法 `fooB` 需要获取一个 `JObjectArray` 的实例,所以在 .NET 中的第一步是将 .NET Prog[]
数组转换为 JObjectArray
。我们通过调用第三个构造函数来实现:
// in .NET
Prog[] args = ...
JObjectArray array = new JObjectArray (args, Prog.JClass);
int r = _fooB.CallInt (this, array);
JObjectArray
构造函数首先使用 JNI 函数 `NewObjectArray` 分配一个 `jobjectarray`,并提供由 `Prog.JClass` 引用的 jclass。然后,JObjectArray
的构造函数使用 JNI 函数 `SetObjectArrayElement` 将包含在 `Prog[]` args 中的元素复制到新创建的对象中。
我们已经看到了如何调用一个接受数组的 Java 方法。返回数组时使用类似的模式。假设我们想调用 Java 方法 `fooA`:
public Prog[] fooA ();
我们知道,对 `fooA` 的调用如下:
JObject o = _fooA.CallObject (this);
在这种情况下,我们必须将一个 JObject
强制转换为 .NET 数组。我们将使用复制构造函数:
JObjectArray array = new JObjectArray (o);
并获取数组中的元素作为 JObject[]
数组,然后逐个复制到新分配的 Prog[]
中:
JObject[] oArray = (JObject[]) array.Elements;
int l = array.Length;
Prog[] r = new Prog[l];
for (int i = 0; i < l; i++) {
r[i] = new Prog (oArray[i]);
}
请注意,理想情况下,我们希望写成如下形式:
Prog[] r = (Prog[]) array.Elements;
当前的实现出于性能原因并未这样做。与上一节中注释的类型转换一样,我们可以使用反射来实现数组类型转换,因为 JMethod
知道 o
的类型,而 JObjectArray
可以使用 JObject
中的 static JClass
属性来实例化正确的对象数组。
配置
配置机制允许您指定库加载路径、classpath
以及希望传递给 Java 运行时的命令行参数。当您需要设置复杂的 CLASSPATH
(通常在 J2EE 应用程序中是这种情况)或者您的 JVM 的名称不是 *jvm.dll* 或 *libjvm.so* 时,这一点尤其重要。
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="jvm">
<section name="settings" type="Jni.Net.Bridge.JNISectionHandler, Jni.Net.Bridge"/>
</sectionGroup>
</configSections>
<jvm>
<settings>
<jvm.dll value="C:\Program Files\Java\jre1.5.0_07\bin\client\jvm.dll"/>
<java.class.path value="minijavart.jar"/>
<java.class.path value="."/>
<java.library.path value="C:\Program Files\Java\jre1.5.0_07\lib"/>
<java.option value="-verbose:gc,class"/>
</settings>
</jvm>
</configuration>
在此示例中,我们指定 JVM 包含在名为 *jvm.dll* 的 DLL 中;classpath
应包含 *minijavart.jar* 文件和 "." 目录(在当前目录中,因为此示例中未给出相对或绝对路径);原生库(例如 System.loadLibrary()
)应在 *C:\Program Files\Java\jre1.5.0_07\lib* 下搜索;并且类垃圾回收应在详细级别进行跟踪。
如果 *jvm.dll* 在 PATH
或注册表 HKEY_LOCAL_MACHINE\SOFTWARE\JavaSoft\Java Runtime Environment 下找不到,则需要在 settings 部分中指定它。
示例源代码和构建
项目使用 Visual C# 2005 Express 和 Visual C++ 2005 Express 在 .NET Framework 2.0 中开发。
源 zip 文件包含两个 Visual Express 项目,第一个是 `bridge` 项目,它是 *bridge.dll* 的 C++ 项目。首先,打开此项目并进行构建。第二个项目是 `JNI.NET.Bridge` 项目,这是一个 C# 项目,其中包含 *JNI.NET.Bridge.dll* 的所有源代码以及一个示例测试程序。
使用 Visual C# 2005 Express 或 Visual Studio 2005 打开它并进行构建。
运行测试项目以检查一切是否正常。
待续...
在下一部分中,我将介绍如何实现 .NET 代理,以便以更面向对象的方式调用 Java 类,以及如何从 Java 调用 .NET。
历史
- 2006 年 6 月 8 日:初始发布