聊聊併發程式設計的10個坑,千萬小心!!!

蘇三說技術發表於2022-03-04

前言

對於從事後端開發的同學來說,併發程式設計肯定再熟悉不過了。

說實話,在java中併發程式設計是一大難點,至少我是這麼認為的。不光理解起來比較費勁,使用起來更容易踩坑。

不信,讓繼續往下面看。

今天重點跟大家一起聊聊併發程式設計的10個坑,希望對你有幫助。

1. SimpleDateFormat執行緒不安全

在java8之前,我們對時間的格式化處理,一般都是用的SimpleDateFormat類實現的。例如:

@Service
public class SimpleDateFormatService {

    public Date time(String time) throws ParseException {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return dateFormat.parse(time);
    }
}

如果你真的這樣寫,是沒問題的。

就怕哪天抽風,你覺得dateFormat是一段固定的程式碼,應該要把它抽取成常量。

於是把程式碼改成下面的這樣:

@Service
public class SimpleDateFormatService {

   private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public Date time(String time) throws ParseException {
        return dateFormat.parse(time);
    }
}

dateFormat物件被定義成了靜態常量,這樣就能被所有物件共用。

如果只有一個執行緒呼叫time方法,也不會出現問題。

但Serivce類的方法,往往是被Controller類呼叫的,而Controller類的介面方法,則會被tomcat執行緒池呼叫。換句話說,可能會出現多個執行緒呼叫同一個Controller類的同一個方法,也就是會出現多個執行緒會同時呼叫time方法。

而time方法會呼叫SimpleDateFormat類的parse方法:

@Override
public Date parse(String text, ParsePosition pos) {
    ...
    Date parsedDate;
    try {
        parsedDate = calb.establish(calendar).getTime();
        ...
    } catch (IllegalArgumentException e) {
        pos.errorIndex = start;
        pos.index = oldStart;
        return null;
    }
   return parsedDate;
} 

該方法會呼叫establish方法:

Calendar establish(Calendar cal) {
    ...
    //1.清空資料
    cal.clear();
    //2.設定時間
    cal.set(...);
    //3.返回
    return cal;
}

其中的步驟1、2、3是非原子操作。

但如果cal物件是區域性變數還好,壞就壞在parse方法呼叫establish方法時,傳入的calendar是SimpleDateFormat類的父類DateFormat的成員變數:

public abstract class DateFormat extends Forma {
    ....
    protected Calendar calendar;
    ...
}

這樣就可能會出現多個執行緒,同時修改同一個物件即:dateFormat,它的同一個成員變數即:Calendar值的情況。

這樣可能會出現,某個執行緒設定好了時間,又被其他的執行緒修改了,從而出現時間錯誤的情況。

那麼,如何解決這個問題呢?

  1. SimpleDateFormat類的物件不要定義成靜態的,可以改成方法的區域性變數。
  2. 使用ThreadLocal儲存SimpleDateFormat類的資料。
  3. 使用java8的DateTimeFormatter類。

2. 雙重檢查鎖的漏洞

單例模式無論在實際工作,還是在面試中,都出現得比較多。

我們都知道,單例模式有:餓漢模式懶漢模式兩種。

餓漢模式程式碼如下:

public class SimpleSingleton {
    //持有自己類的引用
    private static final SimpleSingleton INSTANCE = new SimpleSingleton();

    //私有的構造方法
    private SimpleSingleton() {
    }
    //對外提供獲取例項的靜態方法
    public static SimpleSingleton getInstance() {
        return INSTANCE;
    }
}

使用餓漢模式的好處是:沒有執行緒安全的問題,但帶來的壞處也很明顯。

private static final SimpleSingleton INSTANCE = new SimpleSingleton();

一開始就例項化物件了,如果例項化過程非常耗時,並且最後這個物件沒有被使用,不是白白造成資源浪費嗎?

還真是啊。

這個時候你也許會想到,不用提前例項化物件,在真正使用的時候再例項化不就可以了?

這就是我接下來要介紹的:懶漢模式

具體程式碼如下:

public class SimpleSingleton2 {

    private static SimpleSingleton2 INSTANCE;

    private SimpleSingleton2() {
    }

    public static SimpleSingleton2 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SimpleSingleton2();
        }
        return INSTANCE;
    }
}

示例中的INSTANCE物件一開始是空的,在呼叫getInstance方法才會真正例項化。

