1.2.1 线程安全之可见性问题

多线程中的问题

  • 所见非所得

  • 无法肉眼去检测程序的准确性

  • 不同的运行平台有不同的表现

  • 错误很难重现

  • 代码举例:

    public class VisibilityDemo {
        private boolean flag = true;
    
        public static void main(String[] args) throws InterruptedException {
            VisibilityDemo demo1 = new VisibilityDemo();
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    int i=0;
                    while (demo1.flag){
                        i++;
                    }
                    System.out.println(i);
                }
            });
            thread1.start();
            TimeUnit.SECONDS.sleep(2);
            // 设置flag为false,预期使上面的线程结束while循环,但是没有成功
            demo1.flag=false;
            System.out.println("被设置为false了");
        }
    }
    复制代码

    从内存结构到内存模型的原理,来分析排查导致的原因。

从内存结构到内存模型

工作内存缓存

  • JVM内存分为工作内存(线程独享的内存,保存在cpu高速缓存),和主内存(java堆,保存java对象实例的)。
  • 多核情况下,不同CPU中运行的线程是直接与对应的CPU缓存交互数据的,极有可能与主内存的数据在极短时间内不一致。导致程序运行结果与预期不一致。

指令重排

  • Java编程语言的语义允许编译器和微处理器执行优化,这些优化可以与不正确的同步代码交互,从而产生看似矛盾的行为。
  • 即时编译器(JIT compiler, just-in-time compiler),提供了代码优化机制,来提高 JVM 的性能。也正是由于开启了 JIT,使得 class 文件在运行成为汇编语言时进行指令重排。
  • 可以通过设置 JVM 参数来关闭 JIT 优化机制:-Djava.compiler=NONE。但是 JVM 的性能会降低很多。所以java 提供了 volatile 关键字来解决这个问题:CPU缓存可能导致非常短的时间内数据不一致
  • volatile :声明这个字段不能被保存在CPU缓存中,每次都要从主存中读取。解决了数据在多线程间的可见性问题。

内存模型 - 含义

  • 内存模型实际上就是一种规范。决定了在程序的每个点可以读取什么值。
  • 比如,每个线程都由自己私有的工作内存(线程栈内存),要想多线程之间进行数据交互就需要在共享内存或堆内存中进行访问。而这就需要一种规范,来规定每个操作。具体的内存模型的实现就需要不同平台的 JVM 的不同实现。
  • 内存模型描述了程序的可能行为,程序的所有执行产生的结果都可以由内存模型预测。具体的实现者任意实现,包括操作的重新排序和删除不必要的同步。

内存模型 - Shared Variables 共享变量描述

  • 可以在线程之间共享的内存称为共享内存或堆内存。
  • 所有实例字段、静态字段和数组元素都存储在堆内存中。

内存模型 - 线程操作的定义

  • 操作定义
    • write:要写的变量以及要写的值;
    • read:要读的变量以及可见的写入值(由此,我们可以确定可见的值)。
    • lock:要锁定的管程(监视器monitor);
    • unlock:要解锁的管程;
    • 外部操作(socket等...);
    • 启动和终止。
  • 程序顺序:如果一个程序没有数据竞争,那么程序的所有执行看起来都是顺序一致的。
  • 本规范只涉及线程间的操作;

内存模型 - 对于同步的规则定义(抽象的规范要求)

  • 启动线程的操作与线程中的第一个操作同步(线程能看到操作的数据变化,内存模型规定:数据发生变化要及时反馈)

  • 对于监视器 m 的解锁与所有后续操作对于 m 的加锁同步(同步关键字synchronized,保持可见性。内存模型规定:锁的变化要及时反馈通知其他线程,同步区块内的数据要同步到主内存,保持可见性)

  • 对于 volatile 变量 v 的写入,与所有其他线程后续对 v 的读同步(volatile变量的v保持可见。线程1操作了v,后续线程能读到最新的v)

  • 对于每个属性写入默认值(0,false,null)与每个线程对其进行的操作同步(属性的默认值,在它变化前,就必须能被读取到)

  • 线程 T1 的最后操作与线程 T2 发现线程 T1 已经结束同步。(isAlive,join 可以判断线程是否终结。线程中止状态的可见性)

  • 如果线程 T1 中断了 T2,那么线程 T1 的中断操作与其他所有线程发现 T2 被中断了同步(线程被中止,那么它所有的监听者都要收到这个事件。同步=====可见性)

    通过抛出 InterruptedException 异常,或调用 Thread.interrupted 或 Thread.isInterrupted

内存模型 - happens-before 先行发生原则

happens-before关系主要用于强调两个有冲突的动作之间的顺序,以及定义数据的发生时机。

具体的虚拟机实现,有必要确保以下原则的成立:

  • 某个线程中的每个动作都 happens-before 该线程中的该动作后面的动作。
  • 某个管程(monitor)上的 unlock 动作 happens-before 同一个管程上后续的 lock 动作。
  • 对某个 volatile 字段的写操作 happens-before 每个后续对该 volatile 字段的读操作。
  • 在某个线程线程上调用 start() 方法 happens-before 该启动了的线程中任意动作。
  • 某个线程中的所有动作 happens-before 任意其他线程成功从该线程对象上的 join() 中返回。
  • 如果某个动作 a happens-before 动作 b,且 b happens-before 动作 c,则有 a happens-before c。

当程序包含两个没有被 happens-before 关系排序的冲突访问时,就称存在数据争用

遵守了这个原则,也就意味着有些代码不能进行重排序!

内存模型 - final 在 JVM 中的处理

  • final 在该对象的构造函数中设置对象的字段,当线程看到该对象时,将始终看到该对象的final字段的正确构造版本。(类中final字段,在构造函数中被赋值,创建对象以后在被不同的线程读取字段时,保证都能读到构造函数中赋予的值。如果是普通字段,就不能保证,可能有的线程读的是默认值。普通字段没有被要求可见性。)

  • 如果在构造函数中设置字段后发生读取,则会看到该final字段分配的值,否则它将看到默认值。

    伪代码实例:public finalDemo(){ x=1;y=x;}; y会等于1;

  • 读取该共享对象的 final 成员变量之前,先要读取共享对象。

    伪代码实例:r=new ReferenceObj();k=r.f;这两个操作不能重排序。

  • 通常 static final 是不可以修改的字段。然后System.in,System.out 和 System.err 是static final 字段,遗留原因,必须允许通过set方法改变,我们将这些字段称为写保护,以区别于普通 final 字段。

内存模型 - Word Tearing 字节处理

内存模型 - double 和 long 的特殊处理

volatile关键字总结

  • 可见性问题:让一个线程对共享变量的修改,能够及时的被其他线程看到。

根据 JMM 中规定的 happen before 和同步原则:

要满足这些条件,所以 volatile 关键字就有这些功能:

  1. 禁止缓存;volatile变量的访问控制符会加个ACC_VOLATILE。
  2. 对 volatile 变量相关的指令不做重排序;

猜你喜欢

转载自juejin.im/post/5d8384685188253eee6bee0e