实践总结:在 Java 中调用 Go 代码

原作者:刘家财;原文发表于 2020 年 8 月 8 日:《实践总结:在 Java 中调用 Go 代码》。本文在授权范围内整理原有中文全文,保留调用路径、类型映射、示例索引与性能数据,并对旧示例的内存和 ABI 风险补充静态审查说明。补充内容不冒充作者原话。

在 Java 中调用 Go,可以沿着下面这条路线实现:

Go → cgo → C 共享库 → JNA → Java

整条链路主要解决两个问题:一是数据类型如何在语言边界上转换,二是什么时候、由哪一方清理不再使用的数据。原文的完整示例项目覆盖了这些环节。原文中的部分链接指向已失效的 master 分支,本文链接已调整为核验时存在的 main 分支;实际发布库时,应另外固定经过验证的提交和工具链版本。

Go到Java的内存所有权示意:C.CString和C.CBytes把数据复制到C堆,Java读取后必须按分配器约定释放,Go堆指针不能靠类型转换交给C长期保存
编辑自绘:跨语言调用中的数据复制与释放责任。实线表示数据路径,释放动作必须与分配器及所有权约定一致。

Go → cgo:把 Go 编译为 C 共享库

第一步借助 cgo,把 Go 代码编译成可供 C 调用的共享库。cgo 提供名为 C 的伪包,使 Go 能访问 C 的类型、变量和函数,例如 C.size_t 和 C.stdout。

原文列出了五个用于 Go/C 数据转换的特殊函数。下面是说明性的伪 Go 函数签名,而不是让你重新定义这些函数:

// Go string → C字符串:在C堆上分配,调用者负责安排释放。
func C.CString(string) *C.char

// Go []byte → C数组:在C堆上分配,调用者负责安排释放。
func C.CBytes([]byte) unsafe.Pointer

// 以NUL结尾的C字符串 → Go string
func C.GoString(*C.char) string

// 指定长度的C数据 → Go string
func C.GoStringN(*C.char, C.int) string

// 指定长度的C数据 → Go []byte
func C.GoBytes(unsafe.Pointer, C.int) []byte

C.CString 和 C.CBytes 都会分配 C 堆内存并复制数据。这部分内存不会因为 Java 或 Go 对象被垃圾回收而自动释放,需要由接口约定的一方安排释放。要在 Go 中调用 C.free,必须在 cgo 前导声明中包含 stdlib.h。

原文提醒,不应直接向 C 返回包含 Go 指针的 slice、map 等值,并给出了当时观察到的错误:

panic: runtime error: cgo result has Go pointer

原文从 GC 与悬挂指针解释这一点:如果 C 不受约束地保留 Go 管理的数据,Go 运行时就无法可靠掌握这些引用的生命周期。对本文的字符串和字节数据,实用的方案是使用 C.CString 或 C.CBytes 复制到 C 堆,再以 char* 或 void* 形式传递。

编辑订正:不是“把任何数据强制转换成 unsafe.Pointer 就安全了”。现行 cgo 指针规则按内存由谁分配来区分 Go 指针和 C 指针,而不是按指针的静态类型区分。现代 Go 还提供 runtime.Pinner 等受约束的固定内存机制,但这不允许任意把 string、slice、interface 交给 C 长期持有。本文采用的是明确复制到 C 堆的路线,不依赖绕开运行时检查。

为了说明 C.CBytes 的复制行为,原文引用了 Go 源码在特定历史提交中的实现片段:

const cBytesDef = `
func _Cfunc_CBytes(b []byte) unsafe.Pointer {
    p := _cgo_cmalloc(uint64(len(b)))
    pp := (*[1<<30]byte)(p)
    copy(pp[:], b)
    return p
}
`

这段代码来自原文指定的历史提交,用于解释分配与复制,不是当前 Go 的稳定内部 API,也不建议把 1<<30 的数组转换照搬为自己的通用实现。

内存在哪一方释放,要由接口约定决定。原文给出的简例是在 Go 侧创建 C 字符串、调用 C 打印函数,然后释放:

func main() {
    cs := C.CString("Hello from stdio")
    C.myprint(cs)
    C.free(unsafe.Pointer(cs))
}

这是一个省略上下文的片段;完整文件还需要 import “C”、unsafe,以及 myprint 和 free 的适当 C 声明。不能因为正文出现了 main,就认定这几行可以独立编译。

