深入谈谈String.intern()在JVM的实现

前言

String 类的intern方法可能大家比较少用也比较陌生,虽然实际项目中并不太建议使用intern方法,可以在 Java 层来实现类似的池,但我们还是要知道它的原理机制不是。

关于intern方法

通过该方法可以返回一个字符串标准对象,JVM 有一个专门的字符串常量池来维护这些标准对象,常量池是一个哈希 map 结构,字符串对象调用intern方法会先检查池中是否已经存在该字符串的标准对象,如果存在则直接返回标准对象,如果不存在则会往池中创建标准对象并且返回该对象。

查找过程是使用字符串的值作为 key 进行的,也就是说对于相同的字符串值获取到的都是同一个标准对象,比如在 Java 层可以有多个字符串值为“key1”的字符串对象,但通过intern方法获取到的都是同一个对象。

有什么作用

那么intern方法有什么作用呢?前面我们知道了 Java 层只要字符串的值相等那么通过intern获取到的一定是同一个对象,也就是所谓的标准对象。比如下面,

String st = new String("hello world");
String st2 = new String("hello world");
System.out.println(st.intern() == st2.intern());
复制代码

发现了吗?我们竟然能用==来对比两个对象的值了,要知道在 Java 中这样比较只能判断它们是否为同一个引用的,但通过intern方法处理后就可以直接这样对比了,比起equals可是快很多啊,性能蹭蹭涨。你可能会说是啊,那是因为intern已经做了类似equals的比较操作了啊,这里照样会很耗时的好嘛!是的,你说的没错,但假如我后面要进行多次比较,那是不是就体现出优势来了,只要做一次equals后面比较全部都可以用==进行快速比较了。

另外,某些场景下也能达到节省内存的效果,比如要维护大量且可能重复的字符串对象,比如十万个字符串对象,而字符串值相同的有九万个,那么通过intern方法就可以将字符串对象数减少到一万,值相同的都共用同一个标准对象。

加入运行时常量池

在 Java 层有两种方式能将字符串对象加入到运行时常量池中:

  • 在程序中直接使用双引号来声明字符串对象,执行时该对象会被加入到常量池。比如下面,该类被编译成字节码后在运行时有相应的指令会将其添加到常量池中。
public class Test{
    public static void main(String[] args){
        String s = "hello";
    }
}
复制代码
  • 另外一种是通过 String 类的intern方法,它能检测常量池中是否已经有当前字符串存在,如果不存在则将其加入到常量池中。比如下面,
String s = new String("hello");
s.intern();
复制代码

再来个例子

JDK9。

public class Test {
	public static void main(String[] args) {
		String s = new String("hello");
		String ss = new String("hello");
		System.out.println(ss == s);
		String sss = s.intern();
		System.out.println(sss == s);
		String ssss = ss.intern();
		System.out.println(ssss == sss);

		System.out.println("=========");

		String s2 = "hello2";
		String ss2 = new String("hello2");
		System.out.println(ss2 == s2);
		String sss2 = s2.intern();
		System.out.println(sss2 == s2);
		String ssss2 = ss2.intern();
		System.out.println(ssss2 == sss2);
	}
}
复制代码
false
false
true
=========
false
true
true
复制代码

常量池的实现

Java 层很简单,仅仅将intern定义为本地方法。

public native String intern();
复制代码

对应为JVM_InternString函数,主要先通过JNIHandles::resolve_non_null函数转成 JVM 层的 oop 指针,再调StringTable::intern函数获得最终返回的对象,最后再通过JNIHandles::make_local转换成 Java 层的对象并返回。

JNIEXPORT jobject JNICALL
Java_java_lang_String_intern(JNIEnv *env, jobject this)
{
    return JVM_InternString(env, this);
}

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
复制代码

主要看StringTable::intern,StringTable 就是 JVM 运行时用来存放常量的常量池。它的结构为一个哈希 Map,大致如下图所示,

