一、JNI调用开销分析:别让“桥”成了瓶颈
做Camera HAL优化的朋友都知道,JNI是个绕不开的坎。说白了,Java层和C++层之间通信,全靠这座“桥”。但这座桥的通行费,比你想象的要贵得多。
我个人习惯在项目初期就做一次JNI调用开销的摸底测试。你猜怎么着?一次空的JNI调用,在高端芯片上大概要花50-100纳秒。听起来不多?但如果你在预览流里每帧调用几百次,那帧率直接掉一截。
1.1 为什么JNI调用这么贵?
JNI开销主要来自三方面:
- 线程切换与状态保存:从Java栈切换到Native栈,寄存器要压栈恢复
- 参数校验与类型转换:JNI层会检查参数类型、数组长度等
- GC屏障:JNI调用期间,GC可能被阻塞
核心结论:JNI调用次数越少越好,单次传递的数据量越大越好。
1.2 实测数据:不同调用方式的耗时对比
| 调用方式 | 单次耗时(ns) | 1000次耗时(μs) |
|---|---|---|
| 空JNI调用(无参数) | ~60 | ~60 |
| 传递1个int参数 | ~80 | ~80 |
| 传递1个ByteBuffer(Direct) | ~120 | ~120 |
| 传递1个byte[]数组 | ~350 | ~350 |
看到没?传递byte[]数组比DirectBuffer贵了将近3倍。为什么?因为JNI要拷贝数组内容。我在项目中遇到过,有人把每帧的YUV数据用byte[]传,结果CPU时间全耗在拷贝上了。
我的建议:能用DirectBuffer就别用byte[]。如果必须用数组,考虑用GetCriticalArrayElements绕过拷贝,但要注意临界区不能阻塞。
二、DirectBuffer vs ByteBuffer:谁才是性能之王?
这个问题我经常被问到。直接说结论:在Camera HAL场景下,DirectBuffer几乎是唯一选择。
2.1 两者的本质区别
- ByteBuffer(Heap):数据在Java堆上,JNI访问时需要拷贝到Native堆
- DirectBuffer:数据在Native堆上,JNI可以直接访问指针,零拷贝
你想想看,Camera每帧的数据量有多大?1080p的YUV420就是一帧3MB左右。如果每帧都拷贝一次,30帧就是90MB/s的拷贝开销。这还没算上GC压力。
2.2 什么时候用ByteBuffer?
说实话,我只有在数据量很小(比如几百字节的控制参数)或者需要频繁修改的场景下,才会考虑Heap ByteBuffer。因为DirectBuffer的分配和释放成本比较高。
注意:DirectBuffer的内存不受Java堆控制,分配太多会导致Native内存泄漏。我曾经见过一个项目,每帧都new DirectBuffer,结果OOM了——不是Java OOM,是Native OOM。
2.3 最佳实践:复用DirectBuffer
// 在初始化时分配一次,后续复用
private DirectBuffer mBuffer;
private long mNativePtr;
public void init(int size) {
mBuffer = ByteBuffer.allocateDirect(size);
mNativePtr = ((DirectBuffer) mBuffer).getPointer();
}
// JNI端直接操作指针
JNIEXPORT void JNICALL
Java_com_example_CameraHal_processFrame(JNIEnv* env, jobject thiz,
jlong ptr, jint size) {
uint8_t* data = reinterpret_cast<uint8_t*>(ptr);
// 直接处理data,零拷贝
}
嗯,这里要注意:getPointer()方法在Android 9以上才公开,低版本需要反射或者用JNI的GetDirectBufferAddress。
三、C++层性能优化:把每一纳秒都榨干
到了C++层,优化空间就大了。我个人习惯把Camera HAL的C++代码分成三层:
- 接口层:处理JNI调用,尽量少做事
- 处理层:核心算法,用SIMD、多线程优化
- 驱动层:直接操作硬件,用mmap、DMA
3.1 内存分配:能复用就别new
我在项目中遇到过,有人每帧都new一个vector<uint8_t>,然后析构。这会导致频繁的malloc/free,产生内存碎片。正确的做法是:
- 用对象池或环形缓冲区
- 用std::array代替std::vector(如果大小固定)
- 用placement new避免重新分配
3.2 算法优化:SIMD是王道
图像处理里,NEON指令集能带来4倍以上的加速。比如做YUV转RGB:
// 普通C++实现
for (int i = 0; i < size; i++) {
dst[i] = yuv_to_rgb(src[i]);
}
// NEON优化
uint8x16_t y = vld1q_u8(src);
uint8x16_t u = vld1q_u8(src + offset);
uint8x16_t v = vld1q_u8(src + offset2);
// ... 一次处理16个像素
说实话,手写NEON汇编有点痛苦。我建议用ARM的Intrinsics函数,编译器会帮你做寄存器分配。
3.3 多线程:别让CPU闲着
Camera HAL里,Pipeline的每个阶段都可以并行。我常用的模式是:
- 用std::thread或pthread创建固定线程池
- 用无锁队列(如boost::lockfree)传递数据
- 用条件变量做同步,避免忙等
避坑指南:我曾经在某个项目里用了std::async,结果发现它在Android上会创建大量线程,导致调度开销爆炸。后来换成固定线程池,性能提升30%。
四、NDK开发最佳实践:少走弯路
NDK开发坑不少,我踩过的坑能写本书。这里挑几个最关键的:
4.1 ABI选择:别一股脑全编
很多项目把armeabi、armeabi-v7a、arm64-v8a、x86全编了。其实大部分设备只需要arm64-v8a。我建议:
- 只编arm64-v8a(覆盖95%以上设备)
- 如果需要兼容32位,加armeabi-v7a
- x86/x86_64只在模拟器测试时用
4.2 编译优化:打开-O3和LTO
在CMakeLists.txt里加上:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -flto -march=armv8-a+simd")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -flto")
嗯,这里要注意:LTO(链接时优化)能跨编译单元优化,但会显著增加编译时间。我一般在Release版本才开。
4.3 日志与调试:别用printf
在NDK里,printf不会输出到logcat。要用__android_log_print:
#include <android/log.h>
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "CameraHAL", __VA_ARGS__)
我曾经见过有人用cout调试NDK,结果找了半天没看到输出——因为cout默认输出到stdout,而Android没有终端。
4.4 异常处理:别用C++异常
Android的NDK对C++异常支持不太好,而且异常会破坏JNI的栈帧。我建议:
- 用返回值表示错误(如返回int,0表示成功)
- 用errno或自定义错误码
- 实在要用异常,确保在JNI边界捕获并转换为错误码
我的习惯:在C++层定义一个Result<T>类型,类似Rust的Result,既安全又清晰。
五、知识体系总览
下面这张图总结了本章的核心内容,从JNI调用到C++优化,再到NDK实践,一条线串起来:
这张图把优化路径理清楚了。从JNI调用开始,到Buffer选择,再到C++层的算法和内存优化,最后落到NDK工程实践上。每一步都踩过坑,每一步都有优化空间。
总结一句话:JNI/Native优化的核心就是减少拷贝、复用内存、榨干CPU指令集。做到这三点,Camera HAL的性能不会差。
公众号:蓝海资料掘金营,微信deep3321