嗯,不錯不錯。但這段程式碼還是有問題。

假如有多個執行緒中都呼叫了getInstance方法,那麼都走到 if (INSTANCE == null) 判斷時,可能同時成立,因為INSTANCE初始化時預設值是null。這樣會導致多個執行緒中同時建立INSTANCE物件,即INSTANCE物件被建立了多次,違背了只建立一個INSTANCE物件的初衷。

為了解決餓漢模式懶漢模式各自的問題,於是出現了:雙重檢查鎖

具體程式碼如下:

public class SimpleSingleton4 {

    private static SimpleSingleton4 INSTANCE;

    private SimpleSingleton4() {
    }

    public static SimpleSingleton4 getInstance() {
        if (INSTANCE == null) {
            synchronized (SimpleSingleton4.class) {
                if (INSTANCE == null) {
                    INSTANCE = new SimpleSingleton4();
                }
            }
        }
        return INSTANCE;
    }
}

需要在synchronized前後兩次判空。

但我要告訴你的是:這段程式碼有漏洞的。

有什麼問題?

public static SimpleSingleton4 getInstance() {
    if (INSTANCE == null) {//1
        synchronized (SimpleSingleton4.class) {//2
            if (INSTANCE == null) {//3
                INSTANCE = new SimpleSingleton4();//4
            }
        }
    }
    return INSTANCE;//5
}

getInstance方法的這段程式碼,我是按1、2、3、4、5這種順序寫的,希望也按這個順序執行。

但是java虛擬機器實際上會做一些優化,對一些程式碼指令進行重排。重排之後的順序可能就變成了:1、3、2、4、5,這樣在多執行緒的情況下同樣會建立多次例項。重排之後的程式碼可能如下:

public static SimpleSingleton4 getInstance() {
    if (INSTANCE == null) {//1
       if (INSTANCE == null) {//3
           synchronized (SimpleSingleton4.class) {//2
                INSTANCE = new SimpleSingleton4();//4
            }
        }
    }
    return INSTANCE;//5
}

原來如此,那有什麼辦法可以解決呢?

答:可以在定義INSTANCE是加上volatile關鍵字。具體程式碼如下:

public class SimpleSingleton7 {

    private volatile static SimpleSingleton7 INSTANCE;

    private SimpleSingleton7() {
    }

    public static SimpleSingleton7 getInstance() {
        if (INSTANCE == null) {
            synchronized (SimpleSingleton7.class) {
                if (INSTANCE == null) {
                    INSTANCE = new SimpleSingleton7();
                }
            }
        }
        return INSTANCE;
    }
}

volatile關鍵字可以保證多個執行緒的可見性,但是不能保證原子性。同時它也能禁止指令重排

雙重檢查鎖的機制既保證了執行緒安全,又比直接上鎖提高了執行效率,還節省了記憶體空間。

此外,如果你想了解更多單例模式的細節問題,可以看看我的另一篇文章《單例模式,真不簡單

3. volatile的原子性

從前面我們已經知道volatile,是一個非常不錯的關鍵字,它能保證變數在多個執行緒中的可見性,它也能禁止指令重排,但是不能保證原子性

使用volatile關鍵字禁止指令重排,前面已經說過了,這裡就不聊了。

可見性主要體現在:一個執行緒對某個變數修改了,另一個執行緒每次都能獲取到該變數的最新值。

先一起看看反例:

public class VolatileTest extends Thread {

    private  boolean stopFlag = false;

    public boolean isStopFlag() {
        return stopFlag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();

        }
        stopFlag = true;
        System.out.println(Thread.currentThread().getName() + " stopFlag = " + stopFlag);
    }

    public static void main(String[] args) {
        VolatileTest vt = new VolatileTest();
        vt.start();

        while (true) {
            if (vt.isStopFlag()) {
                System.out.println("stop");
                break;
            }
        }
    }
}

上面這段程式碼中,VolatileTest是一個Thread類的子類,它的成員變數stopFlag預設是false,在它的run方法中修改成了true。

然後在main方法的主執行緒中,用vt.isStopFlag()方法判斷,如果它的值是true時,則列印stop關鍵字。

但vt.isStopFlag()的結果始終是false。

那麼,如何才能讓stopFlag的值修改了,在主執行緒中通過vt.isStopFlag()方法,能夠獲取最新的值呢?

正例如下:

public class VolatileTest extends Thread {

