Java【多线程基础3】导致线程不安全的 4 种原因及解决方式

这篇具有很好参考价值的文章主要介绍了Java【多线程基础3】导致线程不安全的 4 种原因及解决方式。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。


前言

📕各位读者好, 我是小陈, 这是我的个人主页
📗小陈还在持续努力学习编程, 努力通过博客输出所学知识
📘如果本篇对你有帮助, 烦请点赞关注支持一波, 感激不尽
📙 希望我的专栏能够帮助到你:
JavaSE基础: 基础语法, 类和对象, 封装继承多态, 接口, 综合小练习图书管理系统等
Java数据结构: 顺序表, 链表, 堆, 二叉树, 二叉搜索树, 哈希表等
JavaEE初阶: 多线程, 网络编程, TCP/IP协议, HTTP协议, Tomcat, Servlet, Linux, JVM等(正在持续更新)

上篇[多线程基础2]主要介绍了 : Thread类 的构造方法, 常用成员属性, 常用成员方法以及多线程的状态, 状态转换
本篇继续介绍多线程相关的基础内容, 内容较多, 分为若干篇持续分享


提示:是正在努力进步的小菜鸟一只,如有大佬发现文章欠佳之处欢迎批评指点~ 废话不多说,直接上干货!

一、线程不安全的原因

如何定义线程是否安全?

如果多线程环境的代码执行结果, 和单线程环境的代码执行结果一致, 则认为线程是安全的, 否则认为线程不安全

下面介绍线程不安全的几个原因


1, 多线程调度的随机性(抢占式执行)

这是导致多线程环境下 线程不安全 的最根本原因

由于多个线程是 “抢占式执行的” , 所以造成了多线程调度的随机性, 无序性

多线程在 CPU 上并发执行, 而 CPU 只能看懂二进制的指令, 所以多线程调度时的随机性, 无序性, 就有可能造成这些指令的混乱

所以多个线程互相影响起来, 也是无迹可寻的


2, 原子性

原子性是指 : 不可分割的最小单位, CPU 执行的一条指令, 就是满足原子性的

然而一行 Java 代码(即便很简单易懂), 也不一定满足原子性, 因为这一行代码可能分为很多条指令

如果不满足原子性, 在多线程环境下, CPU 正在执行线程 A 的代码对应的指令, 此时另一个线程过来插了一脚, CPU 去执行线程 B 的代码对应的指令, 整个程序就有可能发生错误


3, 内存可见性

可见性指 : 一个线程对共用变量的修改, 能够及时地被其他线程看到

如果不满足内存可见性, 在多线程环境下, 线程 A 修改了某个共用变量的值, 线程 B 看不到这这共用变量被修改了, 还在使用修改前的值, 程序就有可能发生错误

哎? 为啥线程 A 修改了共用变量, 线程 B 不能及时看到呢? 这就要谈谈 Java 内存模型


3.1, Java 内存模型(JMM)

为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁

线程之间的共用变量存在 主内存 (Main Memory)
每一个线程都有自己的 “工作内存” (Working Memory)
当线程要读取一个共用变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据
当线程要修改一个共用变量的时候, 也会先修改工作内存中的副本, 再同步回主内存

这里的主内存才是平常说的内存, 工作内存 其实是 寄存器 高速缓存

从寄存器中读取数据, 比从内存中读取数据快了 3 ~ 4 个数量级( 1k ~ 1w 倍), 而从内存中读取数据, 比从硬盘中读取数据快了 3 ~ 4 个数量级( 1k ~ 1w 倍),

所以读取速度 : 寄存器 >> 内存 >> 硬盘 (高速缓存的速度介于寄存器和内存之间)

不满足内存可见性的情况 :

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的, 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了, 效率就大大提高了

此时修改线程A 的工作内存中的值, 线程B 的工作内存不一定会及时变化

为什么Java官方要把 寄存器和高速缓存, 定义成一个新的术语"工作内存"?
因为早期的CPU中是没有高速缓存的, 并且由于Java的可移植性, 为了应付不同电脑上的硬件软件差异, 保证文档的规范性, 适用性, 定义了"工作内存"


4, 指令重排序

例如 : 如下有四条指令
1, 我从宿舍出发
2, 要去食堂吃饭
3, 要去快递站拿快递
4, 要帮舍友带饭

按照1 --> 2 --> 3 --> 4 的顺序执行, 我的路线是这样的 :
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁

把 3 , 4 互换位置后, 按照1 --> 2 --> 4 --> 3 的顺序执行, 我的路线是这样的 :
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
保证逻辑不变 的前提下, 更改多条指令的顺序, 从而提高程序执行效率, 这就是指令重排序

有些指令重排序能够提高执行效率 有时不能

这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价, 这是指令重排序可能造成的弊端

