記一次synchronized鎖字串引發的坑兼再談Java字串

五月的倉頡發表於2017-04-04

問題描述

業務有一個需求,我把問題描述一下:

通過代理IP訪問國外某網站N,每個IP對應一個固定的網站N的COOKIE,COOKIE有失效時間。

併發下,取IP是有一定策略的,取到IP之後拿IP對應的COOKIE,發現COOKIE超過失效時間,則呼叫指令碼訪問網站N獲取一次資料。

為了防止多執行緒取到同一個IP,同時發現該IP對應的COOKIE失效,同時去呼叫指令碼更新COOKIE,針對IP加了鎖。為了保證鎖的全域性唯一性,在鎖前面加了標識業務的字首,使用synchronized(lock){...}的方式,鎖住"鎖字首+IP",這樣保證多執行緒取到同一個IP,也只有一個IP會更新COOKIE。

不知道這個問題有沒有說清楚,沒說清楚沒關係,寫一段測試程式碼:

public class StringThread implements Runnable {

    private static final String LOCK_PREFIX = "XXX---";
    
    private String ip;
    
    public StringThread(String ip) {
        this.ip = ip;
    }

    @Override
    public void run() {
        String lock = buildLock();
        synchronized (lock) {
            System.out.println("[" + JdkUtil.getThreadName() + "]開始執行了");
            // 休眠5秒模擬指令碼呼叫
            JdkUtil.sleep(5000);
            System.out.println("[" + JdkUtil.getThreadName() + "]結束執行了");
        }
    }
    
    private String buildLock() {
        StringBuilder sb = new StringBuilder();
        sb.append(LOCK_PREFIX);
        sb.append(ip);
        
        String lock = sb.toString();
        System.out.println("[" + JdkUtil.getThreadName() + "]構建了鎖[" + lock + "]");
        
        return lock;
    }
    
}

簡單說就是,傳入一個IP,儘量構建一個全域性唯一的字串(這麼做的原因是,如果字串的唯一性不強,比方說鎖的"192.168.1.1",如果另外一段業務程式碼也是鎖的這個字串"192.168.1.1",這就意味著兩段沒什麼關聯的程式碼塊卻要序列執行,程式碼塊執行時間短還好,程式碼塊執行時間長影響極其大),針對字串加鎖。

預期的結果是併發下,比如5條執行緒傳入同一個IP,它們構建的鎖都是字串"XXX---192.168.1.1",那麼這5條執行緒針對synchronized塊,應當序列執行,即一條執行完畢再執行另外一條,但是實際上並不是這樣。

寫一段測試程式碼,開5條執行緒看一下效果:

public class StringThreadTest {

    private static final int THREAD_COUNT = 5;
    
    @Test
    public void testStringThread() {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(new StringThread("192.168.1.1"));
        }
        
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i].start();
        }
        
        for (;;);
    }
    
}

執行結果為:

[Thread-1]構建了鎖[XXX---192.168.1.1]
[Thread-1]開始執行了
[Thread-3]構建了鎖[XXX---192.168.1.1]
[Thread-3]開始執行了
[Thread-4]構建了鎖[XXX---192.168.1.1]
[Thread-4]開始執行了
[Thread-0]構建了鎖[XXX---192.168.1.1]
[Thread-0]開始執行了
[Thread-2]構建了鎖[XXX---192.168.1.1]
[Thread-2]開始執行了
[Thread-1]結束執行了
[Thread-3]結束執行了
[Thread-4]結束執行了
[Thread-0]結束執行了
[Thread-2]結束執行了

看到Thread-0、Thread-1、Thread-2、Thread-3、Thread-4這5條執行緒儘管構建的鎖都是同一個"XXX-192.168.1.1",但是程式碼卻是並行執行的,這並不符合我們的預期。

關於這個問題,一方面確實是我大意了以為是程式碼其他什麼地方同步控制出現了問題,一方面也反映出我對String的理解還不夠深入,因此專門寫一篇文章來記錄一下這個問題並寫清楚產生這個問題的原因和應當如何解決。

 

問題原因

這個問題既然出現了,那麼應當從結果開始推導起,找到問題的原因。先看一下synchronized部分的程式碼:

@Override
public void run() {
    String lock = buildLock();
    synchronized (lock) {
        System.out.println("[" + JdkUtil.getThreadName() + "]開始執行了");
        // 休眠5秒模擬指令碼呼叫
        JdkUtil.sleep(5000);
        System.out.println("[" + JdkUtil.getThreadName() + "]結束執行了");
    }
}

因為synchronized鎖物件的時候,保證同步程式碼塊中的程式碼執行是序列執行的前提條件是鎖住的物件是同一個,因此既然多執行緒在synchronized部分是並行執行的,那麼可以推測出多執行緒下傳入同一個IP,構建出來的lock字串並不是同一個。

接下來,再看一下構建字串的程式碼:

private String buildLock() {
    StringBuilder sb = new StringBuilder();
    sb.append(LOCK_PREFIX);
    sb.append(ip);
        
    String lock = sb.toString();
    System.out.println("[" + JdkUtil.getThreadName() + "]構建了鎖[" + lock + "]");
        
    return lock;
}

lock是由StringBuilder生成的,看一下StringBuilder的toString方法:

public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

那麼原因就在這裡:儘管buildLock()方法構建出來的字串都是"XXX-192.168.1.1",但是由於StringBuilder的toString()方法每次都是new一個String出來,因此buildLock出來的物件都是不同的物件。

 

