详解 Java 内存模型与原子性、可见性、有序性

详解 Java 内存模型与原子性、可见性、有序性,第1张

详解 Java 内存模型与原子性、可见性、有序性

原子性
什么是原子性
类比物理机,拥有缓存一致性协议来规定主内存和高速缓存之间的 *** 作逻辑,那么 JMM 中主内存与工作内存之间有没有具体的交互协议呢?

Of Course!JMM 中定义了以下 8 种 *** 作规范来完成一个变量从主内存拷贝到工作内存、以及从工作内存同步回主内存这一类的实现细节。Java 虚拟机实现时必须保证下面提及的每一种 *** 作都是原子的、不可再分的。

暂时放下到底是哪 8 种 *** 作,我们先谈何为原子?

原子(atomic)本意是 “不能被进一步分割的最小粒子”,而原子 *** 作(atomic operation)意为 “不可被中断的一个或一系列 *** 作”。

举个经典的简单例子,银行转账,A 像 B 转账 100 元。转账这个 *** 作其实包含两个离散的步骤:

步骤 1:A 账户减去 100
步骤 2:B 账户增加 100
我们要求转账这个 *** 作是原子性的,也就是说步骤 1 和步骤 2 是顺续执行且不可被打断的,要么全部执行成功、要么执行失败。

试想一下,如果转账 *** 作不具备原子性会导致什么问题呢?

比如说步骤 1 执行成功了,但是步骤 2 没有执行或者执行失败,就会导致 A 账户少了 100 但是 B 账户并没有相应的多出 100。

对于上述这种情况,符合原子性的转账 *** 作应该是如果步骤 2 执行失败,那么整个转账 *** 作就会失败,步骤 1 就会回滚,并不会将 A 账户减少 100。

OK,了解了原子性的概念后,我们再来看 JMM 定义的 8 种原子 *** 作具体是啥,以下了解即可,没必要死记:

lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read *** 作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个 *** 作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个 *** 作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write *** 作使用。
write(写入):作用于主内存的变量,它把store *** 作从工作内存中得到的变量的值放入主内存的变量

事实上,对于 double 和 long 类型的变量来说,load、store、read 和 write *** 作在某些平台上允许有例外,称为 “long 和 double 的非原子性协定”,不过一般不需要我们特别注意,这里就不再过多赘述了。

这 8 种 *** 作当然不是可以随便用的,为了保证 Java 程序中的内存访问 *** 作在并发下仍然是线程安全的,JMM 规定了在执行上述 8 种基本 *** 作时必须满足的一系列规则。

这我就不一一列举了,多提这么一嘴的原因就是下文会涉及一些这其中的规则,为了防止大家看的时候云里雾里,所以先前说明白比较好。

上面我们举了一个转账的例子,那么,在具体的代码中,非原子性 *** 作可能会导致什么问题呢?

看下面这段代码,各位不妨考虑一个的问题,如果两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果一定是 0 吗?

结果不一定是0,可能是-1,也可能是1或者其他的数,这其实就是线程不安全的,至于这段代码线程不安全的原因,就是 Java 中对静态变量自增和自减 *** 作并不是原子 *** 作,它俩其实都包含三个离散的 *** 作:

步骤 1:读取当前 i 的值
步骤 2:将 i 的值加 1(减 1)
步骤 3:写回新值
可以看出来这是一个 读 - 改 - 写 的 *** 作。
在单线程环境下是不会有任何问题的,但是在多线程的情况下是会存在着线程上下文的切换的,这是由于cpu资源的分配是以时间片的形式去分配的,当thread1占用CPU资源的时间过了之后便会将资源分配给thread2,从而导致上下文的切换,这个时候执行的结果自然就是不准确的了,如下图所示:


那么,如何实现原子 *** 作,也就是如何保证原子性呢?

对于这个问题,其实在处理器和 Java 编程语言层面,它们都提供了一些有效的措施,比如处理器提供了总线锁和缓存锁,Java 提供了锁和循环 CAS 的方式,这里我们简单解释下 Java 保证原子性的措施。

由 Java 内存模型来直接保证的原子性变量 *** 作包括 read、load、assign、use、store 和 write 这 6 个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double 的非原子性协定,各位只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。

如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock *** 作来满足这种需求。

尽管 JVM 并没有把 lock 和 unlock *** 作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个 *** 作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized 关键字,因此在 synchronized 块之间的 *** 作也具备原子性。

而除了 synchronized 关键字这种 Java 语言层面的锁,juc 并发包中的 java.util.concurrent.locks.Lock 接口也提供了一些类库层面的锁,比如 ReentrantLock。

另外,随着硬件指令集的发展,在 JDK 5 之后,Java 类库中开始使用基于 cmpxchg 指令的 CAS *** 作(又来一个重点),该 *** 作由 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个方法包装提供。不过在 JDK 9 之前 Unsafe 类是不开放给用户使用的,只有 Java 类库可以使用,譬如 juc 包里面的整数原子类,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS *** 作来实现。

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

可见性:就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。


如上图所示,在JMM中可见性的表现即为线程
修改了值之后马上回写到主内存中,即线程
从主内存中获取到的就是线程
修改之后的数据。

那么应该如何保证可见性呢?
各位可能脱口而出使用 volatile 关键字修饰共享变量,但除了这个,容易被大家忽略的是,其实 sunchronized 和 final 这俩关键字也能保证可见性。

上面我提过一嘴,为了保证 Java 程序中的内存访问 *** 作在并发下仍然是线程安全的,JMM 规定了在执行 8 种基本原子 *** 作时必须满足的一系列规则,这其中有一条规则正是 sychronized 能够保证原子性的理论支撑,如下:

对一个变量执行 unlock *** 作之前,必须先把此变量同步回主内存中(执行 store、write *** 作)
也就是说 synchronized在修改了工作内存中的变量后,解锁前会将工作内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。

至于 final 关键字的可见性需要结合其内存语义深入来讲,这里就先简单的概括下:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final 字段的值。

如何保证有序性呢?

在Java里面可以使用syncronized和volatile两个关键字去保证有序性,其中volatile除了保证可见性之外还包含着禁止指令重排序的功能,这也就是volatile为什么可以保证有序性的原因所在。

而syncronized 允许在同一时刻只有一个线程对变量进行lock *** 作,这就相当于单线程 *** 作了,由于存在as-if-serial原则规定了单线程运算重排序之后程序执行的结果不能改变,因此syncronized也保证了有序性

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5697641.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存