深刻解析String#intern

轉自:https://tech.meituan.com/in_depth_understanding_string_intern.htmlhtml

引言

在 JAVA 語言中有8中基本類型和一種比較特殊的類型String。這些類型爲了使他們在運行過程當中速度更快,更節省內存,都提供了一種常量池的概念。常量池就相似一個JAVA系統級別提供的緩存。java

8種基本類型的常量池都是系統協調的,String類型的常量池比較特殊。它的主要使用方法有兩種:c++

  • 直接使用雙引號聲明出來的String對象會直接存儲在常量池中。
  • 若是不是用雙引號聲明的String對象,可使用String提供的intern方法。intern 方法會從字符串常量池中查詢當前字符串是否存在,若不存在就會將當前字符串放入常量池中

接下來咱們主要來談一下String#intern方法。程序員

一, intern 的實現原理

首先深刻看一下它的實現原理。express

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()&nbsp;==&nbsp;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&trade; 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 的方法,但註釋寫的很是明瞭。「若是常量池中存在當前字符串, 就會直接返回當前字符串. 若是常量池中沒有此字符串, 會將此字符串放入常量池中後, 再返回」。apache

2,native 代碼

在 jdk7後,oracle 接管了 JAVA 的源碼後就不對外開放了,根據 jdk 的主要開發人員聲明 openJdk7 和 jdk7 使用的是同一分主代碼,只是分支代碼會有些許的變更。因此能夠直接跟蹤 openJdk7 的源碼來探究 intern 的實現。json

####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.hbash

/* 
* java.lang.String 
*/  
JNIEXPORT jstring JNICALL  
JVM_InternString(JNIEnv *env, jstring str);
複製代碼

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

// 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++實現的StringTableintern方法, StringTableintern方法跟Java中的HashMap的實現是差很少的, 只是不能自動擴容。默認大小是1009。

要注意的是,String的String Pool是一個固定大小的Hashtable,默認值大小長度是1009,若是放進String Pool的String很是多,就會形成Hash衝突嚴重,從而致使鏈表會很長,而鏈表長了後直接會形成的影響就是當調用String.intern時性能會大幅降低(由於要一個一個找)。

在 jdk6中StringTable是固定的,就是1009的長度,因此若是常量池中的字符串過多就會致使效率降低很快。在jdk7中,StringTable的長度能夠經過一個參數指定:

  • -XX:StringTableSize=99991

二,jdk6 和 jdk7 下 intern 的區別

相信不少 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中的解釋

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 區域後,再來解釋爲何會有上述的打印結果。

jdk7圖1

  • 在第一段代碼中,先看 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 的引用地址明顯不一樣。圖中畫的很清晰。

jdk7圖2

  • 來看第二段代碼,從上邊第二幅圖中觀察。第一段代碼和第二段代碼的改變就是 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 方法時,若是存在堆中的對象,會直接保存對象的引用,而不會從新建立對象。

三,使用 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
使用 intern

826ms
未使用 intern

經過上述結果,咱們發現不使用 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,加強系統的健壯性。

引用:

如下是幾個比較關鍵的幾篇博文。感謝!

相關文章
相關標籤/搜索