关于线程安全问题
创始人
2025-05-30 03:06:15
0

目录

  • 什么是线程安全
  • 线程安全问题及解决
    • 第一种情况 (针对1,2,3点)
      • 解决 (synchronized关键字)
    • 第二种情况 (针对4,5点)
      • 解决 (volatile 关键字)

什么是线程安全

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境下应该出现的结果,则说这个程序是线程安全的。

线程安全问题及解决

线程不安全原因:

  1. 抢占式执行 (罪魁祸首)
  2. 多个线程修改同一个变量
  3. 修改操作不是原子的 (单个cpu指令)
  4. 内存可见性引起的线程不安全
  5. 指令重排序引起的线程不安全

第一种情况 (针对1,2,3点)

举个例子:
count初始值为0, 我们要对count加加2W次, 规定要用两个线程进行加加, 每个线程加加1W次.
写出如下代码:

class Counters{private int count = 0;public void add() {count++;}public int get() {return count;}
}
public class Test2 {public static void main(String[] args) throws InterruptedException {Counters counters = new Counters();Thread t1 = new Thread(() -> {for(int i = 0; i < 10000; i++) {counters.add();}});Thread t2 = new Thread(() -> {for(int i = 0; i < 10000; i++) {counters.add();}});t1.start();t2.start();//等待两个线程结束t1.join();t2.join();System.out.println(counters.get());}
}

按理说结果应该是结果应该是20000的, 可是我们发现运行结果却次次不同:
在这里插入图片描述
在这里插入图片描述
为什么会出现这种情况呢?

其实 count++ 这一个操作是由三个CPU指令构成 (体现第3点: 不是原子的):

