写JNA最难受的地方不是不知道怎么加载动态库也不是结构体映射搞不定而是面对C函数里一堆int *pOut、char **ppBuf这类指针参数时不知道该把哪个Java类型塞进去。尤其是项目做到后面你迟早会碰上一个接口签名长这样的函数传入一个指针进去函数在内部把内存填好然后把结果回传给你。这个时候值传递已经彻底不够用了ByReference就是为这种场景准备的。这个系列前两篇把JNA的环境搭建、基础映射、Structure和Pointer的用法都过了一遍这篇专门把“引用传递”这件事彻底讲透。什么是ByReference、内置了哪些类型、怎么自定义扩展、内存谁来管、线程安全怎么处理会用真实案例一步步拆开。该踩的坑我都替你踩过了这篇文章就是把这些经验直接写成能抄的作业。1. 引用传递这件事到底烦在哪1.1 JNA里的三种参数传递方式JNA的参数传递模式归根结底就是三件事值传递、引用传递、指针传递。值传递对应C语言里的普通参数比如int、double、charJNA里直接写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与LongByReferenceIntByReference大概是所有引用类型里出场率最高的。用法非常简单三步走创建对象、传给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 dataJNA里直接写成继承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 ListString 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的子类如果只实现接口而不用PointerTypeJNA不一定认。我最初自己踩过这个坑自定义了一个类实现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对象置为nullJVM一回收这块内存Native侧再用就是访问已经释放的内存。解决办法很朴素在Native函数明确告诉你不再使用这个指针之前Java侧保持对这个对象的强引用。可以把ByReference对象保存在一个成员变量里或者放进一个List持有等到C库触发了某个“完成回调”事件后再释放引用。public class CallbackHandler { private IntByReference progressRef; private final ListByReference 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 ListDeviceInfo 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(); } ListDeviceInfo 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.freeJNA内部把内存管理包了大半第二真正麻烦的还是那个谁也绕不开的“Native函数自己malloc出来的内存到底怎么释放”如果C库没有配套提供free_device_list之类的释放函数这块内存就会一直泄漏。我看过不少JNA项目问题出在封装层对“内存归属权”完全没有概念。写封装类之前先想清楚三个问题这块指针内存是谁分配的释放的责任在调用方还是C库Java对象被GC时Native内存由谁接管把这三个问题写成注释放在类头部后面维护的人会感激你。6. 高频异常与避坑速查6.1 典型运行时报错的排查路径最经典的问题是“函数调用不报错但Java侧拿到的值还是初始值”。遇到这种问题第一反应应该是看C函数的参数声明是不是int *而不是int。JNA对值类型和引用类型的处理是分开的如果你把Java参数声明成intJNA传过去的就是一个值拷贝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分配用PointerByReferenceshareOffset(i)用Structure[]直接声明参数Native保存指针异步使用在Java侧持有强引用直到完成回调函数调用完就把引用对象置null结构体作为输出参数自定义类继承Structure内部加ByReference接口直接用父类Structure传参JNA可能按值传递Nativelong参数用NativeLongByReference用LongByReferenceWindows/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引用传递基本就过关了。后面再遇到结构体数组、动态内存释放这类进阶场景思路完全一致想清楚内存是谁的引用怎么传读数怎么读三个问题一过代码自然就写得稳。
阅读完成 · 觉得有帮助?