java中的String

谈起String,大家肯定一定都不陌生,肯定也都使用过,出去面试的时候也有碰到过问相关原理的。今天就结合String相关源码对其相关原理做一个简要的分析。

String相关源码解析

注:考虑到String源码比较简单,本文将针对一些比较容易造成误解的地方为切入点做相关分析,另外,本文源码的jdk版本为:jdk1.7.0_79

String的不可变性

对于初使用java的小伙伴来说,很容易误认为String对象是可变的,但是其实String对象是一旦声明创建好后就不允许改变的,那么接下来我们结合源码来看看String是如何实现不可变的:

  1. 使用final关键字来保证不可变:


    String成员变量

    从源码可以看出:

    • String是一个final类,保证使用者不能通过继承来修改String类;
    • 每一个String对象都维护着一个被final关键词修饰的char类型的数组value,看到这里大家可能有一个疑惑了,数组其实是一个引用类型,final只能限制value引用不变,但是数组元素的值是可以改变的啊,那不是可以通过修改数据的值来修改String的内容咯?来个简单例子试下:


      不可变测试

      运行结果:


      运行结果

      从运行结果可以看出:String并没有被修改。当然咯,你能想到的可以改变的地方Java开发者肯定也想到了,他们不会给你这个修改的机会的:
      String构造方法

      从该构造方法可以看出,String很鸡贼的copy了一份,从而以保证外部数组的改变完全不会影响到String对象。

  2. 一旦有改变就重新创建一个新的对象
    从外部修改String对象是不可能了,那我们可以通过String提供的一些方法,比如substringreplace来修改么?以substring方法实现为例,我们来看下能不能修改:

    substring实现

    从源码加红框部分可以看出:只要剪切后的字符串与原字符串不相等就会创建一个新的String对象,并不能修改原来的String对象。

注:可变的字符串可以用StringBuilder和StringBuffer声明

==与String.equals()

在Java中,==是对比两个内存单元的内容是否一样,如果是原始类型,直接比较它们的值是否相同,如果是引用类型,比较的就是引用的值,换言之就是比较两个对象的地址是否一样。

equals()方法则是Object类定义的:

Object.equals实现

从源码可以看出,Object类的equals实现很简单,就是使用==来匹配。如果对应的类不重写equals方法,那么equals方法其实也就是比较对象地址。看到这里小伙伴们估计有疑惑了,既然用的都是==,没有这个方法,其实也是可以使用的,为什么还要让equals方法存在呢?equals方法存在的意义其实是希望子类重写这个方法的,对象的比较需要根据具体的业务属性值来做比较,而不是只有两个对象的地址相同它们才相等。

接下来我们看看String是如何实现equals方法的:


String.equals实现

从源码可以看出:

  1. 如果两个String对象地址相同,它们两个肯定相等,直接返回true;

  2. 如果两个String对象地址不想同,比较它们的私有属性:字符数组value,如果两个value长度相同并且每一个字符都相等,则两个字符串相等,否则,不相等。

String的equals比较的是字符串的值是否相等,并不拘泥于内存地址。

+与StringBuilder.append()

看了好多好多博客都说String的+运算效率要比StringBuilder.append()的效率低很多很多,但是我跟他们的看法并不相同,来个简单的例子验证下我的看法:

测试案例

用javap -c反编译下:
String+反编译结果

从反编译结果可以看出, +在做单个变量拼接的时候其实用的是StringBuilder.append()方法, 所以它们的效率并没有太大的差别。但是,如果把+放在循环中做字符串循环拼接时,+的效率就会低很多。来个简单的例子:
循环测试案例

同样用javap -c看下反编译下:
循环String+反编译结果

从反编译结果可以看出,每一次的循环都会产生一个新的StringBuilder对象,通过StringBuilder的append方法完成字符串+操作。在循环的过程中,result长度越来越长,占用的空间也就会越来越大,在使用String.append()做拼接的时候比较会容易出现OOM,同时,StringBuilder.toString()也会copy一个新的字符串,在分配空间的时候也比较容易出现OOM。总结来说,为什么说循环的拼接+的性能查主要是因为大量循环中的大量内存使用使内存开销变大,这会导致频繁的GC,而且更多的是full gc,所以效率才会急剧下降。