主要逻辑是先计算 utf-8 编码的字符串对应的 unicode 编码的长度,按照 unicode 编码所需的长度创建新的数组并将字符串转换成 unicode 编码,最后再调另外一个intern函数。

oop StringTable::intern(const char* utf8_string, TRAPS) {
  if (utf8_string == NULL) return NULL;
  ResourceMark rm(THREAD);
  int length = UTF8::unicode_length(utf8_string);
  jchar* chars = NEW_RESOURCE_ARRAY(jchar, length);
  UTF8::convert_to_unicode(utf8_string, chars, length);
  Handle string;
  oop result = intern(string, chars, length, CHECK_NULL);
  return result;
}
复制代码

逻辑如下,

  1. 通过java_lang_String::hash_code得到哈希值。
  2. 根据哈希值调用lookup_shared函数查找查看共享哈希表中是否已经有这个值的字符串对象,如果有则直接返回找到的对象,该函数会间接调用lookup函数,后面会进一步分析。
  3. 是否使用了其他哈希算法,是的话重新计算哈希值。
  4. 通过hash_to_index函数计算哈希值对应的索引值。
  5. 通过lookup_in_main_table函数到对应索引值的桶内去查找字符串对象,如果找到就返回该对象。
  6. 以上都没在哈希表中找到的话则需要添加到表中了,用MutexLocker加锁,然后调用basic_add函数完成添加操作,该函数后面会进一步分析。
  7. 返回字符串对象。
oop StringTable::intern(Handle string_or_null, jchar* name,
                        int len, TRAPS) {
  unsigned int hashValue = java_lang_String::hash_code(name, len);
  oop found_string = lookup_shared(name, len, hashValue);
  if (found_string != NULL) {
    return found_string;
  }
  if (use_alternate_hashcode()) {
    hashValue = alt_hash_string(name, len);
  }
  int index = the_table()->hash_to_index(hashValue);
  found_string = the_table()->lookup_in_main_table(index, name, len, hashValue);

  if (found_string != NULL) {
    if (found_string != string_or_null()) {
      ensure_string_alive(found_string);
    }
    return found_string;
  }
  Handle string;
  if (!string_or_null.is_null()) {
    string = string_or_null;
  } else {
    string = java_lang_String::create_from_unicode(name, len, CHECK_NULL);
  }
  oop added_or_found;
  {
    MutexLocker ml(StringTable_lock, THREAD);
    added_or_found = the_table()->basic_add(index, string, name, len,
                                  hashValue, CHECK_NULL);
  }

  if (added_or_found != string()) {
    ensure_string_alive(added_or_found);
  }

  return added_or_found;
}
复制代码

常量池是一个哈希表,那么它默认的桶的数量是多少呢?看下面的定义,64位系统上默认为 60013,而32位的则为 1009。

const int defaultStringTableSize = NOT_LP64(1009) LP64_ONLY(60013);
复制代码

查找哈希表的逻辑为,

  1. 哈希值对桶数取余得到索引。
  2. 通过索引获取对应的桶信息。
  3. 获取桶的偏移。
  4. 获取桶的类型。
  5. 获取 entry。
  6. 如果是VALUE_ONLY_BUCKET_TYPE类型的桶,则直接解码偏移量对应的对象,该类型的 entries 中每个 entry 只有一个4字节用来表示偏移量,即u4 offset;
  7. 如果是普通类型的桶,则遍历 entry 找到指定哈希值的 entry 对应的偏移量,然后解码偏移量对应的对象。其中entries 中每个 entry 有8个字节,结构为u4 hash;union {u4 offset; narrowOop str; },前面为哈希值,后面为偏移或字符对象指针。
  8. 两种不同类型的结构可以由以下简单展示,第一个桶和第三个桶是普通类型,指向[哈希+偏移量]组成的很多 entry ,而第二个桶是VALUE_ONLY_BUCKET_TYPE类型,直接指向[偏移量]。
buckets[0, 4, 5, ....]
        |  |  |
        |  |  +---+
        |  |      |
        |  +----+ |
        v       v v
