详细解析Java内存模型与原子性-可见性-有序性

在这里插入图片描述

为什么要学习并发编程

其实无论语言、中间件和框架再如何先进,我们都不应该完全依赖于它们完成并发处理的所有事情,了解并发的内幕并学习其中的思想,仍然是成为一个高级程序员的必经之路。
如果只是为了面试而去背诵题目,不了解其中的原理,这对我们的长足发展是百害无益的

为什么需要并发编程?

这里摘录《Java 并发编程的艺术》书中的一段话来回答这个问题,我们为什么需要并发线程?
多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。

从物理机原理得到的启发

物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义,因此,我们有必要学习下物理机中处理问题的方法。
上述说过并发编程可以提高CPU的利用率,主要原因就是因为存储设备的速度和CPU的运行速度相比实在是天差地别,所以CPU不得不花费大量的时间进行等待
软件层面我们使用并发编程,那么硬件层面如何解决呢?其实就是在CPU和主内存中加入一层或多层读写速度尽可能接近 CPU 运算速度的高速缓存来作为缓冲。
将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
但是这样会带来一个问题,并发的情况下,我们如何保证缓存一致性?
其实就是多个CPU都共享一个主内存的时候,各自的缓存区数据很可能是不一致的,那么我们同步到主内存的时候以谁的额数据为准?
所以为了解决这个一致性问题,CPU在访问缓存和进行读写操作时都要遵循一些协议
,为此引入了内存模型的概念
在这里插入图片描述
不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也拥有自己的内存模型,称为 Java 内存模型(Java Memory Model,JMM),其目的就是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

Java内存模型

JMM(内存模型)规定了所有变量都必须存储在主内存中,每条线程都有自己独有的工作内存
线程的工作内存存储了被该线程使用的所有变量的主存副本,线程对变量的操作(读写、赋值)都必须在工作内存中进行,不能直接读写主存中的数据
在这里插入图片描述
此处的主内存可以与前面所说的物理机的主内存类比,当然,实际上它仅是虚拟机内存的一部分,工作内存可与前面讲的高速缓存类比。
这里的变量其实和我们日常编程中所说的变量不一样,它包括了实例字段、静态字段和构成数组对象的元素(堆和方法区中的数据),但是不包括局部变量与方法参数,因为后面这俩是线程私有的,不会被共享,自然就不会存在竞争问题。

原子性

什么是原子性
物理机中有哦缓存一致性协议规定主内存和高速缓存的操作逻辑,那么JMM中的主内存和工作内存有没有类似的协议呢?
当然是有的,JMM 中定义了以下 8 种操作规范来完成一个变量从主内存拷贝到工作内存、以及从工作内存同步回主内存这一类的实现细节。Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
原子我们都知道是一种最小的粒子,不可再分,那么原子操作就是不可中断的一个或一系列操作
举个经典的简单例子,银行转账,A 像 B 转账 100 元。转账这个操作其实包含两个离散的步骤:
步骤 1:A 账户减去 100
步骤 2:B 账户增加 100
我们要求转账这个操作是原子性的,也就是说步骤 1 和步骤 2 是顺续执行且不可被打断的,要么全部执行成功、要么执行失败。
试想一下,如果转账操作不具备原子性会导致什么问题呢?
比如说步骤 1 执行成功了,但是步骤 2 没有执行或者执行失败,就会导致 A 账户少了 100 但是 B 账户并没有相应的多出 100。
对于上述这种情况,符合原子性的转账操作应该是如果步骤 2 执行失败,那么整个转账操作就会失败,步骤 1 就会回滚,并不会将 A 账户减少 100。
理解了原子操作,我们来看看JMM定义的8种原子操作是哪些
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量
上述我们举了一个简单的转账例子,那么我们看一下具体编程如果涉及到非原子性操作会怎么样
下述代码是对一个静态变量count自增5000再自减5000,答案应该是0
在这里插入图片描述
但是我们可以保证它一定是0吗?不一定,它还可能是正数或负数
所以我们上述的代码是线程不安全的,所谓线程不安全,就是说在单线程环境下正常运行的一段代码,在多线程环境中可能发生各种意外情况,导致无法得到正确的结果。
那么线程安全就简单了,一段代码被多个线程访问后,仍然保证运行的结果是正确的,那么就是线程安全
上述代码不安全的原因,其实就是Java 中对静态变量自增和自减操作并不是原子操作,自增和自减其实分解成了三个步骤
1.读取count的值
2.对count自增或者自减
3.存储新的count值
我们可以归纳为读-改-写
我们以i++自加操作为例子
在这里插入图片描述
在这里插入图片描述
1.getstatic:读取静态变量i的值0
2.iconst_1:准备一个常量1
3.iadd:i加1
4.putstatic:将修改后的值存入变量i
如果是单线程情况,那么肯定是不会有任何问题
在这里插入图片描述
但是在多线程的环境下,由于 CPU 时间片调度的原因,可能 Thread1 正在执行自增操作着呢,CPU 剥夺了它的资源占用,转而分配给了 Thread2,也就是**发生了线程上下文切换。**这样,就可能导致本该是一个连续的读改写动作(连续执行的三个步骤)被打断了。
以下是多线程情况下出现负数的图解
在这里插入图片描述
总结来说,如果多个 CPU 同时对某个共享变量进行读-改-写操作,那么这个共享变量就会被多个 CPU 同时处理,**由于 CPU 时间片调度等原因,某个线程的读-改-写操作可能会被其他线程打断,**导致操作完后共享变量的值和我们期望的不一致。

