原文:从字符串到常量池,一文看懂String类设计 - 知乎 (zhihu.com)
原文:深入解析String#intern - 美团技术团队 (meituan.com)
建议直接看原文,这里只是做一个备份
从一道面试题开始
看到这个标题,你肯定以为我又要讲这道面试题了
// 这行代码创建了几个对象?
String s3 = new String("1");
是的,没错,我确实要从这里开始
这道题就算你没做过也肯定看到,总所周知,它创建了两个对象,一个位于堆上,一个位于常量池中。
这个答案粗看起来是没有任何问题的,但是仔细思考确经不起推敲。
如果你觉得我说的不对的话,那么可以思考下面这两个问题
- 你说它创建了两个对象,那么这两个对象分别是怎样创建的呢?我们回顾下Java创建对象的方式,一共就这么几种
- 使用new关键字创建对象
- 使用反射创建对象(包括Class类的
newInstance
方法,以及Constructor类的newInstance
方法) - 使用clone复制一个对象
- 反序列化得到一个对象
你说它创建了两个对象,那你告诉我除了new出来那个对象外,另外一个对象怎么创建出来的?
- 堆跟常量池到底什么关系?不是说在
JDK1.7
之后(含1.7版本)常量池已经移到了堆中了吗?如果说常量池本身就位于堆中的话,那么这种一个对象在堆中,一个对象在常量池
的说法还准确吗?
如果你也产生过这些疑问的话,那么请耐心看完这篇文章!要解释上面的问题首先我们得对常量池有个准确的认知。
常量池
通常来说,我们提到的常量池分为三种
- class文件中的常量池
- 运行时常量池
- 字符串常量池
对于这三种常量池,我们需要搞懂下面几个问题?
- 这个常量池在哪里?
- 这个常量池用来干什么呢?
- 这三者有什么关系?
接下来,我们带着这些问题往下看
class文件中的常量池
位置在哪?
顾名思义,class文件中的常量池当然是位于class文件中,而class文件又是位于磁盘上。
用来干什么的?
在学习class文件中的常量池前,我们首选需要对class文件的结构有一定了解
Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文
件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数
据,没有空隙存在。
------------《深入理解Java虚拟机》
整个class文件的组成可以用下图来表示
对本文而言,我们只关注其中的常量池部分,常量池可以理解为class文件中资源仓库,它是class文件结构中与其它项目关联最多的数据类型,主要用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
现在我们知道了class文件中常量池的作用:存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。很多时候知道了一个东西的概念并不能说你会了,对于程序员而言,如果你说你已经会了,那么最好的证明是你能够通过代码将其描述出来,所以,接下来,我想以一种直观的方式让大家感受到常量池的存在。通过分析一段简单代码的字节码,让大家能更好感知常量池的作用。
talk is cheap ,show me code
我们以下面这段代码为例,通过javap
来查看class文件中的具体内容,代码如下:
/** * @author 程序员DMZ * @Date Create in 22:59 2020/6/15 * @公众号 微信搜索:程序员DMZ */ public class Main { public static void main(String[] args) { String name = "dmz"; } }
进入Main.java
文件所在目录,执行命令:javac Main.java
,那么此时会在当前目录下生成对应的Main.class文件
。再执行命令:javap -v -c Main.class
,此时会得到如下的解析后的字节码信息
public class com.dmz.jvm.Main minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER // 这里就是常量池了 Constant pool: #1 = Methodref #4.#20 // java/lang/Object."<init>":()V #2 = String #21 // dmz #3 = Class #22 // com/dmz/jvm/Main #4 = Class #23 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 LocalVariableTable #10 = Utf8 this #11 = Utf8 Lcom/dmz/jvm/Main; #12 = Utf8 main #13 = Utf8 ([Ljava/lang/String;)V #14 = Utf8 args #15 = Utf8 [Ljava/lang/String; #16 = Utf8 name #17 = Utf8 Ljava/lang/String; #18 = Utf8 SourceFile #19 = Utf8 Main.java #20 = NameAndType #5:#6 // "<init>":()V #21 = Utf8 dmz #22 = Utf8 com/dmz/jvm/Main #23 = Utf8 java/lang/Object // 下面是方法表 { public com.dmz.jvm.Main(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 7: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/dmz/jvm/Main; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 // 可以看到方法表中的指令引用了常量池中的常量,这也是为什么说常量池是资源仓库的原因 // 因为它会被class文件中的其它结构引用 0: ldc #2 // String dmz 2: astore_1 3: return LineNumberTable: line 9: 0 line 10: 3 LocalVariableTable: Start Length Slot Name Signature 0 4 0 args [Ljava/lang/String; 3 1 1 name Ljava/lang/String; } SourceFile: "Main.java"
在上面的字节码中,我们暂且关注常量池中的内容即可。主要看这两行
#2 = String #14 // dmz #14 = Utf8 dmz
如果要看懂这两行代码,我们需要对常量池中String类型常量的结构有一定了解,其结构如下:
CONSTANT_String_infotag标志常量类型的标签index指向字符串字面量的索引
对应到我们上面的字节码中,tag=String,index=#14
,所以我们可以知道,#2
是一个字面量为#14
的字符串类型常量。而#14
对应的字面量信息(一个Utf8
类型的常量)就是dmz
。
常量池作为资源仓库,最大的用处在于被class文件中的其它结构所引用,这个时候我们再将注意力放到main方法上来,对应的就是这三条指令
0: ldc #2 // String dmz 2: astore_1 3: return
ldc
:这个指令的作用是将对应的常量的引用压入操作数栈,在执行ldc
指令时会触发对它的符号引用进行解析,在上面例子中对应的符号引用就是#2
,也就是常量池中的第二个元素(这里就能看出方法表中就引用了常量池中的资源)
astore_1
:将操作数栈底元素弹出,存储到局部变量表中的1号元素
return
:方法返回值为void,标志方法执行完成,将方法对应栈帧从栈中弹出
下面我用画图的方式来画出整个流程,主要分为四步
- 解析
ldc
指令的符号引用(#2
) - 将
#2
对应的常量的引用压入到操作数栈顶 - 将操作数栈的元素弹出并存储到局部变量表中
- 执行
return
指令,方法执行结束,弹出栈区该方法对应的栈帧
第一步:
在解析#2
这个符号引用时,会先到字符串常量池中查找是否存在对应字符串实例的引用,如果有的话,那么直接返回这个字符串实例的引用,如果没有的话,会创建一个字符串实例,那么将其添加到字符串常量池中(实际上是将其引用放入到一个哈希表中),之后再返回这个字符串实例对象的引用。
到这里也能回答我们之前提出的那个问题了,一个对象是new出来的,另外一个是在解析常量池的时候JVM自动创建的
第二步:
将第一步得到的引用压入到操作数栈,此时这个字符串实例同时被操作数栈以及字符串常量池引用。
第三步:
操作数栈中的引用弹出,并赋值给局部变量表中的1号位置元素,到这一步其实执行完了String name = "dmz"
这行代码。此时局部变量表中储存着一个指向堆中字符串实例的引用,并且这个字符串实例同时也被字符串常量池引用。
第四步:
这一步我就不画图了,就是方法执行完成,栈帧弹出,非常简单。
在上文中,我多次提到了字符串常量池,它到底是个什么东西呢?我们还是分为两部分讨论
- 位置在哪?
- 用来干什么的?
字符串常量池
位置在哪?
字符串常量池比较特殊,在JDK1.7
之前,其存在于永久代中,到JDK1.7及
之后,已经中永久代移到了堆中。当然,如果你非要说永久代也是堆的一部分那我也没办法。
另外还要说明一点,经常有同学会将方法区
,元空间
,永久代(permgen space)
的概念混淆。请注意
-
方法区
是JVM
在内存分配时需要遵守的规范,是一个理论,具体的实现可以因人而异 -
永久代
是hotspot
的jdk1.8
以前对方法区的实现,使用jdk1.7
的老司机肯定以前经常遇到过java.lang.OutOfMemoryError: PremGen space
异常。这里的PermGen space
其实指的就是方法区。不过方法区和PermGen space
又有着本质的区别。前者是JVM的规范
,而后者则是JVM规范
的一种实现,并且只有HotSpot
才有PermGen space
。 -
元空间
是jdk1.8
对方法区的实现,jdk1.8
彻底移除了永久代,其实,移除永久代的工作从JDK 1.7
就开始了。JDK 1.7
中,存储在永久代的部分数据就已经转移到Java Heap或者Native Heap。但永久代仍存在于JDK 1.7
中,并没有完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了Java heap;类的静态变量(class statics)转移到了Java heap。到jdk1.8
彻底移除了永久代,将JDK7中还剩余的永久代信息全部移到元空间,元空间相比对永久代最大的差别是,元空间使用的是本地内存(Native Memory)。
用来干什么的?
字符串常量池,顾名思义,肯定就是用来存储字符串的嘛,准确来说存储的是字符串实例对象的引用。我查阅了很多博客、资料,它们都会说,字符串常量池中存储的就是字符串对象。其实我们可以类比下面这段代码:
HashSet<Person> persons = new HashSet<Person>;
在persons
这个集合中,存储的是Person
对象还是Person
对象对应的引用呢?
所以,请大声跟我念三遍
字符串常量池存储的是字符串实例对象的引用!
字符串常量池存储的是字符串实例对象的引用!
字符串常量池存储的是字符串实例对象的引用!
下面我们来看R大博文下评论的一段话:
简单来说,HotSpot VM里StringTable是个哈希表,里面存的是驻留字符串的引用(而不是驻留字符串实例自身)。也就是说某些普通的字符串实例被这个StringTable引用之后就等同被赋予了“驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例里只有一份,被所有的类共享。类的运行时常量池里的CONSTANT_String类型的常量,经过解析(resolve)之后,同样存的是字符串的引用;解析的过程会去查询StringTable,以保证运行时常量池所引用的字符串与StringTable所引用的是一致的。
------R大博客
从上面我们可以知道
- 字符串常量池本质就是一个哈希表
- 字符串常量池中存储的是字符串实例的引用
- 字符串常量池在被整个JVM共享
- 在解析运行时常量池中的符号引用时,会去查询字符串常量池,确保运行时常量池中解析后的直接引用跟字符串常量池中的引用是一致的
为了更好理解上面的内容,我们需要去分析String中的一个方法-----intern()
intern方法分析
/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class <code>String</code>. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this <code>String</code> object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this <code>String</code> object is added to the * pool and a reference to this <code>String</code> object is returned. * <p> * It follows that for any two strings <code>s</code> and <code>t</code>, * <code>s.intern() == t.intern()</code> is <code>true</code> * if and only if <code>s.equals(t)</code> is <code>true</code>. * <p> * All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * <cite>The Java™ Language Specification</cite>. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */ public native String intern();
String#intern
方法中看到,这个方法是一个 native 的方法,但注释写的非常明了。“如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回”。
关于其详细的分析可以参考:美团:深入解析String#intern
珠玉在前,所以本文着重就分析下intern方法在JDK
不同版本下的差异,首先我们要知道引起差异的原因是因为**JDK1.7及之后
将字符串常量池从永久代挪到了堆中。**
我这里就以美团文章中的示例代码来进行分析,代码如下:
public static void main(String[] args) { String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2); String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4); }
打印结果是
- jdk6 下
false false
- jdk7 下
false true
在美团的文章中已经对这个结果做了详细的解释,接下来我就用我的图解方式再分析一波这个过程
jdk6 执行流程
**第一步:**执行String s = new String("1")
,要清楚这行代码的执行过程,我们还是得从字节码入手,这行代码对应的字节码如下:
public static void main(java.lang.String[]); Code: 0: new #2 // class java/lang/String 3: dup 4: ldc #3 // String 1 6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V 9: astore_1 10: return
new
:创建了一个类的实例(还没有调用构造器函数),并将其引用压入操作数栈顶
dup
:复制栈顶数值并将复制值压入栈顶,这是因为invokespecial
跟astore_1
各需要消耗一个引用
ldc
:解析常量池符号引用,将实际的直接引用压入操作数栈顶
invokespecial
:弹出此时栈顶的常量引用及对象引用,执行invokespecial
指令,调用构造函数
astore_1
:将此时操作数栈顶的元素弹出,赋值给局部变量表中1号元素(0号元素存的是main函数的参数)
我们可以将上面整个过程分为两个阶段
- 解析常量
- 调用构造函数创建对象并返回引用
在解析常量的过程中,因为该字符串常量是第一次解析,所以会先在永久代中创建一个字符串实例对象,并将其引用添加到字符串常量池中。此时内存状态如下:
当真正通过new方式创建对象完成后,对应的内存状态如下,因为在分析class文件中的常量池
的时候已经对栈区做了详细的分析,所以这里就省略一些细节了,在执行完这行代码后,栈区存在一个引用,指向 了堆区的一个字符串实例内存状态对应如下:
**第二步:**紧接着,我们调用了s的intern方法,对应代码就是s.intern()
当intern方法执行时,因为此时字符串常量池中已经存在了一个字面量信息跟s相同的字符串的引用,所以此时内存状态不会发生任何改变。
**第三步:**执行String s2 = "1"
,此时因为常量池中已经存在了字面量1的对应字符串实例的引用,所以,这里就直接返回了这个引用并且赋值给了局部变量s2。对应的内存状态如下:
到这里就很清晰了,s跟s2指向两个不同的对象,所以s==s2肯定是false嘛~
如果看过美团那篇文章的同学可能会有些疑惑,我在图中对常量池的描述跟美团文章图中略有差异,在美团那篇文章中,直接将具体的字符串实例放到了字符串常量池中,而在我上面的图中,字符串常量池存的永远时引用,它的图是这样画的
就我查阅的资料而言,我个人不赞同这种说法,常量池中应该保存的仅仅是引用。关于这个问题,我已经向美团的团队进行了留言,也请大佬出来纠错!
接着我们分析s3跟s4,对应的就是这几行代码:
String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4);
我们一行行分析,看看执行完后,内存的状态是什么样的
第一步:String s3 = new String("1") + new String("1")
,执行完成后,堆区多了两个匿名对象,这个我们不用多关注,另外堆区还多了一个字面量为11的字符串实例,并且栈中存在一个引用指向这个实例
实际上上图中还少了一个匿名的StringBuilder
的对象,这是因为当我们在进行字符串拼接时,编译器默认会创建一个StringBuilder
对象并调用其append
方法来进行拼接,最后再调用其toString
方法来转换成一个字符串,StringBuilder
的toString
方法其实就是new一个字符串
public String toString() { // Create a copy, don't share the array return new String(value, 0, count); }
这也是为什么在图中会说在堆上多了一个字面量为11的字符串实例的原因,因为实际上就是new出来的嘛!
第二步:s3.intern()
调用intern
方法后,因为字符串常量池中目前没有11这个字面量对应的字符串实例的应用,所以JVM会先从堆区复制一个字符串实例到永久代中,再将其引用添加到字符串常量池中,最终的内存状态就如下所示
第三步:String s4 = "11"
这应该没啥好说的了吧,常量池中有了,直接指向对应的字符串实例
到这里可以发现,s3跟s4指向的根本就是两个不同的对象,所以也返回false
jdk7 执行流程
在jdk1.7中,s跟s2的执行结果还是一样的,这是因为 String s = new String("1")
这行代码本身就创建了两个字符串对象,一个属于被常量池引用的驻留字符串,而另外一个只是堆上的一个普通字符串对象。跟1.6的区别在于,1.7中的驻留字符串位于堆上,而1.6中的位于方法区中,但是本质上它们还是两个不同的对象,在下面代码执行完后
String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2);
内存状态为:
但是对于s3跟s4确不同了,因为在jdk1.7中不会再去复制字符串实例了,在intern方法执行时在发现堆上有对应的对象之后,直接将这个对应的引用添加到字符串常量池中,所以代码执行完,内存状态对应如下:
看到了吧,s3跟s4指向的同一个对象,这是因为intern方法执行时,直接s3这个引用复制到了常量池,之后执行String s4= "11"
的时候,直接再将常量池中的引用复制给了s4,所以s3==s4肯定为true啦。
在理解了它们之间的差异之后,我们再来思考一个问题,假设我现在将代码改成这个样子,那么运行结果是什么样的呢?
public static void main(String[] args) { String s = new String("1"); String sintern = s.intern(); String s2 = "1"; System.out.println(sintern == s2); String s3 = new String("1") + new String("1"); String s3intern = s3.intern(); String s4 = "11"; System.out.println(s3intern == s4); }
上面这段代码运行起来结果会有差异吗?大家可以自行思考~
在我们对字符串常量池有了一定理解之后会发现,其实通过String name = "dmz"
这行代码申明一个字符串,实际的执行逻辑就像下面这段伪代码所示
/** * 这段代码逻辑类比于 * <code>String s = "字面量"</code>;这种方式申明一个字符串 * 其中字面量就是在""中的值 * */ public String declareString(字面量) { String s; // 这是一个伪方法,标明会根据字面量的值到字符串值中查找是否存在对应String实例的引用 s = findInStringTable(字面量); // 说明字符串池中已经存在了这个引用,那么直接返回 if (s != null) { return s; } // 不存在这个引用,需要新建一个字符串实例,然后调用其intern方法将其拘留到字符串池中, // 最后返回这个新建字符串的引用 s = new String(字面量); // 调用intern方法,将创建好的字符串放入到StringTable中, // 类似就是调用StringTable.add(s)这也的一个伪方法 s.intern(); return s; }
按照这个逻辑,我们将我们将上面思考题中的所有字面量进行替换,会发现不管在哪个版本中结果都应该返回true。
运行时常量池
位置在哪?
位于方法区中,1.6在永久代,1.7在元空间中,永久代跟元空间都是对方法区的实现
用来干什么?
jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable
,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
所以简单来说,运行时常量池就是用来存放class常量池中的内容的。
总结
我们将三者进行一个比较
以一道测试题结束
// 环境1.7及以上 public class Clazz { public static void main(String[] args) { String s1 = new StringBuilder().append("ja").append("va1").toString(); String s2 = s1.intern(); System.out.println(s1==s2); String s5 = "dmz"; String s3 = new StringBuilder().append("d").append("mz").toString(); String s4 = s3.intern(); System.out.println(s3 == s4); String s7 = new StringBuilder().append("s").append("pring").toString(); String s8 = s7.intern(); String s6 = "spring"; System.out.println(s7 == s8); } }
答案是true,false,true。大家可以仔细思考为什么,如有疑惑可以给我留言,或者进群交流!
如果本文对你有帮助的话,记得点个赞吧!也欢迎关注我的公众号,微信搜索:程序员DMZ,或者扫描下方二维码,跟着我一起认认真真学Java,踏踏实实做一个coder。
我叫DMZ,一个在学习路上匍匐前行的小菜鸟!
参考文章:
R大博文:请别再拿“String s = new String("xyz");创建了多少个String实例”来面试了吧
R大知乎回答:JVM 常量池中存储的是对象还是引用呢?
参考书籍:
《深入理解Java虚拟机》第二版
《深入理解Java虚拟机》第三版
《Java虚拟机规范》
思考/总结
JDK6的String#intern若常量池未存在相等字符串,则深拷贝堆中字符串并将引用存储在Hashtable中
JDK7的String#intern若常量池未存在相等字符串,则拷贝堆中字符串引用到Hashtable中
JDK6中
public class Demo01 { public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { String str = new String("123") + new String("XYZ"); str.intern(); String str2 = "123XYZ"; final Field value = String.class.getDeclaredField("value"); value.setAccessible(true); value.set(str, value.get("XXXYYYZZZ")); System.out.println(str); System.out.println(str2); } }
输出
XXXYYY 123XYZ
可以看出确实是深拷贝,字符串数组并未共享。
ldc解析字符串的符号引用并创建对象时,这个字符串对象创建在哪的呢?字符串常量池指的是Hashtable + 其对应的字符串对象还是仅Hashtable?也就是这个字符串对象到底是创建在堆中还是创建在字符串常量池中?
美团文章
在 JAVA 语言中有8中基本类型和一种比较特殊的类型String
。这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个JAVA系统级别提供的缓存。
8种基本类型的常量池都是系统协调的,String
类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的
String
对象会直接存储在常量池中。 - 如果不是用双引号声明的
String
对象,可以使用String
提供的intern
方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中
接下来我们主要来谈一下String#intern
方法。
首先深入看一下它的实现原理。
1,JAVA 代码
/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class <code>String</code>. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this <code>String</code> object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this <code>String</code> object is added to the * pool and a reference to this <code>String</code> object is returned. * <p> * It follows that for any two strings <code>s</code> and <code>t</code>, * <code>s.intern() == t.intern()</code> is <code>true</code> * if and only if <code>s.equals(t)</code> is <code>true</code>. * <p> * All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * <cite>The Java™ Language Specification</cite>. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */ public native String intern();
String#intern
方法中看到,这个方法是一个 native 的方法,但注释写的非常明了。“如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回”。
2,native 代码
在 jdk7后,oracle 接管了 JAVA 的源码后就不对外开放了,根据 jdk 的主要开发人员声明 openJdk7 和 jdk7 使用的是同一分主代码,只是分支代码会有些许的变动。所以可以直接跟踪 openJdk7 的源码来探究 intern 的实现。
####native实现代码: \openjdk7\jdk\src\share\native\java\lang\String.c
Java_java_lang_String_intern(JNIEnv *env, jobject this) { return JVM_InternString(env, this); }
\openjdk7\hotspot\src\share\vm\prims\jvm.h
/* * java.lang.String */ JNIEXPORT jstring JNICALL JVM_InternString(JNIEnv *env, jstring str);
\openjdk7\hotspot\src\share\vm\prims\jvm.cpp
// String support /////////////////////////////////////////////////////////////////////////// JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str)) JVMWrapper("JVM_InternString"); JvmtiVMObjectAllocEventCollector oam; if (str == NULL) return NULL; oop string = JNIHandles::resolve_non_null(str); oop result = StringTable::intern(string, CHECK_NULL); return (jstring) JNIHandles::make_local(env, result); JVM_END
\openjdk7\hotspot\src\share\vm\classfile\symbolTable.cpp
oop StringTable::intern(Handle string_or_null, jchar* name, int len, TRAPS) { unsigned int hashValue = java_lang_String::hash_string(name, len); int index = the_table()->hash_to_index(hashValue); oop string = the_table()->lookup(index, name, len, hashValue); // Found if (string != NULL) return string; // Otherwise, add to symbol to table return the_table()->basic_add(index, string_or_null, name, len, hashValue, CHECK_NULL); }
\openjdk7\hotspot\src\share\vm\classfile\symbolTable.cpp
oop StringTable::lookup(int index, jchar* name, int len, unsigned int hash) { for (HashtableEntry<oop>* l = bucket(index); l != NULL; l = l->next()) { if (l->hash() == hash) { if (java_lang_String::equals(l->literal(), name, len)) { return l->literal(); } } } return NULL; }
它的大体实现结构就是: JAVA 使用 jni 调用c++实现的StringTable
的intern
方法, StringTable
的intern
方法跟Java中的HashMap
的实现是差不多的, 只是不能自动扩容。默认大小是1009。
要注意的是,String的String Pool是一个固定大小的Hashtable
,默认值大小长度是1009,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern
时性能会大幅下降(因为要一个一个找)。
在 jdk6中StringTable
是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。在jdk7中,StringTable
的长度可以通过一个参数指定:
-XX:StringTableSize=99991
相信很多 JAVA 程序员都做做类似 String s = new String("abc")
这个语句创建了几个对象的题目。 这种题目主要就是为了考察程序员对字符串对象的常量池掌握与否。上述的语句中是创建了2个对象,第一个对象是”abc”字符串存储在常量池中,第二个对象在JAVA Heap中的 String 对象。
来看一段代码:
public static void main(String[] args) { String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2); String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4); }
打印结果是
- jdk6 下
false false
- jdk7 下
false true
具体为什么稍后再解释,然后将s3.intern();
语句下调一行,放到String s4 = "11";
后面。将s.intern();
放到String s2 = "1";
后面。是什么结果呢
public static void main(String[] args) { String s = new String("1"); String s2 = "1"; s.intern(); System.out.println(s == s2); String s3 = new String("1") + new String("1"); String s4 = "11"; s3.intern(); System.out.println(s3 == s4); }
打印结果为:
- jdk6 下
false false
- jdk7 下
false false
####1,jdk6中的解释
注:图中绿色线条代表 string 对象的内容指向。 黑色线条代表地址指向。
如上图所示。首先说一下 jdk6中的情况,在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用String.intern
方法也是没有任何关系的。
####2,jdk7中的解释
再说说 jdk7 中的情况。这里要明确一点的是,在 Jdk6 以及以前的版本中,字符串的常量池是放在堆的 Perm 区的,Perm 区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用 intern 是会直接产生java.lang.OutOfMemoryError: PermGen space
错误的。 所以在 jdk7 的版本中,字符串常量池已经从 Perm 区移到正常的 Java Heap 区域了。为什么要移动,Perm 区域太小是一个主要原因,当然据消息称 jdk8 已经直接取消了 Perm 区域,而新建立了一个元区域。应该是 jdk 开发者认为 Perm 区域已经不适合现在 JAVA 的发展了。
正式因为字符串常量池移动到 JAVA Heap 区域后,再来解释为什么会有上述的打印结果。
- 在第一段代码中,先看 s3和s4字符串。
String s3 = new String("1") + new String("1");
,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String("1")
我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。 - 接下来
s3.intern();
这一句代码,是将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个 “11” 的对象,关键点是 jdk7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。 -
最后
String s4 = "11";
这句代码中”11”是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较s3 == s4
是 true。 -
再看 s 和 s2 对象。
String s = new String("1");
第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern();
这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。 -
接下来
String s2 = "1";
这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同。图中画的很清晰。
- 来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是
s3.intern();
的顺序是放在String s4 = "11";
后了。这样,首先执行String s4 = "11";
声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern();
时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。 - 第二段代码中的 s 和 s2 代码中,
s.intern();
,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1");
的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。
####小结 从上述的例子代码可以看出 jdk7 版本对 intern 操作和常量池都做了一定的修改。主要包括2点:
- 将String常量池 从 Perm 区移动到了 Java Heap区
-
String#intern
方法时,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。
1,intern 正确使用例子
接下来我们来看一下一个比较常见的使用String#intern
方法的例子。
代码如下:
static final int MAX = 1000 * 10000; static final String[] arr = new String[MAX]; public static void main(String[] args) throws Exception { Integer[] DB_DATA = new Integer[10]; Random random = new Random(10 * 10000); for (int i = 0; i < DB_DATA.length; i++) { DB_DATA[i] = random.nextInt(); } long t = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])); arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern(); } System.out.println((System.currentTimeMillis() - t) + "ms"); System.gc(); }
运行的参数是:-Xmx2g -Xms2g -Xmn1500M
上述代码是一个演示代码,其中有两条语句不一样,一条是使用 intern,一条是未使用 intern。结果如下图
2160ms
826ms
通过上述结果,我们发现不使用 intern 的代码生成了1000w 个字符串,占用了大约640m 空间。 使用了 intern 的代码生成了1345个字符串,占用总空间 133k 左右。其实通过观察程序中只是用到了10个字符串,所以准确计算后应该是正好相差100w 倍。虽然例子有些极端,但确实能准确反应出 intern 使用后产生的巨大空间节省。
细心的同学会发现使用了 intern 方法后时间上有了一些增长。这是因为程序中每次都是用了 new String
后,然后又进行 intern 操作的耗时时间,这一点如果在内存空间充足的情况下确实是无法避免的,但我们平时使用时,内存空间肯定不是无限大的,不使用 intern 占用空间导致 jvm 垃圾回收的时间是要远远大于这点时间的。 毕竟这里使用了1000w次intern 才多出来1秒钟多的时间。
2,intern 不当使用
看过了 intern 的使用和 intern 的原理等,我们来看一个不当使用 intern 操作导致的问题。
在使用 fastjson 进行接口读取的时候,我们发现在读取了近70w条数据后,我们的日志打印变的非常缓慢,每打印一次日志用时30ms左右,如果在一个请求中打印2到3条日志以上会发现请求有一倍以上的耗时。在重新启动 jvm 后问题消失。继续读取接口后,问题又重现。接下来我们看一下出现问题的过程。
####1,根据 log4j 打印日志查找问题原因
在使用log4j#info
打印日志的时候时间非常长。所以使用 housemd 软件跟踪 info 方法的耗时堆栈。
- trace SLF4JLogger.
- trace AbstractLoggerWrapper:
- trace AsyncLogger
org/apache/logging/log4j/core/async/AsyncLogger.actualAsyncLog(RingBufferLogEvent) sun.misc.Launcher$AppClassLoader@109aca82 1 1ms org.apache.logging.log4j.core.async.AsyncLogger@19de86bb org/apache/logging/log4j/core/async/AsyncLogger.location(String) sun.misc.Launcher$AppClassLoader@109aca82 1 30ms org.apache.logging.log4j.core.async.AsyncLogger@19de86bb org/apache/logging/log4j/core/async/AsyncLogger.log(Marker, String, Level, Message, Throwable) sun.misc.Launcher$AppClassLoader@109aca82 1 61ms org.apache.logging.log4j.core.async.AsyncLogger@19de86bb
代码出在 AsyncLogger.location
这个方法上. 里边主要是调用了 return Log4jLogEvent.calcLocation(fqcnOfLogger);
和Log4jLogEvent.calcLocation()
Log4jLogEvent.calcLocation()
的代码如下:
public static StackTraceElement calcLocation(final String fqcnOfLogger) { if (fqcnOfLogger == null) { return null; } final StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); boolean next = false; for (final StackTraceElement element : stackTrace) { final String className = element.getClassName(); if (next) { if (fqcnOfLogger.equals(className)) { continue; } return element; } if (fqcnOfLogger.equals(className)) { next = true; } else if (NOT_AVAIL.equals(className)) { break; } } return null; }
经过跟踪发现是 Thread.currentThread().getStackTrace();
的问题。
####2, 跟踪Thread.currentThread().getStackTrace()的 native 代码,验证String#intern
Thread.currentThread().getStackTrace();
native的方法:
public StackTraceElement[] getStackTrace() { if (this != Thread.currentThread()) { // check for getStackTrace permission SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkPermission( SecurityConstants.GET_STACK_TRACE_PERMISSION); } // optimization so we do not call into the vm for threads that // have not yet started or have terminated if (!isAlive()) { return EMPTY_STACK_TRACE; } StackTraceElement[][] stackTraceArray = dumpThreads(new Thread[] {this}); StackTraceElement[] stackTrace = stackTraceArray[0]; // a thread that was alive during the previous isAlive call may have // since terminated, therefore not having a stacktrace. if (stackTrace == null) { stackTrace = EMPTY_STACK_TRACE; } return stackTrace; } else { // Don't need JVM help for current thread return (new Exception()).getStackTrace(); } } private native static StackTraceElement[][] dumpThreads(Thread[] threads);
下载 openJdk7的源码查询 jdk 的 native 实现代码,列表如下【这里因为篇幅问题,不详细罗列涉及到的代码,有兴趣的可以根据文件名称和行号查找相关代码】:
\openjdk7\jdk\src\share\native\java\lang\Thread.c \openjdk7\hotspot\src\share\vm\prims\jvm.h line:294: \openjdk7\hotspot\src\share\vm\prims\jvm.cpp line:4382-4414: \openjdk7\hotspot\src\share\vm\services\threadService.cpp line:235-267: \openjdk7\hotspot\src\share\vm\services\threadService.cpp line:566-577: \openjdk7\hotspot\src\share\vm\classfile\javaClasses.cpp line:1635-[1651,1654,1658]:
完成跟踪了底层的 jvm 源码后发现,是下边的三条代码引发了整个程序的变慢问题。
oop classname = StringTable::intern((char*) str, CHECK_0); oop methodname = StringTable::intern(method->name(), CHECK_0); oop filename = StringTable::intern(source, CHECK_0);
这三段代码是获取类名、方法名、和文件名。因为类名、方法名、文件名都是存储在字符串常量池中的,所以每次获取它们都是通过String#intern
方法。但没有考虑到的是默认的 StringPool 的长度是1009且不可变的。因此一旦常量池中的字符串达到的一定的规模后,性能会急剧下降。
####3,fastjson 不当使用 String#intern
导致这个 intern 变慢的原因是因为 fastjson 对String#intern
方法的使用不当造成的。跟踪 fastjson 中的实现代码发现,
####com.alibaba.fastjson.parser.JSONScanner#scanFieldSymbol()
if (ch == '\"') { bp = index; this.ch = ch = buf[bp]; strVal = symbolTable.addSymbol(buf, start, index - start - 1, hash); break; }
####com.alibaba.fastjson.parser.SymbolTable#addSymbol()
:
/** * Constructs a new entry from the specified symbol information and next entry reference. */ public Entry(char[] ch, int offset, int length, int hash, Entry next){ characters = new char[length]; System.arraycopy(ch, offset, characters, 0, length); symbol = new String(characters).intern(); this.next = next; this.hashCode = hash; this.bytes = null; }
fastjson 中对所有的 json 的 key 使用了 intern 方法,缓存到了字符串常量池中,这样每次读取的时候就会非常快,大大减少时间和空间。而且 json 的 key 通常都是不变的。这个地方没有考虑到大量的 json key 如果是变化的,那就会给字符串常量池带来很大的负担。
这个问题 fastjson 在1.1.24版本中已经将这个漏洞修复了。程序加入了一个最大的缓存大小,超过这个大小后就不会再往字符串常量池中放了。
[1.1.24版本的com.alibaba.fastjson.parser.SymbolTable#addSymbol()
Line:113]代码
public static final int MAX_SIZE = 1024; if (size >= MAX_SIZE) { return new String(buffer, offset, len); }
这个问题是70w 数据量时候的引发的,如果是几百万的数据量的话可能就不只是30ms 的问题了。因此在使用系统级提供的String#intern
方式一定要慎重!
本文大体的描述了 String#intern
和字符串常量池的日常使用,jdk 版本的变化和String#intern
方法的区别,以及不恰当使用导致的危险等内容,让大家对系统级别的 String#intern
有一个比较深入的认识。让我们在使用和接触它的时候能避免出现一些 bug,增强系统的健壮性。
以下是几个比较关键的几篇博文。感谢!
- Save Memory by Using String Intern in Java
- Java String array: is there a size of method?
- Understanding String Table Size in HotSpot
- How is Java’s String#intern() method implemented?
- JDK7里的String.intern的变化