前言
列舉大家平時在工作中最容易犯的幾個併發錯誤,都是在實際項目代碼中看到的鮮活例子,希望對大家有幫助。
First Blood
線上總是出現:ERROR 1062 (23000) Duplicate entry 'xxx' for key 'yyy',我們來看一下有問題的這段代碼:
UserBindInfo info = selectFromDB(userId);
if(info == null){
info = new UserBindInfo(userId,deviceId);
insertIntoDB(info);
}else{
info.setDeviceId(deviceId);
updateDB(info);
}
在併發情況下,第一步判斷都爲空,就會有2個或者多個線程進入插入數據庫操作,這時候就出現了同一個ID插入多次。
正確處理姿勢:
insert into UserBindInfo values(#{userId},#{deviceId}) on duplicate key update deviceId=#{deviceId}多次的情況,導致插入失敗。
一般情況下,可以用insert...on duplicate key update... 解決這個問題。
注意: 如果UserBindInfo表存在主鍵以及一個以上的唯一索引,在併發情況下,使用insert...on duplicate key,可能會產生死鎖(Mysql5.7),可以這樣處理:
try{
UserBindInfoMapper.insertIntoDB(userBindInfo);
}catch(DuplicateKeyException ex){
UserBindInfoMapper.update(userBindInfo);
}
Double Kill
小心你的全局變量,如下面這段代碼:
public class GlobalVariableConcurrentTest {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 100, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(1000));
while (true){
threadPoolExecutor.execute(()->{
String dateString = sdf.format(new Date());
try {
Date parseDate = sdf.parse(dateString);
String dateString2 = sdf.format(parseDate);
System.out.println(dateString.equals(dateString2));
} catch (ParseException e) {
e.printStackTrace();
}
});
}
}
}
可以看到有異常拋出
全局變量的SimpleDateFormat,在併發情況下,存在安全性問題,阿里Java規約明確要求謹慎使用它。
除了SimpleDateFormat,其實很多時候,面對全局變量,我們都需要考慮併發情況是否存在問題,如下
@Component
public class Test {
public static List<String> desc = new ArrayList<>();
public List<String> getDescByUserType(int userType) {
if (userType == 1) {
desc.add("普通會員不可以發送和查看郵件,請購買會員");
return desc;
} else if (userType == 2) {
desc.add("恭喜你已經是VIP會員,盡情的發郵件吧");
return desc;
}else {
desc.add("你的身份未知");
return desc;
}
}
}
因爲desc是全局變量,在併發情況下,請求getDescByUserType方法,得到的可能並不是你想要的結果。
Trible Kill
假設現在有如下業務:控制同一個用戶訪問某個接口的頻率不能小於5秒。一般很容易想到使用redis的 setnx操作來控制併發訪問,於是有以下代碼:
if(RedisOperation.setnx(userId, 1)){
RedisOperation.expire(userId,5,TimeUnit.SECONDS));
//執行正常業務邏輯
}else{
return “訪問過於頻繁”;
}
假設執行完setnx操作,還沒來得及設置expireTime,機器重啓或者突然崩潰,將會發生死鎖。該用戶id,後面執行setnx永遠將爲false,這可能讓你永遠損失那個用戶。
那麼怎麼解決這個問題呢,可以考慮用SET key value NX EX max-lock-time ,它是一種在 Redis 中實現鎖的方法,是原子性操作,不會像以上代碼分兩步執行,先set再expire,它是一步到位。
客戶端執行以上的命令:
如果服務器返回 OK ,那麼這個客戶端獲得鎖。
如果服務器返回 NIL ,那麼客戶端獲取鎖失敗,可以在稍後再重試。
設置的過期時間到達之後,鎖將自動釋放
Quadra Kill
我們看一下有關ConcurrentHashMap的一段代碼,如下:
//全局變量
Map<String, Integer> map = new ConcurrentHashMap();
Integer value = count.get(k);
if(value == null){
map.put(k,1);
}else{
map.put(k,value+1);
}
假設兩條線程都進入 value==null,這一步,得出的結果是不是會變小?OK,客官先稍作休息,閉目養神一會,我們驗證一下,請看一個demo:
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
testConcurrentMap();
}
}
private static void testConcurrentMap() {
final Map<String, Integer> count = new ConcurrentHashMap<>();
ExecutorService executorService = Executors.newFixedThreadPool(2);
final CountDownLatch endLatch = new CountDownLatch(2);
Runnable task = ()-> {
for (int i = 0; i < 5; i++) {
Integer value = count.get("k");
if (null == value) {
System.out.println(Thread.currentThread().getName());
count.put("k", 1);
} else {
count.put("k", value + 1);
}
}
endLatch.countDown();
};
executorService.execute(task);
executorService.execute(task);
try {
endLatch.await();
if (count.get("k") < 10) {
System.out.println(count);
}
} catch (Exception e) {
e.printStackTrace();
}
表面看,運行結果應該都是10對吧,好的,我們再看運行結果 :
運行結果出現了5,所以這樣實現是有併發問題的,那麼正確的實現姿勢是啥呢?
Map<K,V> map = new ConcurrentHashMap();
V v = map.get(k);
if(v == null){
V v = new V();
V old = map. putIfAbsent(k,v);
if(old != null){
v = old;
}
}
可以考慮使用putIfAbsent解決這個問題
(1)如果key是新的記錄,那麼會向map中添加該鍵值對,並返回null。
(2)如果key已經存在,那麼不會覆蓋已有的值,返回已經存在的值
我們再來看看以下代碼以及運行結果:
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
testConcurrentMap();
}
}
private static void testConcurrentMap() {
ExecutorService executorService = Executors.newFixedThreadPool(2);
final Map<String, AtomicInteger> map = Maps.newConcurrentMap();
final CountDownLatch countDownLatch = new CountDownLatch(2);
Runnable task = ()-> {
AtomicInteger oldValue;
for (int i = 0; i < 5; i++) {
oldValue = map.get("k");
if (null == oldValue) {
AtomicInteger initValue = new AtomicInteger(0);
oldValue = map.putIfAbsent("k", initValue);
if (oldValue == null) {
oldValue = initValue;
}
}
oldValue.incrementAndGet();
}
countDownLatch.countDown();
};
executorService.execute(task);
executorService.execute(task);
try {
countDownLatch.await();
System.out.println(map);
} catch (Exception e) {
e.printStackTrace();
}
}
Penta Kill
現有如下業務場景:用戶手上有一張現金券,可以兌換相應的現金,
錯誤示範一
if(isAvailable(ticketId){
1、給現金增加操作
2、deleteTicketById(ticketId)
}else{
return “沒有可用現金券”
}
解析: 假設有兩條線程A,B兌換現金,執行順序如下:
1.線程A加現金
2.線程B加現金
3.線程A刪除票標誌
4.線程B刪除票標誌
顯然,這樣有問題了,已經給用戶加了兩次現金了。
錯誤示範2
if(isAvailable(ticketId){
1、deleteTicketById(ticketId)
2、給現金增加操作
}else{
return “沒有可用現金券”
}
併發情況下,如果一條線程,第一步deleteTicketById刪除失敗了,也會多添加現金。
正確處理方案
if(deleteAvailableTicketById(ticketId) == 1){
1、給現金增加操作
}else{
return “沒有可用現金券”
}
個人公衆號
如果你是個愛學習的好孩子,可以關注我公衆號,一起學習討論。
如果你覺得本文有哪些不正確的地方,可以評論,也可以關注我公衆號,私聊我,大家一起學習進步哈。