JNI编程笔记四

本文重点介绍JNI字符串部分操作,由于字符串也是对象类型,所以字符串的操作与基本数据类型是有差异的。前面介绍了JNI在操作对象类型时,必须借助于相应的函数,JNI也提供了不同的字符串操作函数。由于不同编程语言之间底层字符串存储格式不同,所以在C与Java之间操作字符串时,如果包含了中文编码,而又没有做特殊处理,直接使用JNI提供的字符串函数,这时候容易出现中文的乱码问题,那么如何解决乱码问题呢?

Unicode UTF-8 UTF-16

在介绍C语言访问Java中String之前,先了解一下有关字符编码的知识点。

Java中String的存储格式默认是UTF-16编码,而且UTF-16是也是Unicode字符集规定的标准编码方案

Unicode只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。UTF-8是目前互联网上使用最广的一种Unicode实现方式,它是一种可变长编码,UTF-8用1到6个字节编码Unicode字符。对于英语字符来说,一般ASCII编码同UTF-8是相同的,所以说UTF-8是一种兼容ASCII编码方式。

上文说了UTF-16是Unicode字符集规定的标准编码方案,所以一般所说的Unicode编码是可以理解为就是使用的UTF-16编码。UTF-16也是一种变长的编码方式,但是它不同于UTF-8编码,UTF-8编码可能是1到6之间的任意整数字节编码,而UTF-16则只可能是2或者4个字节长度的编码方式。如果使用UTF-16编码方式意味着在ASCII中单个英文字符也需要两个字节存储,所以UTF-16是不兼容ASCII编码的。

除了比较常用的UTF-8和UTF-16编码方式之外,还有其它UTF编码方式,比如UTF-32,有兴趣的可以网上查阅其它相关资料了解一下。

在Java中String默认存储格式是UTF-16,那么一个字符占用几个字节长度,我们可以借助代码Java中方法验证一下。有一点需要说明,Java在底层使用UTF-16编码,但是在使用String提供的一些相关方法时,如果不指定编码格式,它默认对外输出采用的是UTF-8编码方式

接下来借助于一个简单示例,我们看一下不同的字符串在不同语言环境下所占用的字节数。

Java中String示例

String类中getBytes()方法在不传入编码类型的情况下,默认使用如下方法返回的编码格式。

public static Charset defaultCharset() {
	if (defaultCharset == null) {
		synchronized (Charset.class) {
			String csn = AccessController.doPrivileged(
				new GetPropertyAction("file.encoding"));
			Charset cs = lookup(csn);
			if (cs != null)
				defaultCharset = cs;
			else
				defaultCharset = forName("UTF-8");
		}
	}
	return defaultCharset;
}

Java中使用UTF-8和UTF-16时字符串占用的字节数。

String str="A";
System.out.println(str.getBytes().length);// 1
System.out.println(str.getBytes("UTF-16").length);// 4
System.out.println("========================");
str="AB";
System.out.println(str.getBytes().length);// 2
System.out.println(str.getBytes("UTF-16").length);// 6
System.out.println("========================");

String str01="中";
System.out.println(str01.getBytes().length); // 3
System.out.println(str01.getBytes("UTF-16").length);// 4
System.out.println("========================");
str01="中国";
System.out.println(str01.getBytes().length);// 6
System.out.println(str01.getBytes("UTF-16").length);// 6

C中String示例

char *str01 = "A";
printf("str01 len :%d \n", strlen(str01));//str01 len :1
printf("%s \n", "=============================");

char *str02 = "AB";
printf("str02 len :%d \n", strlen(str02));//str02 len :2
printf("%s \n", "=============================");

char *str03 = "中";
printf("str03 len :%d \n", strlen(str03));//str03 len :2
printf("%s \n", "=============================");

char *str04 = "中国";
printf("str04 len :%d \n", strlen(str04));//str04 len :4

JNI中String示例

char *str01 = "A";
jstring jstr01 = (*env)->NewStringUTF(env, str01);
jsize size01=(*env)->GetStringUTFLength(env,jstr01);
printf("size01:%ld \n",size01);// size01:1
printf("%s \n", "===================================");

