本篇学习线程中的安全问题
文章目录
线程安全
什么是线程安全问题
线程安全问题是指在多线程环境下,共享资源被多个线程同时访问时可能出现的数据不一致或不正确的情况。
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
多线程安全的原因
不安全的原因:
- 抢占式执行
- 多个线程修改同一个变量
一个线程修改同一个变量;多个线程读取同一个变量;多个线程修改不同的变量=这些操作都是安全的 - 修改操作,不是原子的(不可分割的最小单位,就是原子)
- 内存可见性,引起的线程不安全
- 指令重排序,引起的线程不安全
本质上是线程之间的调度顺序是不确定的,线程安全问题,全是因为,线程的无序调度,导致执行顺序不确定,结果就变化了
例如count++操作,
1.load ,把内存中的数据读取到cpu寄存器中
2.add,把寄存器中的值,进行+1计算
3.save,把寄存器中的值写回到内存中
指令原子性
什么是原子性
原子性(Atomicity)是指一个操作要么全部执行成功,要么完全不执行,不能出现部分执行的情况。
原子性是确保多线程环境下数据操作的一致性和正确性的重要概念。如果一个操作具有原子性,那么在多个线程同时对共享资源进行读写时,不会出现竞态条件和数据不一致的问题。
并且原子操作是一种不可中断的操作。
原子性被破坏导致的问题
-
竞态条件(Race Condition):当多个线程同时访问和修改同一个共享资源时,由于缺乏原子性,可能出现竞态条件。竞态条件会导致不可预测的结果,破坏程序的正确性和一致性。
-
数据不一致:如果一个操作被中断了,而且没有使用原子操作来保证其原子性,可能会导致共享资源的数据不一致。例如,在多线程环境下,一个线程进行读取操作时,另一个线程正在进行写入操作,如果读写操作不是原子的,可能导致读取到不正确或不完整的数据。
-
死锁:当多个线程在竞争有限的资源时,由于缺乏原子性,可能导致死锁情况的发生。死锁是指多个线程相互等待对方释放资源,导致程序无法继续执行的情况。
-
脏数据:缺乏原子性可能导致脏数据的问题。脏数据是指处于中间状态或无效状态的数据,对于其他线程来说是不可用的或不正确的。
-
性能下降:缺乏原子性会导致线程频繁地争夺共享资源,可能导致性能下降。由于没有原子操作的保证,需要使用额外的同步机制或锁来保护共享资源,增加了线程之间的竞争和开销。
由内存可见性,引起的线程不安全
什么是内存可见性
这个操作是:
load,从内存读取数据到cpu寄存器中
cmp,比较寄存器里的值是否为0
在操作时,load操作的时间要远远大于cmp的时间(load是读内存(主内存),cmp是读寄存器(工作内存),cmp快)
当编译器就发现load开销很大,同时load每次结果都相同,编译器就会把load的给优化掉了,只有第一次的load真正执行了,后续操作都是cmp,不load,相当于复用之前寄存器中load过的值,就会造成编译器的优化。
编译器的优化:是能够智能的调整你的代码逻辑,保证程序结果不便的前提下,通过加减语句,通过语句变换,通过一系列操作,让整个程序执行的效率大大提升。但是,编译器对程序结果不变的单线程判定十分准确,但是在多线程中,可能导致,调整之后,效率提高了,但是结果变了(编译器出现了误判)
所谓内存可见性,就是在多线程环境下,编译器对于代码优化,产生了误判,从而导致出现bug
没有内存可见性的问题
-
数据不一致:当一个线程修改了共享变量的值,其他线程可能无法立即看到这个修改,导致数据不一致的情况发生。例如,一个线程对变量进行了更新,但另一个线程在读取该变量时仍然看到旧的数值,这会导致程序逻辑错误。
-
无限循环和死锁:如果一个线程在自己的本地缓存中看不到其他线程对共享变量的修改,那么它可能会进入一个无限循环或死锁状态。这是因为该线程可能一直在等待变量的值改变,而实际上其他线程已经修改了这个变量的值。
-
无序和乱序执行:由于缺乏内存可见性,指令重排和编译器优化可能会导致线程中的操作发生乱序执行,即顺序不可预测。这样可能会导致出现意外的结果或错误。
-
非原子操作的问题:如果多个线程同时对一个共享变量进行非原子操作,例如递增或递减操作,缺乏内存可见性可能导致竞态条件和计算结果错误。
解决办法
解决办法:暂停优化(volatile关键字,被volatile修饰的变量,此时编译器就会进制优化,保证每次都是从内存读取数据)
加上volatile关键字之后,此时编译器就能够保证每次都是从内存读取flag变量的值
指令重排序
什么是指令重排序
指令重排序(Instruction Reordering)是指在计算机中,编译器或处理器为了优化程序性能而对指令的执行顺序进行重新排列的过程。
为什么要指令重排序
指令重排序是为了提高程序的执行效率和性能。通过对指令的执行顺序进行调整和优化,可以使计算机系统更好地利用硬件资源,减少指令的等待时间,并发挥处理器的并行处理能力。
-
提高指令级别并行性:处理器可以对独立的指令进行并行执行,从而加快程序的执行速度。指令重排序可以使处理器在不改变程序语义的前提下,找到更多的指令之间的并行关系,提高指令级别并行性。
-
减少指令相关性延迟:指令之间可能存在依赖关系,需要等待前一条指令执行完成后才能执行后面的指令。通过重排序可以尽量减少指令之间的相关性延迟,充分利用处理器资源,提高执行效率。
-
优化内存访问延迟:指令重排序可以调整指令的执行顺序,使得CPU更好地预测和缓存数据,减少内存访问的延迟。例如,将后续使用的数据预先加载到缓存中,以便更快地访问。
-
提高流水线利用率:现代处理器采用了流水线技术,将指令的执行过程划分为多个阶段,使得多条指令可以同时在不同阶段进行处理。指令重排序可以优化流水线的利用率,避免流水线的空闲等待,提高整体性能。
虽然指令重排序可以提高程序执行效率,但需要保证程序的语义正确性。
指令重排序的基本底线是什么
指令重排序的基本底线是保证程序的语义正确性。虽然指令重排序可以提高程序的执行效率和性能,但在进行指令重排序时必须满足以下基本底线:
-
单线程语义一致性:指令重排序不能改变单线程程序的执行结果。对于单线程的程序,其输出结果必须与没有进行任何指令重排序时的结果一致。
-
程序顺序一致性:在多线程并发执行的情况下,指令重排序也需要满足程序顺序一致性。即,程序的执行结果必须与一个按照程序编写顺序排列的串行执行的结果一致。
-
内存可见性保证:指令重排序不能改变多线程程序中共享变量的值的可见性。即,一个线程修改共享变量的值之后,其他线程必须能够看到该修改,而不是看到修改之前的旧值。
指令重排序在多线程场景下会引入哪些问题
在多线程场景下,指令重排序可能引入以下问题:
-
数据竞争:指令重排序可能会导致数据竞争的问题。数据竞争发生在多个线程同时访问共享数据,并且其中至少一个线程进行了写操作。如果指令重排序不正确地改变了读和写操作的顺序,可能使得程序的执行结果出现错误。
-
可见性问题:指令重排序可能会导致可见性问题。当一个线程修改了共享变量的值,其他线程需要能够看到该修改后的值。如果指令重排序改变了读写操作的顺序,可能导致其他线程看到修改之前的旧值,从而引发不一致的结果。
-
依赖关系错误:一些指令之间可能存在依赖关系,即后面的指令依赖于前面指令的执行结果。如果指令重排序不正确地改变了两条相关指令的执行顺序,可能导致程序逻辑出错,产生错误的结果。
-
死锁和饥饿:指令重排序可能会导致死锁或饥饿现象。当多个线程同时尝试获取相同的资源或锁时,如果指令重排序引入了错误的执行顺序,可能导致线程之间出现死锁或某些线程一直无法获得资源,进而导致饥饿问题。
指令重排序的过程
指令重排序
new触发指令重排序:
1.创建内存
2.调用构造方法
3.把内存地址,赋给引用
在创建时,2、3顺序可以颠倒
解决线程安全的方法
- 线程之间不能数据共享
- 共享的数据不会被修改
- 通过机制保护线程安全
保证线程安全的机制
解决办法就是锁操作
锁的核心操作有两个:
1.加锁
2.解锁
一旦某个线程加锁之后,其他线程也想加锁,就需要阻塞等待,直到拿到锁的进程释放锁了为止
synchronized
synchronized 是Java中的一种关键字,用于实现线程之间的同步。
锁的线程调度式抢占式执行的!!!
synchronized的特征
-
互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
所以sunchronized是互斥锁 -
刷新内存
synchronized 的工作过程:
获得互斥锁
从主内存拷贝变量的最新副本到工作的内存
执行代码
将更改后的共享变量的值刷新到主内存
释放互斥锁
所以 synchronized 也能保证内存可见性.
- 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
代码示例
使用代码块
synchronized (this) this这里表示的是锁对象(在针对哪个对象加锁),如果两个线程,针对同一个对象加锁,此时就会出现“锁竞争”(一个线程先拿到锁,另一个对象就会阻塞等待),如果两个线程,针对不同对象加锁,此时就不会存在锁竞争,各自获取各自的锁即可,()里的锁对象,可以写作任意一个Object对象(内置类型不行,基本数据类型)
此处写了this,就相当于 ounter counter = new Counter();
synchronized public void add(){//效果是相同的都是代码块加锁
count++;
}//如果之间给方法使用synchronized修饰,此时就相当于以this为锁对象
如果synchronized修饰的是静态方法(staic),此时就不是给this加锁了,而是给类对象加锁
更常见的是手动指定一个锁对象
volatile 关键字
volatile 能保证内存可见性
volatile 修饰的变量, 能够保证 “内存可见性”。
代码在写入 volatile 修饰的变量的时候。
改变线程工作内存中volatile变量副本的值。
将改变后的副本的值从工作内存刷新到主内存。
代码在读取 volatile 修饰的变量的时候。
从主内存中读取volatile变量的最新值到线程的工作内存中。
从工作内存中读取volatile变量的副本。
注意:
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.。
volatile 关键字的作用
volatile不保证原子性。
volatile适用的场景,是一个线程读,一个线程写的情况(保证内存可见性);
禁止指令重排序(指令重排序,也是编译器优化的策略,调整了代码执行的顺序,让程序更高效,前提也是保证整体逻辑不变。
synchronized则是多个线程写操作。
wait 和 notify
wait就是让某个线程先暂停下来等一等
notify就是把该线程唤醒能够继续执行
wait 和 notify是Object,只要是类对象(不是基本数据类型)都可以使用wait notify
wait操作常出现的错误:
IllegalMonitorStateException:非法的锁状态异常
wait主要做三件事情:
1.解锁(必须先加锁才可以解锁)
2.阻塞等待
3.当收通知的时候,就唤醒,同时尝试重新获取锁~~
在wait应用时,必须先进行加锁操作synchronized,同时加锁对象必须和wait的对象是同一个!!!(wait必须放在synchronized中使用)
ontify也要放在synchronized中使用)
唤醒 操作
notifyAll 可以有多个线程,等待同一个对象
比如在t1,t2,t3中都调用object.wait
此时如果在main方法中调用object.notify,会随机唤醒上述的一个线程(另外两个仍是waiting状态)
如果是调用object.notifyAll,此时就会把上述三个线程都唤醒~~,此时这三个线程就会重新竞争锁,然后依次执行
wait和sleep的区别对比:wait有有个带参数的版本,用来体现超时时间
他俩初心不同:wait解决的是线程之间的顺序控制,sleep单纯是让当前线程休眠一会
实现/使用上也有明显的区别:wait要搭配锁使用。sleep则不需要
代码示例:
public class ThreadDemo16 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(()->{
while (true){
try {
System.out.println("wait 开始");
synchronized (object){
object.wait();
}
System.out.println("wait 结束");
}catch (InterruptedException e){
e.printStackTrace();
}
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(()->{
synchronized (object){
System.out.println("notify 开始");
object.notify();
System.out.println("notify 结束");
}
});
t2.start();
}
}
线程饿死~
wait:发现条件不满足/时机不成熟,就会阻塞等待
notify:其他线程构造了一个成熟的条件,就会唤醒阻塞的线程。
从而线程饿死。