线程都能带来哪些风险?想了解线程安全问题吗?请仔细阅读本篇文章,将详细带你学习什么是线程安全!!!
目录
一、什么是线程安全和线程不安全?
简单来说,某个代码,在多线程环境下执行,会出bug,就叫做线程不安全。
线程安全:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
本质上是因为线程之间的调度顺序是不确定的,那么我来举例说明一下:
class Counter{ private int count = 0; public void add(){ count++; } public int get(){ return count; } } class ThreadDemo{ 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()); } }
上述代码是俩个线程针对同一个变量,各自自增5w次,那么结果会是什么?
是10w吗?还是其他?下面来看结果:
此时会疑惑???为什么不是正常预期中的结果呢?这是因为由多线程引起的bug,线程不安全问题!!!
那么为什么会出现上述情况呢,和线程的随机调度密切相关!!!
count++操作,本质上是三个cpu指令构成
1.load,把内存中的数据读取到cpu寄存器中
2.add,把寄存器中的值,进行+1运算
3.save,把寄存器的值写回到内存中
初始状态:
由于多线程调度顺序是不确定的,实际执行过程中,这俩线程的++操作实际的指令排列顺序就有很多种可能!!!如下图三种:
那么不同排列顺序下,执行结果,可能是截然不同的!!!
那么上面代码的线程执行顺序可能是:
此时发现,俩个线程自增俩次,结果是1,说明bug出现了,其中一次的自增的结果,被另一次给覆盖了!!!
由于线程的调度顺序是随机的,无序的,出现bug之后,得到的结果一定是<=10w
那么如果要是正确的结果,俩个线程,各自自增,此时是没bug 的,那么执行顺序应该是:
t1和t2是俩个线程,可能是运行在不同的cpu核心上,也可能是运行在同一个cpu核心上(但是是并发的)
那么归根到底,线程安全问题,全是因为线程的无序调度,导致了执行顺序不确定,结果就不一致了!!!
二、线程不安全的原因
三、如何解决线程不安全问题
需要从原因入手,能否让count++变成原子的呢?
那么加锁可以有效的使count++变成原子操作,锁,就能够起到保证“原子性”的效果。
一旦某个线程加锁了之后,其他线程也想加锁,就不能之间加上,就需要一直等到拿到锁的线程释放锁了为止,那么java中如何加锁?
使用synchronized关键字,直接使用这个关键字来实现加锁效果,锁有俩个核心操作,加锁和解锁:使用代码块来表示
进入synchronized修饰的代码块的时候,就会触发加锁,出了synchronized代码块,就会触发解锁。
其中的this表示锁对象,表示对哪个对象加锁,如果俩个线程针对同一个对象加锁,此时就会出现“锁竞争”(一个线程先拿到锁,另一个线程阻塞等待),如果俩个线程针对不同线程加锁,就不会出现锁竞争,各自获取各自的锁
上述代码中,这里线程是在竞争同一个锁对象,就会产生锁竞争,此时就可以保证++操作是原子的,不受影响了
由于t1已经lock了,t2再尝试进行lock,就会出现阻塞等待的情况,此时就可以保证t2的load一定在t1的save之后,此时计算的结果就是线程安全的,加锁本质上是把并发的变成了串行的。
上述代码,一个线程的工作应该是:
1.创建i
2.判断i<50000
3.调用add
4.count++
5.add返回
6.i++
下面是加锁之后的代码:
class Counter{
private int count = 0;
public void add(){
synchronized (this) {
count++;
}
}
public int get(){
return count;
}
}
class ThreadDemo{
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());
}
}
加锁也可以直接给方法加锁,用synchronized修饰,如果synchronized修饰静态方法,就是给类对象加锁
还可以指定一个锁对象进行加锁:
四、内存可见性问题-volatile
先写个bug出来:
public class ThreadDemo1 { public static int flag = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while (flag == 0) { // 空着 // try { // Thread.sleep(10); // } catch (InterruptedException e) { // e.printStackTrace(); // } } System.out.println("循环结束! t1 结束!"); }); Thread t2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.println("请输入一个整数: "); flag = scanner.nextInt(); }); t1.start(); t2.start(); } }
预期效果:t1通过flag ==0 作为条件进行循环,初始情况,将进入循环;t2通过控制台输入了一个整数,一旦用户输入了非0的值,此时t1的循环就会立即结束,从而t1线程退出!!
实际效果:输入非0的值之后,t1线程并没有退出,循环没有结束,仍然在运行!!
实际效果 != 预期效果
那么为什么会有这个问题?内存可见性的锅,
while循环,flag==0,load从内存读取数据到cpu寄存器,cmp比较寄存器里的值是否是0。
此处的load时间开销远远高于cmp!!!
于是编译器就发现:
1.load的开销很大
2.每次load的结果都一样
此时编译器就做了一个非常大胆的操作,把load就给优化掉了,只有第一次执行了load,后续都只cmp,不load!!!---这是编译器优化的手段,此时结果就发生了变化,出现了误判!!!
编译器优化,就是能够智能的调整你的代码执行逻辑,保证程序结果不变的前提下,通过加减语句,通过一系列操作,让整个程序执行的效率大大提升
那么我们为了解决这个问题,加上volatile关键字之后,此时编译器不再优化,此时编译器就能够保证每次都从内存中读取flag变量的值
4.1volatile详解
volatile不保证原子性,volatile适合的场景是一个线程读,一个线程写的情况,synchronized则是多个线程写。
上面讲述了什么叫保证内存可见性,那么volatile还有另外一个效果,就是禁止指令重排序,所谓指令重排序,也是编译器优化的策略,调整了代码的执行的顺序,让程序更高效,前提是保证整体逻辑不会变。
举个例子:
假如小明去超市买菜,按照出口->入口的方式去买
那么如果调整顺序,先买入口的再买出口的,执行效果不变,效率提高了。那么使用volatile就可以禁止指令重排序,大部分情况下对于指令重排序都是正确的,但是加上volatile也什么影响哦!
4.2“房子装修”
如果是这样一个代码:
大体可以分成三部操作:
1.申请内存空间
2.调用构造方法(初始化内存的数据)
3.把对象的引用赋值给s(内存地址的赋值)
这个操作就像是买房子:
先装修了,再拿到钥匙,就叫做“精装修房”;先拿到钥匙,再装修,房子就叫做“毛坯房”
假设俩个线程开始执行,t1按照1 3 2的顺序执行,最后装修,当t1执行完1 3 之后,即将执行2的时候,t2开始执行,由于t1的3已经执行过了,这个引用就非空了!!!
t2线程就尝试调用s.learn(),由于还是个毛坯房,没有初始化过,此时learn就可能会出现bug!!