前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >初步认识线程安全性

初步认识线程安全性

作者头像
会说话的丶猫
发布2020-08-11 21:26:07
3820
发布2020-08-11 21:26:07
举报

前言

  这段时间在学习java并发编程这方面的知识,目前正在看《java并发编程之美》,书写的感觉蛮不错的,很细致,个人觉得少了点实际应用场景的案例,但总体来说各方面的知识点写的很详细,从原理到demo都会呈现给读者,此外还看了咕咆的一些教学视屏,里面的讲解让我深有体会,赶紧开个学习专题用以记录自己学习的点点滴滴。

初步认识 Volatile

  一段代码引发的思考下面这段代码,演示了一个使用 volatile 以及没使用volatile 这个关键字,对于变量更新的影响。

代码语言:javascript
复制
public class VolatileDemo {
    public /*volatile*/ static boolean stop = false;

    public static void main(String[] args) throws
            InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
        });
        thread.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop = true;
    }
}

volatile 的作用

  volatile 可以使得在多处理器环境下保证了共享变量的可见性,那么到底什么是可见性呢?不知道大家有没有思考过这个问题。

  在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性为了实现跨线程写入的内存可见性,必须使用到一些机制来实现。而 volatile 就是这样一种机制。

volatile 关键字是如何保证可见性的?

  当一个变量被声明为volatile时,线程在写入变量时不会把值写入寄存器或者其他地方,而是会把值刷新到主内存。当有其他线程需要获取该共享变量时,会从主内存中获取到最新的值,而不会使用当前线程工作内存中的值。volatile的语义和synchronized有点相似,具体就是说,当线程写入volatile变量值时就等价于线程退出了synchronized代码块(把工作内存中的变量同步到主内存中),读取volatile变量值时就等价于线程进入到同步代码块(先清空本地内存变量,再从主存中获取最新值)。

volatile不具有原子性

Volatile实现内存可见性是通过store和load指令完成的;也就是对volatile变量执行写操作时,会在写操作后加入一条store指令,即强迫线程将最新的值刷新到主内存中;而在读操作时,会加入一条load指令,即强迫从主内存中读入变量的值。但volatile不保证volatile变量的原子性。

从硬件层面了解可见性的本质

  一台计算机中最核心的组件是 CPU、内存、以及 I/O 设备。在整个计算机的发展历程中,除了 CPU、内存以及 I/O 设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化

1. CPU 增加了高速缓存

2. 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率

3. 编译器的指令优化,更合理的去利用好 CPU 的高速缓存然后每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程

CPU 高速缓存

  线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。

通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。

什么叫缓存一致性呢?

  首先,有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。

由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法

1. 总线锁

2. 缓存锁

总线锁和缓存锁

  总线锁,简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的。

  如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行。所以引入了缓存锁,它核心机制是基于缓存一致性协议来实现的。

缓存一致性协议

  为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等。最常见的就是 MESI 协议。接下来给大家简单讲解一下 MESIMESI 表示缓存行的四种状态,分别是

1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致

2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改

3. S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致

4. I(Invalid) 表示缓存已经失效在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作

  对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写

使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果

JMM

  JMM 全称是 Java Memory Model. 什么是 JMM 呢?通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

  需要注意的是,JMM 并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在 JMM 中,也会存在缓存一致性问题和指令重排序问题。只是 JMM 把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。

  JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。

  Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。

JMM 是如何解决可见性有序性问题的

  简单来说,JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉:volatile、synchronized、final;

HappenBefore

  它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程

JMM 中有哪些方法建立 happen-before 规则

  1. 一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是 as-if-serial。单个线程中的代码顺序不管怎么变,对于结果来说是不变的顺序规则表示 1 happenns-before 2; 3 happens-before 4

  2. volatile 变量规则,对于 volatile 修饰的变量的写的操作,一定 happen-before 后续对于 volatile 变量的读操作;根据 volatile 规则,2 happens before 3

  3. 传递性规则,如果 1 happens-before 2; 3happensbefore 4; 那么传递性规则表示: 1 happens-before 4;

  4. start 规则,如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作

  5. join 规则,如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程A 从 ThreadB.join()操作成功返回。

  6. 监视器锁的规则,对一个锁的解锁,happens-before 于随后对这个锁的加锁。

假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-08-06 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 初步认识 Volatile
    • volatile 的作用
      • volatile 关键字是如何保证可见性的?
        • volatile不具有原子性
        • 从硬件层面了解可见性的本质
          • CPU 高速缓存
            • 什么叫缓存一致性呢?
              • 总线锁和缓存锁
                • 缓存一致性协议
                • JMM
                  • JMM 是如何解决可见性有序性问题的
                    • HappenBefore
                      • JMM 中有哪些方法建立 happen-before 规则
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档