谈起String,大家肯定一定都不陌生,肯定也都使用过,出去面试的时候也有碰到过问相关原理的。今天就结合String相关源码对其相关原理做一个简要的分析。
String相关源码解析
注:考虑到String源码比较简单,本文将针对一些比较容易造成误解的地方为切入点做相关分析,另外,本文源码的jdk版本为:
jdk1.7.0_79
String的不可变性
对于初使用java的小伙伴来说,很容易误认为String对象是可变的,但是其实String对象是一旦声明创建好后就不允许改变的,那么接下来我们结合源码来看看String是如何实现不可变的:
-
使用final关键字来保证不可变:
从源码可以看出:
- String是一个final类,保证使用者不能通过继承来修改String类;
-
每一个String对象都维护着一个被final关键词修饰的char类型的数组value,看到这里大家可能有一个疑惑了,数组其实是一个引用类型,final只能限制value引用不变,但是数组元素的值是可以改变的啊,那不是可以通过修改数据的值来修改String的内容咯?来个简单例子试下:
运行结果:
从运行结果可以看出:String并没有被修改。当然咯,你能想到的可以改变的地方Java开发者肯定也想到了,他们不会给你这个修改的机会的:
从该构造方法可以看出,String很鸡贼的copy了一份,从而以保证外部数组的改变完全不会影响到String对象。
-
一旦有改变就重新创建一个新的对象
从外部修改String对象是不可能了,那我们可以通过String提供的一些方法,比如substring
,replace
来修改么?以substring
方法实现为例,我们来看下能不能修改:
从源码加红框部分可以看出:只要剪切后的字符串与原字符串不相等就会创建一个新的String对象,并不能修改原来的String对象。
注:可变的字符串可以用StringBuilder和StringBuffer声明
==与String.equals()
在Java中,==
是对比两个内存单元的内容是否一样,如果是原始类型,直接比较它们的值是否相同,如果是引用类型,比较的就是引用的值,换言之就是比较两个对象的地址是否一样。
equals()
方法则是Object类定义的:
从源码可以看出,Object类的equals实现很简单,就是使用
==
来匹配。如果对应的类不重写equals方法,那么equals方法其实也就是比较对象地址。看到这里小伙伴们估计有疑惑了,既然用的都是==
,没有这个方法,其实也是可以使用的,为什么还要让equals方法存在呢?equals方法存在的意义其实是希望子类重写这个方法的,对象的比较需要根据具体的业务属性值来做比较,而不是只有两个对象的地址相同它们才相等。
接下来我们看看String是如何实现equals方法的:
从源码可以看出:
如果两个String对象地址相同,它们两个肯定相等,直接返回true;
如果两个String对象地址不想同,比较它们的私有属性:字符数组value,如果两个value长度相同并且每一个字符都相等,则两个字符串相等,否则,不相等。
String的equals比较的是字符串的值是否相等,并不拘泥于内存地址。
+与StringBuilder.append()
看了好多好多博客都说String的+
运算效率要比StringBuilder.append()的效率低很多很多,但是我跟他们的看法并不相同,来个简单的例子验证下我的看法:
用javap -c反编译下:
从反编译结果可以看出,
+
在做单个变量拼接的时候其实用的是StringBuilder.append()
方法, 所以它们的效率并没有太大的差别。但是,如果把+
放在循环中做字符串循环拼接时,+
的效率就会低很多。来个简单的例子:同样用javap -c看下反编译下:
从反编译结果可以看出,每一次的循环都会产生一个新的StringBuilder对象,通过StringBuilder的append方法完成字符串
+
操作。在循环的过程中,result长度越来越长,占用的空间也就会越来越大,在使用String.append()做拼接的时候比较会容易出现OOM,同时,StringBuilder.toString()也会copy一个新的字符串,在分配空间的时候也比较容易出现OOM。总结来说,为什么说循环的拼接+
的性能查主要是因为大量循环中的大量内存使用使内存开销变大,这会导致频繁的GC,而且更多的是full gc,所以效率才会急剧下降。
String常量池与String.intern()
JVM开发者为了提高性能和减少内存的开销,在实例化字符串时使用字符串常量池,并提供以下使用规则:
每一个字符串常量在常量池中全局唯一;
通过
String ss = "test"
双引号声明的字符串会直接存储在常量池中;字符串对象可以通过String.intern()方法将其保存到常量池中。
接下来以一个简单的例子,我们来看看在内存中的关系到底是怎么样的:
从上图测试代码可以看出,声明了三个字符串对象a、b、c,a,b采用双引号方式声明,都直接指向JVM字符串常量池,
a == b
应该返回true,c采用new关键字声明,此时会在堆上创建一个对象,c指向该对象,但是,c的value还是指向JVM常量池中的test
字符串,此时,a == c
应该返回false。我们实际运行下看下返回结果到底是不是这样:从运行结果可以清晰的看到,上面的分析是正确的。
接下来,我们来看下,在用双引号方式声明字符串时,HotSpot是如何实现直接将其放在常量池中的。我们就上面的字符串测试案例,javap -c反编译下:
从反编译结果可以看出,
String a = "test"
对应两条JVM指令:
ldc #2
加载常量池中的指定项的引用到栈中,这里#2表示加载第二项("test")到栈中;astore_<n>
将引用赋值给第n个局部变量,astore_1
表示将1中的引用赋值给第一个局部变量,即String a = "test"
。
我们来看下ldc指令在HotSpot中是如何实现的:
注:ldc指令在
interpreterRuntime.cpp
文件中实现
ldc指令会根据加载的不同的常量进行一些不同的操作,当加载的是字符串常量时,会调用
constantPoolOop.string_at
方法进行相关处理:从源码可以看出,string_at主要干了这两件事儿:
获取当前constantPoolOop实例的句柄;
调用string_at_impl方法获取字符串引用。
接下来我们看看string_at_impl是如何获取字符串引用的:
从源码可以看出,字符串对象最终其实是调用
StringTable::intern
来方法生成的,生成后会把该字符串对象引用更新到常量池中,下一次如果再通过ldc指令声明相同字符串时就直接返回该字符串的引用。这就是String内存关系测试中a == b
为什么返回true,因为它们其实都指向常量池中的同一个引用。
String.intern()
从源码可以看出,
String.intern()
是一个native的方法,在使用intern方法时:
如果常量池中已经存在当前字符串,就直接返回当前字符串;
如果常量池中不存在当前字符串,将该字符串添加到常量池中,然后返回该字符串的引用。
既然是native的方法,那HotSpot中它到底是如何实现的呢?
HotSpot1.7中的intern
注:intern方法在String.c文件中实现
从源码可以看出,intern方法实现的核心在于
JVM_InternString
方法:
注:JVM_InternString方法在jvm.cpp文件中实现
跟ldc一样,intern最终也调用了
StringTable::intern
方法生成字符串的,接下来重点就是分析StringTable的相关实现了。
StringTable
StringTable实现很简单,跟Java中的HashMap类似,接下来我们就来看看StringTable相关声明:
StringTable的声明在symbolTable.hpp文件中,从源码可以看出:StringTable继承了Hashtable,它的构造参数指定了StringTable的大小为StringTableSize,默认值为1009。
注:StringTableSize相关声明在globals.hpp文件中:
StringTable初始化
在创建StringTable时,通过其构造函数就完成了它的初始化,接下来我们就来看看StringTable初始化到底干了些什么。由于StringTable继承了Hashtable,我们就先来看看Hashtable相关实现:
Hashtable的声明在hashtable.hpp中,从源码可以看出,Hashtable是一个模板类,继承了基类BasicHashtable,初始化相关也在基类BasicHashtable中实现:
在BasicHashtable的初始化中,主要干了以下三件事:
调用
initialize
方法初始化BasicHashtable相关基本值;调用
NEW_C_HEAP_ARRAY
方法在堆上为其分配桶节点空间;清空桶节点中的数据。
看完StringTable相关初始化之后,我们就该来进入正题,看看StringTable::intern
方法的相关实现了。
StringTable::intern实现
从源码可以看出:
调用
java_lang_String::hash_string
方法根据String对象中字符数组的拷贝name和字符数组长度len计算字符串的hash值;-
调用
hash_to_index
方法根据该字符串的hash值计算出字符串在StringTable中桶的位置index:
-
调用
lookup
方法在StringTable查找该字符串:
遍历桶节点下的HashtableEntry链表,如果在链表中可以找到对应的hash值,并且字符串的值也相同,那么该字符串在StringTable中已经存在,返回该字符串的引用,否则,返回NULL
。 -
如果StringTable中存在该字符串,返回字符串引用,否则,调用
basic_add
方法添加字符串引用到StringTable中:
需要注意的,并不会每一个字符串都进行复制操作,只要满足!string_or_null.is_null() && (!JavaObjectsInPerm || string_or_null()->is_perm())
条件就不会进行字符串复制,HashtableEntry其实封装的就是原字符串的hash值和句柄。注:
JavaObjectsInPerm的默认值为false
另外,其实整个添加字符串引用到StringTable的操作是调用
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实现原理,接下来就来个小例子实践下:
我们分别在jdk6和jdk7下运行下,结果竟然是:
jdk6:
false false
jdk7:
true false
吼吼,还能出现这个操作,相同的代码输出结果竟然还是不一样的~接下来就来解释下为什么输出是不一样的。
jdk6中的intern
jdk6中StringTable是放在Perm区的,它和heap有内存隔离,在执行intern方法时,如果StringTable中不存在该字符串,JVM就会在StringTable中复制该字符串并且返回引用,针对上述案例:
变量a分配在heap上,
a.intern()
指向的是Perm区StringTable中的引用,跟a指向的不是同一个引用,在做==
判断时返回false;同理,对于变量b也是一样的,
b.intern()
和b指向的也不是同一个引用,在做==
判断当然也返回false。
jdk7中的intern
由于Perm区是一个静态区域,主要存储一些加载类的信息,方法片段等内容,默认的大小也很小,一旦大量使用intern很容易就出现Perm区的oom。所以在jdk7中,StringTable从Perm区迁移到和heap。针对上述案例:
对于变量a,在做intern操作时,此时StringTable不存在"miaomiao test String",JVM会复制变量a的引用到StringTable中,
a.intern()
和a其实指向相同的引用,在做==
判断时返回true
;对于变量b,StringTable一开始就存在字符串
java
,b.intern()
返回的是StringTable中的引用,跟b指向的不是同一个引用,所以在做==
判断时返回false
。
后记
涉及到HotSpot源码分析起来总是比较费劲,如果小伙伴们有C/C++基础我相信看起来应该不会很费劲,看完这个,面试再问到String相关问题一定不会卡壳。如果有问题可以留言啊,一起讨论。