关于线程安全问题
创始人
2025-05-30 03:52:31
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 还有一个特性: 禁止指令重排序(指令重排序也是编译器的优化策略)

相关内容

热门资讯

前端-session、jwt 目录:   (1)session (2&#x...
linux入门---制作进度条 了解缓冲区 我们首先来看看下面的操作: 我们首先创建了一个文件并在这个文件里面添加了...
关于测试,我发现了哪些新大陆 关于测试 平常也只是听说过一些关于测试的术语,但并没有使用过测试工具。偶然看到编程老师...
前缀和与对数器与二分法 1. 前缀和 假设有一个数组,我们想大量频繁的去访问L到R这个区间的和,...
nodejs:本地安装nvm实... 一、背景-使用不同版本node的原因 vue3+ts、nuxt3版本,node...
JAVA集合知识整理 Java集合知识整理 HashMap相关 HashMap的底层数据结构:jdk1.8之...
无刷直流电机介绍及单片机控制实... 无刷直流电机介绍及单片机控制实例前言基本概念优势与劣势使用寿命基本结构使用单片机控制实例电子调速器&...
fwdiary(2) dp2 1.传纸条  AcWing 275. 传纸条 - AcWing 走两条路,走一条最大的...
常用的DOS命令 常用的DOS命令 DOS(Disk Operating System,磁...
<C++> 类和对象(下) 1.const成员函数将const修饰的“成员函数”称之为const成员函数,cons...