Java并發編程之由于靜態變量錯誤使用可能導致的并發問題
- 1.1 前言
- 1.2 業務背景
- 1.3 問題分析
- 1.4 為什么呢?
- 1.5 修復方案
- 2 演示示例源碼下載
1.1 前言
我們知道在 Java 后端服務開發中,如果出現并發問題一般都是由于在多個線程中使用了共享的變量導致的。
今天我們就一起來看一個由于靜態變量錯誤使用可能導致的并發問題。
PS:
- 今天分享的這個案例,只有程序啟動后,首次調用的時候就開并發調用才可能出現,所以相對比較隱蔽。
- 由于這個并發問題不是百分百出現,所以差點埋下雷,幸好在 QA發現后,在不相信玄學的組長的領導下,終于發現了罪魁禍首。
- 當時發現問題的表象就是:同樣的代碼同樣的鏡像,服務部署完成后是有問題的,重啟后可能就沒問題了。
- 以下代碼示例均已脫敏,簡化了非問題相關部分。
1.2 業務背景
為了優化程序的執行性能,避免每次創建都初始化一個集合,因此創建了一個靜態工具類ConstantUtils.java
。
import java.util.HashSet;
import java.util.Set;public class ConstantUtils {private static Set<String> goodApiList=new HashSet<>();public static Set<String> getGoodApiList() {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}return goodApiList;}/*** 靜態工具類應該禁用其構造方法*/private ConstantUtils(){}
}
最開始的思路是嘗試通過懶加載在靜態方法中初始化了一些值,這樣只有調用ConstantUtils.getGoodApiList()
方法的時候才會初始化。
然后接下來,如果并發調用接口則會并發執行calculate 這個方法,也就是說會并發執行new MyCounter();
public class MyProscessor{public void calculate(Map<String, String> dbResult) throws Exception {MyProxy myProxy = new MyProxy(); myProxy.setCounter(new MyCounter());return myProxy.doCount(dbResult);}
}
然后我們在這個MyCounter的成員變量中調用了ConstantUtils.getGoodApiList();
方法,避免在new出來的不同的MyCounter
實例中多次創建 goodApiList集合。
public class MyCounter(){private final Set<String> goodApiList= ConstantUtils.getGoodApiList();public void doCount(Map<String, String> dbResult){Long count=0L;if(goodApiList.contains("xxxx")){count++;....}...}
}
最后判斷做了統計數量。
可以確定的是 dbResult 中的結果是固定不變的,但是同樣的代碼,同樣的鏡像,部署兩次后,卻統計出來的 count 數量卻不一致。
而且經過最終排查后發現 goodApiList 的結果可能出現多種情況,比如下面兩種情況:
情況一:HashSet 集合里面確實 A,B,C,D,E,F,G,H,I,J,K
,但是調用size方法不是 11 而是 13 甚至 15
情況二:HashSet 集合里面確實 A,B,C,D,E,
,但是調用size方法不是 11 而是 13 甚至 16
看到這里,你看到問題出在哪里了么?
1.3 問題分析
如果你看出來了,那么恭喜你,很機智。
如果沒看出來,也沒關系,我們一起梳理下思路:
- 首先我們在一個類中,如果并發調用接口導致并發執行了
new MyCounter();
- 然后在
new MyCounter();
的實例中調用了ConstantUtils.getGoodApiList()
; - 然而
getGoodApiList()
方法是這樣實現的:
public static Set<String> getGoodApiList() {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}return goodApiList;
}
上面代碼執行可能出現這樣一個情況,當程序剛部署玩后,假設一個線程A執行到添加 B之后,兩外一個線程也進來了。
public static Set<String> getGoodApiList() {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");// ... 假設線程 A 此時執行到這里了goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}return goodApiList;
}
線程 B 執行的時候,由于線程 A已經給goodApiList放入了幾個值了,因此此時goodApiList.isEmpty 為 false,直接返回了 goodApiList.
那等十分鐘后再次非并發調用呢?結果會恢復正常么?
按照最開始的想法,不管怎么樣,最終線程 A 執行完,
那么goodApiList里面放的元素肯定就是 A,B,C,D,E,F,G,H,I,J,K
且 goodApiList.size() 就是 11了,是么?
如果你也這么認為那就大錯特錯了。
為了測試,我們這里用一個測試類來復現這個問題
import junit.framework.TestCase;import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;/**** @author qingfeng.zhao* @date 2024/6/6* @apiNote*/
public class ConstantUtilsTest extends TestCase {private static final int THREAD_NUM = 100; // 模擬100個并發線程private static final int COUNT_DOWN = THREAD_NUM; // 計數器public void setUp() throws Exception {super.setUp();}public void tearDown() throws Exception {}public void testGetGoodApiList()throws InterruptedException {ExecutorService executorService = Executors.newFixedThreadPool(THREAD_NUM);CountDownLatch latch = new CountDownLatch(COUNT_DOWN);for (int i = 0; i < THREAD_NUM; i++) {executorService.submit(() -> {try {// 模擬業務邏輯,調用ConstantUtils.getGoodsList()方法Set<String> goodList= ConstantUtils.getGoodApiList();} finally {latch.countDown(); // 線程執行完畢,計數器減一}});}latch.await(); // 等待所有線程執行完畢executorService.shutdown(); // 關閉線程池System.out.println("所有線程執行完畢!");for (int i = 0; i < 10; i++) {Set<String> goodList=ConstantUtils.getGoodApiList();System.out.println("-----start--------------");for (String item:goodList){System.out.println(item+"---"+goodList.size());}System.out.println("-----end--------------");}}
}
程序執行有時候會是這個樣子:
有時候又會是這個樣子:
1.4 為什么呢?
我們知道,HashSet 是一個非線程安全的集合.
當在多線程環境下,執行 HashSet的 add方法,如果算哈希槽的時候,如果發生沖突就會導致兩次 add都失敗,從而發生添加元素丟失。
1.5 修復方案
當然解決的方案有很多種,這里采用最簡單的一種解決方案。
將數據初始化部分放到靜態代碼塊中
import java.util.HashSet;
import java.util.Set;/*** @author xing yun*/
public class ConstantUtils {private static final Set<String> goodApiList=new HashSet<>();static {if(goodApiList.isEmpty()){goodApiList.add("A");goodApiList.add("B");goodApiList.add("C");goodApiList.add("D");goodApiList.add("E");goodApiList.add("F");goodApiList.add("G");goodApiList.add("H");goodApiList.add("I");goodApiList.add("J");goodApiList.add("K");}}public static Set<String> getGoodApiList() {return goodApiList;}/*** 靜態工具類應該禁用其構造方法*/private ConstantUtils(){}
}
2 演示示例源碼下載
- 命令行下載
git clone https://github.com/geekxingyun/concurrent-question-fixed-sample.git
- 訪問 github首頁
https://github.com/geekxingyun/concurrent-question-fixed-sample