美文网首页
java中的String

java中的String

作者: miaoLoveCode | 来源:发表于2018-02-23 19:39 被阅读911次

    谈起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相关问题一定不会卡壳。如果有问题可以留言啊,一起讨论。

    相关文章

      网友评论

          本文标题:java中的String

          本文链接:https://www.haomeiwen.com/subject/xgdazxtx.html