FASTJSON2提升Java字符串编码解码性能的技巧
1. 常见字符串编码
常见的字符串编码有:
- LATIN1 只能保存ASCII字符,又称ISO-8859-1。
- UTF-8 变长字节编码,一个字符需要使用1个、2个或者3个byte表示。由于中文通常需要3个字节表示,中文场景UTF-8编码通常需要更多的空间,替代的方案是GBK/GB2312/GB18030。
- UTF-16 2个字节,一个字符需要使用2个byte表示,又称UCS-2 (2-byte Universal Character Set)。根据大小端的区分,UTF-16有两种形式,UTF-16BE和UTF-16LE,缺省UTF-16指UTF-16BE。Java语言中的char是UTF-16LE编码。
- GB18030 变长字节编码,一个字符需要使用1个、2个或者3个byte表示。类似UTF8,中文只需要2个字符,表示中文更省字节大小,缺点是在国际上不通用。
编码 |
LATIN1 |
UTF8 |
UTF16 |
GB18030 |
长度 |
定长为1 |
变长1/2/3 |
定长2 |
变长1/2/3 |
计算速度 |
快 |
慢 |
快 |
慢 |
英文存储空间 |
小 |
小 |
大 |
|
中文存储空间 |
||||
典型场景 |
存储常用编码 |
计算常用编码 |
中文存储 |
为了计算方便,内存中字符串通常使用等宽字符,Java语言中char和.NET中的char都是使用UTF-16。早期Windows-NT只支持UTF-16。
2. 编码转换性能
UTF-16和UTF-8之间转换比较复杂,通常性能较差。
如下是一个将UTF-16转换为UTF-8编码的实现,可以看出算法比较复杂,所以性能较差,这个操作也无法使用vector API做优化。
关于Vector API 参考链接 : http://openjdk.java.net/jeps/0
staticintencodeUTF8(char[] utf16, intoff, intlen, byte[] dest, intdp) { intsl=off+len, last_offset=sl-1; while (off<sl) { charc=utf16[off++]; if (c<0x80) { // Have at most seven bitsdest[dp++] = (byte) c; } elseif (c<0x800) { // 2 dest, 11 bitsdest[dp++] = (byte) (0xc0| (c>>6)); dest[dp++] = (byte) (0x80| (c&0x3f)); } elseif (c>='\uD800'&&c<'\uE000') { intuc; if (c<'\uDC00') { if (off>last_offset) { dest[dp++] = (byte) '?'; returndp; } chard=utf16[off]; if (d>='\uDC00'&&d<'\uE000') { uc= (c<<10) +d+0xfca02400; } else { thrownewRuntimeException("encodeUTF8 error", newMalformedInputException(1)); } } else { uc=c; } dest[dp++] = (byte) (0xf0| ((uc>>18))); dest[dp++] = (byte) (0x80| ((uc>>12) &0x3f)); dest[dp++] = (byte) (0x80| ((uc>>6) &0x3f)); dest[dp++] = (byte) (0x80| (uc&0x3f)); off++; // 2 utf16 } else { // 3 dest, 16 bitsdest[dp++] = (byte) (0xe0| ((c>>12))); dest[dp++] = (byte) (0x80| ((c>>6) &0x3f)); dest[dp++] = (byte) (0x80| (c&0x3f)); } } returndp; }
由于Java中char是UTF-16LE编码,如果需要将char[]转换为UTF-16LE编码的byte[]时,可以使用sun.misc.Unsafe#copyMemory方法快速拷贝。比如:
staticintwriteUtf16LE(char[] chars, intoff, intlen, byte[] dest, finalintdp) { UNSAFE.copyMemory(chars , CHAR_ARRAY_BASE_OFFSET+off*2 , dest , BYTE_ARRAY_BASE_OFFSET+dp , len*2 ); dp+=len*2; returndp; }
3. Java String的编码
不同版本的JDK String的实现不一样,从而导致有不同的性能表现。char是UTF-16编码,但String在JDK 9之后内部可以有LATIN1编码。
3.1. JDK 6之前的String实现
staticclassString { finalchar[] value; finalintoffset; finalintcount; }
在Java 6之前,String.subString方法产生的String对象和原来String对象共用一个char[] value,这会导致subString方法返回的String的char[]被引用而无法被GC回收。于是使得很多库都会针对JDK 6及以下版本避免使用subString方法。
3.2. JDK 7/8的String实现
staticclassString { finalchar[] value; }
JDK 7之后,字符串去掉了offset和count字段,value.length就是原来的count。这避免了subString引用大char[]的问题,优化也更容易,从而JDK7/8中的String操作性能比Java 6有较大提升。
3.3. JDK 9/10/11的实现
staticclassString { finalbytecode; finalbyte[] value; staticfinalbyteLATIN1=0; staticfinalbyteUTF16=1; }
JDK 9之后,value类型从char[]变成byte[],增加了一个字段code,如果字符全部是ASCII字符,使用value使用LATIN编码;如果存在任何一个非ASCII字符,则用UTF16编码。这种混合编码的方式,使得英文场景占更少的内存。缺点是导致Java 9的String API性能可能不如JDK 8,特别是传入char[]构造字符串,会被做压缩为latin编码的byte[],有些场景会下降10%。
4. 快速构造字符串的方法
为了实现字符串是不可变特性,构造字符串的时候,会有拷贝的过程,如果要提升构造字符串的开销,就要避免这样的拷贝。
比如如下是JDK8的String的一个构造函数的实现
publicfinalclassString { publicString(charvalue[]) { this.value=Arrays.copyOf(value, value.length); } }
在JDK8中,有一个构造函数是不做拷贝的,但这个方法不是public,需要用一个技巧实现MethodHandles.Lookup & LambdaMetafactory绑定反射来调用,文章后面有介绍这个技巧的代码。
publicfinalclassString { String(char[] value, booleanshare) { // assert share : "unshared not supported";this.value=value; } }
快速构造字符的方法有三种:
- 使用MethodHandles.Lookup & LambdaMetafactory绑定反射
- 使用JavaLangAccess的相关方法
- 使用Unsafe直接构造
这三种方法,1和2性能差不多,3比1和2略慢,但都比直接new字符串要快得多。JDK8使用JMH测试的数据如下
BenchmarkModeCntScoreErrorUnitsStringCreateBenchmark.invokethrpt5784869.350±1936.754ops/msStringCreateBenchmark.langAccessthrpt5784029.186±2734.300ops/msStringCreateBenchmark.unsafethrpt5761176.319±11914.549ops/msStringCreateBenchmark.newStringthrpt5140883.533±2217.773ops/ms
在JDK 9之后,对全部是ASCII字符的场景,直接构造能达到更好的效果。
4.1 基于MethodHandles.Lookup & LambdaMetafactory绑定反射的快速构造字符串的方法
4.1.1 JDK8快速构造字符串
MethodHandles.Lookup & LambdaMetafactory要绑定非public的方法,需要特别构造一个TRUSTED的MethodHandles。
staticBiFunction<char[], Boolean, String>getStringCreatorJDK8() throwsThrowable { Constructor<MethodHandles.Lookup>constructor=MethodHandles.Lookup.class.getDeclaredConstructor(Class.class, int.class); constructor.setAccessible(true); MethodHandleslookup=constructor.newInstance( String.class , -1// Lookup.TRUSTED ); MethodHandles.Lookupcaller=lookup.in(String.class); MethodHandlehandle=caller.findConstructor( String.class, MethodType.methodType(void.class, char[].class, boolean.class) ); CallSitecallSite=LambdaMetafactory.metafactory( caller , "apply" , MethodType.methodType(BiFunction.class) , handle.type().generic() , handle , handle.type() ); return (BiFunction) callSite.getTarget().invokeExact(); } if (JDKUtils.JVM_VERSION==8) { BiFunction<char[], Boolean, String>stringCreator=JDKUtils.getStringCreatorJDK8(); char[] chars=newchar[]{'a', 'b', 'c'}; Stringapply=stringCreator.apply(chars, Boolean.TRUE); System.out.println(apply); }
4.1.2 JDK 11快速构造字符串的方法
staticToIntFunction<String>getStringCode11() throwsThrowable { Constructor<MethodHandles.Lookup>constructor=MethodHandles.Lookup.class.getDeclaredConstructor(Class.class, int.class); constructor.setAccessible(true); MethodHandles.Lookuplookup=constructor.newInstance( String.class , -1// Lookup.TRUSTED ); MethodHandles.Lookupcaller=lookup.in(String.class); MethodHandlehandle=caller.findVirtual( String.class, "coder", MethodType.methodType(byte.class) ); CallSitecallSite=LambdaMetafactory.metafactory( caller , "applyAsInt" , MethodType.methodType(ToIntFunction.class) , MethodType.methodType(int.class, Object.class) , handle , handle.type() ); return (ToIntFunction<String>) callSite.getTarget().invokeExact(); } if (JDKUtils.JVM_VERSION==11) { Function<byte[], String>stringCreator=JDKUtils.getStringCreatorJDK11(); byte[] bytes=newbyte[]{'a', 'b', 'c'}; Stringapply=stringCreator.apply(bytes); assertEquals("abc", apply); }
4.1.3 JDK 17快速构造字符串的方法
在JDK 17中,MethodHandles.Lookup使用Reflection.registerFieldsToFilter对lookupClass和allowedModes做了保护,网上搜索到的通过修改allowedModes的办法是不可用的。
在JDK 17中,要通过配置JVM启动参数才能使用MethodHandlers。如下:
--add-opens java.base/java.lang.invoke=ALL-UNNAMED
staticBiFunction<byte[], Charset, String>getStringCreatorJDK17() throwsThrowable { Constructor<MethodHandles.Lookup>constructor=MethodHandles.Lookup.class.getDeclaredConstructor(Class.class, Class.class, int.class); constructor.setAccessible(true); MethodHandles.Lookuplookup=constructor.newInstance( String.class , null , -1// Lookup.TRUSTED ); MethodHandles.Lookupcaller=lookup.in(String.class); MethodHandlehandle=caller.findStatic( String.class, "newStringNoRepl1", MethodType.methodType(String.class, byte[].class, Charset.class) ); CallSitecallSite=LambdaMetafactory.metafactory( caller , "apply" , MethodType.methodType(BiFunction.class) , handle.type().generic() , handle , handle.type() ); return (BiFunction<byte[], Charset, String>) callSite.getTarget().invokeExact(); } if (JDKUtils.JVM_VERSION==17) { BiFunction<byte[], Charset, String>stringCreator=JDKUtils.getStringCreatorJDK17(); byte[] bytes=newbyte[]{'a', 'b', 'c'}; Stringapply=stringCreator.apply(bytes, StandardCharsets.US_ASCII); assertEquals("abc", apply); }
4.2 基于JavaLangAccess快速构造
通过SharedSecrets提供的JavaLangAccess,也可以不拷贝构造字符串,但是这个比较麻烦,JDK 8/11/17的API都不一样,不同的JDK所在包名的方法都有所不同,对一套代码兼容不同的JDK版本不方便,不建议使用。
JavaLangAccessjavaLangAccess=SharedSecrets.getJavaLangAccess(); javaLangAccess.newStringNoRepl(b, StandardCharsets.US_ASCII);
4.3 基于Unsafe实现快速构造字符串
staticfinalUnsafeUNSAFE; static { Unsafeunsafe=null; try { FieldtheUnsafeField=Unsafe.class.getDeclaredField("theUnsafe"); theUnsafeField.setAccessible(true); unsafe= (Unsafe) theUnsafeField.get(null); } catch (Throwableignored) {} UNSAFE=unsafe; } ////////////////////////////////////////////Objectstr=UNSAFE.allocateInstance(String.class); UNSAFE.putObject(str, valueOffset, chars);
注意:在JDK 9之后,实现是不同,比如:
Objectstr=UNSAFE.allocateInstance(String.class); UNSAFE.putByte(str, coderOffset, (byte) 0); UNSAFE.putObject(str, valueOffset, (byte[]) bytes);
4.4 快速构建字符串的技巧应用:
如下的方法格式化日期为字符串,性能就会非常好。
staticBiFunction<byte[], Charset, String>stringCreatorJDK17=null; staticFunction<byte[], String>stringCreatorJDK11=null; staticBiFunction<char[], Boolean, String>stringCreatorJDK8=null; publicStringformatYYYYMMDD(Calendarcalendar) throwsThrowable { intyear=calendar.get(Calendar.YEAR); intmonth=calendar.get(Calendar.MONTH) +1; intdayOfMonth=calendar.get(Calendar.DAY_OF_MONTH); bytey0= (byte) (year/1000+'0'); bytey1= (byte) ((year/100) %10+'0'); bytey2= (byte) ((year/10) %10+'0'); bytey3= (byte) (year%10+'0'); bytem0= (byte) (month/10+'0'); bytem1= (byte) (month%10+'0'); byted0= (byte) (dayOfMonth/10+'0'); byted1= (byte) (dayOfMonth%10+'0'); if (JDKUtils.JVM_VERSION>=9) { byte[] bytes=newbyte[]{y0, y1, y2, y3, m0, m1, d0, d1}; if (JDKUtils.JVM_VERSION==17) { if (stringCreatorJDK17==null) { stringCreatorJDK17=JDKUtils.getStringCreatorJDK17(); } returnstringCreatorJDK17.apply(bytes, StandardCharsets.US_ASCII); } if (JDKUtils.JVM_VERSION<=11) { if (stringCreatorJDK11==null) { stringCreatorJDK11=JDKUtils.getStringCreatorJDK11(); } returnstringCreatorJDK11.apply(bytes); } returnnewString(bytes, StandardCharsets.US_ASCII); } char[] chars=newchar[]{ (char) y0, (char) y1, (char) y2, (char) y3, (char) m0, (char) m1, (char) d0, (char) d1 }; if (JDKUtils.JVM_VERSION==8) { if (stringCreatorJDK8==null) { stringCreatorJDK8=JDKUtils.getStringCreatorJDK8(); } returnstringCreatorJDK8.apply(chars, true); } returnnewString(chars); }
5. 快速遍历字符串的办法
无论JDK什么版本,String.charAt都是一个较大的开销,JIT的优化效果并不好,无法消除参数index范围检测的开销,不如直接操作String里面的value数组。
publicfinalclassString { privatefinalcharvalue[]; publiccharcharAt(intindex) { if ((index<0) || (index>=value.length)) { thrownewStringIndexOutOfBoundsException(index); } returnvalue[index]; } }
在JDK 9之后的版本,charAt开销更大
publicfinalclassString { privatefinalbyte[] value; privatefinalbytecoder; publiccharcharAt(intindex) { if (isLatin1()) { returnStringLatin1.charAt(value, index); } else { returnStringUTF16.charAt(value, index); } } }
5.1 获取String.value的方法
获取String.value的方法有如下:
- 使用Field反射
- 使用Unsafe
Unsafe和Field反射在JDK 8 JMH的比较数据如下:
BenchmarkModeCntScoreErrorUnitsStringGetValueBenchmark.reflectthrpt5438374.685±1032.028ops/msStringGetValueBenchmark.unsafethrpt51302654.150±59169.706ops/ms
5.1.1 使用反射获取String.value
staticFieldvalueField; static { try { valueField=String.class.getDeclaredField("value"); valueField.setAccessible(true); } catch (NoSuchFieldExceptionignored) {} } ////////////////////////////////////////////char[] chars= (char[]) valueField.get(str);
5.1.2 使用Unsafe获取String.value
staticlongvalueFieldOffset; static { try { FieldvalueField=String.class.getDeclaredField("value"); valueFieldOffset=UNSAFE.objectFieldOffset(valueField); } catch (NoSuchFieldExceptionignored) {} } ////////////////////////////////////////////char[] chars= (char[]) UNSAFE.getObject(str, valueFieldOffset); staticlongvalueFieldOffset; staticlongcoderFieldOffset; static { try { FieldvalueField=String.class.getDeclaredField("value"); valueFieldOffset=UNSAFE.objectFieldOffset(valueField); FieldcoderField=String.class.getDeclaredField("coder"); coderFieldOffset=UNSAFE.objectFieldOffset(coderField); } catch (NoSuchFieldExceptionignored) {} } ////////////////////////////////////////////bytecoder=UNSAFE.getObject(str, coderFieldOffset); byte[] bytes= (byte[]) UNSAFE.getObject(str, valueFieldOffset);
6. 更快的encodeUTF8方法
当能直接获取到String.value时,就可以直接对其做encodeUTF8操作,会比String.getBytes(StandardCharsets.UTF_8)性能很多。
6.1 JDK8高性能encodeUTF8的方法
staticintencodeUTF8(char[] src, intoffset, intlen, byte[] dst, intdp) { intsl=offset+len; intdlASCII=dp+Math.min(len, dst.length); // ASCII only optimized loopwhile (dp<dlASCII&&src[offset] <'\u0080') { dst[dp++] = (byte) src[offset++]; } while (offset<sl) { charc=src[offset++]; if (c<0x80) { // Have at most seven bitsdst[dp++] = (byte) c; } elseif (c<0x800) { // 2 bytes, 11 bitsdst[dp++] = (byte) (0xc0| (c>>6)); dst[dp++] = (byte) (0x80| (c&0x3f)); } elseif (c>='\uD800'&&c< ('\uDFFF'+1)) { //Character.isSurrogate(c) but 1.7finalintuc; intip=offset-1; if (c>='\uD800'&&c< ('\uDBFF'+1)) { // Character.isHighSurrogate(c)if (sl-ip<2) { uc=-1; } else { chard=src[ip+1]; // d >= '\uDC00' && d < ('\uDFFF' + 1)if (d>='\uDC00'&&d< ('\uDFFF'+1)) { // Character.isLowSurrogate(d)uc= ((c<<10) +d) + (0x010000- ('\uD800'<<10) -'\uDC00'); // Character.toCodePoint(c, d) } else { dst[dp++] = (byte) '?'; continue; } } } else { //if (c>='\uDC00'&&c< ('\uDFFF'+1)) { // Character.isLowSurrogate(c)dst[dp++] = (byte) '?'; continue; } else { uc=c; } } if (uc<0) { dst[dp++] = (byte) '?'; } else { dst[dp++] = (byte) (0xf0| ((uc>>18))); dst[dp++] = (byte) (0x80| ((uc>>12) &0x3f)); dst[dp++] = (byte) (0x80| ((uc>>6) &0x3f)); dst[dp++] = (byte) (0x80| (uc&0x3f)); offset++; // 2 chars } } else { // 3 bytes, 16 bitsdst[dp++] = (byte) (0xe0| ((c>>12))); dst[dp++] = (byte) (0x80| ((c>>6) &0x3f)); dst[dp++] = (byte) (0x80| (c&0x3f)); } } returndp; }
- 使用encodeUTF8方法举例
char[] chars=UNSAFE.getObject(str, valueFieldOffset); // ensureCapacity(chars.length * 3)byte[] bytes= ...; // intbytesLength=IOUtils.encodeUTF8(chars, 0, chars.length, bytes, bytesOffset);
这样encodeUTF8操作,不会有多余的arrayCopy操作,性能会得到提升
6.1.1 性能测试比较
- 测试代码
publicclassEncodeUTF8Benchmark { staticStringSTR="01234567890ABCDEFGHIJKLMNOPQRSTUVWZYZabcdefghijklmnopqrstuvwzyz一二三四五六七八九十"; staticbyte[] out; staticlongvalueFieldOffset; static { out=newbyte[STR.length() *3]; try { FieldvalueField=String.class.getDeclaredField("value"); valueFieldOffset=UnsafeUtils.UNSAFE.objectFieldOffset(valueField); } catch (NoSuchFieldExceptione) { e.printStackTrace(); } } publicvoidunsafeEncodeUTF8() throwsException { char[] chars= (char[]) UnsafeUtils.UNSAFE.getObject(STR, valueFieldOffset); intlen=IOUtils.encodeUTF8(chars, 0, chars.length, out, 0); } publicvoidgetBytesUTF8() throwsException { byte[] bytes=STR.getBytes(StandardCharsets.UTF_8); System.arraycopy(bytes, 0, out, 0, bytes.length); } publicstaticvoidmain(String[] args) throwsRunnerException { Optionsoptions=newOptionsBuilder() .include(EncodeUTF8Benchmark.class.getName()) .mode(Mode.Throughput) .timeUnit(TimeUnit.MILLISECONDS) .forks(1) .build(); newRunner(options).run(); } }
- 测试结果
EncodeUTF8Benchmark.getBytesUTF8thrpt520690.960±5431.442ops/msEncodeUTF8Benchmark.unsafeEncodeUTF8thrpt534508.606±55.510ops/ms
从结果来看,通过unsafe + 直接调用encodeUTF8方法, 编码的所需要开销是newStringUTF8的58%
6.2 JDK9/11/17高性能encodeUTF8的方法
publicstaticintencodeUTF8(byte[] src, intoffset, intlen, byte[] dst, intdp) { intsl=offset+len; while (offset<sl) { byteb0=src[offset++]; byteb1=src[offset++]; if (b1==0&&b0>=0) { dst[dp++] =b0; } else { charc= (char)(((b0&0xff) <<0) | ((b1&0xff) <<8)); if (c<0x800) { // 2 bytes, 11 bitsdst[dp++] = (byte) (0xc0| (c>>6)); dst[dp++] = (byte) (0x80| (c&0x3f)); } elseif (c>='\uD800'&&c< ('\uDFFF'+1)) { //Character.isSurrogate(c) but 1.7finalintuc; intip=offset-1; if (c>='\uD800'&&c< ('\uDBFF'+1)) { // Character.isHighSurrogate(c)if (sl-ip<2) { uc=-1; } else { b0=src[ip+1]; b1=src[ip+2]; chard= (char) (((b0&0xff) <<0) | ((b1&0xff) <<8)); // d >= '\uDC00' && d < ('\uDFFF' + 1)if (d>='\uDC00'&&d< ('\uDFFF'+1)) { // Character.isLowSurrogate(d)uc= ((c<<10) +d) + (0x010000- ('\uD800'<<10) -'\uDC00'); // Character.toCodePoint(c, d) } else { return-1; } } } else { //if (c>='\uDC00'&&c< ('\uDFFF'+1)) { // Character.isLowSurrogate(c)return-1; } else { uc=c; } } if (uc<0) { dst[dp++] = (byte) '?'; } else { dst[dp++] = (byte) (0xf0| ((uc>>18))); dst[dp++] = (byte) (0x80| ((uc>>12) &0x3f)); dst[dp++] = (byte) (0x80| ((uc>>6) &0x3f)); dst[dp++] = (byte) (0x80| (uc&0x3f)); offset++; // 2 chars } } else { // 3 bytes, 16 bitsdst[dp++] = (byte) (0xe0| ((c>>12))); dst[dp++] = (byte) (0x80| ((c>>6) &0x3f)); dst[dp++] = (byte) (0x80| (c&0x3f)); } } } returndp; }
- 使用encodeUTF8方法举例
bytecoder=UNSAFE.getObject(str, coderFieldOffset); byte[] value=UNSAFE.getObject(str, valueFieldOffset); if (coder==0) { // ascii arraycopy} else { // ensureCapacity(chars.length * 3)byte[] bytes= ...; // intbytesLength=IOUtils.encodeUTF8(value, 0, value.length, bytes, bytesOffset); }
这样encodeUTF8操作,不会有多余的arrayCopy操作,性能会得到提升
7. 重要提醒
上面这些技巧都不是给新手使用的,使用不当会容易导致BUG,如果你没彻底搞懂,就不要用!