看了这篇文章,我搞懂了StringTable

3,381 阅读11分钟

好好学习,天天向上

本文已收录至我的Github仓库DayDayUP:github.com/RobodLee/DayDayUP,欢迎Star,更多文章请前往:目录导航

前言

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性能优化的问题。

写在最后

文章到这里就结束了,可能内容上有些纰漏,大家将就着看吧,毕竟水平有限。如果你觉得我的文章写的对你有些帮助,请不要忘了点赞,转发,收藏,关注哦!

微信公众号
微信公众号