第一个问题:什么是线程安全问题?
线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。 线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。
第二个问题:为什么会出现线程安全问题?
出现线程安全的问题的根源其实是在于我们之前说过的多线程“抢占式执行,随机调度”的特性决定的。当我们在使用多线程进行编程的时候,是躲不过这一“万恶之源”的。我们只可以通过一些编程手段来解决这些线程安全的问题。
我们可以看一下下面这部分的代码。
(这是一个典型的多线程的线程安全问题,里面会出现脏数据,也就是多个线程对同一个变量进行更改的问题)
首先我们来看当我们写两个线程进行更改同一个变量的情况:
1. package Thread; 2. 3. class Countsum{ 4. private static int count=0; 5. public void CountAdd(){ 6. count++; 7. } 8. public int getCount(){ 9. 10. return count; 11. } 12. } 13. public class ThreadDemo15 { 14. public static void main(String[] args) { 15. Countsum countsum=new Countsum(); 16. //第一个线程t1 17. Thread t1 =new Thread(()->{ 18. 19. for (int i = 0; i <50000; i++) { 20. countsum.CountAdd(); 21. } 22. 23. }); 24. //第二个线程t2 25. Thread t2=new Thread(()->{ 26. for(int i=0;i<50000;i++){ 27. countsum.CountAdd(); 28. } 29. }); 30. //两个线程操作同一个变量 31. t1.start(); 32. t2.start(); 33. //让t1,t2两个线程执行完,再执行main线程,这里让main线程阻塞 34. try { 35. t1.join(); 36. } catch (InterruptedException e) { 37. throw new RuntimeException(e); 38. } 39. try { 40. t2.join(); 41. } catch (InterruptedException e) { 42. throw new RuntimeException(e); 43. } 44. //打印最后的结果,看和预期值10_0000是否一致。 45. System.out.println(countsum.getCount()); 46. } 47. 48. 49. }
预期值:10_0000
第一次运行:64603
第二次运行:73388
第三次运行:75233
每一次的结果都和预期值相差甚远。这就说明期间发生了脏读了,也揭示了线程的不安全性。
那么具体的过程是怎样变成这样的?
首先我们需要知道count++这个过程到底是怎么实现的。
我们从CPU的角度出发:count++主要是由三个指令实现的
1、(load)把内存中count的值加载到CPU的寄存器当中
2、(add)把寄存器中的数值加1
3、(save)把寄存器中的值放回到内存中,对原来的值进行覆盖。
我们画个示意图:
同样,也正是因为这个过程需要多个步骤来进行实现,就使得多线程的“抢占式执行,随机调度”得以充分发挥作用了。我们都知道排列组合。在这10万次循环中,会有无数种排列的情况出现,所以基本上每一次的结果不会相等,但是不排除相等的情况。
这里我们就列举一种情况来进行说明即可:
比如这种情况:
我们来分析一下这个过程:
假设初始值为0.
如果是正常情况下结果应该是2,但是这里结果却是1。这就和上面的程序是一样的道理。
如果要得到正确结果应该是这种的步骤:
就是像这样的能够得到正确的数据。
第三个问题:如何解决多线程安全问题?
答案:加锁
那么java中加锁的方式有很多种,最常使用的是 synchronized 关键字。我们可以给上述代码的自增函数内部自增操作上加synchronized 关键字或者直接给自增的方法加上synchronized关键字就是加锁成功了。
加锁成功后在看一下程序:
1. package Thread; 2. 3. class Countsum{ 4. private static int count=0; 5. public void CountAdd(){ 6. synchronized (this){ 7. count++; 8. } 9. 10. } 11. public int getCount(){ 12. 13. return count; 14. } 15. } 16. public class ThreadDemo15 { 17. public static void main(String[] args) { 18. Countsum countsum=new Countsum(); 19. //第一个线程t1 20. Thread t1 =new Thread(()->{ 21. 22. for (int i = 0; i <50000; i++) { 23. countsum.CountAdd(); 24. } 25. 26. }); 27. //第二个线程t2 28. Thread t2=new Thread(()->{ 29. for(int i=0;i<50000;i++){ 30. countsum.CountAdd(); 31. } 32. }); 33. //两个线程操作同一个变量 34. t1.start(); 35. t2.start(); 36. //让t1,t2两个线程执行完,再执行main线程,这里让main线程阻塞 37. try { 38. t1.join(); 39. } catch (InterruptedException e) { 40. throw new RuntimeException(e); 41. } 42. try { 43. t2.join(); 44. } catch (InterruptedException e) { 45. throw new RuntimeException(e); 46. } 47. //打印最后的结果,看和预期值10_0000是否一致。 48. System.out.println(countsum.getCount()); 49. } 50. 51. 52. }
这个结果就和我们的预期值一样了。
第四个问题:产生线程不安全的原因有哪些?
1、线程是抢占式执行的,线程间的调度充满随机性。(线程不安全的根本原因)
2、多个线程对同一个变量进行修改操作。
3、针对变量的操作不是原子的,通过加锁操作就是把几个指令打包成一个原子的。
4、内存可见性。
这里需要简单理解一下几个名词:
1)原子性 我们可以简单的理解为打包为一个整体
第五个问题:内存可见性问题及解决方案
2)内存可见性
内存可见性问题其实是编译器优化的结果。
我们这里以一个线程读取数字,一个线程修改数字为例:
t线程负责读取istrue的值,main线程负责修改istrue的值。
1. package Thread; 2. 3. import java.util.Scanner; 4. 5. public class ThreadDemo16 { 6. public static int istrue=0; 7. public static void main(String[] args) { 8. Thread t=new Thread(()->{ 9. while(istrue==0){ 10. 11. } 12. System.out.println("t线程结束!"); 13. }); 14. t.start(); 15. Scanner scanner=new Scanner(System.in); 16. System.out.println("请输入一个数字:"); 17. istrue=scanner.nextInt(); 18. System.out.println("main线程执行完毕"); 19. 20. } 21. }
我们看一下执行结果:
当我们输入一个5的时候,我们原来是应该让t线程结束的,然而main线程结束后,t线程却进入了死循环当中,也就是说,此时的istrue还是0,并没有得到修改。这到底是什么原因导致的呢?
原因是这样的:由于从内存中读是要比从寄存器中读慢很多的(好几个数量级吧大概) 这里的t线程需要不断的循环读取istrue的值,如果我们的main线程不做出修改,那么t线程读取到的值就一直是一样的值。于是编译器就可能会进行优化,让t线程直接从寄存器中读取数据,也就是省去了load的操作,这一大胆的行为使得后续我们对istrue进行的修改都无法让t线程感知到,也就是说修改失去了作用。所以t线程并不会终止。
那么如何解决内存可见性的问题呢?
1、使用synchronized 关键字。synchronized 不光能够保证原子性,同时也能够保证内存可见性。被synchronized 包裹起来的代码,编译器就不敢轻易做出上述假设,就相当于手动禁止了编译器的优化。
2、使用volatile关键字。volatile和原子性无关,但是能够保证内存可见性。使得编译器每次都要重新从内存中读取istrue的值。
方案一:使用volatile关键字(最常用)
public static volatile int istrue=0;
方案二: 使用synchronized关键字
有时候我们也可以使用一些别的操作,比如sleep啊等等的,不过这些不太可靠哈。
1. while(istrue==0){ 2. try { 3. Thread.sleep(1000); 4. } catch (InterruptedException e) { 5. throw new RuntimeException(e); 6. } 7. 8. }
编译器优化总的来说还是比较玄学的!!!
说到这,还有一个由编译器优化引发的问题!!!
第六个问题:指令重排序问题?
指令重排序问题听着挺吓人,其实就是个排序问题罢了。
指令重排序是编译器优化的结果,编译器会对我们写的代码进行重排序从而来提高编译的效率,但是有时候一旦发生指令重排序,就可能会使得程序与我们预期的结果不同了。(在单线程中指令的重排序不会产生太大的影响,但是在多线程中容易出现严重bug,需要多注意!)我们要保证逻辑不变,对顺序进行调整。(使用synchronized可以进行禁止指令的重排序)