这篇文章,让我一杯奶茶的时间搞懂了“StringTable”
前言
String应该是Java使用最多的类吧,很少有Java程序没有使用到String的。在Java中创建对象是一件挺耗费性能的事,而且我们又经常使用相同的String对象,那么创建这些相同的对象不是白白浪费性能吗。所以就有了StringTable这一特殊的存在,StringTable叫做字符串常量池,用于存放字符串常量,这样当我们使用相同的字符串对象时,就可以直接从StringTable中获取而不用重新创建对象。那么,StringTable都有哪些特性呢?接下来就让我们好好探讨一下StringTable。
String的一些特性
String的不可变性
在讲介绍StringTable之前,就不得不提一下String的不可变性,因为只有当String是不可变的才使得StringTable的实现成为可能。当我们定义一个字符串时:
String s = "hello";
这时候,“hello”就被存放在StringTable中,而变量s是一个引用,s指向了StringTable中的“hello”。
当我们把s的值改一下,改成”hello world“
String s = "hello";
s = "hello world";
这时候,并不是原先s指向的”hello“的值改变为了”hello world“,而是指向了一个新的字符串。
如何去验证是指向了一个新的字符串而不是修改其内容呢,我们可以打印一下hash值看看。
String s = "hello";
System.out.println(System.identityHashCode(s));
s = "hello world";
System.out.println(s.hashCode());
s = "hello";
System.out.println(System.identityHashCode(s));
可以看到,第一次和第三次的hash值一样,第二次hash值和其它两次不同,说明确实是指向了一个新的对象而不是修改了String的值。
那么**String是怎么实现不可变的呢?**我们来看一下String类的源码:
从源码中我们可以看出,首先String类是final的,说明其不可被继承,就不会被子类改变其不可变的特性;其次,String的底层其实是一个被final修饰的数组,说明这个value在确定值后就不能指向一个新的数组。这里我们要明确一点,被final修饰的数组虽然不能指向一个新的数组,但却是可以修改数组的值的:
既然可以被修改,那String怎么是不可变的呢?因为String类并没有提供任何一个方法去修改数组的值,所以String的不可变性是由于其底层的实现,而不是一个final。
那么**String为什么要设计成不可变的呢?**我觉得是因为出于安全性的考量,试想一下,在一个程序中,有多个地方同时引用了一个相同的String对象,但是你可能只是想在一个地方修改String的内容,要是String是可变的,导致了所有的String的内容都改变了,万一这是在一个重要场景下,比如传输密码什么的,不就出大问题了吗。所以String就被设计成了不可变的。
字符串的拼接
说完了String的不可变性,再来聊一聊字符串的拼接问题,看下面一段程序
public static void main(String[] args) {
String a = "hello";
String b = " world!";
String c = a+b;
}
就是这么简单了一段程序,你知道它是怎么实现的吗?我们来看一下这段代码对应的字节码指令:
我就不一行行解释这些字节码指令是什么意思了,我们重点看一下用红色标注的几行代码,看不懂前面的字节码指令没关系,可以看后面的注释。可以看到,字符串拼接其实就是调用StringBuilder的append()方法,然后调用了toString()方法返回一个新的字符串。
StringTable讲解
字符串什么时候被放入StringTable的
先来简单介绍一下StringTable。它的底层数据结构是HashTable,每个元素都是key-value结构,采用了数组+单向链表的实现方式。
再来看下面一段代码:
public static void main(String[] args) {
-> String a = "hello";
String b = " world!";
String c = "hello world!";
}
在类加载后,“hello”这些字符串仅仅是当作符号被加载进了运行时常量池中,还没有成为字符串对象,这是因为Java中的字符串采用了延迟加载的机制,就是程序运行到具体某一行的时候再去加载。比如当程序运行到箭头所指向的那一行时,“hello”会从一个符号变成一个字符串对象,然后去StringTable中找有没有相同的字符串对象,如果有的话就返回对应的地址给变量a,如果没有的话就把“hello”放入StringTable中,然后再把地址给变量a。我们来看一下是不是这样:
String s1 = "hello world";
String s2 = "hello world";
String s3 = "hello world";
String s4 = "hello world";
System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
System.out.println(System.identityHashCode(s4));
可以看到,四个字符串对象的hash值都一样,说明如果StringTable中已经有了相同的对象就会指向同一个对象而不是指向新的对象。
new String()的时候都干了什么
当我们使用new String()去创建一个字符串对象时和直接写String a = "hello"是不一样的。前者保存在堆内存中,后者保存在StringTable中。
其实StringTable也是在堆中,我后面会详细说明。我们先来验证一下上面的说法:
String a = "hello";
String b = new String("hello");
System.out.println(a == b);
看一下运行结果:
结果很显然肯定是false,说明两者确实不是一个对象。而且上面提到指向字符串常量时会先从StringTable中查找,找到就直接返回找到的字符串,但是new String()的时候却不是这样,每new 一个String就会在堆里面创建一个新的String对象,即使是相同的内容,比如我创建4个String对象。
String s1 = new String("hello world");
String s2 = new String("hello world");
String s3 = new String("hello world");
String s4 = new String("hello world");
这时候在堆里面就会存在4个String对象:
我们再来打印一下hash看看是不是4个对象:
System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
System.out.println(System.identityHashCode(s4));
从结果中看出,确实是4个不同的对象。
intern方法是干吗的
我们先来看一段代码:
String s1 = new String("hello world");
String s2 = "hello world";
String s3 = s1.intern();
System.out.println(s1 == s2);
System.out.println(s2 == s3);
大家看看能不能分析出结果是什么,如果你已经知道结果,说明你已经掌握了intern方法,如果不知道,就看我下面的讲解。
结果是false和true,intern方法是干吗的呢?
intern方法的作用就是尝试将一个字符串放入StringTable中,如果不存在就放入StringTable并返回StringTable中的地址,如果存在的话就直接返回StringTable中的地址。这是jdk1.8版本中intern方法的作用,jdk1.6版本中有些不同,1.6中intern尝试将字符串对象放入StringTable,如果有则并不会放入,如果没有会把此对象复制一份,放入StringTable, 再把StringTable中的对象返回。不过我们在这里不讨论1.6版本。
解释一下上面的代码:首先我们在堆中创建了一个"hello world"字符串对象,s1指向了这个堆中的对象;然后在StringTable中创建了一个值为"hello world"的字符串常量对象,s2指向了这个StringTable中的对象;最后我们尝试将s1指向的堆中对象放入StringTable中,发现已经有了,所以就返回了StringTable中的字符串对象的地址给了s3。所以s1和s2指向了同一个对象,s2和s3是一个对象。就像下图这样:
要是把代码稍微改一下呢:
String s1 = new String("hello world").intern();
String s2 = "hello world";
System.out.println(s1 == s2);
这时候结果就是true了。我们来分析一下:首先使用了new String()在堆中创建了字符串对象,然后调用了其intern()方法,所以就从StringTable中查找有没有同样的字符串,发现没有,就将字符串放入StringTable中,然后将StringTable中的对象的地址给了s1;到第二行的时候,因为没有用new String(),所以就直接从StringTable中查找,发现有,就将StringTable中的对象的地址给了s2;所以s1、s2指向了同一个对象。
StringTable的位置
前面已经提到了StringTable在堆中,现在来验证一下。验证的方式很简单,我们放入大量的字符串导致内存溢出,看看是哪个部分内存溢出就知道StringTable在哪儿了。
ArrayList list = new ArrayList();
String str = "hello";
for(int i = 0;i < Integer.MAX_VALUE;i++) {
String s = str + i;
str = s;
list.add(s.intern());
}
我们先是调用了intern方法将字符串放入StringTable,再用一个ArrayList去存放字符串,目的是为了避免垃圾回收,因为这样的话每个字符串都会被强引用,就不会被垃圾回收了,垃圾回收了就不会看到我们想要的结果。来看一下结果:
很明显,是堆内存发生了内存溢出,这样就可以确定StringTable是存放在堆中的。不过这是从1.7版本开始的,1.7之前保存在永久代中。
StringTable的垃圾回收
既然前面提到了垃圾回收,我们就来验证一下StringTable会不会发生垃圾回收。还是上面的代码,只不过稍微修改一下:
String str = "hello";
for(int i = 0;i < 10000;i++) {
String s = str + i;
s.intern();
}
这里没有再将字符串放入ArrayList了,要不然就算是发生了内存溢出也不会垃圾回收。为了看到垃圾回收的过程,所以添加几个虚拟机参数,先不指定堆大小:
运行程序,看看打印情况:
因为堆内存足够大,所以没有发生垃圾回收,我们现在将堆内存设置的小一点,,来个1m:
-Xmx1m
再来运行下程序:
这回因为堆内存不够,发生了多次垃圾回收,所以说,StringTable也会因为内存不足导致垃圾回收。
StringTable底层实现以及性能调优
在介绍性能调优之前不得不说一说StringTable的底层实现,前面已经提到了StringTable底层是一个HashTable,HashTable长什么样呢?其实就是数组+链表,每个元素是一个key-value。当存入一个元素的时候,就会将其key通过hash函数计算得出数组的下标并存放在对应的位置。
比如现在有一个key-value,这个key通过hash函数计算结果为2,那么就把value存放在数组下标为2的位置。但是如果现在又有一个key通过hash函数计算出了相同的结果,比如也是2,但2的位置已经有值了,这种现象就叫做哈希冲突,怎么解决呢?这里采用了链表法:
链表法就是将下标一样的元素通过链表的形式串起来,如果数组容量很小但是元素很多,那么发生哈希冲突的概率就会提高。大家都知道,链表的效率远没有数组那么高,哈希冲突过多会影响性能。所以为了减少哈希冲突的概率,所以可以适当的增加数组的大小。数组的每一格在StringTable中叫做bucket,我们可以增加bucket的数量来提高性能,默认的数量为60013个,来看一个对比:
long startTime = System.nanoTime();
String str = "hello";
for(int i = 0;i < 500000;i++) {
String s = str + i;
s.intern();
}
long endTime = System.nanoTime();
System.out.println("花费的时间为:"+(endTime-startTime)/1000000 + "毫秒");
先通过一个虚拟机参数将bucket指定的小一点,来个2000吧:
-XX:StringTableSize=2000
运行一下:
一共花费了1.2秒。再来将bucket的数量增加一点,来个20000个:
-XX:StringTableSize=20000
运行一下:
可以看到,这次只花了0.19秒,性能有了明显的提升,说明这样确实可以优化StringTable。这里只介绍了一种提升性能的方法,篇幅有限,就不再多说了,我以后可能会专门写一篇文章来专门讲讲StringTable性能优化的问题。
本文地址:https://blog.csdn.net/weixin_51954021/article/details/109603030
上一篇: 逻辑运算符中的&与&&的区别
下一篇: java8 时间操作