导出 Go 函数

要让 C 调用 Go 函数,需要在 package main 中用 //export 标记导出函数,再采用 c-shared 构建模式。原文以 macOS 动态库为例;Linux 一般使用 .so 扩展名:

# macOS示例;Linux可将输出名称改为libawesome.so
go build -v -o libawesome.dylib -buildmode=c-shared ./main.go

命令会写入本地构建产物,并生成声明导出函数签名的头文件。它要求目标平台适用的 Go 工具链、启用 cgo 和可用的 C 编译器;仅修改扩展名并不能把一个平台的二进制变成另一平台的库。

原文的 Hello 接收 Go 字符串,将内容转成大写并添加前缀,返回新分配的 C 字符串:

//export Hello
func Hello(msg string) *C.char {
    return C.CString("hello " + strings.ToUpper(msg))
}

对应的生成头文件包含类似定义:

typedef struct { const char *p; ptrdiff_t n; } _GoString_;
typedef _GoString_ GoString;
extern char* Hello(GoString p0);

p 指向字符串数据,n 表示长度;ptrdiff_t 是指针相减结果所用的有符号整数类型。本文补列了原项目头文件中的 GoString typedef,便于完整理解最后一行。实际 ABI 请以目标平台本次生成的头文件为准,不能把旧头文件当作跨所有体系结构的约定。

可对照作者的 main.go 和 libawesome.h。静态核验还发现,仓库头文件没有列出当前 main.go 中的 Hello2,说明生成文件与源码可能未同步;应使用实际构建生成的头文件,不直接复用仓库中的旧产物。

cgo → JNA:让 Java 调用 C 接口

原文比较了两条常见路径:JNA 和 JNI。JNA 的优势是使用方便,主要通过编写 Java 映射声明,让框架处理 C/Java 类型转换;JNI 则需要更多桥接代码,也提供更直接的底层控制。

原文把 JNI 概括为“性能好、调用繁琐”。这里应保留为选择方向,不把它视为所有调用场景下的性能定论;具体结果受数据转换、调用次数、复制成本与实现方式影响。作者提供了 JNA/JNI 对比文章作为延伸阅读。

JNA → Java:类型映射与动态库打包

JNA 将 Java 基本类型映射到对应的 C 类型。原文摘录的映射表如下;其中平台相关宽度不能忽略:

C 原生类型 大小或含义 Java / JNA 类型 原表常见 Windows 类型
char 8 位整数 byte BYTE、TCHAR
short 16 位整数 short WORD
wchar_t 16/32 位字符 char TCHAR
int 32 位整数 int DWORD
int 布尔值 boolean BOOL
long 32/64 位整数 NativeLong LONG
long long 64 位整数 long __int64
float 32 位浮点数 float —
double 64 位浮点数 double —
char* C 字符串 String LPCSTR
void* 指针 Pointer LPVOID、HANDLE、LPXXX

这是原文引用的历史入门表,不是完整 ABI 规范。TCHAR 取决于 Windows 字符集设置;Java char 固定为 16 位,而原生 wchar_t 可能为 32 位。涉及宽字符、无符号取值范围和平台相关整数时,要结合实际头文件与 JNA 文档核对。尤其要注意:char* 映射为 String 只说明字符串转换关系,不能据此推断原生分配的内存会被自动释放。

对于 C 的结构体和指针,JNA 分别提供 Structure 与 Pointer。原文引用的是 JNA 5.6.0 文档;使用其他版本时应读取对应版本说明,并核对字段顺序、对齐、按值或按引用传递。

作者还指出,按 JNA Getting Started 中的资源加载方式,把动态库放进 classpath 的平台目录,可以一同打包进 JAR,减少使用基础库时额外配置路径的需要。原文目录示例如下:

resources/
├── darwin
│   └── libawesome.dylib
├── linux-x86-64
│   └── libawesome.so
├── linux-aarch64
│   └── libawesome.so

目录前缀须符合实际 JNA 版本的平台资源查找规则。每个平台及体系结构需要匹配的二进制和依赖;放入 JAR 并不能消除本地动态库的兼容要求。

复杂返回值的五个示例

vladimirvivien/go-cshared-examples 演示了 Add、Cosine、Sort 和 Log 等函数的 JNA 调用。作者认为这些示例还不足以说明 string、slice 等复杂返回值的处理,因此补充了以下五个例子。本文逐一读取了所链接源文件及相关辅助类型;下面的代码摘录用于说明关键差异,不假装组成一个单文件可运行项目。

1. BadStringDemo:直接返回 String 会遗失释放入口

BadStringDemo.java 将 Hello 返回的 char* 声明为 Java String:

public static native String Hello(GoString.ByValue msg);

JNA 可以把字符内容转换成 Java 字符串,但不会因为做了转换就知道该如何释放 C.CString 分配的内存。调用方又没有保留原始 Pointer,因此这段演示会泄漏。原文件用无限循环放大泄漏现象;本文不把那个循环列为日常运行步骤。

原文件还用 msg.length() 传递字符串长度。这个长度是 Java UTF-16 代码单元数,不能通用于 UTF-8 编码后的字节数;示例中纯 ASCII 文本掩盖了这种差异。

2. GoodStringDemo:保留指针,用完后释放

GoodStringDemo.java 改为返回 Pointer,设置 UTF-8 字符串编码,并在 finally 中释放原生内存。核心片段是:

public static native Pointer Hello(GoString.ByValue msg);

String msg = "jna 你好 demo";
GoString.ByValue goStr = new GoString.ByValue();
goStr.p = msg;
goStr.n = msg.getBytes(Constants.UTF8).length;

Pointer ptr = null;
try {
    ptr = GoodStringDemo.Hello(goStr);
    System.out.println(ptr.getString(0, Constants.UTF8));
} finally {
    if (ptr != null) {
        Native.free(Pointer.nativeValue(ptr));
    }
}

finally 使正常返回与 Java 异常路径都能走到释放逻辑;先按一致的 UTF-8 编码求字节长度,也解决了中文文本的长度问题。原项目 Constants.UTF8 的值是 “utf8″,加载动态库时使用 Library.OPTION_STRING_ENCODING 配置相同编码。

释放器边界:原示例使用 Native.free。跨平台封装时,更稳妥的接口是让分配内存的同一原生库导出释放函数,保证分配与释放使用匹配的运行时/分配器,尤其不要跨不兼容的 C 运行时堆随意释放。下面是编辑补充的释放函数片段;它不是原项目现有的导出接口:

// cgo前导声明需包含:#include <stdlib.h>
//export FreeBuffer
func FreeBuffer(p unsafe.Pointer) {
    C.free(p)
}

Java 侧应在重新生成库和头文件后,把 FreeBuffer 映射为接收 Pointer 的原生函数,再用它替换 Native.free。只能对该库明确交给调用方所有权的分配结果调用一次,不能用于任意 Pointer 或 Go 堆地址。

3. AutoClosableStringDemo:用 try-with-resources 表达所有权

AutoClosableStringDemo.java 在前一个方案上包装出实现 AutoCloseable 的 FreeableString,使字符串资源能在离开 try 块时关闭:

public static native FreeableString Hello(GoString.ByValue msg);

GoString.ByValue goStr = new GoString.ByValue("中国 China");
try (FreeableString text = AutoClosableStringDemo.Hello(goStr)) {
    System.out.println(text.getString());
}

这个结构把释放责任放到类型上,调用者更容易正确使用。不过,核验到的原版 FreeableString.close() 释放后没有清空内部指针;重复 close 可能双重释放,close 后再次 getString 则可能读取已释放内存。

修正时,应保存待释放指针,把包装器置为已关闭状态,再通过匹配的释放函数释放;getString() 必须拒绝已关闭对象。若对象可被多线程共享,还需要同步访问或明确禁止并发关闭/读取,单纯把指针设为 null 不能自动解决线程竞态。

4. ReturnByteSliceDemo:指针加长度返回字节数据

ReturnByteSliceDemo.java 展示了 Go 多返回值与字节数据的处理。Go 侧先把切片复制到 C 堆,然后返回指针和长度:

//export ReturnByteSlice
func ReturnByteSlice() (unsafe.Pointer, int) {
    bs := []byte("hello world from golang")
    return C.CBytes(bs), len(bs)
}

生成的 C 接口把多返回值放入结构体:

struct ReturnByteSlice_return {
    void* r0;
    GoInt r1;
};
extern struct ReturnByteSlice_return ReturnByteSlice();

