写JNA最难受的地方,不是不知道怎么加载动态库,也不是结构体映射搞不定,而是面对C函数里一堆int *pOut、char **ppBuf这类指针参数时,不知道该把哪个Java类型塞进去。尤其是项目做到后面,你迟早会碰上一个接口签名长这样的函数:传入一个指针进去,函数在内部把内存填好,然后把结果回传给你。这个时候,值传递已经彻底不够用了,ByReference就是为这种场景准备的。
这个系列前两篇把JNA的环境搭建、基础映射、Structure和Pointer的用法都过了一遍,这篇专门把“引用传递”这件事彻底讲透。什么是ByReference、内置了哪些类型、怎么自定义扩展、内存谁来管、线程安全怎么处理,会用真实案例一步步拆开。该踩的坑我都替你踩过了,这篇文章就是把这些经验直接写成能抄的作业。
1. 引用传递这件事,到底烦在哪
1.1 JNA里的三种参数传递方式
JNA的参数传递模式,归根结底就是三件事:值传递、引用传递、指针传递。值传递对应C语言里的普通参数,比如int、double、char,JNA里直接写Java的基本类型就行,函数内部怎么改都不影响Java这边的变量。指针传递对应C语言的Type *,但JNA里如果搞不清楚指针的归属,很容易写出野指针访问崩溃的代码。
引用传递是JNA单独定义的一套语义,它在底层帮你开了一块Native内存,把Java的值塞进去,然后把这块内存的地址传给C函数。C函数修改的是这块内存,JNA负责把修改后的值再读回Java端。用生活里的例子类比,值传递相当于你把文件复印件交给对方,他怎么改都影响不到你的原件;引用传递相当于你把文件原件放在公共柜子里,然后把柜子钥匙交给他,他改了原件,你打开柜子看到的自然就是修改后的内容。
这个机制在JNA里对应的就是com.sun.jna.ptr.ByReference接口,以及它的一票子类实现。最常打交道的有IntByReference、LongByReference、PointerByReference、WStringByReference,还有ByteByReference、ShortByReference、FloatByReference、DoubleByReference这些。他们的用法套路完全一致:构造时设置初始值,传给Native函数,函数返回后再通过getValue()把结果拿出来。
1.2 什么场景非用它不可
判断什么时候该用ByReference,有个简单的口诀:C函数如果只是读取这个参数,用值传递;如果函数会修改这个参数并希望调用者拿到修改结果,就必须用引用传递。
典型场景第一个是“输出参数”。很多C库的API都遵循一种设计规范,函数返回值只用来表示成功或失败,真正的业务结果全部通过参数指针回传。比如一个获取设备信息的接口,很可能是这种签名:
int getDeviceInfo(char *deviceId, int *pStatus, double *pVersion);deviceId入参传缓冲区,pStatus和pVersion都是输出参数,函数内部往这两个地址写入数据。这种时候,Java端如果只声明int status = 0传进去,C函数改的只是它自己栈上的一份拷贝,Java这边永远拿不到结果。
第二个典型场景是“缓冲区填充”。比如读取文件、读取注册表、读取系统日志,C函数往往要求调用者先定义一个足够大的char[]或char *buffer,函数负责往里面填内容,填完再通过指针告诉你实际写入长度。JNA里对应Memory配合PointerByReference,或者用byte[]加WStringByReference,都能解决,但选型不一样踩的坑也完全不一样。
第三个场景是“Native侧回调后保存状态”。有些C库注册了回调函数之后,并不会立刻执行,而是等某个异步事件触发后才调用。这个时候如果你把一个Java对象的引用传进去,一定要小心JVM的垃圾回收。稍后我会在内存生命周期那一节专门讲这个,这里先提个醒,ByReference对象创建出来之后,Java侧必须保住它不被GC,否则Native侧拿着一个已经回收的指针去写,就是经典的SIGSEGV崩溃。
2. JNA内置的引用类型逐个拆解
2.1 数字型引用:IntByReference与LongByReference
IntByReference大概是所有引用类型里出场率最高的。用法非常简单,三步走:创建对象、传给Native函数、函数返回后读取新值。
IntByReference statusRef = new IntByReference(0); int ret = lib.getDeviceInfo(deviceId, statusRef, versionRef); int status = statusRef.getValue();构造方法new IntByReference(int value)做的事情,是在Native内存中分配4字节空间并初始化为传入的值。JNA源码里这段逻辑其实很朴素:构造一个Pointer,把值写进去,后续getPointer()返回的就是这块内存的地址,getValue()则从这个地址读回Java基本类型。
LongByReference、ShortByReference、ByteByReference、FloatByReference、DoubleByReference套路一模一样,只是分配的内存空间大小不同。实际使用时要注意长度匹配的问题,int是4字节,long在C语言里可能是8字节,Windows平台上long实际是4字节,但JNA的NativeLong会帮你处理平台差异。如果调用的C库用long *作为参数,Java端就别声明成LongByReference,而应该用NativeLongByReference。
NativeLongByReference countRef = new NativeLongByReference(new NativeLong(0)); lib.getCount(countRef); long count = countRef.getValue().longValue();NativeLong是JNA针对C语言long的跨平台适配类型,在64位Linux/macOS下是64位,在64位Windows下是32位。这个细节如果不注意,在Windows上开发好的代码拿到Linux服务器上跑,读出来的数据就是错乱的。
2.2 指针与字符串引用:PointerByReference与字符串系列
PointerByReference是另一个高频类型,但它和前面的数字型引用有本质区别。数字型引用里面存的是数字值,PointerByReference里面存的是一块内存的地址,也就是“指针的指针”。C语言里的char **、void **这类参数,对应的就是它。
最常见的用途是C函数内部动态分配内存,然后把指针回传给你。比如一个C库想返回一段格式化的字符串,它的签名可能是:
int getErrorMessage(int code, char **msgPtr);函数内部*msgPtr = malloc(...),然后往这块内存写字符串。Java端的对应写法是:
PointerByReference msgRef = new PointerByReference(); int ret = lib.getErrorMessage(107, msgRef); Pointer msgPointer = msgRef.getValue(); if (msgPointer != null) { String message = msgPointer.getString(0); }这里有个关键点:PointerByReference的getValue()返回的是Pointer,不是字符串。你需要再调用Pointer.getString(0)才能拿到内容,而且拿到之后要考虑这块内存到底谁来释放。如果C函数用的malloc,而你用的JNA默认分配器,两侧的内存分配器不同,直接释放很容易出问题。
字符串引用类型的选定,取决于C库使用的编码格式。Linux下绝大多数C库默认UTF-8,直接用String就行;Windows下很多API是UTF-16宽字符串,必须用WString和WStringByReference;老式C库还可能是GBK等本地编码,这种情况最麻烦,String默认编码不对就会出来一堆乱码。
WStringByReference nameRef = new WStringByReference(new WString("")); lib.getUserName(nameRef); String name = nameRef.getValue().toString();WStringByReference的内部实现和数字型引用类似,分配好内存后把Java的WString写进去,Native函数可以原地修改内容,Java端随后再读出来。但注意,如果C函数内部修改了字符串长度超过原始分配的空间,就会发生缓冲区溢出,这和C语言里常见的字符串安全问题一样,JNA不会帮你做边界检查。
3. 结构体与自定义引用类型
3.1 结构体参数的两种姿势
结构体作为函数参数,在JNA里有两种语义:按值传递和按引用传递。按值传递对应C语言的struct Data data,JNA里直接写成继承Structure的类,什么都不用标。按引用传递对应C语言的struct Data *data,函数会修改结构体内部的字段,这时同样继承Structure但要多做一件事。
JNA官方推荐的做法是在Structure子类里加上一个ByReference内部接口,怎么理解这件事呢。其实JNA对StructureByReference的处理方式非常直接:它传出去的是结构体内存的首地址,C函数通过这个地址去修改结构体各个字段,Java端因为持有同一个对象,所以修改后的值自然能直接读取。
public class DeviceInfo extends Structure { public byte[] deviceId = new byte[32]; public int status; public double version; public DeviceInfo() { super(); } @Override protected List<String> getFieldOrder() { return Arrays.asList("deviceId", "status", "version"); } public static class ByReference extends DeviceInfo implements Structure.ByReference {} }接口声明改成这样:
int getDeviceInfo(DeviceInfo.ByReference info);调用的时候:
DeviceInfo info = new DeviceInfo.ByReference(); lib.getDeviceInfo(info); System.out.println("status=" + info.status + ", version=" + info.version);这里要特别提醒:DeviceInfo.ByReference和DeviceInfo在JVM里是同一个对象的内存,C函数往info.status写入时,JNA在函数返回后不需要做任何同步,因为Native修改的就是Java对象背后那同一块原生内存。这也是Structure.ByReference和Structure作为值传递时的最大区别——值传递时Java端和Native端各自持有一份拷贝。
3.2 自定义引用类型:手写一个ByReference子类
除了JNA内置的那几个引用类型,你完全可以自己定义引用类型,只要实现ByReference接口就行。这个能力在遇到C库自定义了类型别名时特别有用,比如C侧定义typedef int STATUS;,或者把time_t当64位整数用,你可以在Java侧声明TimeTByReference,让代码语义更清晰。
看一个实际例子:
public class TimeTByReference extends PointerType implements ByReference { public TimeTByReference() { super(); } public TimeTByReference(long value) { super(new Memory(NativeLong.SIZE)); getPointer().setLong(0, value); } public long getValue() { Pointer pointer = getPointer(); if (pointer == null) { return 0; } return pointer.getLong(0); } }注意这里不是直接实现ByReference接口,而是继承了PointerType并标记ByReference。JNA对传参的判断逻辑更多是看类型本身是否是PointerType的子类,如果只实现接口而不用PointerType,JNA不一定认。我最初自己踩过这个坑,自定义了一个类实现ByReference接口,结果函数调用直接报IllegalArgumentException,换成继承PointerType之后一切正常。
还有一种更常见的自定义场景,是需要把C函数内部的“输出参数绑定到结构体字段”。比如函数要返回一个结构体数组,C侧签名是int getRecords(Record **records, int *count),Java端的思路是先声明PointerByReference recordsRef和IntByReference countRef,调用后拿到Pointer数组的首地址,然后把这连续内存逐个映射成Record结构体对象。
PointerByReference recordsRef = new PointerByReference(); IntByReference countRef = new IntByReference(0); lib.getRecords(recordsRef, countRef); Pointer recordsPtr = recordsRef.getValue(); int count = countRef.getValue(); Record[] records = new Record[count]; for (int i = 0; i < count; i++) { records[i] = new Record(recordsPtr.shareOffset((long) i * Record.SIZE)); }shareOffset计算的是每一条记录在这块连续内存里的起始地址,每条Record的大小就是Record.SIZE。如果结构体里有对齐要求,直接用JNA的Structure默认对齐规则就行,SIZE的值JNA会帮你算好。
4. 内存生命周期与并发,两个最容易出事的坑
4.1 Native持有引用时的内存归属问题
用ByReference最大的隐藏风险,是Java对象的GC生命周期和Native的指针使用周期不一致。如果C函数只是同步调用,函数返回后就不再碰那个指针,那么JNA的默认机制已经足够安全,Java对象内存随你JVM怎么回收都无所谓。但有一种情况必须手动干预:C库注册了异步回调,回调里保存了传入的指针,过了一段时间才继续读写这个指针。
打个比方,你把家里钥匙交给一个装修师傅,说好本周施工,师傅拿到钥匙之后并没有马上用,而是把钥匙放进了抽屉,下周才来装修。你以为装修已经结束了,把门锁换了,师傅再来直接就进不去了。Native侧持有指针也是同样的问题,Java侧如果把ByReference对象置为null,JVM一回收这块内存,Native侧再用就是访问已经释放的内存。
解决办法很朴素:在Native函数明确告诉你不再使用这个指针之前,Java侧保持对这个对象的强引用。可以把ByReference对象保存在一个成员变量里,或者放进一个List持有,等到C库触发了某个“完成回调”事件后再释放引用。
public class CallbackHandler { private IntByReference progressRef; private final List<ByReference> pinnedRefs = new ArrayList<>(); public void startAsyncTask() { progressRef = new IntByReference(0); pinnedRefs.add(progressRef); lib.startAsyncTask(progressRef); } public void onTaskFinished() { pinnedRefs.remove(progressRef); } }CallbackHandler这类写法是在实际项目中形成的经验。有一次我做某图像处理库的封装,函数有一个FrameCallback,回调参数里传了一个PointerByReference,在回调触发之前Java端对象就被GC了,程序时不时崩溃,排查了两天才定位到是强引用没保住的问题。从那以后,所有会跨线程存指针的对象,我统一都放进一个“钉子列表”里。
4.2 多线程访问同一个引用对象的同步策略
ByReference对象并不是线程安全的,这一点在JNA的官方文档里没有特别强调,但实际并发场景下是会出问题的。如果两个线程同时读写同一个IntByReference,一个线程调setValue,另一个线程调getValue,底层就是两个Pointer在访问同一块内存,轻则读到的数据不一致,重则Native侧在写的时候被Java侧的读打断,生成半新半旧的数据。
哪种场景容易遇到这个问题?最常见的是异步任务加轮询模式:一个工作线程调用C函数更新某个引用类型的值,主线程周期性读取这个值来做进度显示。如果不做同步,进度条就会偶发跳变。
解决思路有两个层面。简单层面的方案是给引用对象加锁,所有读取和写入都包在synchronized代码块里。
synchronized (progressRef) { progressRef.setValue(newValue); } synchronized (progressRef) { int current = progressRef.getValue(); }复杂层面的方案是干脆不用ByReference做跨线程桥梁,改成单向无锁设计,工作线程写入结束后,通过回调或AtomicInteger通知主线程。如果数据本来就是基本类型,完全可以用AtomicInteger或AtomicLong替代ByReference,只在线程终结时才把结果读出来。实际开发中,我建议优先考虑“引用对象只在单一线程内使用”,跨线程通信用JNA的Callback机制更优雅。
还有一点容易被忽略:JNA在调用Native函数时不会做任何同步,它假设调用方自己保证线程安全。所以同一个动态库的同一个函数,如果C侧实现本身就不是线程安全的,你并发调用依然会出问题。这也算是一个底层认知,JNA只是一个桥,桥本身是安全的,桥两端的责任要你自己承担。
5. 完整实战:封装一个设备信息查询接口
5.1 场景设计与C侧接口定义
纸上谈兵说得再多,不如直接来一个能跑通的全流程案例。假设我这里有一个某公司内部自研的硬件管理动态库,对外提供一组C接口,其中核心函数是获取设备信息。C侧头文件定义大概是这样的:
typedef struct { char deviceId[32]; int status; double version; } DeviceInfo; int get_device_list(DeviceInfo **devices, int *count); int get_device_status(const char *deviceId, int *pStatus, char *errBuf, int errBufLen); int set_device_status(const char *deviceId, int status, int *pResult);这三个函数分别覆盖了三种引用传递场景:DeviceInfo **代表“需要回传指针数组”,int *代表“输出参数”,char *配合长度参数代表“应用层提供缓冲区由Native填充”。这个设计在真实C库中非常典型,几乎把ByReference的所有玩法都涵盖了。
Java侧第一步是定义Library接口。注意get_device_list的参数要拆解成三层:第一个参数是PointerByReference,因为C侧要在函数内部分配一块连续内存存放结构体数组;第二个参数是IntByReference,因为C侧要回传数组长度。返回值是int,用来表示错误码。
public interface DeviceLib extends Library { DeviceLib INSTANCE = Native.load("device_lib", DeviceLib.class); int get_device_list(PointerByReference devices, IntByReference count); int get_device_status(String deviceId, IntByReference pStatus, byte[] errBuf, int errBufLen); int set_device_status(String deviceId, int status, IntByReference pResult); }5.2 Java侧完整实现与调用过程
工具类DeviceApi的完整代码直接贴出来,每个方法都加上注释,包括内存释放的处理方式。
public class DeviceApi { private final DeviceLib lib = DeviceLib.INSTANCE; public List<DeviceInfo> listDevices() { PointerByReference devicesRef = new PointerByReference(); IntByReference countRef = new IntByReference(0); int ret = lib.get_device_list(devicesRef, countRef); if (ret != 0) { throw new RuntimeException("get_device_list failed, code=" + ret); } Pointer devicesPtr = devicesRef.getValue(); int count = countRef.getValue(); if (devicesPtr == null || count <= 0) { return Collections.emptyList(); } List<DeviceInfo> result = new ArrayList<>(count); for (int i = 0; i < count; i++) { DeviceInfo info = new DeviceInfo(devicesPtr.shareOffset((long) i * DeviceInfo.SIZE)); result.add(info); } // 这里需要调用C库提供的释放函数,释放devicesPtr指向的内存 return result; } public DeviceStatus getDeviceStatus(String deviceId) { IntByReference statusRef = new IntByReference(-1); byte[] errBuf = new byte[256]; int ret = lib.get_device_status(deviceId, statusRef, errBuf, errBuf.length); if (ret != 0) { String errMsg = new String(errBuf, StandardCharsets.UTF_8).trim(); throw new RuntimeException("get_device_status failed, code=" + ret + ", msg=" + errMsg); } return DeviceStatus.fromCode(statusRef.getValue()); } public void setDeviceStatus(String deviceId, DeviceStatus status) { IntByReference resultRef = new IntByReference(0); int ret = lib.set_device_status(deviceId, status.getCode(), resultRef); if (ret != 0 || resultRef.getValue() != 1) { throw new RuntimeException("set_device_status failed"); } } }整个流程跑下来,你会有几个直观感受:第一,ByReference用起来确实顺手,不需要手动调用Native.malloc和Native.free,JNA内部把内存管理包了大半;第二,真正麻烦的还是那个谁也绕不开的“Native函数自己malloc出来的内存到底怎么释放”,如果C库没有配套提供free_device_list之类的释放函数,这块内存就会一直泄漏。
我看过不少JNA项目,问题出在封装层对“内存归属权”完全没有概念。写封装类之前,先想清楚三个问题:这块指针内存是谁分配的?释放的责任在调用方还是C库?Java对象被GC时Native内存由谁接管?把这三个问题写成注释放在类头部,后面维护的人会感激你。
6. 高频异常与避坑速查
6.1 典型运行时报错的排查路径
最经典的问题是“函数调用不报错,但Java侧拿到的值还是初始值”。遇到这种问题,第一反应应该是看C函数的参数声明是不是int *而不是int。JNA对值类型和引用类型的处理是分开的,如果你把Java参数声明成int,JNA传过去的就是一个值拷贝,Native函数怎么改都影响不到Java端。解决方案是把接口方法上的参数类型从int改成IntByReference,然后调用处用xxx.getValue()读取。
第二类高频问题是SIGSEGV (0xb) at pc=...崩溃。这种崩溃十有八九是Native写内存越界了。常见的元凶是缓冲区长度不匹配,比如C函数要写32字节的字符串,你Java这边只给了16字节的byte[];或者用了WString但C侧按char*处理,编码长度对不上。排查方法是先用Native.setProtected(true)把JVM的崩溃保护开起来,JNA就会捕获非法内存访问并发出一条包含访问地址的Java异常,而不是直接让JVM进程崩掉。
Native.setProtected(true);开了这个选项之后,崩溃会变成带详细堆栈的java.lang.Error,方便定位到具体是哪一个Native调用出了问题。生产环境不建议开着,但调试阶段强烈建议开。
第三种情况是结构体字段读出来全是0或者乱码。绝大部分原因是getFieldOrder()里声明的字段顺序和C结构体的定义顺序不一致。JNA靠字段名顺序去算偏移量,顺序错了整个内存布局就全乱了。
6.2 一份可以直接收藏的避坑清单
我把JNA项目里关于引用传递的坑整理成清单,发布在项目的README里,现在直接分享出来:
| 场景 | 正确做法 | 错误做法 |
|---|---|---|
| C函数修改int参数 | 用IntByReference,调用后getValue() | 用int传参,指望函数修改后Java侧变量变了 |
C函数返回char ** | 用PointerByReference,拿到Pointer后再getString(0) | 用StringByReference去接,拿不到正确内容 |
| Windows下UTF-16字符串 | 用WString/WStringByReference | 用String,中文直接乱码 |
| 结构体数组由Native分配 | 用PointerByReference+shareOffset(i) | 用Structure[]直接声明参数 |
| Native保存指针异步使用 | 在Java侧持有强引用,直到完成回调 | 函数调用完就把引用对象置null |
| 结构体作为输出参数 | 自定义类继承Structure,内部加ByReference接口 | 直接用父类Structure传参,JNA可能按值传递 |
Nativelong参数 | 用NativeLongByReference | 用LongByReference,Windows/Linux行为不一致 |
| C库内部malloc的内存 | 确认C库有对应的free函数,封装层负责调用 | 无脑Pointer.dispose(),可能崩溃或泄漏 |
这个表我后来补充了第8行背后的故事。某次项目中,我调用了一个返回指针的C函数,按照惯例调用Pointer.dispose()释放内存,结果程序直接崩溃。后来排查才明白,那块内存是C库自己的内存池管理的,根本不允许外部释放。正确做法是调用C库提供的lib.free(ptr)。从那时起,我养成了一个习惯:每一处涉及到内存的Native调用,都先在C头文件里确认“谁来释放”,再写Java代码。
关于字符串参数还有一个容易被忽略的细节,如果你用byte[] errBuf = new byte[256]传给C函数,Java数组会被JNA自动映射成指针,Native函数往里写内容之后,Java侧直接读同一个数组的内容就可以了,不需要再转一次引用类型。只有函数内部会重新分配缓冲区地址的时候,才需要PointerByReference。
按照个人经验,最适合入手练习的场景是把你手头某个C库的int*输出参数改成IntByReference跑一遍,再把它返回的字符串指针改成PointerByReference跑一遍。这两个用例通了之后,JNA引用传递基本就过关了。后面再遇到结构体数组、动态内存释放这类进阶场景,思路完全一致:想清楚内存是谁的,引用怎么传,读数怎么读,三个问题一过,代码自然就写得稳。