分布式并发编程,线程安全性,原理分析

初步认识 Volatile

一段代码引发的思考

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

public class VolatileDemo {public /*volatile*/ static boolean stop=false;public static void main(String[] args) throwsInterruptedException {Thread thread=new Thread(()->{int i=0;while(!stop){i++;}});thread.start();System.out.println('begin start thread');Thread.sleep(1000);}}

volatile 的作用

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

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

为了实现跨线程写入的内存可见性,必须使用到一些机制来实现。而 volatile 就是这样一种机制

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

我们可以使用【hsdis】这个工具,来查看前面演示的这段代码的汇编指令,具体的使用请查看使用说明文档

在运行的代码中,设置 jvm参数如下

【-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*App.*(替换成实际运行的代码)】

然后在输出的结果中,查找下 lock指令,会发现,在修改带有 volatile修饰的成员变量时,会多一个 lock指令。lock是一种控制指令,在多处理器环境下,lock 汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的一个效果。

为了让大家更好的理解可见性的本质,我们需要从硬件层面进行梳理

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

一台计算机中最核心的组件是 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协议。接下来给大家简单讲解一下MESI

MESI表示缓存行的四种状态,分别是

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对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果

总结可见性的本质

由于 CPU 高速缓存的出现使得 如果多个 cpu 同时缓存了相同的共享数据时,可能存在可见性问题。也就是 CPU0修改了自己本地缓存的值对于 CPU1 不可见。不可见导致的后果是 CPU1 后续在对该数据进行写入操作时,是使用的脏数据。使得数据最终的结果不可预测。

很多同学肯定希望想在代码里面去模拟一下可见性的问题,实际上,这种情况很难模拟。因为我们无法让某个线程指定某个特定 CPU,这是系统底层的算法, JVM 应该也是没法控制的。还有最重要的一点,就是你无法预测 CPU缓存什么时候会把值传给主存,可能这个时间间隔非常短,短到你无法观察到。最后就是线程的执行的顺序问题,因为多线程你无法控制哪个线程的某句代码会在另一个线程的某句代码后面马上执行。

所以我们只能基于它的原理去了解这样一个存在的客观事实了解到这里,大家应该会有一个疑问,刚刚不是说基于缓存一致性协议或者总线锁能够达到缓存一致性的要求吗?为什么还需要加volatile 关键字?或者说为什么还会存在可见性问题呢?

MESI 优化带来的可见性问题

MESI协议虽然可以实现缓存的一致性,但是也会存在一些问题。

就是各个 CPU缓存行的状态是通过消息传递来进行的。如果CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。在 cpu 中引入了Store Bufferes。

CPU0 只需要在写入共享数据时,直接把数据写入到 store bufferes中,同时发送 invalidate消息,然后继续去处理其他指令。

当收到其他所有CPU发送了 invalidate acknowledge消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。

但是这种优化存在两个问题

1. 数据什么时候提交是不确定的,因为需要等待其他 cpu给回复才会进行数据同步。这里其实是一个异步操作2. 引入了 storebufferes后,处理器会先尝试从 storebuffer中读取值,如果 storebuffer 中有数据,则直接从storebuffer中读取,否则就再从缓存行中读取

我们来看一个例子

exeToCPU0和exeToCPU1分别在两个独立的CPU上执行。假如 CPU0 的缓存行中缓存了 isFinish 这个共享变量,并且状态为(E)、而 Value可能是(S)状态。

那么这个时候,CPU0 在执行的时候,会先把 value=10的指令写入到storebuffer中。并且通知给其他缓存了该value变量的 CPU。在等待其他 CPU通知结果的时候,CPU0会继续执行 isFinish=true这个指令。

而因为当前 CPU0缓存了 isFinish并且是 Exclusive状态,所以可以直接修改 isFinish=true。这个时候 CPU1 发起 read操作去读取 isFinish 的值可能为 true,但是 value的值不等于10。

这种情况我们可以认为是 CPU的乱序执行,也可以认为是一种重排序,而这种重排序会带来可见性的问题volatile 关键字?或者说为什么还会存在可见性问题呢?

这下硬件工程师也抓狂了,我们也能理解,从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决。

所以硬件工程师就说: 既然怎么优化都不符合你的要求,要不你来写吧。

所以在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush store bufferes中的指令。软件层面可以决定在适当的地方来插入内存屏障。

CPU 层面的内存屏障

什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。

X86的memory barrier指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的

Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的

Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题

总的来说,内存屏障的作用可以通过防止 CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关键字的代码,这个关键字会生成一个 Lock的汇编指令,这个指令其实就相当于实现了一种内存屏障这个时候问题又来了,内存屏障、重排序这些东西好像是和平台以及硬件架构有关系的。作为 Java 语言的特性,一次编写多处运行。我们不应该考虑平台相关的问题,并且这些所谓的内存屏障也不应该让程序员来关心。