作者在 Java 中用按值传递的 Structure 映射结构体,r0 为 Pointer,r1 为 long,再通过 getByteArray(0, (int) ret.r1) 把数据复制到 Java byte[],最后在 finally 中释放 r0。

这里有三项需要修订:首先,Java long 对应本例生成的 64 位 GoInt,不能无条件推广到所有目标 ABI;其次,长度转 int 前必须检查非负、上限与缓冲区协议,否则窄化转换可能截断;最后,二进制数据不要当作 NUL 结尾的 C 字符串。原代码用 Native.toString(buf, “utf8”) 打印这个纯文本样本,对可能含零字节的数据会有截断语义;确认是 UTF-8 文本时可用 new String(buf, StandardCharsets.UTF_8),一般二进制则直接保留 byte[]。

更稳定的公开接口可以自行定义 C 结构体,用明确的长度类型与专用释放函数,避免让调用方长期依赖 Go 自动生成的内部布局。任何方案都仍需检查长度、所有权和目标平台的结构体布局。

5. ReturnInterfaceDemo:错误示范,不要释放 Go 指针

ReturnInterfaceDemo.java 刻意演示返回包含 Go 指针的 interface 会触发运行时错误。Go 端返回 error 接口:

//export ReturnInterface
func ReturnInterface() error {
    return fmt.Errorf("err is interface")
}

原生成头文件将它描述成有 t、v 两个指针的 GoInterface,Java 端尝试按 Structure 读取。这个结构体的外观并不意味着调用方获得了其中内存的所有权,更不意味着可以把它当普通 C 分配结果使用。

明确风险:原 Java 示例的 finally 还包含对 t、v 调用 Native.free 的代码。它们不是调用方通过匹配的 C 分配器获得的可释放缓冲区,因此不应执行这种释放;即便某次调用尚未触发预期 panic,也不能把这段 finally 当作正确清理方法。建议用整数状态码配合复制到 C 堆的错误消息,并通过专用函数释放;若确需传递 Go 对象标识,可研究 runtime/cgo.Handle 的受控句柄协议,而不是暴露 interface 内存布局。

direct mapping 与 interface mapping 的性能比较

作者的上述示例采用 direct mapping。interface mapping 的写法可参考前面提到的 go-cshared-examples。作者还做了一个小型基准比较,原文汇总如下:

方法 输入 输出 该次比较较快者 原文比率
Add 两个基本整型 int direct mapping 1.38
Hello string FreeableString interface mapping 1.169
Hello2 string Pointer direct mapping 1.0083

原文据此提出:对于基本类型(把 Pointer 也算在内),direct mapping 更有优势;在这个复杂返回值例子中,interface mapping 略占优势。作者还链接了相关讨论解释这一观察。

数据解释补充:仓库 README 保存了两轮吞吐量结果,每项 Cnt 为 20。Hello 与 Hello2 的误差区间很大,Hello2 的 1.0083 也只是一轮结果中很小的比值差异;不能据此宣布稳定的跨场景优胜者。这些数字是作者历史样本,本文未重跑、未复现,也未把它们包装成现行 JNA 或 Go 的通用性能结论。

把所有权写成接口的一部分

原文最终强调:C 常被用作不同高级语言之间的连接层,但它不会替你自动回收这些原生分配。做 JNA 集成时,除了让数据“能够返回”,还必须说明分配方、释放方、有效长度、字符编码、使用期限和错误路径。

对于本文的路径,一个清晰的约定是:Go 把需要跨边界保存的内容复制到 C 堆,Java 读取或复制结果,然后恰好一次地调用匹配的释放函数;Go 堆对象不能靠强制转换指针来假装拥有这种所有权。这样才有机会在后续的泄漏测试、异常测试和跨平台验证中得到可解释的结果。

原文参考与本次核验范围

本次核验日期为 2026 年 10 月 5 日。已读取完整原文、作者 main 分支中的五个示例、Go 源文件、头文件、字符串辅助类型和历史基准记录;全部只作静态审查,没有执行构建、Java/Go 示例、无限循环、原生内存释放或性能测试。原文未固定完整的平台和版本矩阵,本稿也不声称这些示例已经通过现代工具链验证。

原文与原代码署名保留刘家财,插图为编辑自绘。本文的纠错建议和边界说明均属于编辑增补。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容