使用了并發工具類庫,線程就安全了嗎

并發工具類庫
- 有時會聽到有關線程安全和并發工具的一些片面的觀點和結論,比如“把 HashMap 改為 ConcurrentHashMap ,要不我們試試無鎖的 CopyOnWriteArrayList 吧,性能更好,事實上,這些說法都特殊場景下都不太準确
- 為了友善開發者進行多線程程式設計,現代程式設計語言提供了各種并發工具類 并且提供了 JUC 包 java.util.concurrent , 但是沒有充分了解他們的使用場景、解決的問題,以及最佳實踐的話,盲目使用就可能導緻一些坑、小則損失性能,大則無法保證多線程去看下業務邏輯正确性。
1. 沒有意識到線程重用導緻使用者資訊錯亂的 Bug
ThreadLocal提高一個線程的局部變量,通路某個線程擁有自己局部變量。我們常常使用使用ThreadLocal 用來存儲使用者資訊,但是發現ThreadLocal 有時擷取到的使用者資訊是别人的,
我們知道,ThreadLocal适用于變量線上程間隔離,而在方法或類間共享的場景。如果使用者資訊的擷取比較昂貴(比如從資料庫查詢使用者資訊),那麼在 ThreadLocal中緩存資料是比較合适的做法。但,這麼做為什麼會出現使用者資訊錯亂的 Bug ?
案例 :
private ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
@ApiOperation(value = "test2")
@GetMapping("/test2")
public ResponseMessage test2(@ApiParam(value = "id")@RequestParam(required = false) Integer id) {
//設定使用者資訊之前先查詢一次ThreadLocal中的使用者資訊
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(id);
String after = Thread.currentThread().getName() + ":" + currentUser.get();
//彙總輸出兩次查詢結果
Map result = new HashMap();
result.put("before", before);
result.put("after", after);
return ResultBody.success(result);
在設定使用者資訊之前第一次擷取的值始終應該是 null,但我們要意識到,程式運作在 Tomcat 中,執行程式的線程是 Tomcat 的工作線程,而 Tomcat 的工作線程是基于線程池的。
-
顧名思義,線程池會重用固定的幾個線程,一旦線程重用,那麼很可能首次從 ThreadLocal 擷取的值是之前其他使用者的請求遺留的值。這時,ThreadLocal 中的使用者資訊就是其他使用者的資訊。是以上圖中我新使用者 擷取到了 舊使用者遺留的 資訊,
因為線程的建立比較昂貴,是以web伺服器往往會使用線程池來處理請求,就意味着線程會被重用。這是,使用類似ThreadLocal工具來存放一些資料時,需要特别注意在代碼運作完後,顯式的去清空設定的睡覺。如果在代碼中使用來自定義線程池,也同樣會遇到這樣的問題
優化
@ApiOperation(value = "test2")
@GetMapping("/test2")
public ResponseMessage test2(@ApiParam(value = "id")@RequestParam(required = false) Integer id) {
//設定使用者資訊之前先查詢一次ThreadLocal中的使用者資訊
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(id);
Map result = new HashMap();
try {
String after = Thread.currentThread().getName() + ":" + currentUser.get();
//彙總輸出兩次查詢結果
result.put("before", before);
result.put("after", after);
}finally {
//在finally代碼塊中删除ThreadLocal中的資料,確定資料不串
currentUser.remove();
}
return ResultBody.success(result);
}
1. 使用了線程安全的并發工具,并不代表解決了所有的線程安全問題
JDK 1.5 後推出的 ConcurrentHashMap,是一個高性能的線程安全的哈希表容器。“線程安全”這四個字特别容易讓人誤解,因為 ConcurrentHashMap 隻能保證提供的原子性讀寫操作是線程安全的。
案例
public class Test {
private ConcurrentHashMap<String, Integer> map=new ConcurrentHashMap<String, Integer>();
public static void main(String[] args) {
final Test t=new Test();
for (int i = 0; i < 10000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
t.add("key");
}
}).start();
}
}
public void add(String key){
Integer value=map.get(key);
if(value==null)
map.put(key, 1);
else
map.put(key, value+1);
System.out.println(map.get(key));
}
}
解決:
public class Test {
private ConcurrentHashMap<String, Integer> map=new ConcurrentHashMap<String, Integer>();
public static void main(String[] args) {
final Test t=new Test();
for (int i = 0; i < 10000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
t.add("key");
}
}).start();
}
}
public synchronized void add(String key){
Integer value=map.get(key);
if(value==null)
map.put(key, 1);
else
map.put(key, value+1);
System.out.println(map.get(key));
}
}
如果隻是調用put或者get方法,ConcurrentHashMap是線程安全的,但是如果調用了get後在調用map.put(key, value+1)之前有另外的線程去調用了put,然後你再去執行put,就有可能将結果覆寫掉,但這個其實也不能算ConcurrentHashMap線程不安全,ConcurrentHashMap内部操作是線程安全的,但是外部操作還是要靠自己來保證同步,即使線上程安全的情況下,也是可能違反原子操作規則。。。
3. 沒有認清并發工具的使用場景,因而導緻性能問題
除了 ConcurrentHashMap 這樣通用的并發工具類之外,我們的工具包中還有些針對特殊場景實作的生面孔。一般來說,針對通用場景的通用解決方案,在所有場景下性能都還可以,屬于“萬金油”;而針對特殊場景的特殊實作,會有比通用解決方案更高的性能,但一定要在它針對的場景下使用,否則可能會産生性能問題甚至是 Bug。
CopyOnWrite 是一個時髦的技術,不管是 Linux 還是 Redis 都會用到。在 Java 中,
CopyOnWriteArrayList 雖然是一個線程安全的 ArrayList,但因為其實作方式是,每次
修改資料時都會複制一份資料出來,是以有明顯的适用場景,即讀多寫少或者說希望無鎖讀的場景。
案例:
測試寫的性能
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> cowal = new CopyOnWriteArrayList<Integer>();
ArrayList<Integer> list = new ArrayList<Integer>();
int count = 500;
long time1 = System.currentTimeMillis();
while (System.currentTimeMillis() - time1 < count) {
cowal.add(1);
}
time1 = System.currentTimeMillis();
while (System.currentTimeMillis() - time1 < count) {
list.add(1);
}
System.out.println("CopyOnWriteArrayList在" + count + "毫秒時間内添加元素個數為: "
+ cowal.size());
System.out.println("ArrayList在" + count + "毫秒時間内添加元素個數為: "
+ list.size());
}
- 以 add 方法為例,每次 add 時,都會用 Arrays.copyOf 建立一個新數組,頻繁 add 時記憶體的申請釋放消耗會很大
讀性能比較
public static void main(String[] args) throws InterruptedException {
// create object of CopyOnWriteArrayList
List<Integer> ArrLis = new ArrayList<>();
List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
System.gc();
for (int i = 0; i < 100000; i++) {
ArrLis.add(i);
}
for (int i = 0; i < 100000; i++) {
copyOnWriteArrayList.add(i);
}
Thread.sleep(500);
long startTime = System.currentTimeMillis(); //擷取開始時間
// print CopyOnWriteArrayList
System.out.println("ArrayList: "
+ ArrLis);
// 2nd index in the arraylist
System.out.println(" index: "
+ ArrLis.get(5000));
long endTime = System.currentTimeMillis(); //擷取結束時間
System.out.println(" ArrayList : 程式運作時間:" + (endTime - startTime) + "ms"); //輸出程式運作時間
Thread.sleep(500);
long startTime2 = System.currentTimeMillis(); //擷取開始時間
// print CopyOnWriteArrayList
System.out.println("copyOnWriteArrayList: "
+ copyOnWriteArrayList);
// 2nd index in the arraylist
System.out.println(" index: "
+ copyOnWriteArrayList.get(5000));
long endTime2 = System.currentTimeMillis(); //擷取結束時間
System.out.println(" copyOnWriteArrayList : 程式運作時間:" + (endTime2 - startTime2) + "ms"); //輸出程式運作時間
System.gc();
}
- 總結:雖然JDK 給我們提供了一些并發工具類,我們要想充分展現他的性能 還需要更加的去了解他的機制 ,不然可能就會成為項目中的累贅
個人部落格位址:
http://blog.yanxiaolong.cn/