目录
1. 线程出现线程安全问题的原因(主要原因)
① 线程在系统调度中顺序无序,抢占式执行,谁先抢到谁执行.
② 多线程修改同一个变量
③ 修改操作不是原子(不可分割的最小单位)的,如++操作就涉及到CPU的三个指令:
load 加载,add 增加,save 保存.如果两个线程对同一个变量进行操作时,会有很多排列顺序,就会造成变量值计算的最终结果不是预期结果.
④ 内存可见性:在多线程环境下,编译器对于代码的优化,产生误判(某个变量明明在其它线程执行过程中更改了,但是当前线程不知道),所以会引起bug.比如假设线程A的中断条件为某个变量flag=1才可以中断(默认flag = 0 不满足线程A中断条件),线程B在执行过程中将flag=1(满足线程A中断条件),但是线程A没有休眠,执行速度极快,编译器就会对代码进行优化(只加载一次flag的值),当线程B将flag=1时,线程A并不会重新加载flag的值,所以就会造成线程A无法中断,从而bug出现.
⑤ 指令重排序:完成某个任务的结果一样,但是过程顺序不一致.在多线程情况下,可能会使结果不可预知,从而产生bug.(很难调试出来,完全靠自己去想去理解)这里给大家举个例子:
我们实例化某个对象时,new操作主要分为三步:
1) 创建出对象(建好房子)
2) 构造对象(装修房子)
3) 将生成的地址赋值给对象引用(拿到钥匙).
在多线程情况下,线程调度无序,那么某个线程可能会拿到一个没有构造好的对象(啥也没有,属性都是默认的),那么我们去使用该对象成员变量或方法时,可能就会发生一系列的错误.
2. 出现线程安全问题我们该如何解决
① 锁(加锁解锁synchronized关键字修饰方法进入{}内加锁,出{}外解锁)
● 概念
针对某个修改操作进行加锁,让修改操作变成原子的.就像我们学校的公共澡堂一样,大家去洗澡都是抢占式洗,谁先占到位置就可以把门儿锁了然后洗澡,其它人就进不去了,想洗多久就洗多久,只有我洗完其它人才可以到我的这个位置洗.一旦一个线程先拿到锁对某个修改操作加锁,那么其它线程就无法对该修改操作加锁,需要拿到锁的线程释放锁之后才能加锁.这里不要将join()和加锁混淆哦!join()是将多个线程进行串行,而加锁只会让多个线程间的某个步骤进行串行,其它步骤还是并行.
总的来说,加锁会比不加锁消耗得资源更多,但是会比不加锁的计算更精确.(鱼和熊掌不可兼得嘛~)
● 示例代码
class CountSum { int count = 0; //对++操作进行加锁方式一 // synchronized public void add() { // count++; // } //方式二 public void add() { //this为当前调用add()方法的对象引用 ()中不能放基本数据类型 synchronized(this) { count++; } } } // 使用两个线程来累加 count 的值 // 每个线程循环 1w 次,累加变量 count 的值,count 默认值为 0. // main方法中 CountSum countSum = new CountSum(); Thread thread1 = new Thread(new Thread() { @Override public void run() { for (int i = 0; i < 10000; i++) { countSum.add(); } } }); Thread thread2 = new Thread(() -> { for (int i = 0; i < 10000; i++) { countSum.add(); } }); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(countSum.count);
上述示例代码中,线程thread1和thread2谁先拿到锁谁就先对count进行++操作,没拿到锁的那个线程只能拿到锁的线程执行完修改操作之后再去执行修改操作(阻塞等待直到拿到锁的线程执行完).
注:如果add()方法用static修饰的话,那么就是对该类对象进行加锁,就不是跟示例代码中给类的实例对象加锁了,那么示例代码中填this的位置就应该是CountSum.class.
② 加volatile关键字修饰变量
● 概念
在多线程情况下,某线程A没有休眠就会执行非常快,编译器会对某个变量flag的重复加载进行优化,让flag只被加载一次,那么当另外的线程对flag进行修改后,在线程A中对flag的值还是初始加载的值,就会对变量flag的值产生误判,从而引起bug.这种情况我们就可以使用volatile关键字修饰变量flag,让编译器暂停优化,保证内存可见性(synchronized关键字是否能保证内存可见性目前是存在争议的).
注:volatile关键字不保证原子性,它适用于一个线程读取,另一个修改,而synchronized适用于多个线程读取修改.
● 示例代码
public static int flag = 0; Thread thread1 = new Thread(new Runnable() { @Override public void run() { while (flag == 0) { } System.out.println("thread1结束"); } }); Thread thread2 = new Thread(() -> { Scanner scanner = new Scanner(System.in); System.out.println("请输入要修改flag的值:>"); flag = scanner.nextInt(); }); thread1.start(); thread2.start();
线程一直无法结束,产生了bug.给flag变量添上volatile关键字修饰后,线程正常中断.
注:锁能够解决大部分线程不安全问题,但是volatile关键字只能解决内存可见性和指令重排序问题.
对于指令重排序问题,某些情况下既可以使用volatile关键字(创建时会禁止指令重排序)解决又可以使用synchronized关键字解决,只要能够让指令按照固定顺序依次执行(当然固定顺序的效率不一定就是更优的).