如何解決?

上面的問題原因找到了,就是每次StringBuilder構建出來的物件都是new出來的物件,那麼應當如何解決?這裡我先給解決辦法就是sb.toString()後再加上intern(),下一部分再說原因,因為我想對String再做一次總結,加深對String的理解。

OK,程式碼這麼改:

 1 public class StringThread implements Runnable {
 2 
 3     private static final String LOCK_PREFIX = "XXX---";
 4     
 5     private String ip;
 6     
 7     public StringThread(String ip) {
 8         this.ip = ip;
 9     }
10 
11     @Override
12     public void run() {
13         
14         String lock = buildLock();
15         synchronized (lock) {
16             System.out.println("[" + JdkUtil.getThreadName() + "]開始執行了");
17             // 休眠5秒模擬指令碼呼叫
18             JdkUtil.sleep(5000);
19             System.out.println("[" + JdkUtil.getThreadName() + "]結束執行了");
20         }
21     }
22     
23     private String buildLock() {
24         StringBuilder sb = new StringBuilder();
25         sb.append(LOCK_PREFIX);
26         sb.append(ip);
27         
28         String lock = sb.toString().intern();
29         System.out.println("[" + JdkUtil.getThreadName() + "]構建了鎖[" + lock + "]");
30         
31         return lock;
32     }
33     
34 }

看一下程式碼執行結果:

[Thread-0]構建了鎖[XXX---192.168.1.1]
[Thread-0]開始執行了
[Thread-3]構建了鎖[XXX---192.168.1.1]
[Thread-4]構建了鎖[XXX---192.168.1.1]
[Thread-1]構建了鎖[XXX---192.168.1.1]
[Thread-2]構建了鎖[XXX---192.168.1.1]
[Thread-0]結束執行了
[Thread-2]開始執行了
[Thread-2]結束執行了
[Thread-1]開始執行了
[Thread-1]結束執行了
[Thread-4]開始執行了
[Thread-4]結束執行了
[Thread-3]開始執行了
[Thread-3]結束執行了

可以對比一下上面沒有加intern()方法的執行結果,這裡很明顯5條執行緒獲取的鎖是同一個,一條執行緒執行完畢synchronized程式碼塊裡面的程式碼之後下一條執行緒才能執行,整個執行是序列的。

 

再看String

JVM記憶體區域裡面有一塊常量池,關於常量池的分配

  1. JDK6的版本,常量池在持久代PermGen中分配
  2. JDK7的版本,常量池在堆Heap中分配

字串是儲存在常量池中的,有兩種型別的字串資料會儲存在常量池中:

  1. 編譯期就可以確定的字串,即使用""引起來的字串,比如String a = "123"String b = "1" + B.getStringDataFromDB() + "2" + C.getStringDataFromDB()、這裡的"123"、"1"、"2"都是編譯期間就可以確定的字串,因此會放入常量池,而B.getStringDataFromDB()、C.getStringDataFromDB()這兩個資料由於編譯期間無法確定,因此它們是在堆上進行分配的
  2. 使用String的intern()方法操作的字串,比如String b = B.getStringDataFromDB().intern(),儘管B.getStringDataFromDB()方法拿到的字串是在堆上分配的,但是由於後面加入了intern(),因此B.getStringDataFromDB()方法的結果,會寫入常量池中

常量池中的String資料有一個特點:每次取資料的時候,如果常量池中有,直接拿常量池中的資料;如果常量池中沒有,將資料寫入常量池中並返回常量池中的資料

因此回到我們之前的場景,使用StringBuilder拼接字串每次返回一個new的物件,但是使用intern()方法則不一樣:

"XXX-192.168.1.1"這個字串儘管是使用StringBuilder的toString()方法建立的,但是由於使用了intern()方法,因此第一條執行緒發現常量池中沒有"XXX-192.168.1.1",就往常量池中放了一個
"XXX-192.168.1.1",後面的執行緒發現常量池中有"XXX-192.168.1.1",就直接取常量池中的"XXX-192.168.1.1"。

因此不管多少條執行緒,只要取"XXX-192.168.1.1",取出的一定是同一個物件,就是常量池中的"XXX-192.168.1.1"

這一切,都是String的intern()方法的作用

 

後記

就這個問題解決完包括這篇文章寫完,我特別有一點點感慨,很多人會覺得一個Java程式設計師能把框架用好、能把程式碼流程寫出來沒有bug就好了,研究底層原理、虛擬機器什麼的根本就沒什麼用。不知道這個問題能不能給大家一點啟發:

這個業務場景並不複雜,整個程式碼實現也不是很複雜,但是執行的時候它就出了併發問題了。

如果沒有紮實的基礎:知道String裡面除了常用的那些方法indexOf、subString、concat外還有很不常用的intern()方法
不瞭解一點JVM:JVM記憶體分佈,尤其是常量池
不去看一點JDK原始碼:StringBuilder的toString()方法
不對併發有一些理解:synchronized鎖程式碼塊的時候怎麼樣才能保證多執行緒是序列執行程式碼塊裡面的程式碼的

這個問題出了,是根本無法解決的,甚至可以說如何下手去分析都不知道。

因此,並不要覺得JVM、JDK原始碼底層實現原理什麼的沒用,恰恰相反,這些都是技術人員成長路上最寶貴的東西。

相關文章