這些線程安全的坑,你在工作中踩了麼?

點擊關注公衆號,Java乾貨及時送達

我們知道多線程能併發的處理多個任務,有效地提高複雜應用程序的性能,在實際開發中扮演着十分重要的角色

但是使用多線程也帶來了很多風險,並且由線程引起的問題往往在測試中難以發現,到了線上就會造成重大的故障和損失

下面我會結合幾個實際案例,幫助大家在工作做規避這些問題

多線程問題

首先介紹下使用的多線程會有哪些問題

使用多線程的問題很大程度上源於多個線程對同一變量的操作權,以及不同線程之間執行順序的不確定性

《Java併發編程實戰》這本書中提到了三種多線程的問題:安全性問題、活躍性問題和性能問題

安全性問題

例如有一段很簡單的扣庫存功能操作,如下:

public int decrement(){
 return --count;//count初始庫存爲10
}

在單線程環境下,這個方法能正確工作,但在多線程環境下,就會導致錯誤的結果

--count看上去是一個操作,但實際上它包含三步(讀取-修改-寫入):

  • 讀取count的值
  • 將值減一
  • 最後把計算結果賦值給count

如下圖展示了一種錯誤的執行過程,當有兩個線程1、2同時執行該方法時,它們讀取到count的值都是10,最後返回結果都是9;意味着可能有兩個人購買了商品,但庫存卻只減了1,這對於真實的生產環境是不可接受的

像上面例子這樣由於不恰當的執行時序導致不正確結果的情況,是一種很常見的併發安全問題,被稱爲競態條件

decrement()方法這個導致發生競態條件的代碼區被稱爲臨界區

避免這種問題,需要保證讀取-修改-寫入這樣複合操作的原子性

在Java中,有很多方式可以實現,比如使用synchronize內置鎖或ReentrantLock顯式鎖的加鎖機制、使用線程安全的原子類、以及採用CAS的方式等

活躍性問題

活躍性問題指的是,某個操作因爲阻塞或循環,無法繼續執行下去

最典型的有三種,分別爲死鎖、活鎖和飢餓

死鎖

最常見的活躍性問題是死鎖

死鎖是指多個線程之間相互等待獲取對方的鎖,又不會釋放自己佔有的鎖,而導致阻塞使得這些線程無法運行下去就是死鎖,它往往是不正確的使用加鎖機制以及線程間執行順序的不可預料性引起的

如何預防死鎖

1.儘量保證加鎖順序是一樣的

例如有A,B,C三把鎖。

  • Thread 1的加鎖順序爲A、B、C這樣的。

  • Thread 2的加鎖順序爲A、C,這樣就不會死鎖。

如果Thread2的加鎖順序爲B、A或者C、A這樣順序就不一致了,就會出現死鎖問題。

2.儘量用超時放棄機制

Lock接口提供了tryLock(long time, TimeUnit unit)方法,該方法可以按照固定時長等待鎖,因此線程可以在獲取鎖超時以後,主動釋放之前已經獲得的所有的鎖。可以避免死鎖問題

活鎖

活鎖與死鎖非常相似,也是程序一直等不到結果,但對比於死鎖,活鎖是活的,什麼意思呢?因爲正在運行的線程並沒有阻塞,它始終在運行中,卻一直得不到結果

飢餓

飢餓是指線程需要某些資源時始終得不到,尤其是CPU 資源,就會導致線程一直不能運行而產生的問題。

在 Java 中有線程優先級的概念,Java 中優先級分爲 1 到 10,1 最低,10 最高。

如果我們把某個線程的優先級設置爲 1,這是最低的優先級,在這種情況下,這個線程就有可能始終分配不到 CPU 資源,而導致長時間無法運行。

性能問題

線程本身的創建、以及線程之間的切換都要消耗資源,如果頻繁的創建線程或者CPU在線程調度花費的時間遠大於線程運行的時間,使用線程反而得不償失,甚至造成CPU負載過高或者OOM的後果

舉例說明

線程不安全類

案例1

使用線程不安全集合(ArrayList、HashMap等)要進行同步,最好使用線程安全的併發集合

在多線程環境下,對線程不安全的集合遍歷進行操作時,可能會拋出ConcurrentModificationException的異常,也就是常說的fail-fast機制

下面例子模擬了多個線程同時對ArrayList操作,線程t1遍歷list並打印,線程t2向list添加元素