String常量池与String.intern()

JVM开发者为了提高性能和减少内存的开销,在实例化字符串时使用字符串常量池,并提供以下使用规则:

  1. 每一个字符串常量在常量池中全局唯一;

  2. 通过String ss = "test"双引号声明的字符串会直接存储在常量池中;

  3. 字符串对象可以通过String.intern()方法将其保存到常量池中。

接下来以一个简单的例子,我们来看看在内存中的关系到底是怎么样的:

String内存关系测试

从上图测试代码可以看出,声明了三个字符串对象a、b、c,a,b采用双引号方式声明,都直接指向JVM字符串常量池,a == b应该返回true,c采用new关键字声明,此时会在堆上创建一个对象,c指向该对象,但是,c的value还是指向JVM常量池中的test字符串,此时,a == c应该返回false。我们实际运行下看下返回结果到底是不是这样:
运行结果

从运行结果可以清晰的看到,上面的分析是正确的。

接下来,我们来看下,在用双引号方式声明字符串时,HotSpot是如何实现直接将其放在常量池中的。我们就上面的字符串测试案例,javap -c反编译下:

String双引号声明反编译

从反编译结果可以看出,String a = "test"对应两条JVM指令:

  1. ldc #2
    加载常量池中的指定项的引用到栈中,这里#2表示加载第二项("test")到栈中;

  2. astore_<n>
    将引用赋值给第n个局部变量,astore_1表示将1中的引用赋值给第一个局部变量,即String a = "test"

我们来看下ldc指令在HotSpot中是如何实现的:

注:ldc指令在interpreterRuntime.cpp文件中实现

ldc实现

ldc指令会根据加载的不同的常量进行一些不同的操作,当加载的是字符串常量时,会调用constantPoolOop.string_at方法进行相关处理:
string_at实现

从源码可以看出,string_at主要干了这两件事儿:

  1. 获取当前constantPoolOop实例的句柄;

  2. 调用string_at_impl方法获取字符串引用。

接下来我们看看string_at_impl是如何获取字符串引用的:

string_at_impl实现

从源码可以看出,字符串对象最终其实是调用StringTable::intern来方法生成的,生成后会把该字符串对象引用更新到常量池中,下一次如果再通过ldc指令声明相同字符串时就直接返回该字符串的引用。这就是String内存关系测试a == b为什么返回true,因为它们其实都指向常量池中的同一个引用。

String.intern()

String.intern实现

从源码可以看出,String.intern()是一个native的方法,在使用intern方法时:

  • 如果常量池中已经存在当前字符串,就直接返回当前字符串;

  • 如果常量池中不存在当前字符串,将该字符串添加到常量池中,然后返回该字符串的引用。

既然是native的方法,那HotSpot中它到底是如何实现的呢?

HotSpot1.7中的intern

注:intern方法在String.c文件中实现

HotSpot的intern实现.png

从源码可以看出,intern方法实现的核心在于JVM_InternString方法:

注:JVM_InternString方法在jvm.cpp文件中实现

JVM_InternString实现

跟ldc一样,intern最终也调用了StringTable::intern方法生成字符串的,接下来重点就是分析StringTable的相关实现了。

StringTable
StringTable实现很简单,跟Java中的HashMap类似,接下来我们就来看看StringTable相关声明:

StringTable声明

StringTable的声明在symbolTable.hpp文件中,从源码可以看出:StringTable继承了Hashtable,它的构造参数指定了StringTable的大小为StringTableSize,默认值为1009。

注:StringTableSize相关声明在globals.hpp文件中:

StringTableSize声明

StringTable初始化
在创建StringTable时,通过其构造函数就完成了它的初始化,接下来我们就来看看StringTable初始化到底干了些什么。由于StringTable继承了Hashtable,我们就先来看看Hashtable相关实现:

Hashtable声明

Hashtable的声明在hashtable.hpp中,从源码可以看出,Hashtable是一个模板类,继承了基类BasicHashtable,初始化相关也在基类BasicHashtable中实现:
BasicHashTable构造方法

在BasicHashtable的初始化中,主要干了以下三件事:

  • 调用initialize方法初始化BasicHashtable相关基本值;

  • 调用NEW_C_HEAP_ARRAY方法在堆上为其分配桶节点空间;

  • 清空桶节点中的数据。

