解決Java并發問題的常見思路

寫在文章開頭

近期對一些比較老的項目進行代碼走查,碰到一些極端的并發編程惡習,所以筆者就基于此文演示這類問題以及面對并發編程時我們應該需要了解一些常見套路。

在這里插入圖片描述

Hi,我是sharkChili,是個不斷在硬核技術上作死的java coder,是CSDN的博客專家,也是開源項目Java Guide的維護者之一,熟悉Java也會一點Go,偶爾也會在C源碼邊緣徘徊。寫過很多有意思的技術博客,也還在研究并輸出技術的路上,希望我的文章對你有幫助,非常歡迎你關注我的公眾號:寫代碼的SharkChili,實時獲取筆者最新的技術推文同時還能和筆者進行深入交流。

在這里插入圖片描述

提出一個需求

基于筆者近期走查的案例筆者以一個類似的需求進行演示,這個需求是通過一個定時的任務調度線程從任務表中獲取任務項,通過這個任務項得到要到data表查詢對應任務的數據集并進行數據推送。
此時如果用戶通過頁面點擊暫停,這些正在發送的數據在數據庫中的狀態就會被更新為暫停,完成后再將這個定時調度的線程暫停。

整體流程如下圖所示,理想情況下,兩個線程的工作過程為:

  1. 線程1從數據庫找到任務,并通過這個任務找到數據表找到要發送的數據集,存入內存中。
  2. 線程1更新數據集狀態為待發送,不斷發送數據。
  3. 系統收到用戶頁面的暫停操作,創建一個線程2,從內存中找到要發送的數據,將這些數據集的狀態更新為已暫停。
  4. 線程完成數據暫停后將線程1的執行打斷。

在這里插入圖片描述

問題復現

基于這個需求,筆者給出下面這樣一個錯誤的例子,首先我們定義一下要發送的數據類,可以看到這個類包含id、數據和數據發送狀態:

@Data
@AllArgsConstructor
public class SendData {private int id;private String data;/*** 0 未開始* 1 發送中* 2 已完成* 3 暫停*/private int status;
}

然后我們再給出任務的封裝,如下所示,我們通過任務表可以查到任務的id和名稱,通過id就可以到數據表定位到當前任務的數據集,并將其添加到sendDataLinkedList中:

@Data
@AllArgsConstructor
public class TaskInfo {private int taskId;private String taskName;//數據集private LinkedList<SendData> sendDataLinkedList;//若sendDataLinkedList不為空則彈出第一個元素public SendData popSendData() {if (CollUtil.isNotEmpty(sendDataLinkedList)) {return sendDataLinkedList.pop();}return null;}//將數據添加到sendDataLinkedList中public void addSendData(SendData sendData) {sendDataLinkedList.add(sendData);}
}

然后我們給出模擬數據,可以看到筆者用taskInfoMap 模擬任務表中的數據,用mysqlSendDataList 模擬數據庫中對應task要發送的數據集:

private static List<SendData> mysqlSendDataList = new ArrayList<>();private static Map<Integer, TaskInfo> taskInfoMap = new HashMap<>();static {//模擬其他線程查到要執行的任務,并存入內存taskInfoMap.put(1, new TaskInfo(1, "任務1", new LinkedList<>()));//模擬任務1在mysql表中要發送的電話號碼mysqlSendDataList.add(new SendData(1, "數據1", 0));mysqlSendDataList.add(new SendData(2, "數據2", 0));mysqlSendDataList.add(new SendData(3, "數據3", 0));mysqlSendDataList.add(new SendData(4, "數據4", 0));mysqlSendDataList.add(new SendData(5, "數據5", 0));mysqlSendDataList.add(new SendData(6, "數據6", 0));mysqlSendDataList.add(new SendData(7, "數據7", 0));mysqlSendDataList.add(new SendData(8, "數據8", 0));mysqlSendDataList.add(new SendData(9, "數據9", 0));mysqlSendDataList.add(new SendData(10, "數據10", 0));}

對應的線程代碼如下,可以看到線程1會從數據庫中讀取數據并更新為發送中然后進行發送,并在完成后更新數據庫狀態。
而線程2則是模擬收到用戶狀態請求后,從內存中的任務集找到任務1,然后定位到正在發送的數據集將其數據庫狀態更新為暫停,然后將線程1暫停(這里用stop模擬打斷定時任務)

 public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {//模擬查任務TaskInfo taskInfo = taskInfoMap.get(1);//模擬從數據庫中取出待發送的數據log.info("線程1更新狀態為發送中");List<SendData> dataList = mysqlSendDataList.stream().filter(s -> s.getStatus() == 0).collect(Collectors.toList());//更新狀態為發送中mysqlSendDataList.stream().forEach(d -> d.setStatus(1));//將數據存入鏈表中dataList.forEach(taskInfo::addSendData);while (true) {SendData sendData = taskInfo.popSendData();if (sendData == null) {break;}log.info("發送數據:{} 成功", JSONUtil.toJsonStr(sendData));}//更新狀態為發送完成mysqlSendDataList.stream().forEach(d -> d.setStatus(2));});Thread t2 = new Thread(() -> {//模擬從內存中找到任務,然后從內存中找到正在發送的號碼,并將其數據庫狀態更新為待發送TaskInfo taskInfo = taskInfoMap.get(1);for (SendData sendData : taskInfo.getSendDataLinkedList()) {SendData mysqlSendData = mysqlSendDataList.stream().filter(s -> s.getId() == sendData.getId()).findFirst().get();mysqlSendData.setStatus(3);log.info("暫停任務:{}", JSONUtil.toJsonStr(mysqlSendData));}//打斷正在工作的線程try {t1.wait();t1.interrupt();} catch (InterruptedException e) {e.printStackTrace();}log.info("打斷t1線程,暫停發送任務");});t1.setName("t1");t1.start();t2.setName("t2");t2.start();System.out.println("執行結束");}

正常情況下,這種代碼因為多線程操作單一數據集進行動態迭代刪除時是會拋出ConcurrentModificationException的,但是筆者在走查類似上文這種例子時并為發現這個問題,經過對于流程和場景梳理時得出了答案。
筆者發現這個啟動和暫停任務的場景執行的數據量非常大,因為龐大的數據量,被暫停了任務基本都會在排隊或者剛剛完成數據集狀態更新為發送中就被類似于線程2的代碼完美暫停掉。

在這里插入圖片描述

但是也不免出現一些比較極端的場景:

  1. 任務1正好被執行。
  2. 執行過程中收到暫停信號,線程2讀取內存中任務1的數據集,更新數據庫狀態。
  3. 任務2正準備打斷任務1,CPU又切回線程1,因為線程2暫停數據時并沒有將內存中的數據集刪除,導致這些在數據庫中已經被暫停的數據集仍然被發送了。

最終很可能導致同樣的一批數據被重復發送兩次。

在這里插入圖片描述

對應的現象也就像下面這段代碼一樣,

00:17:43.052 [t1] INFO com.sharkChili.LinkListThreadSafeApplication - 線程1更新狀態為發送中
00:17:49.093 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":1,"data":"數據1","status":3}
00:17:49.716 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":2,"data":"數據2","status":3}
00:17:50.421 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":3,"data":"數據3","status":3}
00:17:50.421 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":4,"data":"數據4","status":3}
00:17:50.421 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":5,"data":"數據5","status":3}
00:17:50.421 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":6,"data":"數據6","status":3}
00:17:50.421 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":7,"data":"數據7","status":3}
00:17:50.421 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":8,"data":"數據8","status":3}
00:17:50.422 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":9,"data":"數據9","status":3}
00:17:50.422 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":10,"data":"數據10","status":3}
00:17:50.422 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 打斷t1線程,暫停發送任務

解決方案

對于此類并發問題的重構并解決的套路考慮的基本要考慮如下兩個點:

  1. 保持原有的業務邏輯
  2. 線程互斥保持在一個維度。
  3. 選用合適的并發容器。

我們都知道重構代碼對于測試的回歸,邏輯的扭轉變化都存在很大的風險點,所以筆者在對這段代碼重構時非常明確的梳理的任務執行的數據流,明確了業務邏輯,這位作者意圖是想在任務暫停時及時更新任務狀態且讓線程1不執行被暫停的任務,所以為了保證暫停的數據集不被線程1發送,首先就需要保證兩個線程操作的集合處于一個維度,而不是像上面的代碼一樣線程1用pop方法,線程2用get加遍歷的方式。

所以筆者改動的第一步,就是像容器安全化,將數據集存儲容器改為ConcurrentLinkedDeque,然后彈出元素的函數改為pollFirst

 //數據集private ConcurrentLinkedDeque<SendData> sendDataLinkedList;//若sendDataLinkedList不為空則彈出第一個元素public SendData popSendData() {return sendDataLinkedList.pollFirst();}

這里我們也給出pollFirst的源碼,可以看到它進行元素彈出時會通過CAS確定彈出的item是否和操作直線得到的一致,只有compare and set成功之后才能彈出。

public E pollFirst() {for (Node<E> p = first(); p != null; p = succ(p)) {E item = p.item;//只有cas成功才能彈出元素if (item != null && p.casItem(item, null)) {unlink(p);return item;}}//若為空直接返回nullreturn null;}

其次為了保證兩個線程操作處于一個維度,筆者將getter容器方法私有化,確保兩者操作都是用同一個pop方法操作:

private ConcurrentLinkedDeque<SendData> getSendDataLinkedList() {return sendDataLinkedList;}

這樣線程2的暫停邏輯就改為實時pop出線程1正在發送的數據再暫停,保證了暫停的數據線程1不會發送:

Thread t2 = new Thread(() -> {//模擬從內存中找到任務,然后從內存中找到正在發送的號碼,并將其數據庫狀態更新為待發送TaskInfo taskInfo = taskInfoMap.get(1);SendData sendData = null;while ((sendData = taskInfo.popSendData()) != null) {SendData finalSendData = sendData;SendData mysqlSendData = mysqlSendDataList.stream().filter(s -> s.getId() == finalSendData.getId()).findFirst().get();mysqlSendData.setStatus(3);log.info("暫停任務:{}", JSONUtil.toJsonStr(mysqlSendData));}//打斷正在工作的線程try {log.info("打斷t1線程,暫停發送任務");t1.stop();} catch (Exception e) {e.printStackTrace();}});

此時再看輸出結果,可以看到線程1發送了一個數據之后,線程2暫停了其余的數據,調度回到線程1,線程1停止了發送,問題解決:

00:50:18.336 [t1] INFO com.sharkChili.LinkListThreadSafeApplication - 線程1更新狀態為發送中
00:50:23.090 [t1] INFO com.sharkChili.LinkListThreadSafeApplication - 發送數據:{"id":1,"data":"數據1","status":1} 成功
00:50:26.242 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":2,"data":"數據2","status":3}
00:50:28.200 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":3,"data":"數據3","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":4,"data":"數據4","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":5,"data":"數據5","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":6,"data":"數據6","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":7,"data":"數據7","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":8,"data":"數據8","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":9,"data":"數據9","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 暫停任務:{"id":10,"data":"數據10","status":3}
00:50:28.201 [t2] INFO com.sharkChili.LinkListThreadSafeApplication - 打斷t1線程,暫停發送任務

小結

總的來說這是一段比較基礎的并發編程問題,本篇文章更著重的是讓讀者了解并發編程時如何復現以及考慮問題的維度,不難看出筆者進行并發編程問題的解決思路就是三步:

  1. 理清數據流和并發代碼邏輯。
  2. 確定合適的容器。
  3. 確保多線程操作互斥在同一個維度。

我是sharkchiliCSDN Java 領域博客專家開源項目—JavaGuide contributor,我想寫一些有意思的東西,希望對你有幫助,如果你想實時收到我寫的硬核的文章也歡迎你關注我的公眾號:
寫代碼的SharkChili,同時我的公眾號也有我精心整理的并發編程JVMMySQL數據庫個人專欄導航。

在這里插入圖片描述

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/716301.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/716301.shtml
英文地址,請注明出處:http://en.pswp.cn/news/716301.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

基于 Amazon EKS 的 Stable Diffusion ComfyUI 部署方案

01 背景介紹 Stable Diffusion 作為當下最流行的開源 AI 圖像生成模型在游戲行業有著廣泛的應用實踐&#xff0c;無論是 ToC 面向玩家的游戲社區場景&#xff0c;還是 ToB 面向游戲工作室的美術制作場景&#xff0c;都可以發揮很大的價值&#xff0c;如何更好地使用 Stable Dif…

scanf和cin的利弊

scanf和cin的利弊&#xff1a; scanf: 利&#xff1a;耗時短&#xff0c;寫法方便輸入固定格式&#xff0c;比如scanf(“%*d%d”,&a)&#xff0c;可以直接忽略第一個輸入&#xff0c;不用創建新對象&#xff0c;再比如scanf(“%1d”,&x[i])&#xff0c;輸入3214&#x…

卡牌——二分

卡牌 題目分析 想一下前面題的特點&#xff0c;是不是都出現了“最大邊長”&#xff0c;“最小的數”這種字眼&#xff0c;那么這里出現了“最多能湊出多少套牌”&#xff0c;我們可以考慮用二分。接下來我們要看一下他是否符合二段性&#xff0c;二分的關鍵在于二段性。 第…

續Java的執行語句、方法--學習JavaEE的day07

day07 一、特殊的流程控制語句 break(day06) continue 1.理解&#xff1a; 作用于循環中&#xff0c;表示跳過循環體剩余的部分&#xff0c;進入到下一次循環 做實驗&#xff1a; while(true){ System.out.println(“111”); System.out.println(“222”); if(true){ conti…

編譯鏈接實戰(25)gcc ASAN、MSAN檢測內存越界、泄露、使用未初始化內存等內存相關錯誤

文章目錄 1 ASAN1.1 介紹1.2 原理編譯時插樁模塊運行時庫2 檢測示例2.1 內存越界2.2 內存泄露內存泄露檢測原理作用域外訪問2.3 使用已經釋放的內存2.4 將漏洞信息輸出文件3 MSAN1 ASAN 1.1 介紹 -fsanitize=address是一個編譯器選項,用于啟用AddressSanitizer(地址

基于SpringBoot的教師考勤管理系統(贈源碼)

作者主頁&#xff1a;易學蔚來-技術互助文末獲取源碼 簡介&#xff1a;Java領域優質創作者 Java項目、簡歷模板、學習資料、面試題庫 教師考勤管理系統是基于JavaVueSpringBootMySQL實現的&#xff0c;包含了管理員、學生、教師三類用戶。該系統實現了班級管理、課程安排、考勤…

基于springboot的足球俱樂部管理系統的設計與實現

** &#x1f345;點贊收藏關注 → 私信領取本源代碼、數據庫&#x1f345; 本人在Java畢業設計領域有多年的經驗&#xff0c;陸續會更新更多優質的Java實戰項目希望你能有所收獲&#xff0c;少走一些彎路。&#x1f345;關注我不迷路&#x1f345;** 一 、設計說明 1.1 課題…

2024.3.3每日一題

LeetCode 用隊列實現棧 題目鏈接&#xff1a;225. 用隊列實現棧 - 力扣&#xff08;LeetCode&#xff09; 題目描述 請你僅使用兩個隊列實現一個后入先出&#xff08;LIFO&#xff09;的棧&#xff0c;并支持普通棧的全部四種操作&#xff08;push、top、pop 和 empty&…

如何取消ChatGPT 4.0的自動續費和會員訂閱(chatgpt4.0自動續費嗎)

如何取消ChatGPT 4.0的自動續費和會員訂閱 ChatGPT 4.0自動續費是否存在 是的&#xff0c;ChatGPT 4.0 Plus會員服務存在自動續費功能。 ChatGPT 4.0 Plus會員服務自動續費 ChatGPT Plus會員服務的自動續費機制用戶在購買ChatGPT 4.0 Plus會員服務后&#xff0c;系統會自動…

npm ERR! code ERESOLVE

1、問題概述&#xff1f; 執行npm install命令的時候報錯如下&#xff1a; tangxiaochuntangxiaochundeMacBook-Pro stf % npm install npm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree npm ERR! npm ERR! While resol…

LeetCode102.二叉樹的層序遍歷

題目 給你二叉樹的根節點 root &#xff0c;返回其節點值的 層序遍歷 。 &#xff08;即逐層地&#xff0c;從左到右訪問所有節點&#xff09;。 示例 輸入&#xff1a;root [3,9,20,null,null,15,7] 輸出&#xff1a;[[3],[9,20],[15,7]]輸入&#xff1a;root [1] 輸出&am…

SpringCloud-MQ消息隊列

一、消息隊列介紹 MQ (MessageQueue) &#xff0c;中文是消息隊列&#xff0c;字面來看就是存放消息的隊列。也就是事件驅動架構中的Broker。消息隊列是一種基于生產者-消費者模型的通信方式&#xff0c;通過在消息隊列中存放和傳遞消息&#xff0c;實現了不同組件、服務或系統…

2024全新手機軟件下載應用排行、平臺和最新發布網站,采用響應式織夢模板

這是一款簡潔藍色的手機軟件下載應用排行、平臺和最新發布網站&#xff0c;采用響應式織夢模板。 主要包括主頁、APP列表頁、APP詳情介紹頁、新聞資訊列表、新聞詳情頁、關于我們等模塊頁面。 地 址 &#xff1a; runruncode.com/php/19703.html 軟件程序演示圖&#xff1a;…

最小高度樹-力扣(Leetcode)

題目鏈接 最小高度樹 思路&#xff1a;本質上是找到樹中的最長路徑。當最長路徑上中間點&#xff08;若路經長為偶數&#xff0c;則中間點僅有一個&#xff0c;否者中間點有兩個&#xff09;作為根時&#xff0c;此時樹高最小。 Code: class Solution { public://拓撲排序int…

【深度優先搜索】【樹】【C++算法】2003. 每棵子樹內缺失的最小基因值

作者推薦 動態規劃的時間復雜度優化 本文涉及知識點 深度優先搜索 LeetCode2003. 每棵子樹內缺失的最小基因值 有一棵根節點為 0 的 家族樹 &#xff0c;總共包含 n 個節點&#xff0c;節點編號為 0 到 n - 1 。給你一個下標從 0 開始的整數數組 parents &#xff0c;其中…

第二講:用geth和以太坊交互

一&#xff1a;安裝geth brew install ethereum geth github網址&#xff1a; https://github.com/ethereum/go-ethereum 二&#xff1a; 用geth連接以太坊 以太坊有主網絡&#xff08;Ethereum Mainnet&#xff09;&#xff0c;有測試網絡&#xff08;Sepolia、Goerli 等等…

設計模式學習筆記 - 設計原則 - 5.依賴反轉原則(控制反轉、依賴反轉、依賴注入)

前言 今天學習 SOLID 中的最后一個原則&#xff0c;依賴反轉原則。 本章內容&#xff0c;可以帶著如下幾個問題&#xff1a; “依賴反轉” 這個概念指的是 “誰跟誰” 的 “什么依賴” 被反轉了&#xff1f; “反轉” 這兩個字該如何理解。我們還經常聽到另外兩個概念&#…

【分塊三維重建】【slam】LocalRF:逐步優化的局部輻射場魯棒視圖合成(CVPR 2023)

項目地址&#xff1a;https://localrf.github.io/ 題目&#xff1a;Progressively Optimized Local Radiance Fields for Robust View Synthesis 來源&#xff1a;KAIST、National Taiwan University、Meta 、University of Maryland, College Park 提示&#xff1a;文章用了s…

【Spring】20 解析Spring注解驅動的容器配置

文章目錄 注解 vs. XMLJavaConfig選項注解配置注解注入順序注解處理器實際運用總結 Spring 框架一直以 XML 配置為主導&#xff0c;然而隨著注解驅動配置的引入&#xff0c;我們不禁思考&#xff1a;是注解配置優于 XML 呢&#xff0c;還是反之&#xff1f;本篇博客將介紹 Spri…

如何將一個遠程git的所有分支推到另一個遠程分支上

如何將一個遠程git的所有分支推到另一個遠程分支上 最初有 12 個分支 執行 git remote add 遠程名 遠程git地址 git push 遠程名 --tags "refs/remotes/origin/*:refs/heads/*"之后就變成 26個分支