1. 程式人生 > >[JVM]深入解析String#intern

[JVM]深入解析String#intern

引言

在 JAVA 語言中有8中基本型別和一種比較特殊的型別String。這些型別為了使他們在執行過程中速度更快,更節省記憶體,都提供了一種常量池的概念。常量池就類似一個JAVA系統級別提供的快取。

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

  • 直接使用雙引號宣告出來的String物件會直接儲存在常量池中。
  • 如果不是用雙引號宣告的String物件,可以使用String提供的intern方法。intern 方法會從字串常量池中查詢當前字串是否存在,若不存在就會將當前字串放入常量池中

接下來我們主要來談一下String#intern方法。

一, 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()&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 的方法,但註釋寫的非常明瞭。“如果常量池中存在當前字串, 就會直接返回當前字串. 如果常量池中沒有此字串, 會將此字串放入常量池中後, 再返回”。

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++實現的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)                [email protected]            1            1ms    [email protected]  
org/apache/logging/log4j/core/async/AsyncLogger.location(String)                                  [email protected]            1           30ms    [email protected]  
org/apache/logging/log4j/core/async/AsyncLogger.log(Marker, String, Level, Message, Throwable)    [email protected]            1           61ms    [email protected]

程式碼出在 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,增強系統的健壯性。