我们看看是否可以在内存方面对字符串对象进行优化,我们注意到如果一个字符串不是通过String.substring方法创建的(因此,当前字符串用于存放真实字符的内部字符数组是不会和其它的字符串共享的),那么字符串中的最后两个字段 - 当前字符串中第一个字符的偏移量以及字符串本身的长度,就是多余的。哈希码也是可以每次在调用hashCode方法的时候动态计算出来,而不需要缓存起来。在通常的情况下,只有字符数组才是必须的。
前面的两段内容,在Java 1.7.0_05以前是对的,但是Oracle在Java 1.7.0_06中对String对象做了一个很大的改变 - 偏移量及字符的长度被去掉了,并增加了一个整型的字段hash32,这就是说在Java 1.7.0_06中的String对象会比之前的String对象少占用8个字节的内存,hash32这个字段在Java 8中也被删除了。下面所提到的String对象,都是针对于Java 1.7.0_05以前的。如果想了解更多的细节,请查看这篇文章Java 1.7.0_06中对String的修改。
不过,针对这种情况,我们也是有办法可以优化的。许多程序在某个时刻处理的数据,通常来说只会有一种编码格式(或者说,至少大部分的数据都有相同的编码)。我们可以将字符数组按照其编码转换相应的字节数组,因此我们只需要保留字节数组,而不是保留字符数组。对于更一般的情况,UTF-8可以被认为是一种安全编码。我们能不能做得更好呢?为了回答这个问题,我们需要回顾一下的Java对象字段的内存布局。
所有的Java对象开始的8个字节都包含了类的相关信息(译者注:这里指的是:在Sun JVM中,(除了数组之外的)对象都有两个机器字(words)的头部。第一个字中包含这个对象的标示哈希码以及其他一些类似锁状态和等标识信息,第二个字中包含一个指向对象的类的引用,参见:http://www.importnew.com/1305.html),以及其标识哈希码(由System.identityHashCode方法返回),数组还会多包括4个字节用于表示当前数组的长度(一个整数类型字段)。这看起来好似用户编写的所有类(不是JDK中的类),都会有一个指向Object类的引用。这些字段在所有声明的字段之后。所有的对象都是由8个字节边界对齐。所有的基本类型()有8种基本类型:int, short, long, float, double, char, boolean, byte)必须由它们的大小进行排列(例如,字符应当由2个字节边界对齐)。
对象引用(包括任何数组)占用4个字节,这对我们来说意味着什么呢?为了让内存的利用最大化,所有对象字段必须占用N *8+4个字节(4,12,20,28等)。在这种情况下,内存中包括的数据100%都是有用的数据。
现在,让我们回到我们的场景,如果我们需要保持大量的字符串,但是有超过90%的字符串的长度都会在一个受限的长度之内(如小于100) 。在这种情况下,我们创建一些类用于存储这些字符串,如第一个类用于存储长度小于或等于12的所有字符串,第二个类用于存储长度在13和20 (包括端点)之间的字符串,第三次类用于存储长度从21到28的字符串,如此类推的创建适合于你当前数据存储类 。每个类都将包含N * 2 + 1个整型字段( 3个整型字段用于字符串长度为12的字符串 , 5个整型字段用于字符串长度为13到20的字符串等)之间的长度,第一个字段将包含四个字节的字符数组(我们已经将它转换为字节数组) ,下一个字段 - 5至8个字节等。最后一个或两个整数将包含多达8个字节或零,用于当前字符串的最后一个字节之后的字节,这当然,就会有一个对数据的限制,即字符0是不允许。
如果输入的字符串太长或不能转换到选定的编码或者包含0这个字符 ,我们可以返回去继续使用输入字符串,如果混合所有这些伪造的字符串和真正的字符串比较容易 - 那就把它们都当做对象处理,并调用它们的toString方法来获得原始的字符串(当然,你必须重写toString方法,如果你想使用您的字符串作为映射的键,equals和hashCode方法也需要重写) 。
如果是一个28个字符长的字符串,可以节约多少内存呢?普通的字符串将会占据32个字节+char数组的大小,char数组的大小包括12个头字节+数组长度* 2++4个字节的数组引用,如这里是28个字符,那么数组所占的大小就是12 + 28 * 2 + 4 = 72字节,这样的字符串的deep size就为72 + 32 = 104字节,而我们的人造字符串将会占据8 (头) + 4 (类) + 4 * 7 ( 7 int字段) = 40个字节。 如果是12个字符长的字符串的String深尺寸为32 + ( 12 + 12 * 2 = 36填充到40字节) = 72字节,而我们的人造字符串的deep size为8 + 4 + 4 * 3 = 24个字节。
所有这些数字都假定当前环境为32位对象指针,他们在最新的Java 6及Java 7中缺省是通过XX:+UseCompressedOops选项设置默认启用的,如果你将明确将其关闭( -XX : - UseCompressedOops ),所有的指针都会占用8个字节。在现实生活中,如果你的堆是32G以下,没有必要担心64位指针的问题。
你如果想了解更多关于Java对象的内存布局,可以参考:http://www.codeinstructions.com/2008/12/java-objects-memory-structure.html。(翻译注,这个是中文翻译的:http://www.importnew.com/1305.html)
Java 6的更新23又增加了一个运行时选项叫- XX:+ UseCompressedStrings ,这增加java.lang.String的另外一个实现,如果可能的话,它将把字符按字节的方式进行存储, Oracle没有指定采用这种转换所使用的编码,因而对于US-ASCII 编码来说,这种转换将会是安全的 。如果你的字符串包含特定国家的字母字符, Java将会返回使用正常的char []型的字符串。所有字节以及基于char数组的字符串,他们有完全相同的接口和外观,对调用代码来说是没有任何区别的。使用这个选项对性能的影响是相当小的,所以,如果你还在继续使用Java 6,所以我会建议你使用它 。
添加此选项到Java启动参数,优化的性能效果不会很明显,基于字节数组的字符串需要占用24 +字节长度的内存字节,而我们的人造字符串只有占用4 +长度字节的内存字节,内存的节约情况在短字符串的存储中效果尤为明显,为了反映优化后的真实情况,拿一个转换成一种不被JDK默认支持的编码来测试(原文:The only truly useful case for described optimization is conversion to an encoding not supported by default JDK conversion.)。
下面是什对第一种情况而实现的字符串(最多12个字符):
private static final Charset US_ASCII = Charset.forName( "US-ASCII" );
public static Object convert( final String str )
{
//discard empty or too long strings as well as strings with '\0'
if ( str == null || str.length() == 0 || str.length() > 12 || str.indexOf( '\0') != -1 )
return str;
//encoder may be stored in ThreadLocal
final CharsetEncoder enc = US_ASCII.newEncoder();
final CharBuffer charBuffer = CharBuffer.wrap( str );
try {
final ByteBuffer byteBuffer = enc.encode( charBuffer );
final byte[] byteArray = byteBuffer.array();
if ( byteArray.length <= 12 )
return new Packed12( byteArray );
//add cases for longer strings here
else
return str;
} catch (CharacterCodingException e) {
//there are some chars not fitting to our encoding
return str;
}
}
private static abstract class PackedBase
{
protected int get( final byte[] ar, final int index )
{
return index < ar.length ? ar[ index ] : 0;
}
protected abstract ByteBuffer toByteBuffer();
protected String toString( final ByteBuffer bbuf )
{
final byte[] ar = bbuf.array();
//skip zero bytes at the tail of the string
int last = ar.length - 1;
while ( last > 0 && ar[ last ] == 0 )
--last;
return new String( ar, 0, last + 1, US_ASCII );
}
public String toString()
{
return toString( toByteBuffer() );
}
}
private static class Packed12 extends PackedBase
{
private final int f1;
private final int f2;
private final int f3;
public Packed12( final byte[] ar )
{ //should be the same logic as in java.util.Bits.getInt, because ByteBuffer.putInt use it
f1 = get( ar, 3 ) | get( ar, 2 ) << 8 | get( ar, 1 ) << 16 | get( ar, 0 ) << 24;
f2 = get( ar, 7 ) | get( ar, 6 ) << 8 | get( ar, 5 ) << 16 | get( ar, 4 ) << 24;
f3 = get( ar, 11 ) | get( ar, 10 ) << 8 | get( ar, 9 ) << 16 | get( ar, 8 ) << 24;
}
protected ByteBuffer toByteBuffer()
{
final ByteBuffer bbuf = ByteBuffer.allocate( 12 );
bbuf.putInt( f1 );
bbuf.putInt( f2 );
bbuf.putInt( f3 );
return bbuf;
}
@Override
public boolean equals(Object o) {
if ( this == o ) return true;
if ( o == null || getClass() != o.getClass() ) return false;
Packed12 packed12 = ( Packed12 ) o;
return f1 == packed12.f1 && f2 == packed12.f2 && f3 == packed12.f3;
}
@Override
public int hashCode() {
int result = f1;
result = 31 * result + f2;
result = 31 * result + f3;
return result;
}
}
使用这个代码,我分别测试了字符数组带与不带选项-XX:+UseCompressedStrings的内存占用情况,以及使用字符串包装后内存占用情况。
private static final int SIZE = 10_000_000;
String[] strings = new String[ SIZE ];
for ( int i = 0; i < SIZE; ++i )
strings[ i ] = "Aa" + i;
Object[] packed = new Object[ SIZE ];
for ( int i = 0; i < SIZE; ++i )
packed[ i ] = convert( "Aa" + i );
内存占用结果如下:
String, no compression | String, -XX:+UseCompressedStrings | packed strings |
722.48 Mb | 645.47 Mb | 268.46 Mb |
正如你所看到的,针对于短字符串的处理,即使和内置的JDK字符串的压缩算法相比,我们的算法执行结果也是非常好,因为没有内存浪费在内部数组管理上面。
该测试是在Java 6更新25中运行的, Java 7中就不支持UseCompressedStrings选项了,在 ava 7中的初始版本会自动忽略这个参数,而Java 7的更新2则输出一个警告到控制台:
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option UseCompressedStrings; support was removed in 7.0
总结
对象java.lang.String被设计成执行速度很快和以及灵活,这就是为什么他们可以与其他字符串共享内部字符数组,它们也缓存本身的哈希码值,因为字符串经常被用作HashMap中的键或HashSet的值。但这些额外的属性,却让短字符串占用了过多的内存,我们可以通过实现我们自己的字符串来避免这种不必要内存开销。
Oracle开发人员试图通过在JAVA 6的后期版本中引入-XX+ UseCompressedStrings选项来解决这个问题,然后不幸的是,在Java 7中就不再支持了,可能是由于没有达到要节约很多内存开销的期待吧。
原文地址:http://java-performance.info/string-packing-converting-characters-to-bytes/