【Java多线程】线程安全问题与解决方案

目录

1、线程安全问题

1.2、线程安全原因

2、线程加锁

2.1、synchronized 关键字

2.2、完善代码

2.3、对同一个线程的加锁操作 

3、内容补充

3.1、内存可见性问题 

3.2、指令重排序问题

3.3、解决方法

3.4、总结 volatile 关键字

1、线程安全问题

  • 某个代码,无论是单线程下执行还是多线程下执行都不会产生bug,被称之为“线程安全”
  • 如果在单线程下执行正确,但是多线程下会产生bug,被称之为“线程不安全”或者“存在线程安全问题”

线程安全问题的典型例子

public class ThreadDemo {private static int count = 0;public static void main(String[] args) throws InterruptedException {// 创建两个线程. 每个线程都针对上述 count 变量循环自增 50w 次Thread t1 = new Thread(() -> {for (int i = 0; i < 500000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 500000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}
}

        按照正常逻辑来看这段代码,结果应该是100w,但是通过多次运行发现这里给的却是一个50w到100w的随机值, 这就是因为出现了线程安全问题导致的结果错误。

问题分析:

count++操作实际上分成三步:

1)load 从内存中读取数据到cpu的寄存器

2)add 把寄存器中的值+1

3)save 把寄存器的值写回内存中

        而由于线程调度是随机调度,抢占式执行的,这就导致了两个线程的count++操作三步骤是会被打乱顺序的。

        例如 t1 线程先执行到 1)的同时, t2 线程也刚好随机调度开始执行 1),导致t1 和 t2 读取到的数据都是0,此时 t1 线程对 寄存器中值0+1,并将 1 写回内存中,接着 t2 也执行相同操作,再次将 1 写回内存中。此时就出现了线程安全问题,两次count++却只让count自增了1次。这就是这段代码为什么不是100w的原因细节。

1.2、线程安全原因

透过两个线程分别对count++,可以看到线程的不安全,有以下原因
1、根本原因,操作系统上线程的调度策略是“随机调度,抢占式执行”的,这就给线程之间执行的顺序带来了很多的变数。是线程安全问题的“罪魁祸首”。
2、代码结构问题,代码中多个线程同时修改一个变量。
3、直接原因,上述多线程修改操作,本身不是“原子的”,即实际count++操作又被分成了三步操作。如果操作本身是“原子的”,那么它要么执行,要么不执行,就不会出现执行一半,就被调度走,让其他线程“可乘之机”。

  • 针对原因1,系统底层对调度线程的逻辑就是随机调度,抢占式执行,无法干预做出任何调整。
  • 针对原因2,代码结构问题有时候是需求决定的,并不是每次都可以从这里入手,因此对于原因2也不好调整。
  • 针对原因3,既然操作非“原子的”,那么可以通过一些特殊手段将其打包成为“整体”,这也是我们接下来要讲到的加锁

2、线程加锁

2.1、synchronized 关键字

其中 locker 可以是任意对象,进入 synchronized 修饰的代码块, 相当于加锁,退出 synchronized 修饰的代码块, 相当解锁。

如果一个线程,针对一个对象加上锁之后,其他线程也尝试对这个对象加锁,就会导致锁竞争进而引起阻塞(BLOCKED),这个阻塞会一直持续到上一个线程释放锁为止。
如果是两个线程分别针对不同的对象进行加锁,此时不会由锁竞争,也就不会阻塞。

可以形象的理解成,每个对象在内存中存储的时候,都存有一块内存表示当前的 "锁定" 状态(类似于厕所的 "有人/无人")。

如果当前是 "无人" 状态,那么就可以使用,使用时需要设为 "有人" 状态。

如果当前是 "有人" 状态,那么其他人无法使用,只能排队。

2.2、完善代码