List<Integer> list = new ArrayList<>();
list.add(0); 
list.add(1); 
list.add(2);  //list: [0,1,2]
System.out.println(list);

//線程t1遍歷打印list
Thread t1 = new Thread(() -> {
  for(int i : list){
    System.out.println(i);
  }
});  

//線程t2向list添加元素
Thread t2 = new Thread(() -> {
  for(int i = 3; i < 6; i++){
    list.add(i);
  }
});

t1.start();
t2.start();

進到拋異常的ArrayList源碼中,可以看到遍歷ArrayList是通過內部實現的迭代器完成的

調用迭代器的next()方法獲取下一個元素時,會先通過checkForComodification()方法檢查modCountexpectedModCount是否相等,若不相等則拋出ConcurrentModificationException

modCount是ArrayList的屬性,表示集合結構被修改的次數(列表長度發生變化的次數),每次調用add或remove等方法都會使modCount加1

expectedModCount是迭代器的屬性,在迭代器實例創建時被賦與和遍歷前modCount相等的值(expectedModCount=modCount

所以當有其他線程添加或刪除集合元素時,modCount會增加,然後集合遍歷時expectedModCount不等於modCount,就會拋出異常

使用加鎖機制操作線程不安全的集合類

List<Integer> list = new ArrayList<>();
list.add(0); 
list.add(1); 
list.add(2);
System.out.println(list);

//線程t1遍歷打印list
Thread t1 = new Thread(() -> {
  synchronized (list){   //使用synchronized關鍵字
    for(int i : list){
      System.out.println(i);
    }
  }
});  

//線程t2向list添加元素
Thread t2 = new Thread(() -> {
  synchronized (list){
    for(int i = 3; i < 6; i++){
      list.add(i);
      System.out.println(list);
    }
  }
});  

t1.start();
t2.start();

如上面代碼,用synchronized關鍵字鎖住對list的操作,就不會拋出異常。不過用synchronized相當於把鎖住的代碼塊串行化,性能上是不佔優勢的

推薦使用線程安全的併發工具類

JDK1.5加入了很多線程安全的工具類供使用,如CopyOnWriteArrayList、ConcurrentHashMap等併發容器

日常開發中推薦使用這些工具類來實現多線程編程

案例2

不要將SimpleDateFormat作爲全局變量使用

SimpleDateFormat實際上是一個線程不安全的類,其根本原因是SimpleDateFormat的內部實現對一些共享變量的操作沒有進行同步

public static final SimpleDateFormat SDF_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
  //兩個線程同時調用SimpleDateFormat.parse方法
  Thread t1 = new Thread(() -> {
    try {
      Date date1 = SDF_FORMAT.parse("2019-12-09 17:04:32");
    } catch (ParseException e) {
      e.printStackTrace();
    }
  });

  Thread t2 = new Thread(() -> {
    try {
      Date date2 = SDF_FORMAT.parse("2019-12-09 17:43:32");
    } catch (ParseException e) {
      e.printStackTrace();
    }
  });

  t1.start();
  t2.start();
}

建議將SimpleDateFormat作爲局部變量使用,或者配合ThreadLocal使用

最簡單的做法是將SimpleDateFormat作爲局部變量使用即可

但如果是在for循環中使用,會創建很多實例,可以優化下配合ThreadLocal使用

//初始化
public static final ThreadLocal<SimpleDateFormat> SDF_FORMAT = new ThreadLocal<SimpleDateFormat>(){
  @Override
  protected SimpleDateFormat initialValue() {
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  }
};
//調用
Date date = SDF_FORMAT.get().parse(wedDate);

推薦使用Java8的LocalDateTime和DateTimeFormatter

LocalDateTime和DateTimeFormatter是Java 8引入的新特性,它們不僅是線程安全的,而且使用更方便

推薦在實際開發中用LocalDateTime和DateTimeFormatter替代Calendar和SimpleDateFormat

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime time = LocalDateTime.now();
System.out.println(formatter.format(time));

鎖的正確釋放

假設有這樣一段僞代碼:

Lock lock = new ReentrantLock();
...  
try{
  lock.tryLock(timeout, TimeUnit.MILLISECONDS)
  //業務邏輯
}
catch (Exception e){
  //錯誤日誌
  //拋出異常或直接返回
}
finally {
  //業務邏輯
  lock.unlock();
}
...