char *str02 = "AB";
jstring jstr02 = (*env)->NewStringUTF(env, str02);
jsize size02 = (*env)->GetStringUTFLength(env, jstr02);
printf("size02:%ld \n", size02);// size02:2
printf("%s \n", "===================================");

char *str03 = "中";
jstring jstr03 = (*env)->NewStringUTF(env, str03);
jsize size03 = (*env)->GetStringUTFLength(env, jstr03);
printf("size03:%ld \n", size03);// size03:4
printf("%s \n", "===================================");

char *str04 = "中国";
jstring jstr04 = (*env)->NewStringUTF(env, str04);
jsize size04 = (*env)->GetStringUTFLength(env, jstr04);
printf("size04:%ld \n", size04);// size04:6

如下表是不同字符串在不同环境下所占内存的字节信息。

字符串Java UTF-8字节C字节JNI UTF-8字节
"A"111
"AB"222
"中"324
"中国"646

从上面表格可以看出,如果是英文字符,无论在哪种环境下,输出后的结果都是一致的,但是中文却有很大的差异性,一旦处理不当,就很有可能引发乱码,JNI中一旦涉及到中文,如果不使用额外逻辑处理,几乎一定会出现乱码。一般情况下C语言环境中文采用的GB2312格式编码,中文占用2字节长度的存储空间,而使用JNI中相关的UTF函数,它们采用的编码都是改良版本的UTF-8编码,Java底层表示字符串默认使用的是UTF-16编码格式。

上述示例中的“中”字,在不同环境下有可能占用2、3和4三种字节长度中的一种,如果存储采用的是2字节存储一个汉字,而解析时却使用了3字节的方式解析,这时候一定会解析出乱码,或者是一堆不认识的文字。

下文我们会介绍一种相对简单方式,通过该方式可以处理不同环境交互中的中文乱码问题。

JNI访问Java中String类型

public native String getStrData(String src);