如何保证原子性

那么我们如何实现原子操作,或者说保证原子性呢?
对于这个问题,其实在处理器和 Java 编程语言层面,它们都提供了一些有效的措施,比如处理器提供了总线锁和缓存锁,Java 提供了锁和循环 CAS 的方式,这里我们简单解释下 Java 保证原子性的措施。
由Java内存模型直接保证原子性的操作有load,assign,read,write,store,use这个6个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double 的非原子性协定,各位只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)
如果应用场景需要保证更大范围的原子性,还有lock和unlock操作满足这种需求
尽管 JVM 并没有把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
而除了 synchronized 关键字这种 Java 语言层面的锁,juc (java.util.concurrent包的缩写)并发包中的 java.util.concurrent.locks.Lock 接口也提供了一些
类库层面的锁
,比如 ReentrantLock。
**在 JDK 5 之后,Java 类库中开始使用基于 cmpxchg 指令的 CAS 操作(又来一个重点),该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个方法包装提供。**不过再JDK9之前都没有开放给用户使用,只有 Java 类库可以使用,譬如 juc 包里面的整数原子类,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作来实现。

使用这种 CAS 措施的代码也常被称为无锁编程(Lock-Free)。(compare and swap)

可见性

什么是可见性
前文提到过,物理机中由于引入了高速缓存,所以存在缓存和主内存间的缓存一致性问题,那么Java虚拟机中同样存在这个问题,表现为工作内存和主内存之间的同步延迟问题,也就是我们所说的内存可见性问题
那么什么是可见性?其实就是当一个线程修改了主内存中的共享变量时,其他的线程可以立马感知到这个变动。
我们回顾一下Java的内存模型(JMM)
在这里插入图片描述
以上图为例,如果线程A和线程B要通信的话,需要经过两个步骤
1)线程 A 把工作内存 A 中更新过的共享变量刷新到主内存中去
2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量
换句话说,线程A要与B通信必须要经过主内存
那这很可能会出现一个问题,我们举个例子

// 线程 1 执行的代码
int i = 0;
i = 1;
// 线程 2 执行的代码
j = i;

