[Java多线程] 线程安全问题1

前言:

小亭子正在努力的学习编程,接下来将开启javaEE的学习~~

分享的文章都是学习的笔记和感悟,如有不妥之处希望大佬们批评指正~~

同时如果本文对你有帮助的话,烦请点赞关注支持一波, 感激不尽~~

目录

什么是线程安全?

线程不安全的原因

线程安全的解决方法

线程安全问题的解决方案 ——-synchronuzed  关键字

线程安全问题的解决方案——volatile 关键字

volatile 和 synchronized 二者的异同:

线程安全问题的解决方案——wait 和 notify

wait()方法

notify()方法

notifyAll()方法

wait方法 和 sleep方法 的对比?


什么是线程安全?

【举个线程不安全的栗子:】


线程不安全的原因

1. 线程是抡占式执⾏的,这个我们处理不了
2. 多个线程修改了同⼀个变量
3. 执⾏的过程没有保证原⼦性
4. 内存可⻅性问题
5. 有序性问题(指令重排序)

1,多个线程修改同一个变量, 多线程调度的随机性(抢占式执行)

这是导致多线程环境下 线程不安全 的最根本原因,多线程环境下系统对线程的调度是无序的,

随机的,多个线程是 “抢占式执行的” ,谁先谁后执行无法判断,所以会导致线程安全问题

 【补充:多个线程修改多个不同变量,多个线程读取同一个变量,一个线程修改多个变量,这些情况都是安全的】

2, 原子性

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

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

【一条 java 语句不一定是原子的,也不一定只是一条指令
比如刚才我们看到的 n++,其实是由三步操作组成的:
1. 从内存把数据读到 CPU
2. 进行数据更新
3. 把数据写回到 CPU

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

【上面的案例就是典型的一个指令中包含多个操作,执行中被横插一脚】

3, 内存可见性

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

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

【补充知识点:JVM模型(java的内存模型)】

线程之间的共用变量存在 主内存 (Main Memory)
每一个线程都有自己的 “工作内存” (Working Memory)

当线程要读取一个共用变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据
当线程要修改一个共用变量的时候, 也会先修改工作内存中的副本, 再同步回主内存

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

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

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

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

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

此时修改线程A 的工作内存中的值, 线程B 的工作内存不一定会及时变 就会造成线程不安全问题,就比如上面那个例子的第二种情况。
 

4.指令重排序

一段代码是这样的:
1. 去前台取下 U 盘
2. 去教室写 10 分钟作业
3. 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序。

多线程下比较复杂,重排序会涉及到底层和编译原理,暂时不讨论了。


线程安全的解决方法

线程安全问题的解决方案 ——-synchronuzed  关键字

上面案例的代码可以修改为

synchronized public void add(){

number++;

}

synchronized关键字 最主要的特性

(1)互斥
【例如 : 线程 A 执行到对象 Counter 的 synchronized 修饰的代码块中时, 线程 B 如果也同时执行到对象 Counter 的 synchronized 修饰的代码块, 线程 B 就会阻塞等待】
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
synchronized的底层是使用操作系统的mutex lock实现的.
(2) 刷新内存
synchronized 的工作过程:
1. 获得互斥锁
2. 从主内存拷贝变量的最新副本到工作的内存
3. 执行代码
4. 将更改后的共享变量的值刷新到主内存
5. 释放互斥锁
所以 synchronized 也能保证内存可见性
(3) 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
【理解 "把自己锁死":一个线程没有释放锁, 然后又尝试再次加锁。举个栗子就是把家钥匙锁到了家里,然后家门钥匙锁到了车里】
 

线程安全问题的解决方案——volatile 关键字

volatile public static int n = 0;

修饰成员变量,每次被线程访问时,强迫从主存中读写该成员变量的值。

volatile 关键字只能保证可见性,不能保证原子性。多个线程同时操作主内存里的同一个变量时,变量数据仍有可能会遭到破坏。

线程执行过程中如果 CPU 一直满载运转,就会默认使用本地内存中的值,而没有空闲读取主存同步数据。
线程执行过程中一旦 CPU 获得空闲,JVM 也会自动同步主存数据,尽可能保证可见性

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

volatile 和 synchronized 二者的异同:

  1.   volatile 关键字用于修饰变量,synchronized 关键字用于修饰方法以及代码块。
  2. volatile 关键字是数据同步的轻量级实现,性能比 synchronized 关键字更好。
  3. volatile 关键字被多线程访问不会发生阻塞,synchronized 关键字可能发生阻塞。
  4. volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
     

线程安全问题的解决方案——wait 和 notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序

【举个栗子:球场上的每个运动员都是独立的 "执行流" , 可以认为是一个 "线程".
而完成一个具体的进攻得分动作, 则需要多个运动员相互配合, 按照一定的顺序执行一定的动作, 线程1 先 "传球" , 线程2 才能 "扣篮".】

完成这个协调工作, 主要涉及到三个方法

wait() / wait(long timeout): 让当前线程进入等待状态.
notify() / notifyAll(): 唤醒在当前对象上等待的线程

注意:

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

2.WaitTask 和 NotifyTask 内部持有同一个 Object locker. WaitTask 和 NotifyTask 要想配合就需要搭配同一个 Object.

wait()方法

wait 做的事情:
使当前执行代码的线程进行等待. (把线程放到等待队列中)
释放当前的锁
满足一定条件时被唤醒, 重新尝试获取这个锁.
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.

wait 结束等待的条件:
其他线程调用该对象的 notify 方法.
wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常

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

notify()方法

notify 方法是唤醒等待的线程.
方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的
其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行
完,也就是退出同步代码块之后才会释放对象锁。

代码示例: 使用notify()方法唤醒线程
创建 WaitTask 类, 对应一个线程, run 内部循环调用 wait.
创建 NotifyTask 类, 对应另一个线程, 在 run 内部调用一次 notify

notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程
【注意:虽然同时唤醒了所有线程,但是这些线程任然存在“竞争锁”,他们的执行顺序还是有先后的】
 

 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();
    }

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

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();

由于 notify方法 和 wait方法的锁对象一致, 锁对象一致, 就会产生锁竞争, 所以 notify方法 结束后, 唤醒 wait方法 的阻塞等待状态, 但 wait方法 需要等 notify方法 把锁释放, 才能重新获取锁, 所以 wait方法 又多了一个等待 notify方法 释放锁的过程

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

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

wait方法 和 sleep方法 的对比?

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间
相同点在于 :
1, 都能让线程等一会, 并且设定时间上限
2, 都能提前被唤醒
但两个方法使用的场景和目的就有本质不同 :
1, wait方法 是为了在多线程环境下, 协调线程之间的执行顺序, 而 sleep方法 只是单纯的让线程休眠
2, wait方法 需要搭配锁(synchronized)使用, sleep方法 不需要

3. wait 是 Object 的方法 sleep 是 Thread 的静态方法
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值