文章目录
线程安全
🍁一、什么是线程安全问题
首先我们需要明白操作系统中线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时线程的执行顺序是不确定的,有一些代码执行顺序不同不影响程序运行的结果,但也有一些代码执行顺序发生改变了重写的运行结果会受影响,这就造成程序会出现bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题。
🍁二、线程不安全实例
public class ThreadDemo9 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
}
class Counter {
private int count = 0;
public void add() {
count++;
}
public int get() {
return count;
}
}
预期结果:100000
实际结果:
为什么两个线程分别自增5w次,而结果不是10w呢?
🍁三、线程不安全原因以解决办法
🌴1.原子性
🍂1.1 定义
一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性的
多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性
🍂1.2 不安全的原因
counter.add() 我们可以把这句话拆分成3个操作:
- load 从内存中读取值到寄存器
- add 进行自增操作
- save 从寄存器写回内存
由于线程执行的随机性,这三步操作可能存在交叉执行(一个正在add,一个正在load)
如果我们通过代码让三步操作固定在一起,就能解决问题了
🍂1.3 synchronized关键词
你在房间中使用ATM时,你把门锁住,其他人就不能进来
你使用完房间后,解锁,这个时候其他人都能进入房间
在java中最常用的加锁操作就是使用synchronized
关键字进行加锁
🍂1.4 synchronized特性
互斥
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
- 没有抢到锁的线程阻塞等待,参与下一次的锁竞争
- 锁竞争
- 两个线程竞争同一把锁, 才会产生阻塞等待
- 两个线程竞争不同把锁, 不会产生阻塞等待
刷新内存
synchronized的工作过程:
- 获得互斥锁
- 从主存拷贝最新的变量到工作内存
- 对变量执行操作
- 将修改后的共享变量的值刷新到主存
- 释放互斥锁
可重入
synchronized是可重入锁
同一个线程可以多次申请成功一个对象锁
某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。
🍂1.5 synchronized使用
- 修饰普通方法
public class SynchronizedDemo {
synchronized public void methond() { }
}
- 修饰代码块
public class SynchronizedDemo {
public void method() {
synchronized (this) { }
}
}
这里的this可以换成任意一个Object类对象,效果和修饰普通效果一样
- 修饰静态方法
public class SynchronizedDemo {
synchronized public static void method() { }
}
- 修饰代码块
public class SynchronizedDemo {
public void method() { synchronized (SynchronizedDemo.class) { }
}
}
- 锁对象
谁调用被synchronized修饰的方法或者类,谁就是锁对象
这里的counter都在调用add方法,counter就是锁对象
🍂1.6 修改示例
public void add() {
synchronized(this){
count++;
}
}
保证counter.add()中的三步操作同时执行,最终结果才能正确。
join与加锁的区别:
- join:让某一个线程完整执行完,完全串行,效率低
- 加锁:某一部分串行,其他是并行
- 保证线程安全的前提下,让效率更高。
🌽2.内存可见性
🍬 1.1 示例
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
// 空着
}
System.out.println("循环结束! t1 结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
通过执行结果可以看到,我们输入一个数字后,程序并不会结束。
🍬 1.2 不安全的原因
该程序执行的主要两个步骤
- load 从内存中读取数据到寄存器
- cmp 比较寄存器的值是否是0
由于while循环体为空,执行速度很快,远远超过比较的速度,这样就导致每次读出来的值都是0,所以编译器就主动进行优化,认为load读出来只会是0,就导致只会进行一次读数据,后面一直进行比较。这也是为什么即使我们输入了非0的数,也不能停止程序
编译器优化
在保证程序结果不变的前提下(多线程不一定),通过加减语句等操作让程序效率提升。
要想解决这个问题,我们只需要让编译器不主动的优化
🍬 1.3 volatile关键词
让编译器不优化其修饰的变量,每次都从内存重新读取
- volatile不能保证原子性
- 适用一个线程读、一个线程写。
工作内存:寄存器+缓存
拓展:
-
CPU缓存:读取速度介于读寄存器和内存之间
-
cpu读数据顺序:寄存器=》缓存1=》缓存2=》缓存3=》内存
🍬 1.4 修改示例
public class ThreadDemo14 {
volatile public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
// 空着
}
System.out.println("循环结束! t1 结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
🌾3 指令重排序
🌳1.1 作用
指令重排序:在保证整体逻辑不变的前提下,调整代码执行顺序,提升效率。
🌳1.2 示例
调整后:
🍁总结
线程不安全的原因:
🎈线程是抢占式的执行,线程间的调度充满了随机性
🎈多个线程对同一个变量进行修改操作
🎈对变量的操作不是原子性的
🎈内存可见性导致的线程安全
🎈指令重排序也会影响线程安全