    private volatile boolean stopFlag = false;

    public boolean isStopFlag() {
        return stopFlag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();

        }
        stopFlag = true;
        System.out.println(Thread.currentThread().getName() + " stopFlag = " + stopFlag);
    }

    public static void main(String[] args) {
        VolatileTest vt = new VolatileTest();
        vt.start();

        while (true) {
            if (vt.isStopFlag()) {
                System.out.println("stop");
                break;
            }
        }
    }
}

volatile關鍵字修飾stopFlag即可。

下面重點說說volatile的原子性問題。

使用多執行緒給count加1,程式碼如下:

public class VolatileTest {

    public volatile int count = 0;

    public void add() {
        count++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 20; i++) {
            new Thread() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        test.add();
                    }
                }

                ;
            }.start();
        }
        while (Thread.activeCount() > 2) {
            //保證前面的執行緒都執行完
            Thread.yield();
        }

        System.out.println(test.count);
    }
}

執行結果每次都不一樣,但可以肯定的是count值每次都小於20000,比如:19999。

這個例子中count是成員變數,雖說被定義成了volatile的,但由於add方法中的count++是非原子操作。在多執行緒環境中,count++的資料可能會出現問題。

由此可見,volatile不能保證原子性

那麼,如何解決這個問題呢?

答:使用synchronized關鍵字。

改造後的程式碼如下:

public class VolatileTest {

    public int count = 0;

    public synchronized void add() {
        count++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 20; i++) {
            new Thread() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        test.add();
                    }
                }

                ;
            }.start();
        }
        while (Thread.activeCount() > 2) {
            //保證前面的執行緒都執行完
            Thread.yield();
        }

        System.out.println(test.count);
    }
}

4. 死鎖

死鎖可能是大家都不希望遇到的問題,因為一旦程式出現了死鎖,如果沒有外力的作用,程式將會一直處於資源競爭的假死狀態中。

死鎖程式碼如下:

public class DeadLockTest {

    public static String OBJECT_1 = "OBJECT_1";
    public static String OBJECT_2 = "OBJECT_2";

    public static void main(String[] args) {
        LockA lockA = new LockA();
        new Thread(lockA).start();

        LockB lockB = new LockB();
        new Thread(lockB).start();
    }

}

class LockA implements Runnable {

