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



來源:五月的倉頡,

www.cnblogs.com/xrq730/p/6662232.html


問題描述


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


通過代理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,代碼這麼改:


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().intern();

        System.out.println("[" + JdkUtil.getThreadName() + "]構建了鎖[" + lock + "]");

 

        return lock;

    }

 

}


看一下代碼執行結果:


[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源碼底層實現原理什麼的沒用,恰恰相反,這些都是技術人員成長路上最寶貴的東西。






來源:五月的倉頡,

www.cnblogs.com/xrq730/p/6662232.html

如有好文章投稿,請點擊 → 這裏瞭解詳情

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章