看完StringTable相关初始化之后,我们就该来进入正题,看看StringTable::intern方法的相关实现了。

StringTable::intern实现

StringTable::intern实现

从源码可以看出:

  1. 调用java_lang_String::hash_string方法根据String对象中字符数组的拷贝name和字符数组长度len计算字符串的hash值;

  2. 调用hash_to_index方法根据该字符串的hash值计算出字符串在StringTable中桶的位置index:

    hash_to_index实现

  3. 调用lookup方法在StringTable查找该字符串:

    lookup实现

    遍历桶节点下的HashtableEntry链表,如果在链表中可以找到对应的hash值,并且字符串的值也相同,那么该字符串在StringTable中已经存在,返回该字符串的引用,否则,返回NULL

  4. 如果StringTable中存在该字符串,返回字符串引用,否则,调用basic_add方法添加字符串引用到StringTable中:

    basic_add实现

    需要注意的,并不会每一个字符串都进行复制操作,只要满足!string_or_null.is_null() && (!JavaObjectsInPerm || string_or_null()->is_perm())条件就不会进行字符串复制,HashtableEntry其实封装的就是原字符串的hash值和句柄。

    注:


    JavaObjectsInPerm声明.png

    JavaObjectsInPerm的默认值为false

    另外,其实整个添加字符串引用到StringTable的操作是调用add_entry方法完成的:

    add_entry实现

    add_entry并没有复杂的自动扩容之类,操作简单粗暴,每次就是直接在对应桶节点下的HashtableEntry链表里做插入。那么,当StringTable中的字符串达到一定规模的时候,hash冲突会灰常严重,从而导致某一个桶节点下的链表会非常非常长,性能也就会急剧下降,很可能查询的时间复杂度就从期望的o(1)降到o(n)了,所以大家在使用的时候也要视情况而定,不要乱用!

    注:jdk6的StringTable的大小是固定不可变的,就是默认的1009,在jdk7中,JVM提供了参数-XX:StringTableSize可以用于修改StringTable的长度。

综上所述,在HotSpot1.7中,在执行intern方法时,如果StringTable已经存在相等的字符串,返回StringTable中的字符串引用,如果不存在,复制字符串的引用到常量池中,然后返回。

jdk6和jdk7中的intern

上面的大篇幅文章介绍了HotSpot1.7中的intern实现原理,接下来就来个小例子实践下:


String.intern()测试

我们分别在jdk6和jdk7下运行下,结果竟然是:

  1. jdk6:false false

  2. jdk7:true false

吼吼,还能出现这个操作,相同的代码输出结果竟然还是不一样的~接下来就来解释下为什么输出是不一样的。

jdk6中的intern
jdk6中StringTable是放在Perm区的,它和heap有内存隔离,在执行intern方法时,如果StringTable中不存在该字符串,JVM就会在StringTable中复制该字符串并且返回引用,针对上述案例:

  1. 变量a分配在heap上,a.intern()指向的是Perm区StringTable中的引用,跟a指向的不是同一个引用,在做==判断时返回false;

  2. 同理,对于变量b也是一样的,b.intern()和b指向的也不是同一个引用,在做==判断当然也返回false。

jdk7中的intern
由于Perm区是一个静态区域,主要存储一些加载类的信息,方法片段等内容,默认的大小也很小,一旦大量使用intern很容易就出现Perm区的oom。所以在jdk7中,StringTable从Perm区迁移到和heap。针对上述案例:

  1. 对于变量a,在做intern操作时,此时StringTable不存在"miaomiao test String",JVM会复制变量a的引用到StringTable中,a.intern()和a其实指向相同的引用,在做==判断时返回true

  2. 对于变量b,StringTable一开始就存在字符串javab.intern()返回的是StringTable中的引用,跟b指向的不是同一个引用,所以在做==判断时返回false

后记

涉及到HotSpot源码分析起来总是比较费劲,如果小伙伴们有C/C++基础我相信看起来应该不会很费劲,看完这个,面试再问到String相关问题一定不会卡壳。如果有问题可以留言啊,一起讨论。

推荐阅读更多精彩内容