什么是原子性

什么是原子性?

类比物理机,拥有缓存一致性协议来规定主内存和高速缓存之间的操作逻辑,那么 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操作从工作内存中得到的变量的值放入主内存的变量

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

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

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

 

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

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

耳熟能详的问题,我们无法保证这段代码执行结果的一定性(正确性),可能是正数、也可能是负数、当然也可能是 0。

那么,我们就把这段代码称为线程不安全的,就是说在单线程环境下正常运行的一段代码,在多线程环境中可能发生各种意外情况,导致无法得到正确的结果。

从线程安全的角度来反向理解线程不安全的概念可能更容易点,这里参考《Java 并发编程实践》上面的一句话:

一段代码在被多个线程访问后,它仍然能够进行正确的行为,那这段代码就是线程安全的

至于这段代码线程不安全的原因,就是 Java 中对静态变量自增和自减操作并不是原子操作,它俩其实都包含三个离散的操作:

  • 步骤 1:读取当前 i 的值
  • 步骤 2:将 i 的值加 1(减 1)
  • 步骤 3:写回新值

可以看出来这是一个 读 - 改 - 写 的操作。

i ++ 操作为例,我们来看看它对应的字节码指令:

上方这段代码对应的字节码是这样的:

简单解释下这些字节码指令的含义:

  • getstatic i:获取静态变量 i 的值
  • iconst_1:准备常量 1
  • iadd:自增(自减操作对应 isub)
  • putstatic i:将修改后的值存入静态变量 i

如果是在单线程的环境下,先自增 5000 次,然后再自减 5000 次,那当然不会发生任何问题。

但是在多线程的环境下,由于 CPU 时间片调度的原因,可能 Thread1 正在执行自增操作着呢,CPU 剥夺了它的资源占用,转而分配给了 Thread2,也就是发生了线程上下文切换。这样,就可能导致本该是一个连续的读改写动作(连续执行的三个步骤)被打断了。

下图出现的就是结果最终是负数的情况:

总结来说,如果多个 CPU 同时对某个共享变量进行读-改-写操作,那么这个共享变量就会被多个 CPU 同时处理,由于 CPU 时间片调度等原因,某个线程的读-改-写操作可能会被其他线程打断,导致操作完后共享变量的值和我们期望的不一致。

另外,多说一嘴,除了自增自减,我们常见的 i = j 这个操作也是非原子性的,它分为两个离散的步骤:

  • 步骤 1:读取 j 的值
  • 步骤 2:将 j 的值赋给 i

如何保证原子性

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

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

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

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

尽管 JVM 并没有把 lockunlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 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)。


作者:飞天小牛肉
链接:https://juejin.cn/post/6959144178596970533
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值