多线程带来的风险-线程安全
目录
3.1.首先就是,我们的抢占式运行和随机性调度造成的,这是根本原因(没办法避免不了)
在前面的学习中,我们知道了线程的出现可以保证我们代码程序的高效运行,提高cpu的使用率。当然在高效运行的同时也会出现问题,
这个问题就是我们今天要学习的多线程带来的风险安全。
为什么会出现线程安全问题?
为什么会出现这种多线程安全呢,其实万恶之源,就是我们多线程抢占式执行,随机调度所导致的。
如果没有多线程,就是一段顺序执行的代码,那么我们的代码是固定的执行结果也是固定的,此时就没有多线程安全问题了。而我们的多线程的出现,会让代码程序陷入抢占式执行,随机性调度的一个情况,此时是没办法保证程序的执行顺序和最终的执行结果的,这个时候咱们的线程的安全问题就出现了(该来的都来了,不该来的可能也会来哈哈哈!!!)
这样的随机性能否被消除了,答案是不能的,在各位前辈学习的时候,帮我们建立了一些解决线程安全问题的方法,后面咱们会详细讲解到!!!
线程安全问题案例!!!
咱们通过下面一段代码,来感受一下线程安全的风险:
package TestDemo;
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class TestDemo4 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(()->{
for(int i = 0; i < 50000;i++){
counter.add();
}
},"thread1");
Thread thread2 = new Thread(()->{
for(int i = 0 ; i < 50000; i++){
counter.add();
}
},"thread2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.count);
}
}
在咱们这段代码中,两个线程各自对count属性自增50000次,我们的预期结果应该是100000,但是我们执行的实际结果是82779,这个地方就出现了我们所说的线程安全问题。
而在我们的add()方法中对count++操作是需要被分成三步执行的:
1.我们得先把内存中的值,也就是count 读取到cpu中的寄存器上 ---> load
2.然后把cpu寄存器里面的值进行++操作,就是运算 --->add
3.最后就是把我们得到的结果写回到内存中 -->save
这三步在固定代码的执行顺序中,大概率是能够得到一个正确的结果,但是在我们的多线程代码程序中就不一定了,都说多线程是抢占式执行 随机性调度的 那么此时此刻就让我们一起来感受感受在多线程情况下有哪些多变的情况:
还是用我们上面的例子,解析两个线程对我们的count属性进行++操作的执行过程:
上面的六种执行顺序,很大程度上体现了线程的抢占式执行和随机调度性,当然咯,线程的调度是随机的,绝不可能仅仅只有这六种可能,准确来说有无数种可能,这个地方只是举例让我们好理解一点。
在第一种情况和第二种情况应该发现了我打对勾了,没错,出现这种情况我们的线程在某个时间点就是安全的,执行结果在某一段上也是可以认为是正确的。
我们分别拿1和5来分析线程在执行调度的过程中,发生的线程安全问题。
这种情况,是thread1线程执行完了,数据保存了,thread2才会读取保存的数据进行数据操作,所以最后的结果是正确的。
而这种情况,是thread2在执行的时候,thread1也在执行,当然上面的情况不是说thread1执行thread2没有执行,只是在thread1执行的时候thread2阻塞了一下。当我们的thread2 加载数据的时候,thread1也加载了初始化数据,又因为我们的thread2的数据执行完后,先对数据进行了保存,导致我们thread1中还在执行原始数据,此时了我们的thread1还没有保存,所以,在thread1执行完后,虽然执行了两次操作,但是内存中最后被写入的结果还是一次执行后的结果。(这跟咱们脏读有点类似).
那么我们有没有解决上述问题的办法了,有,我们的大佬前辈帮我们引入了一个关键字synchronized(后面会详细解释)
这个关键字就是用来对我们代码进行加锁操作的,就是我们thread1线程在对count在进行++操作的时候,thread2如果也想对count进行++操作,那么只能等我们的thread1执行完了,写入了数据了,thread2才能第二次进行++操作,对,就是让我们对thread2阻塞了一下。
下面就来欣赏欣赏我们优化后的代码叭:
package TestDemo;
class Counter{
public int count = 0;
synchronized public void add(){
count++;
}
}
public class TestDemo4 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(()->{
for(int i = 0; i < 50000;i++){
counter.add();
}
},"thread1");
Thread thread2 = new Thread(()->{
for(int i = 0 ; i < 50000; i++){
counter.add();
}
},"thread2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.count);
}
}
此时此刻,我们的实际的结果就是和我们预期的结果一致了,synchronized关键字能很好的改善我们的线程安全问题。
再谈导致线程不安全的原因
除了我们的抢占式运行和随机性调度影响我们的线程安全问题,还有哪些方面影响我们的线程安全呢?
3.1.首先就是,我们的抢占式运行和随机性调度造成的,这是根本原因(没办法避免不了)
3.2.代码结构也是能影响我们线程安全问题的:
比如:像上面我们例子中出现的,多个线程同时修改同一个变量。
控制我们的代码结构是可以简单的避免线程安全问题的;
比如:1.一个线程修改一个变量 是没事的
2.多个线程读取同一个变量没事
3.多个线程修改多个不同的变量
虽然控制我们的代码结构是可以避免简单的线程安全问题的,但是,这也不是万能的,还是要根据代码结构的需求来确定。
3.3.代码的原子性也是能决定我们线程的安全问题的;
我们这里指拥有原子性的代码是安全的,非原子性才是不安全的,才可能出现问题。是不是说原子性的代码就一定是安全的呢,不是的 只是说出现的问题概率非常小。
那么什么是原子性呢,我们可以通过以下例子简单了解一下:
我们可以把一段代码想象成一个厕所,每个线程就是要使用这个厕所的人,此时我们有两个人小王和小杨。当我们小王在进入厕所后,如果没有任何保证机制,此时此刻,小杨也想使用厕所,但是小王还没出来,小杨也不知道小王还在里面同时了这个厕所还没有任何保障机制(就是没有锁),想象一下,当门被小杨推开的时候,小王和小杨四目相对的场景........这个就是不具备原子性。如果有锁,就是具备原子性的。
所以,当一段代码保持原子性的时候,我们称这段代码是安全的(如果没有线程的抢占式执行,有没有原子性都无所谓)。
解决办法:
为了让我们的非原子性变成原子性的代码,我们应该如何做,像我们上面的说的那样,给厕所加吧锁就好了,这个地方也是把代码加把锁就好了,这个锁就是synchronized。
3.4.内存可见性问题:
内存可见性指的就是,一个线程或多个线程对共享变量值进行修改,能够及时被其他线程获取到。
内存可见性问题:一个线程或者多个线程在多一个共享变量值进行修改的时候,其他的线程没有获取到这个共享变量值的修改。
我们举一个简单的例子:
针对同一个变量,我们创建两个线程,一个变量值。一个线程(简称thread1)进行读操作,一个线程(简称thread2)进行修改操作,让线程thread1循环进行很多次,让线程thread2在适当的时机执行一次。
在内存中我们thread1线程在读这个操作的时候,因为是处在循环中,这个循环在执行速度的时候是非常快的,线程Thread1读内存相比于读寄存器,这是一个非常低效的操作,一般都会慢3-4个数量级,当我们在反复读这个操作的时候thread2线程可能迟迟不修改,那么此时我们jvm就会做出一个非常大胆的决定,就不在真正的去内存中读这个变量值,而是把他放在寄存器上,就默认这个变量值没有去修改过,一直都是原始值,那么此时我们的thread2线程在进行修改操作的时候,我们的thread1线程可能就感知不到这个变量被修改了,进一步触发内存可见性问题!!!
其实这都是编译器进行优化代码后的结果:就是编译器觉得我们写的代码比较死板,它觉得按照某种顺序或方式再不改变代码逻辑顺序的情况下调整一下代码能让代码更好的运行。大多数情况下是没问题的,但是在我们的多线程中,可能就会给你来点问题。
因为在我们的多线程中,它是一个抢占式执行,随机性调度的一个执行过程,此时编译器对代码就行优化,很可能会出现一些误判的错误。
import java.util.Scanner;
class Book{
public int count = 0;
}
public class TestDemo14 {
public static void main(String[] args) throws InterruptedException {
Book book = new Book();
Thread thread1 = new Thread(()->{
while(book.count == 0){
}
System.out.println("循环结束!");
});
Thread thread2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
book.count = scanner.nextInt();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
上述代码就是可见性问题,一个线程修改了变量值,但是另一个线程感知不到,所以此时啊这个循环就会一直进行下去......
解决办法:
1.第一种使用锁synchronized
synchronized不光能保证指令的原子性问题,还能保证代码的可见性,被我们synchronized修饰的代码块或方法,编译器是不会随便优化代码的。
2.第二种就是使用volatile关键字,他干个啥哈!!
首先volatile是和原子性无关的,但是它可以保证内存的可见性,就是防止我们的编译器优化不去读这个变量值,每次让这个线程去内存中获取一遍这个变量值.
优化后代码:
import java.util.Scanner;
class Book{
public volatile int count = 0;
}
public class TestDemo14 {
public static void main(String[] args) throws InterruptedException {
Book book = new Book();
Thread thread1 = new Thread(()->{
while(book.count == 0){
}
System.out.println("循环结束!");
});
Thread thread2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
book.count = scanner.nextInt();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
此时就解决了我们的内存可见性问题。
其实内存可见性,是属于我们编译器优化的一个典型案例,简单点讲,他就是个玄学问题,它啥时候优化啥时候不优化,咱是无法预知的,所以在碰到这类问题的时候,我们一定要谨慎处理。
3.5.指令重排序
跟我们的可见性问题类似,他也是属于一种编译器优化的案例:
比如去买菜:(菜单)
鱼(菜市场最里面)
黄瓜(菜市场中间)
香菜(菜市场中间)
鸡蛋(菜市场最外面)
如果按照这个菜单的顺序就先要去菜市场最里面买菜,然后又要返回出来再买,是不是很麻烦,如果能够调整一下这个买菜顺序,效率就会提高:
鸡蛋
黄瓜
香菜
鱼
按照这个顺序,我们不仅可以减少买菜的路程还能够提高办事的效率,这个就叫指令重排序(在保证逻辑不变的情况下,去优化这个执行过程)
当然,如果是在单线程情况下,这个指令重排序还是很可靠的,但是在我们的多线程场景中,编译器就可能会发生误判
解决办法:
还是要请出我们的synchronized关键字,它不光能保证原子性,同时还能保证内存可见性,并且还能禁止指令重排序问题!!!!
上述描述的五个典型线程安全原因不是全部,一段代码可能踩中了上面的原因,可能这个线程是安全的的,可能一段代码没有踩中上面的代码,可能是不安全的....这个就没法确定,还是要根据具体代码具体分析。我们只要记住,多线程运行代码,不出bug就是安全的!!!!
在上面的问题中,咱们也谈到了可以通过锁synchronized来缓解多线程安全问题,那么在下一个章节,我们会重点讲解关于snchronized和volatile的使用和常见问题以及解决方法。
铁汁们,各位老铁的支持,是我写博客最大的动力,如果觉得对大家有帮助,给博主点点赞,如果发现博主有啥写的不好的地方,还请各位老铁大方指出。