线程共享模型之内存二

导读:本篇文章讲解 线程共享模型之内存二,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

目录


上一章讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性。这一章我们进一步深入学习共享变量在多线程间的 可见性 问题与多条指令执行时的 有序性 问题。

2.1 Java 内存模型

JMM

Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面:
  • 原子性保证指令不会受到线程上下文切换的影响
  • 可见性保证指令不会受 cpu 缓存的影响
  • 有序性保证指令不会受 cpu 指令并行优化的影响

2.2 可见性 

先来看一个现象,
main
线程对
run
变量的修改对于
t
线程不可见,导致了
t
线程无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
     Thread t = new Thread(()->{
         while(run){
             // ....
         }
     });
     t.start();
     sleep(1);
     run = false; // 线程t不会如预想的停下来
}

为什么呢?分析一下:

  1. 初始状态:t 线程刚开始从主内存读取了 run 的值到工作内存。线程共享模型之内存二
  2. 因为
    t
    线程要频繁从主内存中读取
    run
    的值,
    JIT
    编译器会将
    run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。

    线程共享模型之内存二

  3. 1
    秒之后,
    main
    线程修改了
    run
    的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

    线程共享模型之内存二

解决方法
        volatile(易变关键字)

        它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况。synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。

2.3 有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; 
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下吧!

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的
CPU
指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令

指令译码

执行指令

内存访问

数据写回 这
5
个阶段:
线程共享模型之内存二

在不改变程序结果的前提下,这些指令的各个阶段可以通过
重排序

组合
来实现
指令级并行
,这一技术在
80’s
中叶到 90’s
中叶占据了计算架构的重要地位。
现代
CPU
支持
多级指令流水线
,例如支持同时执行
取指令

指令译码

执行指令

内存访问

数据写回
的处理器,就可以称之为五级指令流水线
。这时
CPU
可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1
,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
线程共享模型之内存二

 解决方法:         volatile(关键字)         它可以禁用指令重排,从而避免在多线程下指令重排影响结果的正确性。

2.4 volatile 原理

volatile
的底层实现原理是内存屏障,
Memory Barrier

Memory Fence
  • 对 volatile 变量的写指令后会加入写屏障
  • volatile 变量的读指令前会加入读屏障

【如何保证可见性】

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
    public void actor2(I_Result r) {
         num = 2;
         ready = true; // ready 是 volatile 赋值带写屏障
         // 写屏障
    }
  •  读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
    public void actor1(I_Result r) {
         // 读屏障
         // ready 是 volatile 读取值带读屏障
         if(ready) {
             r.r1 = num + num;
         } else {
             r.r1 = 1;
         }
    }

【如何保证有序性】

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    public void actor2(I_Result r) {
         num = 2;
         ready = true; // ready 是 volatile 赋值带写屏障
         // 写屏障
    }
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
    public void actor1(I_Result r) {
         // 读屏障
         // ready 是 volatile 读取值带读屏障
         if(ready) {
             r.r1 = num + num;
         } else {
             r.r1 = 1;
         }
    }

    但是注意,volatile 不能解决指令交错问题: 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去。而有序性的保证也只是保证了本线程内相关代码不被重排序。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之家整理,本文链接:https://www.bmabk.com/index.php/post/2146.html

(0)
小半的头像小半

相关推荐

极客之家——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!