/*
* Class:     com_sunny_demo_JNIMethod
* Method:    getStrData
* Signature: (Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_sunny_demo_JNIMethod_getStrData
(JNIEnv *env, jobject jobj, jstring jstr){
	const char *str = (*env)->GetStringUTFChars(env, jstr, NULL);
	if(str==NULL){
		return NULL;
	}
	printf("c str:%s \n",str);
	char *result = "hello world !";
	strcat(result, str);
	(*env)->ReleaseStringUTFChars(env, jstr, str);
	return (*env)->NewStringUTF(env, result);
}

JNI支持许多操作字符串的函数,这里重点介绍三个函数,它们分别是:NewStringUTF()、GetStringUTFChars()和ReleaseStringUTFChars()。 可以看出上面三个函数都带有UTF的字符,一般开发项目也是采用UTF编码格式,不过这里的UTF是指UTF-8编码。

jstring对应的其实就是Java中的String类,但是它们只是一种映射关系,却不能够进行直接赋值操作。同样,jstring也不能与C/C++中的字符串或者字符指针之间进行直接赋值,必须使用合适的JNI函数将jstring转换为C/C++字符串。

除非值是基本数据类型,基本数据类型在本地代码中JNI数据和C/C++数据之间是可以直接相互赋值的。否则,如果是引用类型,必须使用合适的JNI函数在JNI和C/C++数据类型之间进行合适的转换操作。

GetStringUTFChars

const jbyte* GetStringUTFChars(JNIEnv *env, jstring string, jboolean *isCopy); 

GetStringUTFChars可以把一个jstring指针(指向JVM内部的Unicode字符序列)转化成一个UTF-8格式的 C字符串。如何你确信原始字符串数据只包含7-bit ASCII字符,我们可以把转化后的字符串传递给常规的C库函数使用,如printf。

上文所说的转化后的字符串传递给C库,其实就是指使用GetStringUTFChars函数拿到的一个char指针类型数据,并不是指直接将jstring使用printf函数输出。

标准的ASCII字符有128个,所有的7-bit ASCII字符的值都在1~127之间,这些值在UTF-8编码中保持原样。一个字节如果最高位被设置了,意味着这是一个多字节字符(16-bit Unicode 值)。一个null字符其实是占用两个字节的UTF-8编码。

如下是官方文档中对改良版UTF-8编码的一段描述。
There are two differences between this format and the standard UTF-8 format. First, the null character (char)0 is encoded using the two-byte format rather than the one-byte format. This means that modified UTF-8 strings never have embedded nulls. Second, only the one-byte, two-byte, and three-byte formats of standard UTF-8 are used. The Java VM does not recognize the four-byte format of standard UTF-8; it uses its own two-times-three-byte format instead.

大致意思是说,改良版的UTF-8和标准版的有两个差异,其一,null字符在改良版本中占用两个字节而非一个字节。意味着改良版的字符串中不会出现多个空null字符。其二,改良版的UTF-8的字节长度仅仅是1,2,3三种字节长度中的一种,我们知道标准版UTF-8可以是1-6之间的任意整数长度字节。

在实际开发中不要忘记对GetStringUTFChars函数返回值做检查,GetStringUTFChars函数会在JVM内部分配内存,如果因为内存少而分配失败,此时返回一个NULL,并且会抛出OutOfMemoryError异常。JNI中异常和Java中异常是不同的,一个JNI抛出的异常并不会改变程序的执行流程,因此需要一个显式的return语句跳过C函数中的剩余语句。有关异常会在后续文章中结合示例再做进一步介绍。

isCopy介绍

当从JNI函数GetStringUTFChars中返回得到字符串B时,如果B是原始字符串java.lang.String的拷贝,则isCopy被赋值为JNI_TRUE。如果B和原始字符串指向的是JVM中的同一份数据,则isCopy被赋值为 JNI_FALSE。当isCopy值为JNI_FALSE时,本地代码决不能修改字符串的内容,否则JVM中的原始字符串也会被修改,这会打破Java 语言中字符串不可变的规则。

通常,因为我们不必关心JVM是否会返回原始字符串的拷贝,只需要为isCopy传递NULL作为参数。

JVM是否会通过拷贝原始Unicode字符串来生成UTF-8字符串是不可以预测的,程序员最好假设它会进行拷贝,而这个操作是花费时间和内存的。一个典型的JVM会在Heap上为对象分配内存。一旦一个Java字符串对象的指针被传递给本地代码,GC就不会再碰这个字符串。换言之,这种情况下,JVM必须pin这个对象。可是,大量地pin一个对象是会产生内存碎片的,因为,虚拟机会随意性地来选择是复制还是直接传递指针。

当你不再使用一个从GetStringChars得到的字符串时,不管JVM内部是采用复制还是直接传递指针的方式,都不要忘记调用 ReleaseStringChars。根据方法GetStringChars是复制还是直接返回指针, ReleaseStringChars会释放复制对象时所占的内存,或者unpin 这个对象。

ReleaseStringUTFChars

void ReleaseStringUTFChars(JNIEnv *env, jstring string,const char *utf); 

从GetStringUTFChars中获取的UTF-8字符串在本地代码中使用完毕后,要使用ReleaseStringUTFChars告诉JVM这个UTF-8字符串不会被使用了,因为这个UTF-8字符串占用的内存会被回收。

ReleaseStringUTFChars这里需要释放的是最后一个参数,该参数就是通过GetStringUTFChars函数获取到的本地char指针。需要注意上面所说的在“使用完毕后”,如果后续代码还要使用该字符指针,但是已经调用过ReleaseStringUTFChars函数了,那么代码可能会出现乱码或者直接崩溃,因为字符指针说指向的内存可能已经被回收了。

NewStringUTF

jstring NewStringUTF(JNIEnv *env, const char *bytes); 

NewStringUTF是在本地代码中创建一个Java的java.lang.String对象。如果JVM因为内存不足不能创建一个String对象,这时候会抛出OutOfMemoryError异常,并且返回一个NULL。

其它JNI字符串函数

前面介绍了几个Get、Release和New函数,函数名都带有UTF关键字。除此之外,还有其它几个相似不带有UTF函数,GetStringChars和 ReleaseStringChars获取以Unicode格式编码的字符串,即获取以UTF-16编码的字符串,在某些场景下,这几个方法执行效率反而更高一些,因为它们操作字符的编码方式跟JVM中字符串编码方式是一致的。

我们知道C/C++中字符串是以'\0'为结束符,所以如果想要获取一个字符串的真实长度,需要借助于字符串处理函数strlen。当然了,类似地,在JNI中也提供了获取jstring长度的函数GetStringUTFLength和GetStringLength。

JNI中还提供了其它字符串操作函数,如Get/SetStringRegion、Get/SetStringUTFRegion、Get/ReleaseStringCritical等,具体示例这里就不一一列举了,更多可以参看网络上其它相关资料。

JNI中文乱码

在上文示例的getStrData函数中,如果将字符串result中添加几个中文字符,这时候再次在Java层运行,看一下Java层的返回结果,返回结果中出现乱码了。

那么,应该如何解决上述中文乱码问题呢?其实方式不止一种,网上也有许多资料,本文介绍两种处理方式。

本地代码直接返回jbyteArray

我们可以在本地代码转换为jbyteArray字节数组,然后将jbyteArray返回到Java层即可。

JNIEXPORT jbyteArray JNICALL Java_com_sunny_demo_JNIMethod_getByteArray
(JNIEnv *env, jobject jobj){
	const char *str = "我是程序员";
	jbyteArray jarray = (*env)->NewByteArray(env, strlen(str));
	(*env)->SetByteArrayRegion(env, jarray, 0, strlen(str), str);
	return jarray;
}

本地代码直接返回GB2312格式的jstring

该方式是C反向调用Java方法,在Java中String有一个可以接受字节数组和编码方式的构造方法,这里通过String的构造方法,直接构建一个Java的String对象,然后返回给Java层。

有关调用构造函数和创建对象的内容会在下一篇博文中介绍。

JNIEXPORT jstring JNICALL Java_com_sunny_demo_JNIMethod_getChineseStrFromC
(JNIEnv *env, jobject jobj){
	const char *str = "我是程序员";
	jclass strClazz = (*env)->FindClass(env, "java/lang/String");
	jmethodID constructorId = (*env)->GetMethodID(env, strClazz, "<init>", "([BLjava/lang/String;)V");
	//jbyte -> char 
	//jbyteArray -> char[]
	jbyteArray bytes = (*env)->NewByteArray(env, strlen(str));
	//byte数组赋值 从str这个字符数组,复制到bytes这个字符数组
	(*env)->SetByteArrayRegion(env, bytes, 0, strlen(str), str);

	//字符编码jstring
	jstring charsetName = (*env)->NewStringUTF(env, "GB2312");

	//调用构造函数,返回编码之后的jstring
	return (*env)->NewObject(env, strClazz, constructorId, bytes, charsetName);
}

小结

本文重点介绍了C与Java之间字符串的转换操作,并结合一般示例对JNI提供的常用字符串函数以及各函数的应用场景做了简单介绍。然后介绍了UTF-8、UTF-16和Unicode编码的基本概念,借助于对编码格式的了解,提供了解决C与Java之间交互的中文字符串乱码问题的方案。

其实本文介绍的中文乱码部分,还有一个疑问待解决,为什么C语言中文编码是GB2312呢,即使在Visual Studio更改了源文件的编码格式为UTF-8,上述示例中也必须使用GB2312格式转换。目前初步推测,应该是与底层操作系统的默认编码格式有关,由于示例是在Window系统上编译运行的,而Window编码格式在国内默认是GB2312。

Window提供了查询编码格式的命令chcp,936代码也对应的编码格式恰好是GB2312。

C:\Users\Administrator>chcp
活动代码页: 936

代码页       国家(地区)或语言 
437          美国 
936          中国 - 简体中文(GB2312)
949          韩文
950          繁体中文(Big5)
1200         Unicode        
1201         Unicode (Big-Endian)
52936        简体中文(HZ)
65000        Unicode (UTF-7)
65001        Unicode (UTF-8)

参考资料

JNI编程指南

(三)JNI 中文乱码

评论

您确定要删除吗?删除之后不可恢复