public class ThreadDemo {private static int count = 0;public static void main(String[] args) throws InterruptedException {// 随便创建个对象都行Object locker = new Object();// 创建两个线程. 每个线程都针对上述 count 变量循环自增 50w 次Thread t1 = new Thread(() -> {for (int i = 0; i < 500000; i++) {synchronized (locker) {   //对count++进行加锁操作,打包三步为一步count++;}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 500000; i++) {synchronized (locker) {   //对count++进行加锁操作,打包三步为一步count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = " + count);}
}

通过对count++的整体加锁,使得每一次的count++都是一个整体,解决了此处的线程安全问题。

2.3、对同一个线程的加锁操作 

public static void main(String[] args) {Object locker = new Object();Thread t = new Thread(() -> {synchronized (locker) {synchronized (locker) {System.out.println("hello synchronized");}}});t.start();
}

需要注意的是,这里最直观的感觉是进行了两次加锁,会发生锁冲突。第一次针对locker加锁之后,在还没释放锁的时候又尝试对locker加锁,理论会出现锁冲突,但是这里却可以正常打印。

最关键的问题在于,【Java中的锁是可重入锁】这两次加锁,其实是在同一个线程中进行的,如果是同一个线程对同一个锁的多次加锁,是不会冲突的。

3、内容补充

当然,还有其他能够导致出现线程安全问题的原因:内存可见性问题以及指令重排序问题

3.1、内存可见性问题 

下列代码原本用意是:当用户输入非0数字时,结束线程t1。 

package thread;import java.util.Scanner;public class ThreadDemo {private static int flag = 0;   public static void main(String[] args) {Thread t1 = new Thread(() -> {while (flag == 0) {// 循环体里, 啥都不写会触发内存可见性问题}System.out.println("t1 线程结束!");});Thread t2 = new Thread(() -> {System.out.println("请输入 flag 的值: ");Scanner scanner = new Scanner(System.in);flag = scanner.nextInt();});t1.start();t2.start();}
}

实际运行结果时发现无法结束,t2修改了内存,但是t1内有看到这个内存的变化,就称之为“内存可见性”问题。出现这一问题是JVM的代码优化导致的。

t1 线程中的while语句每次循环是都有两个操作:

1、load 读取内存中 flag 的值到 cpu 寄存器中

2、拿到寄存器的值和 0 比较

上述循环中循环的执行速度非常之快,反复的执行1和2,即使是1秒也可能反复执行了几百万次。而在执行的过程中,有两个关键要点:

1、JVM识别到 load 操作执行的几百万次结果每次都一样(输入前的等待时间里)。

2、而由于 load 操作花费的开销远远超过剩余的其他操作(访问寄存器的操作速度远远超过访问内存)

每次循环可能就是百分之九十九的时间都消耗在 load 操作上,而百分之一的时间消耗在其他操作上,而且JVM发现每次 load 操作读取到的数据都是一样的,那么此时JVM就会认为此处每次 load 的操作是否有存在的必要呢,于是乎JVM就可能会自动执行了代码优化,将上述的 load 操作优化了(只有前几次进行了 load,后续发现 load 一直没有变化,分析代码也没发现哪里修改了flag,因此激进的将load操作优化成了直接使用寄存器中之前“缓存”的值),从而达到大幅度提高循环的执行速度的目的。

3.2、指令重排序问题

指令重排序也是编译器优化的一种方式。保证逻辑不变的前提下,调整原有代码的执行顺序,提高程序的效率。

3.3、解决方法

由于上述两种问题都是由于JVM代码优化导致的

Java提供的 volatile 关键字就可以使上述的优化被强制关闭,可以确保每次循环条件都会重新从内存中读取数据。
强制读取内存,虽然开销大了,效率也低了,但是数据的准确性、逻辑的准确性都提高了。

只需要对 flag 添加一个 volatile 关键字即可解决这一问题。

3.4、总结 volatile 关键字

1、保证内存可见性,每次访问变量必须都要重新读取内存,而不会优化到寄存器/缓存中
2、禁止指令重排序,针对被 volatile 修饰的变量的读写操作相关指令,是不能被重排序的。

【博主推荐】

【Java多线程】Thread类的基本用法-CSDN博客icon-default.png?t=N7T8https://blog.csdn.net/zzzzzhxxx/article/details/136121421?spm=1001.2014.3001.5501 【Java多线程】对进程与线程的理解-CSDN博客icon-default.png?t=N7T8https://blog.csdn.net/zzzzzhxxx/article/details/136115808?spm=1001.2014.3001.5501

【数据结构】二叉树的三种遍历(非递归讲解)-CSDN博客icon-default.png?t=N7T8https://blog.csdn.net/zzzzzhxxx/article/details/136044643?spm=1001.2014.3001.5501

如果觉得作者写的不错,求给博主一个大大的点赞支持一下,你们的支持是我更新的最大动力!

如果觉得作者写的不错,求给博主一个大大的点赞支持一下,你们的支持是我更新的最大动力!

如果觉得作者写的不错,求给博主一个大大的点赞支持一下,你们的支持是我更新的最大动力!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/479408.html

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

相关文章

数据库应用:kylin 部署 达梦数据库DM8

目录 一、实验 1.环境 2.部署前规划 3.部署达梦数据库DM8 4.创建数据库及数据库事例管理 5.达梦数据库的基本操作 二、问题 1.xhost命令报错 2.执行安装程序DMInstall.bin 报错 3.解压安装程序报错 4.安装程序找不到文件 5.图像化界面打不开 6.安装内存太小 7.打开…

【CCEdit】通过扩散模型进行创意且可控的视频编辑

文章目录 CCEdit1. 核心特性1.1 三叉戟网络结构1.2 精细的外观控制1.3 高度的自适应性 2. 三叉戟结构2.1 结构分支&#xff08;ControlNet架构&#xff09;2.2 外观分支2.3 主分支 3. 数据集——BalanceCC benchmark dataset4. 训练5. 长视频编辑6. 使用场景7. 评估指标 CCEdit…

证券公司VIP快速交易通道是什么?可以免费申请吗?

交易速度是王道&#xff01;&#xff01;&#xff01; “快速交易通道&#xff0c;早已不是什么新鲜事了。”深圳某上市券商经纪业务部负责人称&#xff0c;尽管各家券商提供的VIP通道形式不同&#xff0c;功能各异&#xff0c;而且无论是深市还是沪市都能申请VIP交易通道&…

业务签署升级,君子签电子签章助推汽车融资租赁释放消费潜力

汽车融资租赁是一种新型的分期购车方式&#xff0c;承租人通过向汽车融资租赁机构租赁&#xff0c;并进行分期付款&#xff0c;先获得使用权&#xff0c;待分期全部完成&#xff0c;继而获得所有权。这种以租代购、分期付款的购车方式&#xff0c;可以有效降低购车门槛&#xf…

【机器学习算法】KNN鸢尾花种类预测案例和特征预处理。全md文档笔记(已分享,附代码)

本系列文章md笔记&#xff08;已分享&#xff09;主要讨论机器学习算法相关知识。机器学习算法文章笔记以算法、案例为驱动的学习&#xff0c;伴随浅显易懂的数学知识&#xff0c;让大家掌握机器学习常见算法原理&#xff0c;应用Scikit-learn实现机器学习算法的应用&#xff0…

VSCode C/C++无法跳转到定义(又是你 clangd !)

原博客&#xff1a;VSCode C/C无法跳转到定义、自动补全、悬停提示功能_c/c:edit configurations(json)-CSDN博客 我在此基础上加一点&#xff1a; 首先确保自己有这个插件&#xff1a; 点击 齿轮⚙ 符号&#xff0c;进入 配置设置&#xff0c;找到 把 C_cpp : Intelli Sens…

二叉树基本概念与遍历

什么是二叉树 二叉树不同于别的树&#xff0c;因为他每个节点最多只有两个子节点&#xff0c;而别的树可以有无数个子节点。 所以二叉树就是只最多只有两个子节点的树&#xff0c;一个子节点叫右子节点&#xff0c;另一个子节点叫左子结点 而他们的上一层就是父子节点&#xf…

TCP和UDP的使用方法和注意事项

文章目录 客户端和服务器TCP和UDP的特点UDP socket api的使用DatagramSocketDatagramPacketInetSocketAddress API 做一个简单的回显服务器UDP版本的回显服务器TCP版本的回显服务器 客户端和服务器 在网络中&#xff0c;主动发起通信的一方是客户端&#xff0c;被动接受的这一方…

OpenAI最新Sora视频学习与生成的技术分析与最新体验渠道

前言 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家&#xff1a;https://www.captainbed.cn/z ChatGPT体验地址 文章目录 前言OpenAI体验通道Spacetime Latent Patches 潜变量时空碎片, 建构视觉语言系统…

2024阿里云云服务器ECS价格表出炉

2024年最新阿里云服务器租用费用优惠价格表&#xff0c;轻量2核2G3M带宽轻量服务器一年61元&#xff0c;折合5元1个月&#xff0c;新老用户同享99元一年服务器&#xff0c;2核4G5M服务器ECS优惠价199元一年&#xff0c;2核4G4M轻量服务器165元一年&#xff0c;2核4G服务器30元3…

MBD_入门篇_17_Simulink逻辑和位运算模块

17.Simulink逻辑和位运算模块 17.1 概述 Logic And Bit Operation&#xff0c;逻辑和位运算操作模块。 17.2 逻辑运算模块LogicalOperator 可以对输入做与、或、非、异或等逻辑运算。 简单示例。 17.3 比较运算模块RelationOperation 比较两个数的大小等运算。 注意&#xff0c…

QT中事件过滤器

Qt添加事件过滤器&#xff0c;设置拖放listWidget、TreeWidget、TableWidget控件。 #include "mainwindow.h" #include "ui_mainwindow.h"MainWindow::MainWindow(QWidget *parent): QMainWindow(parent), ui(new Ui::MainWindow) {ui->setupUi(this)…