dccmmtop / notebook

个人博客记录
0 stars 0 forks source link

并发编程中的可见性,原子性,有序性问题 #79

Open dccmmtop opened 2 years ago

dccmmtop commented 2 years ago

java内存模型 (JMM)

这种模型和 jvm 中的堆不同, JMM 是抽象概念,不真实存在。它是一种规范,指定了程序中的各变量的访问方式

主内存

JMM 规定所有变量都存放在主内存,主内存是所有线程共享的,但是线程的操作在线程的工作内存中进行,先从主内存读取到线程的工作内存中,然后执行操作,再将工作内存中的值写入主内存中。线程不能直接操作主内存中的数据

工作内存

工作内存是线程独有的,不同的线程无法访问到对方的工作内存,线程间的通信必须通过主内存传值进行

JMM 描述的是变量在共享区域和私有区域的访问方式,变量的访问在多线程下会有 可见性,原子性,可见性三大问题

可见性问题

因为有工作内存的划分,一个线程操作修改某变量的值,没有同步到主内存前,其他线程是无法读取到该变量最新的值,就导致了变量在另外的线程不可见。 示例:

public class CodeVisibility {

    private static boolean initFlag = false;
    // private volatile static boolean initFlag = false;

    private static int counter = 0;

    public static void refresh() {
        System.out.println("refresh data.......");
        initFlag = true;
        System.out.println("refresh data success.......");
    }

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            while (!initFlag) {
                counter++;
            }
            System.out.println("线程:" + Thread.currentThread().getName()
                    + "当前线程嗅探到initFlag的状态的改变, counter: " + counter);
        }, "threadA");
        threadA.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread threadB = new Thread(() -> {
            refresh();
        }, "threadB");
        threadB.start();
    }
}

结果:

可见看到线程A久久不能结束,虽然线程B此时已经修改了 initFlag 的值,但是线程A无法读取到最新值,因为一直没有和主内存同步

volatile

这个关键词可以让变量被修改后立刻使其他线程中的副本可见。在上面的示例代码中,把第3行注释,第4行取消注释后再运行: 可以看到线程A可以立即结束

volatile 原理

  1. 使用 volatile 关键字会强制将在某个线程中修改的共享变量的值立即写入主内存。
  2. 使用 volatile 关键字, 当线程 2 进行修改时, 会导致线程 1 的工作内存中变量的缓存行无效(反映到硬件层的话, 就是 CPU 的 L1或者 L2 缓存中对应的缓存行无效);
  3. 由于线程 1 的工作内存中变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。

特性

  1. 只能控制变量的可见性
  2. 不能解决原子性问题
  3. 还可以禁止CPU指令重排(见下)

volatile 番外

如果不对 initFlag 添加 volatile 标识,线程A就永远无法读取到initFlag的最新值吗?

不一定, 在判断 initFlag 的值时,CPU 先从缓存中取值,只要缓存失效,就会重新在从内存中加载。那么什么时候缓存会失效呢? 对于CPU缓存来说,分为 L1 L2 L3 三级缓存,也就是离CPU最近的那些寄存器,他们的速度依次递减,容量依次递增。而每次CPU缓存的最小单位不是某个变量所占的空间大小,而是固定的字节 ,这样就能减少CPU和内存交互的次数,更好的利用空间局部原理和时间局部性原理。具体细节可以搜索 CPU缓存相关信息

因为CPU一次会让一批缓存失效,有可能 initFlag 的缓存会随着其他值失效而重新从内存加载最新值。如下例子:

public class CodeVisibility {

    // initFlag 不再用 volatile 修饰
    private static boolean initFlag = false;

    // 这里 counter 类型从 int 修改成 Integer
    private static Integer counter = 0;

    public static void refresh() {
        System.out.println("refresh data.......");
        initFlag = true;
        System.out.println("refresh data success.......");
    }

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            while (!initFlag) {
                counter++;
            }
            // 线程仍然可以很快的结束,因为 counter 会导致 cpu 缓存失效,重新从主内存加载最新数据
            System.out.println("线程:" + Thread.currentThread().getName()
                    + "当前线程嗅探到initFlag的状态的改变, counter: " + counter);
        }, "threadA");
        threadA.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread threadB = new Thread(() -> {
            refresh();
        }, "threadB");
        threadB.start();
    }
}

结果: 那么问题又来了,为什么用 int 不会 导致 cpu 缓存失效呢?

个人推测可能使因为 int 比 Integer 所占用的内存更小,CPU缓存放得下,一直没有触发缓存失效。

有序性问题