    @Override
    public void run() {
        synchronized (DeadLockTest.OBJECT_1) {
            try {
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_2) {
                    System.out.println("LockA");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class LockB implements Runnable {

    @Override
    public void run() {
        synchronized (DeadLockTest.OBJECT_2) {
            try {
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_1) {
                    System.out.println("LockB");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

一個執行緒在獲取OBJECT_1鎖時,沒有釋放鎖,又去申請OBJECT_2鎖。而剛好此時,另一個執行緒獲取到了OBJECT_2鎖,也沒有釋放鎖,去申請OBJECT_1鎖。由於OBJECT_1和OBJECT_2鎖都沒有釋放,兩個執行緒將一起請求下去,陷入死迴圈,即出現死鎖的情況。

那麼如果避免死鎖問題呢?

4.1 縮小鎖的範圍

出現死鎖的情況,有可能是像上面那樣,鎖範圍太大了導致的。

那麼解決辦法就是縮小鎖的範圍

具體程式碼如下:

class LockA implements Runnable {

    @Override
    public void run() {
        synchronized (DeadLockTest.OBJECT_1) {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        synchronized (DeadLockTest.OBJECT_2) {
             System.out.println("LockA");
        }
    }
}

class LockB implements Runnable {

    @Override
    public void run() {
        synchronized (DeadLockTest.OBJECT_2) {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        synchronized (DeadLockTest.OBJECT_1) {
             System.out.println("LockB");
        }
    }
}

在獲取OBJECT_1鎖的程式碼塊中,不包含獲取OBJECT_2鎖的程式碼。同時在獲取OBJECT_2鎖的程式碼塊中,也不包含獲取OBJECT_1鎖的程式碼。

4.2 保證鎖的順序

出現死鎖的情況說白了是,一個執行緒獲取鎖的順序是:OBJECT_1和OBJECT_2。而另一個執行緒獲取鎖的順序剛好相反為:OBJECT_2和OBJECT_1。

那麼,如果我們能保證每次獲取鎖的順序都相同,就不會出現死鎖問題。

具體程式碼如下:

class LockA implements Runnable {

    @Override
    public void run() {
        synchronized (DeadLockTest.OBJECT_1) {
            try {
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_2) {
                    System.out.println("LockA");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class LockB implements Runnable {

    @Override
    public void run() {
        synchronized (DeadLockTest.OBJECT_1) {
            try {
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_2) {
                    System.out.println("LockB");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

兩個執行緒,每個執行緒都是先獲取OBJECT_1鎖,再獲取OBJECT_2鎖。

5. 沒釋放鎖

在java中除了使用synchronized關鍵字,給我們所需要的程式碼塊加鎖之外,還能通過Lock關鍵字加鎖。

使用synchronized關鍵字加鎖後,如果程式執行完畢,或者程式出現異常時,會自動釋放鎖。

但如果使用Lock關鍵字加鎖後,需要開發人員在程式碼中手動釋放鎖。

例如:

public class LockTest {

    private final ReentrantLock rLock = new ReentrantLock();

    public void fun() {
        rLock.lock();

        try {
            System.out.println("fun");
        } finally {
            rLock.unlock();
        }
    }
}

程式碼中先建立一個ReentrantLock類的例項物件rLock,呼叫它的lock方法加鎖。然後執行業務程式碼,最後再finally程式碼塊中呼叫unlock方法。

但如果你沒有在finally程式碼塊中,呼叫unlock方法手動釋放鎖,執行緒持有的鎖將不會得到釋放。

6. HashMap導致記憶體溢位

HashMap在實際的工作場景中,使用頻率還是挺高的,比如:接收引數,快取資料,彙總資料等等。

但如果你在多執行緒的環境中使用HashMap,可能會導致非常嚴重的後果。

@Service
public class HashMapService {

    private Map<Long, Object> hashMap = new HashMap<>();

    public void add(User user) {
        hashMap.put(user.getId(), user.getName());
    }
}

在HashMapService類中定義了一個HashMap的成員變數,在add方法中往HashMap中新增資料。在controller層的介面中呼叫add方法,會使用tomcat的執行緒池去處理請求,就相當於在多執行緒的場景下呼叫add方法。

在jdk1.7中,HashMap使用的資料結構是:陣列+連結串列。如果在多執行緒的情況下,不斷往HashMap中新增資料,它會呼叫resize方法進行擴容。該方法在複製元素到新陣列時,採用的頭插法,在某些情況下,會導致連結串列會出現死迴圈。

死迴圈最終結果會導致:記憶體溢位

此外,如果HashMap中資料非常多,會導致連結串列很長。當查詢某個元素時,需要遍歷某個連結串列,查詢效率不太高。

為此,jdk1.8之後,將HashMap的資料結構改成了:陣列+連結串列+紅黑樹

如果同一個陣列元素中的資料項小於8個,則還是用連結串列儲存資料。如果大於8個,則自動轉換成紅黑樹。

為什麼要用紅黑樹?

答:連結串列的時間複雜度是O(n),而紅黑樹的時間複雜度是O(logn),紅黑樹的複雜度是優於連結串列的。

既然這樣,為什麼不直接使用紅黑樹?

答:樹節點所佔儲存空間是連結串列節點的兩倍,節點少的時候,儘管在時間複雜度上,紅黑樹比連結串列稍微好一些。但是由於紅黑樹所佔空間比較大,HashMap綜合考慮之後,認為節點數量少的時候用佔儲存空間更多的紅黑樹不划算。

jdk1.8中HashMap就不會出現死迴圈?

答:錯,它在多執行緒環境中依然會出現死迴圈。在擴容的過程中,在連結串列轉換為樹的時候,for迴圈一直無法跳出,從而導致死迴圈。

那麼,如果想多執行緒環境中使用HashMap該怎麼辦呢?

答:使用ConcurrentHashMap

7. 使用預設執行緒池

我們都知道jdk1.5之後,提供了ThreadPoolExecutor類,用它可以自定義執行緒池

執行緒池的好處有很多,下面主要說說這3個方面。

  1. 降低資源消耗:避免了頻繁的建立執行緒和銷燬執行緒,可以直接複用已有執行緒。而我們都知道,建立執行緒是非常耗時的操作。
  2. 提供速度:任務過來之後,因為執行緒已存在,可以拿來直接使用。
  3. 提高執行緒的可管理性:執行緒是非常寶貴的資源,如果建立過多的執行緒,不僅會消耗系統資源,甚至會影響系統的穩定。使用執行緒池,可以非常方便的建立、管理和監控執行緒。

當然jdk為了我們使用更便捷,專門提供了:Executors類,給我們快速建立執行緒池。

該類中包含了很多靜態方法:

  • newCachedThreadPool:建立一個可緩衝的執行緒,如果執行緒池大小超過處理需要,可靈活回收空閒執行緒,若無可回收,則新建執行緒。
  • newFixedThreadPool:建立一個固定大小的執行緒池,如果任務數量超過執行緒池大小,則將多餘的任務放到佇列中。
  • newScheduledThreadPool:建立一個固定大小,並且能執行定時週期任務的執行緒池。
  • newSingleThreadExecutor:建立只有一個執行緒的執行緒池,保證所有的任務安裝順序執行。

在高併發的場景下,如果大家使用這些靜態方法建立執行緒池,會有一些問題。

那麼,我們一起看看有哪些問題?

  • newFixedThreadPool: 允許請求的佇列長度是Integer.MAX_VALUE,可能會堆積大量的請求,從而導致OOM。
  • newSingleThreadExecutor:允許請求的佇列長度是Integer.MAX_VALUE,可能會堆積大量的請求,從而導致OOM。
  • newCachedThreadPool:允許建立的執行緒數是Integer.MAX_VALUE,可能會建立大量的執行緒,從而導致OOM。

那我們該怎辦呢?

優先推薦使用ThreadPoolExecutor類,我們自定義執行緒池。

具體程式碼如下:

ExecutorService threadPool = new ThreadPoolExecutor(
    8, //corePoolSize執行緒池中核心執行緒數
    10, //maximumPoolSize 執行緒池中最大執行緒數
    60, //執行緒池中執行緒的最大空閒時間,超過這個時間空閒執行緒將被回收
    TimeUnit.SECONDS,//時間單位
    new ArrayBlockingQueue(500), //佇列
    new ThreadPoolExecutor.CallerRunsPolicy()); //拒絕策略

順便說一下,如果是一些低併發場景,使用Executors類建立執行緒池也未嘗不可,也不能完全一棍子打死。在這些低併發場景下,很難出現OOM問題,所以我們需要根據實際業務場景選擇。

8. @Async註解的陷阱

之前在java併發程式設計中實現非同步功能,一般是需要使用執行緒或者執行緒池

執行緒池的底層也是用的執行緒。

而實現一個執行緒,要麼繼承Thread類,要麼實現Runnable介面,然後在run方法中寫具體的業務邏輯程式碼。

開發spring的大神們,為了簡化這類非同步操作,已經幫我們把非同步功能封裝好了。spring中提供了@Async註解,我們可以通過它即可開啟非同步功能,使用起來非常方便。

具體做法如下:

1.在springboot的啟動類上面加上@EnableAsync註解。

@EnableAsync
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2.在需要執行非同步呼叫的業務方法加上@Async註解。

@Service
public class CategoryService {

     @Async
     public void add(Category category) {
        //新增分類
     }
}

3.在controller方法中呼叫這個業務方法。

@RestController
@RequestMapping("/category")
public class CategoryController {

     @Autowired
     private CategoryService categoryService;
  
     @PostMapping("/add")
     public void add(@RequestBody category) {
        categoryService.add(category);
     }
}

這樣就能開啟非同步功能了。

是不是很easy?

但有個壞訊息是:用@Async註解開啟的非同步功能,會呼叫AsyncExecutionAspectSupport類的doSubmit方法。

預設情況會走else邏輯。

而else的邏輯最終會呼叫doExecute方法:

protected void doExecute(Runnable task) {
  Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task));
  thread.start();
}

我去,這不是每次都會建立一個新執行緒嗎?

沒錯,使用@Async註解開啟的非同步功能,預設情況下,每次都會建立一個新執行緒。

如果在高併發的場景下,可能會產生大量的執行緒,從而導致OOM問題。

建議大家在@Async註解開啟的非同步功能時,請別忘了定義一個執行緒池

9. 自旋鎖浪費cpu資源

在併發程式設計中,自旋鎖想必大家都已經耳熟能詳了。

自旋鎖有個非常經典的使用場景就是:CAS(即比較和交換),它是一種無鎖化思想(說白了用了一個死迴圈),用來解決高併發場景下,更新資料的問題。

而atomic包下的很多類,比如:AtomicInteger、AtomicLong、AtomicBoolean等,都是用CAS實現的。

我們以AtomicInteger類為例,它的incrementAndGet沒有每次都給變數加1。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

它的底層就是用的自旋鎖實現的:

public final int getAndAddInt(Object var1, long var2, int var4) {
  int var5;
  do {
      var5 = this.getIntVolatile(var1, var2);
  } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

在do...while死迴圈中,不停進行資料的比較和交換,如果一直失敗,則一直迴圈重試。

如果在高併發的情況下,compareAndSwapInt會很大概率失敗,因此導致了此處cpu不斷的自旋,這樣會嚴重浪費cpu資源。

那麼,如果解決這個問題呢?

答:使用LockSupport類的parkNanos方法。

具體程式碼如下:

private boolean compareAndSwapInt2(Object var1, long var2, int var4, int var5) {
     if(this.compareAndSwapInt(var1,var2,var4, var5)) {
          return true;
      } else {
          LockSupport.parkNanos(10);
          return false;
      }
 }

當cas失敗之後,呼叫LockSupport類的parkNanos方法休眠一下,相當於呼叫了Thread.Sleep方法。這樣能夠有效的減少頻繁自旋導致cpu資源過度浪費的問題。

10. ThreadLocal用完沒清空

在java中保證執行緒安全的技術有很多,可以使用synchroized、Lock等關鍵字給程式碼塊加鎖。

但是它們有個共同的特點,就是加鎖會對程式碼的效能有一定的損耗。

其實,在jdk中還提供了另外一種思想即:用空間換時間

沒錯,使用ThreadLocal類就是對這種思想的一種具體體現。

ThreadLocal為每個使用變數的執行緒提供了一個獨立的變數副本,這樣每一個執行緒都能獨立地改變自己的副本,而不會影響其它執行緒所對應的副本。

ThreadLocal的用法大致是這樣的:

  1. 先建立一個CurrentUser類,其中包含了ThreadLocal的邏輯。
public class CurrentUser {
    private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();
    
    public static void set(UserInfo userInfo) {
        THREA_LOCAL.set(userInfo);
    }
    
    public static UserInfo get() {
       THREA_LOCAL.get();
    }
    
    public static void remove() {
       THREA_LOCAL.remove();
    }
}
  1. 在業務程式碼中呼叫CurrentUser類。
public void doSamething(UserDto userDto) {
   UserInfo userInfo = convert(userDto);
   CurrentUser.set(userInfo);
   ...

   //業務程式碼
   UserInfo userInfo = CurrentUser.get();
   ...
}

在業務程式碼的第一行,將userInfo物件設定到CurrentUser,這樣在業務程式碼中,就能通過CurrentUser.get()獲取到剛剛設定的userInfo物件。特別是對業務程式碼呼叫層級比較深的情況,這種用法非常有用,可以減少很多不必要傳參。

但在高併發的場景下,這段程式碼有問題,只往ThreadLocal存資料,資料用完之後並沒有及時清理。

ThreadLocal即使使用了WeakReference(弱引用)也可能會存在記憶體洩露問題,因為 entry物件中只把key(即threadLocal物件)設定成了弱引用,但是value值沒有。

那麼,如何解決這個問題呢?

public void doSamething(UserDto userDto) {
   UserInfo userInfo = convert(userDto);
   
   try{
     CurrentUser.set(userInfo);
     ...
     
     //業務程式碼
     UserInfo userInfo = CurrentUser.get();
     ...
   } finally {
      CurrentUser.remove();
   }
}

需要在finally程式碼塊中,呼叫remove方法清理沒用的資料。

最近無意間獲得一份阿里大佬寫的刷題筆記,一下子打通了我的任督二脈,進大廠原來沒那麼難。

連結:https://pan.baidu.com/s/1UECE5yuaoTTRpJfi5LU5TQ 密碼:bhbe

最後說一句(求關注,別白嫖我)

如果這篇文章對您有所幫助,或者有所啟發的話,幫忙掃描下發二維碼關注一下,您的支援是我堅持寫作最大的動力。

求一鍵三連:點贊、轉發、在看。

關注公眾號:【蘇三說技術】,在公眾號中回覆:面試、程式碼神器、開發手冊、時間管理有超讚的粉絲福利,另外回覆:加群,可以跟很多BAT大廠的前輩交流和學習。

相關文章