vlambda博客
学习文章列表

带你一次搞明白 Java 多线程(Ⅱ)

1 线程安全问题

非线程安全主要是指多个线程对同一个对象的实例变量进行操作时 , 会出现值被更改 , 值不同步的情况 .

线程安全问题表现为三个方面: 原子性,可见性有序性

1.1 原子性

原子(Atomic)就是不可分割的意思. 原子操作的不可分割有两层 含义:

  1. 访问(读,写)某个共享变量的操作从其他线程来看,该操作要 么已经执行完毕,要么尚未发生, 即其他线程年示到当前操作的中间结果

  2. 访问同一组共享变量的原子操作是不能够交错的 如现实生活中从 ATM 机取款, 对于用户来说,要么操作成功,用户 拿到钱, 余额减少了,增加了一条交易记录; 要么没拿到钱,相当于 取款操作没有发生

  3. Java 有两种方式实现原子性:  

    • 一种是使用锁;  

    • 另一种利用处理器 的 CAS(Compare and Swap)指令.

  4. 锁具有排它性,保证共享变量在某一时刻只能被一个线程访问 .  CAS 指令直接在硬件(处理器和内存)层次上实现 , 看作是硬件锁

 /**
  * @className: Test01
  * @description: TODO 类描述 线程的原子性问题
  * @author: 薛定谔的猫
  * @date: 2022/4/20
  **/
 public class Test01 {
 
     public static void main(String[] args) {
 
         MyInit myInit = new MyInit();
 
         MyInit1 myInit1 = new MyInit1();
         //启动两个线程,不断调用getNum()方法
         for (int i = 1; i <=2; i++) {
             new Thread(
                     new Runnable() {
                         @Override
                         public void run() {
                             while (true){
                                // System.out.println( Thread.currentThread().getName() + " -> " + myInit.getNum());
                                 System.out.println( Thread.currentThread().getName() + " -> C" + myInit1.getNum());
                                 try {
                                     Thread.sleep(100);
                                } catch (InterruptedException e) {
                                     throw new RuntimeException(e);
                                }
                            }
                        }
                    }
            ).start();
        }
 
    }
 
     static class MyInit{
         int num;
         public int getNum(){
             return num++;
             /*
             * ++自增操作实现的步骤 :
             *   读取num的值
             *   num自增
             *   把自增的值再赋值给num变量
             * */
        }
    }
 
     //Java中提供了一个线程安全的AtomicInteger类,保证了操作的原子性
     static class MyInit1{
 
         AtomicInteger num = new AtomicInteger();
         public int getNum(){
             return num.getAndIncrement();
             /*
              * ++自增操作实现的步骤 :
              *   读取num的值
              *   num自增
              *   把自增的值再赋值给num变量
              * */
        }
    }
 }
 

1.2 可见性

 在多线程环境中, 一个线程对某个共享变量进行更新之后 , 后续 其他的线程可能无法立即读到这个更新的结果, 这就是线程安全问 题的另外一种形式: <font color='red'>可见性(visibility). </font>

如果一个线程对共享变量更新后, 后续访问该变量的其他线程可以读到更新的结果, 称这个线程对共享变量的更新对其他线程可见, 否则称这个线程对共享变量的更新对其他线程不可见.

多线程程序因为可见性问题可能会导致其他线程读取到了旧数据 (脏数据).

 /**
  * @className: Test02
  * @description: TODO 类描述 测试线程的可见性
  * @author: 薛定谔的猫
  * @date: 2022/4/20
  **/
 public class Test02 {
 
     public static void main(String[] args) throws InterruptedException {
         MyTask myTask = new MyTask();
         new Thread(myTask).start();
 
         Thread.sleep(1000);
         //主线程1秒后取消子线程
         myTask.cancel();
         /**
          * 可能会出现以下情况 :
          *     再main线程中调用 myTask.cancel()方法 , 把myTask对象的toCancel变量修改位true
          *     可能存在子线程看不到main线程对toCancel做的修改 , 在子线程中toCancel变量一直为false
          * 导致子线程看不到mian线程对toCancel变量更新的原因 , 可能 :
          *     1) JIT即使编译器可能 会对run方法中的while循环进行优化为 :
          *           if (!toCancel){
          *               while (true){
          *                 if (doSomeThing()) {}
          *               }
          *             }
          *     2) 可能与计算机的存储系统有关 . 假设分别有两个 cpu 内核运行main线程与子线程 , 运行子线程的 cpu 无法立即读取运行mian线程 cpu 中的数据
          * */
    }
 
     static class MyTask implements Runnable{
 
         private boolean toCancel = false;
         @Override
         public void run() {
             while (!toCancel){
                 if (doSomeThing()) {
 
                }
                 if(toCancel){
                     System.out.println("任务被取消");
                } else {
                     System.out.println("任务正常结束");
                }
            }
        }
         private boolean doSomeThing(){
             System.out.println("执行某个任务..............");
             try {
                 Thread.sleep(new Random().nextInt(1000));  //模拟执行任务时间
            } catch (InterruptedException e) {
                 throw new RuntimeException(e);
            }
             return true;
        }
         public void cancel(){
             toCancel = true;
             System.out.println("收到 , 取消线程的消息");
        }
    }
 }
 