并且重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论, 了解即可

下面通过实际代码简单展示一下线程不安全的情况


二、示例1

1, 代码示例

首先定义一个 Counter类

class Counter {
    private int number = 0;
    public void add(){
    	number++;
    }
	public int getCount(){
        return number;
    }
}

我们创建两个线程,两个线程都调用 add方法 5k 次,两个线程结束后主线程中获取 number 的值

public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread( () -> {
            for (int i = 0; i < 5000; i++) {
                counter.add();
            }

        });
        Thread thread2 = new Thread( () -> {
            for (int i = 0; i < 5000; i++) {
                counter.add();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.getCount());
    }

预期结果 : 最终number的值是1w,来看运行结果
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
实际结果 : 并不是 1w, 而是小于1w, 并且多次运行的执行结果都不一样
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁


2, 原因分析

原因就在于 add方法 调用后的 number++ 这个操作并不是原子的
这一行代码看似简单易懂,实际上是三条 CPU 指令 :
1, load 从内存中读取 number 的值, 到寄存器中
2, add 修改寄存器中 number 的值, 把 number + 1
3, save 把寄存器中的值写回到内存中
其实就是上述的 Java内存模型 的机制

两个线程并发执行时, 如果是 理想情况 :
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
这三条指令没有相互交错, 就不会对最后的值产生影响, 这是 理性情况

对应的, 如果 thread1线程 在 CPU 上执行到 load 指令时, 读到 number 的值为 1 , 本该继续执行后两条指令, 但是突然被 thread2线程 抢占执行了, CPU 开始执行thread2线程 的三条指令, 就是 非理想情况 :
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
只要是 number++ 时, 两个线程发生了 “抢占式” 执行, 导致了有一次修改无效(被覆盖), 就是 非理性情况

正是因为 number++ 这个操作不是原子的, 所以才会在 “抢占式” 执行时产生问题, 可是, 只要是多线程环境, 就无法改变 “抢占式” 执行这一机制, 那有没有一种可能, 我们把 number++ 变成原子性的呢?

当然可以, 就是通过 “加锁” 来实现 : 使用 snychronized 关键字


3, 解决方法 : synchronized 关键字

只需要在 Counter类 中的 add方法上, 加一个 synchronized关键字

    synchronized public void add(){
            number++;
    }

来看运行结果 :
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
符合上面示例的预期结果

synchronized关键字 最主要的特性就是 : 互斥
例如 : 线程 A 执行到对象 Counter 的 synchronized 修饰的代码块中时, 线程 B 如果也同时执行到对象 Counter 的 synchronized 修饰的代码块, 线程 B 就会阻塞等待
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

其实很好理解 :
张三去上厕所, 进去之后锁门 (加锁) , 此时李四也想上厕所, 他就得在门口憋着, 等张三出来 (解锁) 之后, 才能进去厕所, 锁门 (加锁)
可如果在张三还没出来的时候, 李四和王五都想上厕所, 那么等张三出来 (解锁) 之后, 李四和王五就要抢这个厕所(锁竞争), 谁抢到谁就进去厕所, 锁门(加锁) ,

李四和王五 的过程就是多线程的抢占式执行

厕所, 其实就是一个锁对象

“锁” 这个话题很丰富, 以后还会详细介绍


三、示例2

1, 代码示例

定义一个成员属性 n , 初始化为 0 , 再创建两个线程
在第一个线程中, 如果 n 为 0 , 一直循环, 没有循环体, 在第二个线程中, 从控制台输入整数, 赋值给 n

	public static int n = 0;
    public static void main(String[] args) {
        Thread thread1 = new Thread( () -> {
            while (n == 0) {

            }
            System.out.println(" n 不是 0 了, 循环结束");
        });
        Thread thread2 = new Thread( () -> {
            Scanner scanner = new Scanner(System.in);
            n = scanner.nextInt();
        });
        System.out.print("请输入一个整数 : ");
        thread1.start();
        thread2.start();

预期结果 : 如果输入的 n 不是 0 , thread1 就会退出循环, 执行打印语句
为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁

实际结果 : 并没有退出循环, 但 n 的值确实被修改了


2, 原因分析

既然 n 的值确实被修改了, 那么 thread1线程 中的循环没有退出的原因只能是, thread1线程 没有读取到修改过的 n 的值, 而是一直读取原本的 n 的值

因为在 while 循环中, 没有循环体, 在整个循环中只有两条指令
1, load : 从内存中读取 n 的值到寄存器
2, 从寄存器中读取 n , 比较 n 的值和 0 是否相同

注意, n 的值在内存中被修改是 thread2 里干的事, 和 thread1 线程无关

由于从寄存器中读取数据, 比从内存中读取数据快了 3 ~ 4 个数量级( 1k ~ 1w 倍), 所以对于这一整个循环来说, 执行 1次 指令1, 就可以执行 1k ~ 1w次 指令2

那么从内存中读取 n 的值就成了 “负担” , 所以编译器就进行了优化, 直接省去了指令1, 那么 thread1线程 中, n 的值就永远为 0

这就导致了上述实例中, 对于 n 这个共用变量, 在 thread1线程 和 thread2线程 中不满足 内存可见性, 如何解决这个问题呢? 使用 volatile关键字


3, 解决方法 : volatile 关键字

只需要在共用变量 n 之前加上 volatile关键字 即可

    volatile  public static int n = 0;

volatile 修饰的变量, 能够保证 内存可见性, 能够 强制执行内存和寄存器之间的读写指令, 虽然导致速度慢了, 但是数据变的更准确了

volatile 还可以禁止指令重排序

但是 volatile 不保证原子性, 如果把 示例1 中的 synchronized 关键字 改成 volatile 关键字, 最终执行结果仍然不符合预期

所以, volatile 关键字 适合于一个线程读, 一个线程写的情况


六、wait 和 notify

有些场景下, 可能一个线程需要等另一个线程结束以后再执行, 可以用 join方法
但有些时候, 可能一个线程需要等另一个线程执行一会再执行, 这种情况下就不能用 join方法 了, 需要使用 wait方法 搭配 notify方法

线程 A 调用 wait方法 是让线程 A 进入 WAITING 阻塞状态, 不再继续执行, 由其他线程(比如线程 B)调用 notify方法 来唤醒正在 WAITING 的线程 A

wait 和 notify 都是 Object类 的方法, 并且要写在 synchronized 代码块中

wait 方法, join方法, sleep方法, 这些能造成线程进入堵塞状态的方法都需要用 try-catch 处理 InterruptedException异常

    public static void main(String[] args) {
        Object object = new Object();
        Thread thread = new Thread( () -> {
            synchronized (object) {
                System.out.println("wait 开始");
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait 结束");
            }
        });
        thread.start();
    }

为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
可以看到运行结果: wait并没有结束, 说明 thread线程 正在堵塞

wait方法调用后, 有三步要走 :
1, 释放当前的锁
2, 进入WAITING 状态, 阻塞等待(等待被 notify 唤醒)
3, 被 notify 唤醒了, 尝试重新获取锁, 继续执行未执行完的代码

如果再创建一个线程 thread2, 在 thread2线程 中调用 notify方法, 就可以唤醒 thread线程中正在堵塞等待的 wait方法

注意, notify 方法也要写在 synchronized 代码块中, 并且锁对象必须和 wait方法 的锁对象一致, 否则无法唤醒 wait方法

        Thread thread2 = new Thread( () -> {
            synchronized (object) {
                System.out.println("notify 开始");
                object.notify();
                System.out.println("notify 结束");
            }
        });
        thread2.start();

为什么会发生非线程安全问题,JavaEE初阶,java,jvm,线程安全,锁
由于 notify方法 和 wait方法的锁对象一致, 锁对象一致, 就会产生锁竞争, 所以 notify方法 结束后, 唤醒 wait方法 的阻塞等待状态, 但 wait方法 需要等 notify方法 把锁释放, 才能重新获取锁, 所以 wait方法 又多了一个等待 notify方法 释放锁的过程

综上, wait方法 总共堵塞了两次, 但本质不同 :
1, 第一次, wait方法 开始后, 堵塞的目的是 : 等待被唤醒
2, 第二次, 其他线程的 notify方法 结束后, 堵塞的目的是 : 等待锁释放后拿到锁

如果没有使用 wait方法 就使用 notify方法 , 只能认为是 : 唤醒了个寂寞, 没有任何效果, 也不会报错

wait方法 也可以设置一个参数, 表示等待多久, 如果超出这个限制还没有被唤醒, 就自动被唤醒, 相当于自己给自己定了个闹钟

wait方法 和 sleep方法 的区别 ?
相同点在于 :
1, 都能让线程等一会, 并且设定时间上限
2, 都能提前被唤醒
但两个方法使用的场景和目的就有本质不同 :
1, wait方法 是为了在多线程环境下, 协调线程之间的执行顺序, 而 sleep方法 只是单纯的让线程休眠
2, wait方法 需要搭配锁使用, sleep方法 不需要


总结

以上就是本篇的全部内容, 主要介绍了
多线程环境下线程不安全的原因和解决方案, 简单介绍了 synchronized关键字 和 volatile 关键字, 以及 wait 和 notify 方法的搭配使用

如果本篇对你有帮助,请点赞收藏支持一下,小手一抖就是对作者莫大的鼓励啦😋😋😋~


上山总比下山辛苦
下篇文章见
文章来源地址https://www.toymoban.com/news/detail-785960.html

到了这里,关于Java【多线程基础3】导致线程不安全的 4 种原因及解决方式的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 浏览器同源策略导致跨域问题 No ‘Access-Control-Allow-Origin‘ header 原因及解决方式--(后端、nginx、前端)

    目录 现象 原因 浏览器同源策略 导致结果: 解决方案 跨源资源共享(CORS) 各个端解决方法: 后端: 方式1:重载WebMvcConfigurer方法 方式2:配置监听CorsFilter 方式3:相关类上加注解 @CrossOrigin 注意事项: Nginx解决: 情况1: 前端解决: 本人身份:后端 今天部署线上环境前端

    2024年01月23日
    浏览(41)
  • java基础之线程安全问题以及线程安全集合类

    当多个线程同时访问同一个临界资源时,原子操作可能被破坏,会导致数据丢失, 就会触发线程安全问题 临界资源: 被多个线程同时访问的对象 原子操作: 线程访问临界资源的过程中不可更改和缺失的操作 互斥锁 每个对象都默认拥有互斥锁, 该锁默认不开启. 当开启互斥锁之后

    2024年01月18日
    浏览(37)
  • Java多线程 - 线程安全和线程同步解决线程安全问题

    线程安全问题指的是: 多个线程同时操作同一个共享资源的时候可能会出现业务安全问题,称为线程安全问题。 举例: 取钱模型演示 需求:小明和小红是一对夫妻,他们有一个共同的账户,余额是10万元。 如果小明和小红同时来取钱,而且2人都要取钱10万元,可能出现什么问

    2023年04月15日
    浏览(31)
  • ArrayList为什么不是线程安全的,如何保证线程安全?

    官方曰, 线程安全就是多线程访问时,采⽤了加锁机制,当⼀个线程访问该类的某个数据时,进⾏保护,其他线程不能进⾏访问直到该线程读取完,其他线程才可使⽤。不会出现数据不⼀致或者数据污染。线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数

    2024年02月07日
    浏览(50)
  • Java多线程基础-8:单例模式及其线程安全问题

    单例模式是经典的设计模式之一。什么是设计模式?代码的设计模式类似于棋谱,棋谱就是一些下棋的固定套路,是前人总结出来的一些固定的打法。依照棋谱来下棋,不说能下得非常好,但至少是有迹可循,不会下得很糟糕。代码的设计模式也是一样。 设计模式,就是软件

    2024年02月05日
    浏览(40)
  • 面试题:HashMap线程不安全 ConcurrentHashMap为什么线程安全

    面试的时候先会喊你说说集合,那些集合线程不安全?当你说了 HashMap 线程不安全,面试官可能会进一步询问你是否了解 ConcurrentHashMap ,以及它是如何实现线程安全的。 ArrayList、LinkedList、TreeSet、HashSet、 HashMap 、TreeMap等都是线程不安全的。 HashTable 是线程安全的。 来看个例

    2024年04月23日
    浏览(33)
  • 【多线程基础】 线程安全及解决方案(看这一篇就够了)

    🎉🎉🎉 点进来你就是我的人了 博主主页: 🙈🙈🙈戳一戳,欢迎大佬指点! 欢迎志同道合的朋友一起加油喔 🦾🦾🦾 目录 前言 1. 造成线程不安全的原因有哪些呢? 1.1什么是原子性 1.2什么是内存可见性 1.3共享变量可见性实现的原理  1.4 什么是指令重排序 2.解决线程安全

    2024年02月02日
    浏览(36)
  • java线程安全问题及解决

    当我们使用多个线程访问 同一资源 (可以是同一个变量、同一个文件、同一条记录等)的时候,若多个线程 只有读操作 ,那么不会发生线程安全问题。但是如果多个线程中对资源有 读和写 的操作,就容易出现线程安全问题。 案例: 火车站要卖票,我们模拟火车站的卖票

    2024年02月15日
    浏览(29)
  • 为什么arrayList线程不安全?

            ArrayList是Java中的一种动态数组,它在内部使用数组来存储元素。ArrayList的线程不安全性主要体现在多线程并发访问和修改同一个ArrayList实例时可能出现的问题。         当多个线程同时对ArrayList进行修改操作时,可能会导致数据不一致或者出现异常。这是因为

    2024年02月12日
    浏览(39)
  • Redis——关于它为什么快?使用场景?以及使用方式?为何引入多线程?

    目录 1.既然redis那么快,为什么不用它做主数据库,只用它做缓存? 2.Redis 一般在什么场合下使用?  3.redis为什么这么快? 4.Redis为什么要引入了多线程? redis设计者的初衷,就只是为了存储 小量级的共享数据 。 所以,他敢直接单线程直接干上去,因为数据量小,所以够快

    2024年01月25日
    浏览(38)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包