  1. load, 把内存中的数据读取到CPU寄存器中.
  2. add, 把寄存器中的值进行 +1 运算.
  3. asve, 把寄存器中的值写回内存中.

由于多线程调度是随机的, 它们是抢占式执行的, 因此这两个线程的++的实际指令有多种排列方式.
像下面这种线程就是安全的 :
在这里插入图片描述
但它的排列方式有很多种, 上面两种是极小概率出现的情况, 大部分情况是3个指令分开来排列的, 比如 :
在这里插入图片描述
就拿左边的情况来举例吧.
线程 1 先执行 load 指令,将 0 读取到寄存器中, 然后线程 2 又读取 0, 并++,此时 count 变为 1, 再写入内存中, 此时内存中的 count 就是1了, 然后线程 1 接着执行++, 这里是拿之前读取到寄存器中的 0 进行++ , 得到 1 后再写入内存中, 此时我们发现执行了两次++, 但 count 的值仍为 1 , 这就与我们的预期不符了, 这便是线程不安全, 出现了bug.
(这里体现第2点 : 多线程修改同一个变量)

解决 (synchronized关键字)

我们发现如果让线程指令一直是以下面这种方式运行, 那肯定没问题.
在这里插入图片描述
这里就引入了一个关键字 synchronized, 这就是相当于是给代码加锁, 先来看看它的使用 :

class Counters{private int count = 0;public void add() {synchronized (this) { //这里就是给this加锁, 当执行完count++后自动就解锁了count++;     // 如果两个线程针对同一个对象加锁,就会出现"锁竞争"}   //如果两个线程针对不同对象加锁就不会出现"锁竞争"}    public int get() {return count;}
}
public class Test {public static void main(String[] args) throws InterruptedException {Counters counters = new Counters();Thread t1 = new Thread(() -> {for(int i = 0; i < 10000; i++) {counters.add();}});Thread t2 = new Thread(() -> {for(int i = 0; i < 10000; i++) {counters.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counters.get());}
}

注意 : synchronized 后面括号里是锁对象, 锁对象可以是任意一个 Object 对象, 内置类型(基本数据类型)不行.
上面括号里的 this 就相当于 counters.

举个例子可能好理解点 :
把执行count++这个操作理解为上厕所, 比如张三去上厕所, 上厕所时把门给锁了, 这就相当于给count++加锁, 这时别人就看不到里面, 也不知道里面啥情况, 只有等张三上完厕所把锁给解了, 其他人才能去上厕所, 但其他人都很急就出现"锁竞争"情况, 他们都很急, 没有排队, 所以下一个上厕所的人是随机的.上面的锁对象就是厕所的蹲位.

我们看看输出结果:
在这里插入图片描述
符合预期.

其实还有几种加锁写法:

class Counters{private int count = 0;Object object = new Object();  //创建一个对象放入括号里public void add() {synchronized (object) {  //因为线程之间资源是共用的, 所以不会产生两个object对象count++;}}public int get() {return count;}
}

还有一种运用反射来获取对象的方法(一般不用):

class Counters{private static int count = 0;public void add() {synchronized (Counters.class) {count++;}}public int get() {return count;}
}

还可以给方法加锁 :

class Counters{private int count = 0;synchronized public void add() {  //直接在方法前加synchronized修饰count++;        //此时就相当于以this为锁对象}public int get() {return count;}
}

如果 synchronized 修饰的是静态方法, 那就不是给 this 加锁了, 而是给类对象加锁.

class Counters{private static int count = 0;synchronized public static void add() {count++;}public int get() {return count;}
}

第二种情况 (针对4,5点)

再来举个例子:

import java.util.Scanner;public class ThreadDemo4 {public static int flag;  public static void main(String[] args) {Thread t1 = new Thread(() -> {while(flag == 0) {}System.out.println("t1循环结束, t1结束!");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入flag的值 :");flag = scanner.nextInt();  });t1.start();t2.start();}
}

我们想通过线程 t2 来控制 t1 的循环, 当我们输入 flag 为非零值时, 循环应该是会终止的, 我们来看看效果 :

在这里插入图片描述
可以看到循环好像并没有停下来, flag 的值明明已经不是零了, 为什么还是在循环呢?
其实 while 循环里的 flag == 0 这个操作可以分为两个指令:

  1. load, 从内存读取数据到 cpu 寄存器
  2. cmp, 比较寄存器里的值是否为 0

注意 : 此时这里 load 的时间开销要远大于 cmp.
(读内存比读硬盘快几千倍, 读寄存器又比读内存快几千倍)

一秒就可能执行上亿次这两个指令.
这时, 编译器就发现两点:

  1. load 的开销很大
  2. 每次 load 的结果都是一样的

这时编译器就做出了一个大胆的操作, 把 load 给优化掉了(去掉了), 只有第一次执行 load 才真正执行了, 后续循环都是 cmp ,就是拿第一次 load 到的值去比较.

编译器优化是一件很普遍的事情, 编译器优化能够智能的调整代码的逻辑, 保证结果不变的前提下, 通过一些操作来让程序执行效率大大提升.
编译器对于单线程的代码优化结果是非常准确的, 但多线程下就不一定了, 可能调整之后代码效率是提高了, 但代码结果变了, 出现了bug.

解决 (volatile 关键字)

Java引入volatile 关键字, 来让编译器暂停优化.
被 volatile 修饰的变量, 编译器会禁止对其进行优化, 从而保证每次都是从内存中重新读取数据到寄存器.

import java.util.Scanner;public class ThreadDemo4 {public volatile static int flag;public static void main(String[] args) {Thread t1 = new Thread(() -> {while(flag == 0) {}System.out.println("t1循环结束, t1结束!");});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("请输入flag的值 :");flag = scanner.nextInt();});t1.start();t2.start();}
}

在这里插入图片描述
注意 :

volatile 不保证原子性
volatile 保证了内存的可见性(就是每次都从内存中读取被修饰的变量)
volatile 适用场景是一个线程读, 一个线程写的情况.
上面的 synchronized 则适用于多线程写
volatile 还有一个特性: 禁止指令重排序(指令重排序也是编译器的优化策略)

相关内容

热门资讯

linux入门---制作进度条 了解缓冲区 我们首先来看看下面的操作: 我们首先创建了一个文件并在这个文件里面添加了...
C++ 机房预约系统(六):学... 8、 学生模块 8.1 学生子菜单、登录和注销 实现步骤: 在Student.cpp的...
JAVA多线程知识整理 Java多线程基础 线程的创建和启动 继承Thread类来创建并启动 自定义Thread类的子类&#...
【洛谷 P1090】[NOIP... [NOIP2004 提高组] 合并果子 / [USACO06NOV] Fence Repair G ...
国民技术LPUART介绍 低功耗通用异步接收器(LPUART) 简介 低功耗通用异步收发器...
城乡供水一体化平台-助力乡村振... 城乡供水一体化管理系统建设方案 城乡供水一体化管理系统是运用云计算、大数据等信息化手段࿰...
程序的循环结构和random库...   第三个参数就是步长     引入文件时记得指明字符格式,否则读入不了 ...
中国版ChatGPT在哪些方面... 目录 一、中国巨大的市场需求 二、中国企业加速创新 三、中国的人工智能发展 四、企业愿景的推进 五、...
报名开启 | 共赴一场 Flu... 2023 年 1 月 25 日,Flutter Forward 大会在肯尼亚首都内罗毕...
汇编00-MASM 和 Vis... Qt源码解析 索引 汇编逆向--- MASM 和 Visual Studio入门 前提知识ÿ...
【简陋Web应用3】实现人脸比... 文章目录🍉 前情提要🌷 效果演示🥝 实现过程1. u...
前缀和与对数器与二分法 1. 前缀和 假设有一个数组,我们想大量频繁的去访问L到R这个区间的和,...
windows安装JDK步骤 一、 下载JDK安装包 下载地址:https://www.oracle.com/jav...
分治法实现合并排序(归并排序)... 🎊【数据结构与算法】专题正在持续更新中,各种数据结构的创建原理与运用✨...
在linux上安装配置node... 目录前言1,关于nodejs2,配置环境变量3,总结 前言...
Linux学习之端口、网络协议... 端口:设备与外界通讯交流的出口 网络协议:   网络协议是指计算机通信网...
Linux内核进程管理并发同步... 并发同步并发 是指在某一时间段内能够处理多个任务的能力,而 并行 是指同一时间能够处理...
opencv学习-HOG LO... 目录1. HOG(Histogram of Oriented Gradients,方向梯度直方图)1...
EEG微状态的功能意义 导读大脑的瞬时全局功能状态反映在其电场结构上。聚类分析方法一致地提取了四种头表面脑电场结构ÿ...
【Unity 手写PBR】Bu... 写在前面 前期积累: GAMES101作业7提高-实现微表面模型你需要了解的知识 【技...