1.3 有序性

有序性(Ordering)是指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器运行的其他线程看来是乱序的(Out of Order).

乱序是指内存访问操作的顺序看起来发生了变化

1.3.1 重排序

在多核处理器的环境下,编写的顺序结构,这种操作执行的顺序可 能是没有保障的:

编译器可能会改变两个操作的先后顺序;

处理器也可能不会按照目标代码的顺序执行;

这种一个处理器上执行的多个操作,在其他处理器来看它的顺序 与目标代码指定的顺序可能不一样,这种现象称为重排序.

重排序是对内存访问有序操作的一种优化,可以在不影响单线程 程序正确的情况下提升程序的性能.但是,可能 对多线程程序的正确 性产生影响,即可能导致线程安全问题

重排序与可见性问题类似,不是必然出现的.

与内存操作顺序有关的几个概念:

  • 源代码顺序 :  就是源码中指定的内存访问顺序.

  • 程序顺序 : 处理器上运行的目标代码所指定的内存访问顺序

  • 执行顺序 : 内存访问操作在处理器上的实际执行顺序

  • 感知顺序 : 给定处理器所感知到的该处理器及其他处理器的内存 , 访问操作的顺序 .

    可以把重排序分为指令重排序存储子系统重排序两种.

    • 指令重排序主要是由 JIT 编译器,处理器引起的, 指程序顺序与执 行顺序不一样

    • 存储子系统重排序是由 高速缓存,写缓冲器引起的, 感知顺序与执 行顺序 不一致

1.3.2 指令重排

在源码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致 的情况下,我们就说发生了指令重排序(Instruction Reorder).

指令重排是一种动作,确实对指令的顺序做了调整, 重排序的对象指令.

javac 编译器一般不会执行指令重排序, 而 JIT 编译器可能执行指 令重排序.

处理器也可能执行指令重排序, 使得执行顺序与程序顺序不一致.

指令重排不会对单线程程序的结果正确性产生影响,可能导致多线程程序出现非预期的结果.

1.3.3 存储子系统重排序

存储子系统是指写缓冲器高速缓存.

高速缓存(Cache)是 CPU 中为了匹配与主内存处理速度不匹配而设 计的一个高速缓存

写缓冲器(Store buffer, Write buffer)用来提高写高速缓存操作的效 率

即使处理器严格按照程序顺序执行两个内存访问操作,在存储子 系统的作用下, 其他处理器对这两个操作的感知顺序与程序顺序不 一致,即这两个操作的顺序顺序看起来像是发生了变化, 这种现象称为存储子系统重排序

存储子系统重排序并没有真正的对指令执行顺序进行调整,而是 造成一种指令执行顺序被调整的现象.

存储子系统重排序对象是内存操作的结果.

  • LoadLoad 重排序,一个处理器先后执行两个读操作 L1 和 L2,其他处 理器对两个内存操作的感知顺序可能是 L2->L1

  • StoreStore重排序,一个处理器先后执行两个写操作W1和W2,其他处理器对两个内存操作的感知顺序可能是 W2->W1

  • LoadStore 重排序,一个处理器先执行读内存操作 L1 再执行写内存 操作 W1, 其他处理器对两个内存操作的感知顺序可能是 W1->L1

  • StoreLoad重排序,一个处理器先执行写内存操作W1再执行读内存 操作 L1, 其他处理器对两个内存操作的感知顺序可能是 L1->W1

    内存重排序与具体的处理器微架构有关,不同架构的处理器所允 许的内存重排序不同

    内存重排序可能会导致线程安全问题.假设有两个共享变量

    int data = 0;     boolean ready = false;

    处理器1 处理器2
    data = 1;           //W1 ready = true;   /W2

    while( !ready){}               //L3 sout( data );                   //L4


1.3.4 貌似串行语义

JIT 编译器,处理器,存储子系统是按照一定的规则对指令,内存操作 的结果进行重排序, 给单线程程序造成一种假象----指令是按照源码 的顺序执行的.这种假象称为貌似串行语义. 并不能保证多线程环境 程序的正确性

为了保证貌似串行语义,有数据依赖关系的语句不会被重排序,只 有不存在数据依赖关系的语句才会被重排序.如果两个操作(指令)访 问同一个变量,且其中一个操作(指令)为写操作,那么这两个操作之间 就存在数据依赖关系(Data dependency).

如:

x = 1; y = x + 1; 后一条语句的操作数包含前一条语句的执 行结果;

y = x; x = 1; 先读取 x 变量,再更新 x 变量的值;

x = 1; x = 2; 两条语句同时对一个变量进行写操作

如果不存在数据依赖关系则可能重排序,如:

double price = 45.8;

int quantity = 10;

double sum = price * quantity;

存在控制依赖关系的语句允许重排.一条语句(指令)的执行结果会 决定另一条语句(指令)能否被执行,这两条语句(指令)存在控制依赖关 系(Control Dependency). 如在 if 语句中允许重排,可能存在处理器先 执行 if 代码块,再判断 if 条件是否成立

1.3.5 保证内存访问的顺序性

可以使用 volatile 关键字, synchronized 关键字实现有序

1.4 Java 内存模型