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

預期的結果是並發下,比如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源碼底層實現原理什么的沒用,恰恰相反,這些都是技術人員成長路上最寶貴的東西。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM