本文转自:深入理解Stirng#intern[美团技术团队]

在 JAVA 语言中有8中基本类型和一种比较特殊的类型String。这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个JAVA系统级别提供的缓存。
8种基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中

接下来我们主要来谈一下String#intern方法。
首先深入看一下它的实现原理。

1.JAVA 代码

  1. /**
  2. * Returns a canonical representation for the string object.
  3. * <p>
  4. * A pool of strings, initially empty, is maintained privately by the
  5. * class <code>String</code>.
  6. * <p>
  7. * When the intern method is invoked, if the pool already contains a
  8. * string equal to this <code>String</code> object as determined by
  9. * the {@link #equals(Object)} method, then the string from the pool is
  10. * returned. Otherwise, this <code>String</code> object is added to the
  11. * pool and a reference to this <code>String</code> object is returned.
  12. * <p>
  13. * It follows that for any two strings <code>s</code> and <code>t</code>,
  14. * <code>s.intern()&nbsp;==&nbsp;t.intern()</code> is <code>true</code>
  15. * if and only if <code>s.equals(t)</code> is <code>true</code>.
  16. * <p>
  17. * All literal strings and string-valued constant expressions are
  18. * interned. String literals are defined in section 3.10.5 of the
  19. * <cite>The Java&trade; Language Specification</cite>.
  20. *
  21. * @return a string that has the same contents as this string, but is
  22. * guaranteed to be from a pool of unique strings.
  23. */
  24. 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

  1. Java_java_lang_String_intern(JNIEnv *env, jobject this)
  2. {
  3. return JVM_InternString(env, this);
  4. }

\openjdk7\hotspot\src\share\vm\prims\jvm.h

  1. /*
  2. * java.lang.String
  3. */
  4. JNIEXPORT jstring JNICALL
  5. JVM_InternString(JNIEnv *env, jstring str);

\openjdk7\hotspot\src\share\vm\prims\jvm.cpp

  1. // String support ///////////////////////////////////////////////////////////////////////////
  2. JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
  3. JVMWrapper("JVM_InternString");
  4. JvmtiVMObjectAllocEventCollector oam;
  5. if (str == NULL) return NULL;
  6. oop string = JNIHandles::resolve_non_null(str);
  7. oop result = StringTable::intern(string, CHECK_NULL);
  8. return (jstring) JNIHandles::make_local(env, result);
  9. JVM_END

\openjdk7\hotspot\src\share\vm\classfile\symbolTable.cpp

  1. oop StringTable::intern(Handle string_or_null, jchar* name,
  2. int len, TRAPS) {
  3. unsigned int hashValue = java_lang_String::hash_string(name, len);
  4. int index = the_table()->hash_to_index(hashValue);
  5. oop string = the_table()->lookup(index, name, len, hashValue);
  6. // Found
  7. if (string != NULL) return string;
  8. // Otherwise, add to symbol to table
  9. return the_table()->basic_add(index, string_or_null, name, len,
  10. hashValue, CHECK_NULL);
  11. }

\openjdk7\hotspot\src\share\vm\classfile\symbolTable.cpp

  1. oop StringTable::lookup(int index, jchar* name,
  2. int len, unsigned int hash) {
  3. for (HashtableEntry<oop>* l = bucket(index); l != NULL; l = l->next()) {
  4. if (l->hash() == hash) {
  5. if (java_lang_String::equals(l->literal(), name, len)) {
  6. return l->literal();
  7. }
  8. }
  9. }
  10. return NULL;
  11. }

它的大体实现结构就是: 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 对象。

3.看图理解

第一段代码

  1. public static void main(String[] args) {
  2. String s = new String("1");
  3. s.intern();
  4. String s2 = "1";
  5. System.out.println(s == s2);
  6. String s3 = new String("1") + new String("1");
  7. s3.intern();
  8. String s4 = "11";
  9. System.out.println(s3 == s4);
  10. }

打印结果是

  • jdk6 下false false
  • jdk7 下false true

    1.jdk6中的解释

    image.png

    注:图中绿色线条代表 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 区域后,再来解释为什么会有上述的打印结果。
image.png

  • 在第一段代码中,先看 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 的引用地址明显不同。图中画的很清晰。

第二段代码

  1. public static void main(String[] args) {
  2. String s = new String("1");
  3. String s2 = "1";
  4. s.intern();
  5. System.out.println(s == s2);
  6. String s3 = new String("1") + new String("1");
  7. String s4 = "11";
  8. s3.intern();
  9. System.out.println(s3 == s4);
  10. }

打印结果为:

  • jdk6 下false false
  • jdk7 下false false

    1.jdk6中的解释

    image.png
    如上图所示。首先说一下 jdk6中的情况,在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用String.intern方法也是没有任何关系的。

    2.jdk7中的解释

    image.png

  • 来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是 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 方法时,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。