entries[H,O,H,O,O,H,O,H,O.....]
复制代码
template 
inline T CompactHashtable::lookup(const N* name, unsigned int hash, int len) {
  if (_entry_count > 0) {
    int index = hash % _bucket_count;
    u4 bucket_info = _buckets[index];
    u4 bucket_offset = BUCKET_OFFSET(bucket_info);
    int bucket_type = BUCKET_TYPE(bucket_info);
    u4* entry = _entries + bucket_offset;

    if (bucket_type == VALUE_ONLY_BUCKET_TYPE) {
      T res = decode_entry(this, entry[0], name, len);
      if (res != NULL) {
        return res;
      }
    } else {
      u4* entry_max = _entries + BUCKET_OFFSET(_buckets[index + 1]);
      while (entry < entry_max) {
        unsigned int h = (unsigned int)(entry[0]);
        if (h == hash) {
          T res = decode_entry(this, entry[1], name, len);
          if (res != NULL) {
            return res;
          }
        }
        entry += 2;
      }
    }
  }
  return NULL;
}
复制代码

添加哈希表的逻辑如下,

  1. 是否使用了其他哈希算法,是则重新计算哈希值,并计算对应的索引值。
  2. 通过lookup_in_main_table函数检查哈希表中是否已经存在字符串值。
  3. 创建 entry ,包括了哈希值和字符串对象指针。
  4. 通过add_entry函数添加到哈希表中。
  5. 返回字符串对象。
oop StringTable::basic_add(int index_arg, Handle string, jchar* name,
                           int len, unsigned int hashValue_arg, TRAPS) {

  NoSafepointVerifier nsv;
  unsigned int hashValue;
  int index;
  if (use_alternate_hashcode()) {
    hashValue = alt_hash_string(name, len);
    index = hash_to_index(hashValue);
  } else {
    hashValue = hashValue_arg;
    index = index_arg;
  }
  oop test = lookup_in_main_table(index, name, len, hashValue); 
  if (test != NULL) {
    return test;
  }
  HashtableEntry* entry = new_entry(hashValue, string());
  add_entry(index, entry);
  return string();
}
复制代码

-XX:StringTableSize

前面说了 JVM 默认的情况下的哈希表的桶大小为:64位系统为 60013,而32位的则为 1009。如果我们要改变它的大小,可以通过设置-XX:StringTableSize来达到效果。

-XX:+PrintStringTableStatistics

如果你想看常量池相关的统计,可以设置-XX:+PrintStringTableStatistics,那么 JVM 停止时就会输出相关信息了。比如,

SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     20067 =    481608 bytes, avg  24.000
Number of literals      :     20067 =    838520 bytes, avg  41.786
Total footprint         :           =   1480216 bytes
Average bucket size     :     1.003
Variance of bucket size :     0.994
Std. dev. of bucket size:     0.997
Maximum bucket size     :         8
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :   1003077 =  24073848 bytes, avg  24.000
Number of literals      :   1003077 =  48272808 bytes, avg  48.125
Total footprint         :           =  72826760 bytes
Average bucket size     :    16.714
Variance of bucket size :     9.683
Std. dev. of bucket size:     3.112
Maximum bucket size     :        30
复制代码

-------------推荐阅读------------

我的2017文章汇总——机器学习篇

我的2017文章汇总——Java及中间件

我的2017文章汇总——深度学习篇

我的2017文章汇总——JDK源码篇

我的2017文章汇总——自然语言处理篇

我的2017文章汇总——Java并发篇

------------------广告时间----------------

跟我交流,向我提问:

公众号的菜单已分为“分布式”、“机器学习”、“深度学习”、“NLP”、“Java深度”、“Java并发核心”、“JDK源码”、“Tomcat内核”等,可能有一款适合你的胃口。

为什么写《Tomcat内核设计剖析》

欢迎关注:

你可能感兴趣的:(深入谈谈String.intern()在JVM的实现)