解决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.hqwc.cn/news/506054.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

实战:Oracle Weblogic 11g 安装部署(10.3.6.0)

导读 本文介绍在redhat linux 6.6上安装Oracle weblogic 11g&#xff08;10.3.6.0&#xff09;版本 环境&#xff1a;redhat6.6 jdk 1.7 1、下载webLogic10.3.6 http://www.oracle.com/technetwork/cn/middleware/weblogic/downloads/wls-main-091116-zhs.html2 、在linux的ro…

数的范围 刷题笔记

思路 寻找第一个大于等于目标的 数 因为该数组是升序的 所以 我们可以采用二分的方式 逼近答案 定义一个左指针和一个右指针 当左右指针重合时 就是我们要找的答案 当我们寻找第一个大于等于x的数时 a[mid]>x,答案在mid处 或者在mid的左边 因此让rmid继续逼近 如果…

python:pyecharts 画基金净值 月K线图

pip install pyecharts1.9.1 pyecharts-1.9.1-py3-none-any.whl 我想在本地&#xff08;PC) 画出 基金净值 月K线图&#xff0c;不想每次看图都需联网。 cd my_dir mkdir echarts cd echarts curl -O https://assets.pyecharts.org/assets/echarts.min.js 修改一下开源代码 …

记录一次架构优化处理性能从3千->3万

0.背景 优化Kafka消费入Es&#xff0c;适配600台设备上报数据&#xff0c;吞吐量到达2万每秒 1.环境配置 2.压测工具 3.未优化之前的消费逻辑 4.优化之后的消费流程 5.多线程多ESclient 6.修改ES配置&#xff0c;增加kafka分区&#xff0c;增加线程&#xff0c;提升吞吐量 7.…

【数据结构】实现队列

大家好&#xff0c;我是苏貝&#xff0c;本篇博客带大家了解队列&#xff0c;如果你觉得我写的还不错的话&#xff0c;可以给我一个赞&#x1f44d;吗&#xff0c;感谢❤️ 目录 一. 队列的概念及结构二. 队列的实现队列的结构体初始化销毁队尾插入队头删除显示第一个节点的值…

【双指针】移动零

移动零 链接 . - 力扣&#xff08;LeetCode&#xff09;. - 备战技术面试&#xff1f;力扣提供海量技术面试资源&#xff0c;帮助你高效提升编程技能,轻松拿下世界 IT 名企 Dream Offer。https://leetcode.cn/problems/move-zeroes/submissions/506832592/ 题目 题解 异地变…

RFID(Radio Frequency Identification)技术笔记

一、RFID的介绍 RFID&#xff0c;全称为Radio Frequency Identification&#xff0c;即射频识别技术&#xff0c;也常被称为电子标签或无线射频识别。它是一种非接触式的自动识别技术&#xff0c;通过射频信号自动识别目标对象并获取相关数据&#xff0c;识别过程无需人工干预&…

Redis、Elasticsearch(ES)、RocketMQ和MYSql 持久化对比

在现代大数据和分布式系统中&#xff0c;数据持久化是一个至关重要的话题。本文将针对 Redis、Elasticsearch&#xff08;ES&#xff09;、 RocketMQ和MYSql 这四种常见的数据存储和消息队列系统进行持久化方面的对比分析&#xff0c;帮助读者更好地了解它们各自的特点和适用场…

【Mybatis】多表映射 第二期

文章目录 一、多表映射概念二、对一映射三、对多映射四、多表映射总结4.1 多表映射优化4.2 多表映射总结 一、多表映射概念 多表关系&#xff1a; 一对一一对多 | 多对一多对多 一个 客户 对应 多个订单一个订单 对应 一个客户 举例&#xff1a; 对一 实体类设计&#xff1a…

自学高效备考2024年AMC10:2000-2023年1250道AMC10真题解析

如何通过自学提高初中和高中数学成绩&#xff1f;现在初中和高中有哪些可以参加的数学竞赛&#xff1f;有没有难度适中、兼具趣味性的数学竞赛&#xff1f;现在参与人数较多的初中、高中数学有哪些&#xff1f;... 如果你也在关注以上问题&#xff0c;不妨看看AMC10美国数学竞…

【书生·浦语大模型实战营】第 2 节 -课后作业

第二节 -轻松玩转书生浦语大模型趣味 Demo-课后作业 0.课程体验0.1 鸡兔同笼0.2 逻辑推理0.3 AI会毁灭人类吗&#xff1f; 1.课后作业1.1 基础作业1.1.1 作业11.1.2 作业2 0.课程体验 课程链接&#xff1a;https://github.com/internLM/tutorial 首先&#xff0c;这个课程是免费…

C语言do...while 语句的基本格式是什么?

一、问题 C语⾔中有三种循环语句&#xff0c;do...while 语句是其中的⼀个&#xff0c;它的基本格式是怎样的呢&#xff1f; 二、解答 do...while 语句的⼀般形式为&#xff1a; do语句;while(表达式); 其中语句是循环体&#xff0c;表达式是循环条件。 do...while 语句是这…