這段代碼中在finally代碼塊釋放鎖之前,執行了一段業務邏輯

假如不巧這段邏輯中依賴服務不可用導致佔用鎖的線程不能成功釋放鎖,會造成其他線程因無法獲取鎖而阻塞,最終線程池被打滿的問題

所以在釋放鎖之前;finally子句中應該只有對當前線程佔有的資源(如鎖、IO流等)進行釋放的一些處理

還有就是獲取鎖時設置合理的超時時間

爲了避免線程因獲取不到鎖而一直阻塞,可以設置一個超時時間,當獲取鎖超時後,線程可以拋出異常或返回一個錯誤的狀態碼。其中超時時間的設置也要合理,不應過長,並且應該大於鎖住的業務邏輯的執行時間。

正確使用線程池

案例1

不要將線程池作爲局部變量使用

public void request(List<Id> ids) {
  for (int i = 0; i < ids.size(); i++) {
     ExecutorService threadPool = Executors.newSingleThreadExecutor();
  }
}

在for循環中創建線程池,那麼每次執行該方法時,入參的list長度有多大就會創建多少個線程池,並且方法執行完後也沒有及時調用shutdown()方法將線程池銷燬

這樣的話,隨着不斷有請求進來,線程池佔用的內存會越來越多,就會導致頻繁fullGC甚至OOM。每次方法調用都創建線程池是很不合理的,因爲這和自己頻繁創建、銷燬線程沒有區別,不僅沒有利用線程池的優勢,反而還會耗費線程池所需的更多資源

所以儘量將線程池作爲全局變量使用

案例2

謹慎使用默認的線程池靜態方法

Executors.newFixedThreadPool(int);     //創建固定容量大小的線程池
Executors.newSingleThreadExecutor();   //創建容量爲1的線程池
Executors.newCachedThreadPool();       //創建一個線程池,線程池容量大小爲Integer.MAX_VALUE

上述三個默認線程池的風險點:

newFixedThreadPool創建的線程池corePoolSize和maximumPoolSize值是相等的,使用的阻塞隊列是LinkedBlockingQueue。

newSingleThreadExecutor將corePoolSize和maximumPoolSize都設置爲1,也使用的LinkedBlockingQueue

LinkedBlockingQueue默認容量爲Integer.MAX_VALUE=2147483647,對於真正的機器來說,可以被認爲是無界隊列

  • newFixedThreadPool和newSingleThreadExecutor在運行的線程數超過corePoolSize時,後來的請求會都被放到阻塞隊列中等待,因爲阻塞隊列設置的過大,後來請求不能快速失敗而長時間阻塞,就可能造成請求端的線程池被打滿,拖垮整個服務。

newCachedThreadPool將corePoolSize設置爲0,將maximumPoolSize設置爲Integer.MAX_VALUE,阻塞隊列使用的SynchronousQueue,SynchronousQueue不會保存等待執行的任務

  • 所以newCachedThreadPool是來了任務就創建線程運行,而maximumPoolSize相當於無限的設置,使得創建的線程數可能會將機器內存佔滿。

所以需要根據自身業務和硬件配置創建自定義線程池

線程數建議

線程池corePoolSize數量設置建議

1.CPU密集型應用

CPU密集的意思是任務需要進行大量複雜的運算,幾乎沒有阻塞,需要CPU長時間高速運行。

一般公式:corePoolSize=CPU核數+1個線程。JVM可運行的CPU核數可以通過Runtime.getRuntime().availableProcessors()查看。

2.IO密集型應用

IO密集型任務會涉及到很多的磁盤讀寫或網絡傳輸,線程花費更多的時間在IO阻塞上,而不是CPU運算。一般的業務應用都屬於IO密集型。

參考公式:最佳線程數=CPU數/(1-阻塞係數);   阻塞係數=線程等待時間/(線程等待時間+CPU處理時間) 。

IO密集型任務的CPU處理時間往往遠小於線程等待時間,所以阻塞係數一般認爲在0.8-0.9之間,以4核單槽CPU爲例,corePoolSize可設置爲 4/(1-0.9)=40。當然具體的設置還是要根據機器實際運行中的各項指標而定

參考書籍:Java併發編程實戰






關注Java技術棧看更多幹貨



獲取 Spring Boot 實戰筆記!

本文分享自微信公衆號 - Java技術棧(javastack)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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