先看一个例子:

public class VolatileReOrderSample {
    //定义四个静态变量
    private static int x=0,y=0;
    private static int a=0,b=0;

    public static void main(String[] args) throws InterruptedException {
        int i=0;
        while (true){
            i++;
            x=0;y=0;a=0;b=0;
            //开两个线程,第一个线程执行a=1;x=b;第二个线程执行b=1;y=a
            Thread thread1=new Thread(new Runnable() {
                @Override
                public void run() {
                    //线程1会比线程2先执行,因此用nanoTime让线程1等待线程2 0.01毫秒
                    shortWait(10000);
                    a=1;
                    x=b;
                }
            });
            Thread thread2=new Thread(new Runnable() {
                @Override
                public void run() {
                    b=1;
                    y=a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            //等两个线程都执行完毕后拼接结果
            String result="第"+i+"次执行x="+x+"y="+y;
            //如果x=0且y=0,则跳出循环
            if (x==0&&y==0){
                System.out.println(result);
                break;
            }else{
                System.out.println(result);
            }
        }
    }
    //等待interval纳秒
    private static void shortWait(long interval) {
        long start=System.nanoTime();
        long end;
        do {
            end=System.nanoTime();
        }while (start+interval>=end);
    }
}
复制代码

按照正常思维,永远不会发生 x=0 y=0的场景,但事实并非如此: 下面是线程A B 可能的正常执行情况

  1. 线程A执行完 线程B执行
  2. 线程B执行完 线程A执行:
  3. 线程A B 交叉执行

发生指令重排的情况

指令重排

处理器为了程序的性能可以对程序的执行顺序进行重排,但是,必须满足重排后的执行结果在单线程下结果不能发生改变 这就是 as-if-serial 语义

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖的操作进行重排,因为会改变执行结果,如果两个操作不存在依赖关系,就有可能会被重排,就入上面的代码,在线程A中a=1;x=b这两各操作没有依赖关系,就有可能会重新排序成x=b;a=1 , 线程B同理。

这个执行重排的操作在单线程下没有关系,因为没有影响到最终的执行的结果,但是如果是多线程的场景,就像上面的那个例子,就会发生错误

如何禁止指令重排

volatile

volatile 另一个作用是禁止指令重排,避免多线程下出现乱序执行的情况 重排规则表:

从上面的规则可以看出:

加锁保证有序性

另外还可以使用 synchronize 和 lock 来保证有序性,因为加锁后,每时每刻只有一个线程执行代码,指令重排对单线程没有影响

禁止指令重排的经典应用

看下懒汉模式的单例的问题:

// 懒汉模式 + synchronized 同步锁 + double-check
public final class Singleton {
    private static Singleton instance= null;// 不实例化
    private Singleton(){}// 构造函数
    public static Singleton getInstance(){// 加同步锁,通过该函数向整个系统提供实例
        if(null == instance){// 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回对象
          synchronized (Singleton.class){// 同步锁
             if(null == instance){// 第二次判断
                instance = new Singleton();// 实例化对象
             }
          } 
        }
        return instance;// 返回已存在的对象
    }
}

为了在多线程并发场景下单例仍然有效,加了锁以及双重检测,但是就万无一失了吗?

在第一个判断 if(null == instance) 中,会出先变量instance有值,但是内存区域是空的(没有初始化 ),从而导致程序出现问题。造成这个问题的原因在于 instance = new Singleton(),事实上初始化对象操作不是原子性的,它包含下面两个动作:

  1. 给对象分配内存空间,
  2. 内存空间的初始化
  3. 将内存地址赋值给变量

其中2,3没有依赖关系,经过 编译器或者cpu指令重排后,可能会导致 2,3顺序发生变化:

  1. 给对象分配内存空间,
  2. 将内存地址赋值给变量 //此时变量已经不等于 null, 但是变量指向的内存区域还没有初始化
  3. 内存空间的初始化

假设线程A按照第二种顺序执行,在执行完步骤3时,还没有执行步骤2,线程B执行到第一个if(null == instance)判断,就会直接返回 instance。 这样对于线程B来说 getInstance() 方法返回的是一个没有经过初始化的对象,导致程序出现问题

解决问题的方法很简单: private volatile static Singleton instance= null; 使用 volatile 关键词禁止 instance 变量被执行指令重排优化即可

原子性问题

指的使一个操作是不可中断的,即使在多线程环境下,一旦操作开始就不会被其他线程影响

java 中可以通过 synchronize 和 lock 保证原子性,它们能保证任意时刻只有一个线程访问代码