(0)

相关推荐

  • volatile关键字详解

    volatile的三个特点 保证线程之间的可见性 禁止指令重排 不保证原子性 可见性 概念 可见性是多线程场景中才讨论的,它表示多线程环境中,当一个线程修改了共享变量的值,其他线程能够知道这个修改. ...

  • 到底什么是内存可见性?

    我们都知道,volatile保证了内存可见性和禁止指令重排,但是对于内存可见性这一条,我一直没有完全弄明白,今天咱们一起看一下,这个可见性,到底是如何可见,数据到底是如何可见的. 首先我们要达成一个共 ...

  • 看懂这篇,才能说了解并发底层技术

    零.开局 前两天我搞了两个每日一个知识点,对多线程并发的部分知识做了下概括性的总结.但通过小伙伴的反馈是,那玩意写的比较抽象,看的云里雾里晕晕乎乎的. 所以又针对多线程底层这一块再重新做下系统性的讲解 ...

  • 【干货】连肝7个晚上,总结了关于Java基础的16个问题!

    说说进程和线程的区别? 进程是程序的一次执行,是系统进行资源分配和调度的独立单位,他的作用是是程序能够并发执行提高资源利用率和吞吐率. 由于进程是资源分配和调度的基本单位,因为进程的创建.销毁.切换产 ...

  • Java之volatile如何保证可见性和指令重排序

    Java之volatile如何保证可见性和指令重排序

  • 从硬件级别再看可见性和有序性

    前言 王子之前的文章对于并发编程中的可见性问题已经有了一个初步的介绍,总结出来就是CPU的缓存会导致可见性问题. 这样的解释其实是没有问题的,但这里说的"缓存"其实一个笼统的概念, ...

  • volatile关键字的作用

    volatile关键字的作用 1.java内存模型. 如上图所示,所有线程的共享变量都存储在主内存中,每个线程都有一个独立的工作内存,每个线程不直接操作在主内存中的变量,而是将主内存上变量的副本放进自 ...

  • 快速掌握并发编程---线程池的原理和实战

    池 上图是装水的池子--水池. 流行池化技术,那么到底什么是池化技术呢? 池化技术简单点来说,就是提前保存大量的资源,以备不时之需.在机器资源有限的情况下,使用池化技术可以大大的提高资源的利用率,提升 ...

  • 快速掌握并发编程---ArrayBlockingQueue 底层原理和实战

    背景 在JDK1.5的时候,在新增的Concurrent包中,BlockingQueue很好的解决了多线程中,如何高效安全"传输"数据的问题.通过这些高效并且线程安全的队列类,为我 ...

  • Java并发编程之线程的创建

    简介 线程是基本的调度单位,它被包含在进程之中,是进程中的实际运作单位,它本身是不会独立存在.一个进程至少有一个线程,进程中的多个线程共享进程的资源. Java中创建线程的方式有多种如继承Thread ...

  • Java并发编程实战(5)- 线程生命周期

    在这篇文章中,我们来聊一下线程的生命周期. 目录 概述 操作系统中的线程生命周期 Java中的线程生命周期 Java线程状态转换 运行状态和阻塞状态之间的转换 运行状态和无时限等待状态的切换 运行状态 ...

  • 并发编程大扫盲:带你了解何为线程上下文切换

    回复"面试"获取全套面试资料 在并发程序中,关于线程数设置大小的说法: 线程数量设置太小,可能会导致程序不能充分利用好系统资源. 线程数量设置太大,可能会带来资源的过度竞争,导致上 ...

  • 电水壶的电路原理分析与检测

    电水壶的基本功能是烧水.电水壶根据结构分为一体式和分体式两种,根据功能分为非保温型和保温型两种. 一.分体非保温式电水壶的检测 下面以格来德 WEF-115S 电水壶为例,介绍使用万用表检修分体非保温 ...

  • 豆浆机电路原理分析与故障检测

    下面以九阳 JYDZ-22 型豆浆机为例,介绍用万用表检修豆浆机故障的方法与技巧.该机电路由电源电路.微处理器电路.打浆电路.加热电路构成,如下图所示. 提示:改变图中 R19 的阻值,该电路板就可以 ...

  • 24V开关电源电路构成几工作原理分析

    电路以UC3842振荡芯片为,构成逆变.整流电路.UC3842一种高性能单端输出式电流控制型脉宽调制器芯片,相关引脚功能及内部电路原理已有介绍,此处从略.AC220V电源经共模滤波器L1引入,能较好抑 ...

  • EIS和OIS有啥差别?OIS光学防抖原理分析

    描述 翻阅智能手机的相册,我们总能看到一些拍糊的照片或视频.问题来了,如今哪怕是千元价位的手机都能用上4800万像素的索尼IMX586高端传感器,为何依旧无法保证每一张照片.每一段视频都是无比清晰的呢 ...