当线程1在工作内存中将i更新为1,但是还未更新到主内存的共享变量中时,线程2已经执行了j=i语句,于是这时候j=0而不是1。这就是我们常说的可见性问题,当线程1对共享变量修改之后,线程2并没有立马感知到这个变化
如何保证可见性
大家很可能脱口而出,volatile关键字修饰共享变量!那么其实除了这个,synchronized和final关键字也可以保证可见性
synchronized如何保证可见性
我们上述提到过JMM 规定了在执行 8 种基本原子操作时必须满足的一系列规则,这其中有一条规则正是 sychronized 能够保证原子性的理论支撑,如下:
对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)
也就是说 synchronized在修改了工作内存中的变量后,解锁前会将工作内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。
那么final关键字的可见性需要结合其内存语义深入来讲,这里就先简单的概括下:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final 字段的值。

有序性

什么是有序性
我们再回到物理机,其实除了增加高速缓存之外,为了使 CPU 内部的运算单元能尽量被充分利用,CPU 可能会对输入代码进行乱序执行优化,CPU 会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。
与之类似的,Java 的编译器也有这样的一种优化手段:指令重排序(Instruction Reorder)。
那么既然可以优化CPU计算性能,那么我们可以无限制使用指令重排吗?肯定是不可以的,在重排过程中,CPU和编译器都要遵循一个as-if-serial原则:不管怎么重排序,单线程环境下程序的执行结果不能发生改变。
所以为了保证as-if-serial原则,CPU和编译器在进行指令重排序的时候不会对存在数据依赖关系的操作进行指令重排,因为这样会改变程序的结果
那么什么是数据依赖关系?
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
数据依赖性分为三种类型:写后读、写后写、读后写,看下图
在这里插入图片描述
上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
我们举个简单的例子

int a = 1;		 // A
int b = 2;		 // B
int sum = a + b; // C

我们看一下ABC三个操作的数据依赖关系
在这里插入图片描述
如图可知A和C,B和C存在数据依赖关系,所以C操作不可以重排到A或者B操作的前面,但 A 和 B 之间没有数据依赖关系,所以 CPU 和处理器可以重排序 A 和 B 之间的执行顺序。如下是程序的两种执行顺序:
在这里插入图片描述
但是很可惜的是,这里的数据依赖性仅包含单个CPU 中执行的指令序列和单个线程中执行的操作,不同 CPU 之间和不同线程之间的数据依赖性是不被 CPU 和编译器考虑的。
我们看一下多线程情况下的例子
在这里插入图片描述
假设有两个线程A,B,A执行write方法,将a赋值为1,flag变成True,而B线程执行reader方法,执行a*a语句,那么B执行完之后得到的结果一定是1吗?
答案是不一定,由于操作 1 和操作 2 没有数据依赖关系,CPU 和编译器可以对这两个操作重排序;同样的,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。假设现在指令重排,使得指令的执行顺序是2-1-3-4,看一下发生的情况
在这里插入图片描述
那么如上图右边所示,程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还没有被线程 A 写入,因此线程 B 读到的 a 值仍然是 0。也就是说在这里多线程程序的语义被重排序破坏了。
所以我们可以得出结论:CPU 和 Java 编译器为了优化程序性能,会自发地对指令序列进行重新排序。在多线程的环境下,由于重排序的存在,就可能导致程序运行结果出现错误。
如何保证有序性
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性。
volatile 本身除了保证可见性的语义外,还包含了禁止指令重排序的语义,所以天生就具有保证有序性的功能。
而 synchronized 保证有序性的理论支撑,仍然是 JMM 规定在执行 8 种基本原子操作时必须满足的一系列规则中的某一个提供的:
一个变量在同一个时刻只允许一条线程对其进行 lock 操作
通俗来说,synchronized 通过排他锁的方式保证了同一时间内,被 synchronized 修饰的代码是单线程执行的。所以,这就满足了 as-if-serial 语义的一个关键前提,那就是单线程,这样,有了 as-if-serial 语义的保证,单线程的有序性也就得到保障了。
但是如果仅仅依靠这两个关键字,并发编程将会变得非常繁琐
为此——Happends-before应运而生

Happens-before原则(先行发生)

依赖这个原则,我们可以通过几条简单规则快速解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值