原文转自我自己的个人公众号:Java基础之刨根问底第5集——字符串(由于是拷贝过来的,如果排版有问题,请看公众号文章)
-
本系列不适合初学者,读者应具备一定的Java基础。
-
从本集开始,本系列依据Java11编写。
注意:最近看到一组统计数据,作为JAVA8的下一个LTS(长期支持)版本,JAVA11目前的的使用率已经快追上JAVA8了,所以从本集开始,本系列会基于JAVA11编写。
JAVA中的字符串
字符串的使用在任何一个程序语言中都是非常广泛的,JAVA中通常可以使用以下三种方式来存储字符:
-
String类
-
StringBuffer类
-
StringBuilder类
其中,String类是比较特殊的,作为一个对象,它不属于8种原始数据类型,但也有别于其他普通对象,主要是因为String是不可变的,也就是说String类型一旦初始化后,值就不能再发生改变了。
基于String的不可变性,为了提高效率和节省空间,JVM内部存在一个字符常量池。字符常量池初始是空的,只要程序中初始化一个字符串,就会放入池中暂存,当下一次需要同样的字符串时,就可以直接从池中共享,而不需要重新创建了(理想情况),接下来我们就来详细看看String类型。
1.常量池
实际上JVM中的常量池可以存储很多类型的常量,字符串常量池只是其中的一种,例如,int型对象会放入整型常量池,前提是范围不在-32768到32767之间,因为在这个范围中的整型有对应的指令,不需要创建对象。
下面用一个简单的例子,直观地展示下字符串常量池。
代码比较简单,就是在类中初始化一个字符串,如下:
public class StringExample {
private String str = "abc";
public static void main(String[] args) {
}
}
编译成功后,使用javap反汇编class中的字节码,我们只需要关注结果中的Constant pool部分,如下图所示:
其中的#2就是程序中初始化的String类型的str,可以看到它又指向了#22,下面的#22就是在字符串常量池中缓存的“abc”。
当再次初始化一个值为“abc”的字符串时,就不会再创建新的对象了,代码如下:
public class StringExample {
private String str = "abc";
private String str2 = "abc";
public static void main(String[] args) {
}
}
从上图中可以看到,常量池中有且只有一个“abc”。变量str和str2会共享常量池中“abc”这个对象的引用。
2.new关键字
当String通过new关键字初始化时,情况就会有些不同了,因为new会强制在堆上创建一个对象。将例子中的初始化修改为:
private String str = new String("abc");
这时可以看到,虽然也在字符串常量池中放入的值为“abc”的对象,但在#4的时候还使用构造函数创建了一个String对象。因此,这里虽然只是一行代码,实际上是创建了2个对象,而上一个例子中只有一个“abc”对象(在字符串常量池中)(面试中常常考到的知识点)。
因此,在实际编程中,为了尽可能减少对象的创建个数,可以尽量避免使用new关键字来初始化字符串,而尽可能多地使用直接赋值的方式。
3.字符串常量池的位置
在Java6的时候,字符串常量池存储在永久代(PermGen)中,但因为永久代的大小是固定的,虽然可以设置大小,但毕竟不灵活,这导致了Java6中的字符串常量池一直都比较受限(莫名其妙的OOM),甚至很多框架中会自己用WeakHashMap来替代JVM自身的常量池。下面给出一个WeakHashMap实现常量池的代码示例:
private static final WeakHashMap<String, WeakReference<String>> s_manualCache =
new WeakHashMap<String, WeakReference<String>>(100000);
private static String manualIntern(final String str) {
final WeakReference<String> cached = s_manualCache.get(str);
if (cached != null) {
final String value = cached.get();
if (value != null)
return value;
}
s_manualCache.put(str, new WeakReference<String>(str));
return str;
}
自从Java7将永久代取消后,字符串常量池就被放在了堆中,这样一来,不但大小可以跟着堆内存一起浮动,还可以享受垃圾回收器的照顾。
4.字符串常量池的大小
在Java7u40之前,默认大小是1009。字符串常量池的数据结构和实现与HashMap非常相似,这里的1009指的是HashMap中的桶的个数。
解释下“桶”的含义:简单来说,HashMap中有一个数组,每当有对象要放入HashMap中时,先会对这个对象进行HashCode的计算,然后放入数组的对应位置。由于不同对象的HashCode可能会相同,即:Hash碰撞,因此一个位置上实际上是一个链表,所以一个位置上就像一个桶一样放了多个HashCode相同的对象。
从Java7u40开始字符串常量池桶的大小就增大到了60013,到Java11的时候,又增大到了65536。有资料中说java6、7、8中桶的大小是固定的。我在java8中测试了一下,确实是固定的,但我在用java11测试时发现桶的大小是会自动扩容的,测试用例代码如下:
public static void main(String[] args) throws InterruptedException {
long num = 4000000;
long begin = System.currentTimeMillis();
for(int i = 0; i < num; i++) {
if(i % 50000 == 0) {
System.out.println(i + ":" + (System.currentTimeMillis() - begin));
begin = System.currentTimeMillis();
}
Integer.toString(i).intern();
}
Thread.sleep( Duration.ofSeconds( 5 ).toMillis() );
}
运行程序是加上-XX:+PrintStringTableStatistics参数,用来输出字符串池的统计信息,最后一行的sleep是为了让统计信息有时间计算,否则可能无法输出。输出的结果如下:
可以看到,这里的桶的数量已经到了524288,经过我的反复测试,每次扩容是按照2的n次方来扩的,比如第一次扩容是65536的2倍,然后是4倍,接着是8倍,以此类推。图中bucket size就是一个桶上链表的长度。
从java7开始,可以通过-XX:StringTableSize参数来显示的设置桶的大小。
在设置大小时,参考HashMap的实现方式,建议设置一个素数。对于HashMap来说,java11已经不要求是一个素数了,但字符串池的实现目前不得而知有没有优化,所以设置时还是设置一个素数比较好。
5.“桶”的个数对性能的影响
由于字符串池的实现类似一个HashMap,因此就会有Hash碰撞的问题,多个不同的字符串可能会落在同一个桶上,桶数量越少,Hash碰撞的频率就越频繁,桶上链表的长度就会越长,访问时的效率就会越慢。
我把上面例子里字符的数量改成3百万,输出如下:
。。。。。。
冒号前是目前的记录数量,冒号后是最近向池中放入5万条记录的耗时,可以看到,耗时越来越长,就是因为Hash碰撞的原因导致桶上的链表越来越长。统计信息如下:
从统计中可以看到,最长的一个桶的长度已经达到了92。
6.字符串池的使用
了解了字符串池后,怎么用呢?
用法通常有两种:
-
使用赋值的方式初始化String类型的变量
这种方式是最简单和直接的,只要通过“String str = "abc"”这种方式,就会共享地使用在字符串池中已存在的“abc”对象,如果不存在会先向池中放入该对象。注意:使用new的方式则无论如何都会创建新的对象。
-
使用String类中的intern()方法
intern()方法会先在字符串池中寻找有没有要的字符串,如果有则返回引用,如果没有则向池中放入该字符串后返回引用(上面的例子中有用到)。我能想到的一种使用方式是,如果程序中有大量可重复的字符串,可以在程序启动时,先用intern方法把这些字符串放入池中缓存。用的时候再用intern方法取出。
7.StringBuffer和StringBuilder
相比于不可变的String类型,StringBuffer和StringBuilder则是可变的,其中StringBuffer是线程安全的,而StringBuilder则是线程不安全的(理论上拥有更好的性能)。
我用了一个字符串拼接的例子对比了一下他们的性能,代码如下:
public static void main(String[] args) {
int times = 100000;
System.out.println(timer(times, String.class));
//System.out.println(timer(times, StringBuffer.class));
//System.out.println(timer(times, StringBuilder.class));
}
private static long timer(int times, Class clazz) {
String str = "";
StringBuffer sBuffer = new StringBuffer("");
StringBuilder sBuilder = new StringBuilder("");
long begin = System.currentTimeMillis();
if (clazz == String.class) {
stringTimeTest(times, str);
} else if (clazz == StringBuffer.class) {
sBufferTimeTest(times, sBuffer);
} else if (clazz == StringBuilder.class) {
sBuilderTimeTest(times, sBuilder);
} else {
throw new RuntimeException("error");
}
long end = System.currentTimeMillis();
return end - begin;
}
private static void sBuilderTimeTest(int times, StringBuilder sBuilder) {
for (int i = 0; i < times; i++) {
sBuilder.append(i);
}
}
private static void sBufferTimeTest(int times, StringBuffer sb) {
for (int i = 0; i < times; i++) {
sb.append(i);
}
}
private static void stringTimeTest(int times, String str) {
for (int i = 0; i < times; i++) {
str += i;
}
}
例子比较简单,就是分别用StringBuffer和StringBuilder的append方法以及“+”号来拼接10万个字符。
首先是String类型的变量用“+”号拼接,耗时4052ms;
其次是StringBuffer使用append拼接,耗时16ms;
最后是StringBuilder使用append拼接,耗时0ms(^_^)。
8.总结
-
使用赋值的方式初始化String,尽量不用new的方式;
-
有字符串拼接需求的,尽量使用StringBuilder,如果要线程安全使用StringBuffer。尤其是大量拼接,绝对不要用“+”号。
更多内容请关注我的公众号: