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

問題描述程序員

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

經過代理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",這就意味着兩段沒什麼關聯的代碼塊卻要串行執行,代碼塊執行時間短還好,代碼塊執行時間長影響極其大),針對字符串加鎖。app

預期的結果是併發下,好比5條線程傳入同一個IP,它們構建的鎖都是字符串"XXX---192.168.1.1",那麼這5條線程針對synchronized塊,應當串行執行,即一條運行完畢再運行另一條,可是實際上並非這樣。框架

寫一段測試代碼,開5條線程看一下效果:ide

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-一、Thread-二、Thread-三、Thread-4這5條線程儘管構建的鎖都是同一個"XXX-192.168.1.1",可是代碼倒是並行執行的,這並不符合咱們的預期。網站

關於這個問題,一方面確實是我大意了覺得是代碼其餘什麼地方同步控制出現了問題,一方面也反映出我對String的理解還不夠深刻,所以專門寫一篇文章來記錄一下這個問題並寫清楚產生這個問題的緣由和應當如何解決。ui

 

問題緣由this

這個問題既然出現了,那麼應當從結果開始推導起,找到問題的緣由。先看一下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源碼底層實現原理什麼的沒用,偏偏相反,這些都是技術人員成長路上最寶貴的東西。

相關文章
相關標籤/搜索