计算机系统中,CPU 和内存之间是通过总线进行通信的,当某个线程占有 CPU 执行指令的时候,会尽可能的将一些需要从内存中访问的变量缓存在自己的高速缓存区中,而修改也不会立即映射到内存。

而此时,其他线程将看不到内存中该变量的任何改动,这就是我们说的内存可见性问题。连续的文章中,我们总共提出了两种解决办法。

其一是使用关键字 volatile 修饰共享的全局变量,而 volatile 的实现原理大致分两个步骤,任何对于该变量的修改操作都会由虚拟机追加一条指令立马将该变量所在缓存区中的值回写内存,接着将失效该变量在其他 CPU 缓存区的引用。也就意味着,其他 CPU 如果再想要使用该变量,缓存中是没有的,进而逼迫去访问内存拿最新的数据。

其二是使用关键字 synchronized 并借助对象内置锁实现数据一致性,主要思路是,如果一个线程因为竞争某个锁失败而被阻塞了,那么它就认为别的线程正在工作,很可能会改了某些共享变量的数据,进而在获得锁后第一时间重新刷内存中的数据,同时一个线程走出同步代码块之前会同步数据到内存。

其实我们也很少会使用第二种方法来解决内存可见性问题,着实有点大材小用的感觉,使用 volatile 关键字算是一个比较常用的方式。但是 volatile 是有特定的适用场景的,也具有它的局限性,我们一起来看。

volatile 的局限性

废话不多说,先看一段代码:

public class MainTest {
private static volatile int count; @Test
public void testVolatile() throws InterruptedException {
Thread1[] thread1s = new Thread1[100];
for (int i = 0; i < 100; i++){
thread1s[i] = new Thread1();
thread1s[i].start();
} for (int j = 0; j < 100; j++){
thread1s[j].join();
}
System.out.println(count);
}
//每个线程随机自增 count
private class Thread1 extends Thread{
@Override
public void run(){
try {
Thread.sleep((long) (Math.random() * 500));
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
}
}

我们将变量 count 使用 volatile 进行修饰,然后创建一百个线程并启动,按照我们之前的理解,变量 count 的值一旦被修改就可以被其他线程立马看到,不会缓存在自己的工作内存。但是结果却不是这样。

多次运行,结果不尽相同

94

96

98

....

其实原因很简单,我们只说过 volatile 会在变量值被修改后回写内存并失效其他 CPU 缓存中该变量的引用迫使其他线程从主存中重新去获取该变量的值。

但是 count++ 这个操作并不是原子操作,之前我们说过这一点,这个操作会使得 CPU 做以下几件事情:

  • 从 CPU 缓存读出变量的值放入寄存器 A 中
  • 为 count 加一并将值保存在另一个寄存器 B 中
  • 将寄存器 B 中的数据写到缓存并通过缓存锁回写内存

而如果第一步刚执行结束,或第二步刚执行结束,但没有执行第三步的时候,其他的某个线程更改了该变量的值并失效了当前 CPU 中缓存中该变量的引用,那么第三步会由于缓存失效而先去内存中读一个值过来,然后用寄存器 B 中的值覆盖缓存并刷到内存中。

这就意味着,在此之前其他线程的修改被覆盖,进而我们得不到我们预期的结果。结论就是,volatile 关键字具有可见性而不具有原子性。

原子类型变量

JDK1.5 以后由 Doug Lea 大神设计的 java.util.concurrent.atomic 包中包含了原子类型相关的所有类。

其中,

  • AtomicBoolean:对应的 Boolean 类型的原子类型
  • AtomicInteger:对应的 Integer 类型的原子类型
  • AtomicLong:类似
  • AtomicIntegerArray:对应的数组类型
  • AtomicLongArray:类似
  • AtomicReference:对应的引用类型的原子类型
  • AtomicIntegerFieldUpdater:字段更新类型

剩余的几个类的作用,我们稍后再详细介绍。

针对基本类型所对应的原子类型,我们以 AtomicInteger 这个类为例,看看它的源码实现情况。

AtomicInteger 相关实现

内部定义了一个 int 类型的变量 value,并且 value 修饰为 volatile,表示 value 这个字段值的任何修改都对其他线程立即可见。

而构造函数允许你传入一个初始的 value 数值,不传的话就会导致 value 的值为零。

public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

这个方法就是原子的「i++」操作,我们跟进去看:

public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5;
}

几个参数简单说一下,var1 是我们的 AtomicInteger 实例引用,var2 是一个字段偏移量,通过它我们可以定位到其中的 value 字段。var4 这里固定为一。

代码的逻辑也是简单的,取出内部 value 字段的值并暂存在变量 value5 中,然后再次判断,如果 value 字段的值依然等于 value5,那么将原子操作式将 value 修改为 value4 + value5,本质上就是加一。

否则,说明在当前线程上次访问后,又有其他线程修改了这个 value 字段的值,于是我们重新获取这个字段的值,直到没有人修改为止并自增它。

这个 compareAndSwapInt 方法我们一般把它叫做『CAS』,底层有系统指令做支撑,是一个比较并修改的原子指令,如果值等于 A 则将它修改为 B,否则返回。

AtomicInteger 中的其余方法大致类似,都是依赖这个『CAS』方法实现的。

  • int getAndAdd(int delta):自增 delta 并获取修改之前的值
  • int incrementAndGet():自增并获取修改后的值
  • int decrementAndGet():自减并获取修改后的值
  • int addAndGet(int delta):自增 delta 并获取修改后的值

基于这一点,我们重构上述的线程不安全的 demo:

//构建一个原子类型变量 aCount
private static volatile AtomicInteger aCount = new AtomicInteger(0);
@Test
public void testAtomic() throws InterruptedException {
Thread2[] threads = new Thread2[100];
for (int i = 0; i < 100; i++){
threads[i] = new Thread2();
threads[i].start();
}
for (int i = 0; i < 100; i++){
threads[i].join();
}
System.out.println(aCount.get());
} private class Thread2 extends Thread{
@Override
public void run(){
try {
Thread.sleep((long) (500 * Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
//原子自增
aCount.getAndIncrement();
}
}

修改后的代码无论运行多少次,总会得到结果 100 。有关 AtomicLong、AtomicReference 的相关内容大致类似,都是依赖我们这个『CAS』方法,这里不再赘述。

FieldUpdater 是基于反射来原子修改变量的值,这里不多说了,下面我们看看『CAS』的一些问题。

CAS 的局限性

ABA 问题

CAS 有一个典型问题就是「ABA 问题」,我们知道 CAS 工作的基本原理是,先读取目标变量的值,然后调用原子指令判断该值是否等于我们期望的值,如果等于就认为没有被别人改过,否则视作数据脏了,重新去读变量的值。

但是问题是,如果变量 a 的值为 100,我们的 CAS 方法也读到了 100,接着来了一个线程将这个变量改为 999,之后又来一个线程再改了一下,改成 100 。而轮到我们的主线程发现 a 的值依然是 100,它视作没有人和它竞争修改 a 变量,于是修改 a 的值。

这种情况,虽然 CAS 会更新成功,但是会存在潜在的问题,中途加入的线程的操作对于后一个线程根本是不可见的。而一般的解决办法是为每一次操作加上加时间戳,CAS 不仅关注变量的原始值,还关注上一次修改时间。

循环时间长开销大

我们的 CAS 方法一般都定义在一个循环里面,直到修改成功才会退出循环,如果在某些并发量较大的情况下,变量的值始终被别的线程修改,本线程始终在循环里做判断比较旧值,效率低下。

所以说,CAS 适用于并发量不是很高的情况下,效率远远高于锁机制。

只能保证一个变量的原子操作

CAS 只能对一个变量进行原子性操作,而锁机制则不同,获得锁之后,就可以对所有的共享变量进行修改而不会发生任何问题,因为别人没有锁不能修改这些共享变量。

总结一下,锁其实是一种悲观的思想,「我认为所有人都会和我来竞争某些资源的使用,所以我得到资源之后把它锁上,用完再释放掉锁」,而 CAS 则是一种乐观的思想,「我以为只有我一个人在使用这些资源,假如有人也在使用,那我再次尝试即可」。

CAS 是以后的各种并发容器的实现基石,是一种乐观的、非阻塞式的算法,将有助于提升我们的并发性能。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:OneJavaCoder,所有文章都将同步在公众号上。

CAS 无锁式同步机制的更多相关文章

  1. CAS无锁机制原理

    原子类 java.util.concurrent.atomic包:原子类的小工具包,支持在单个变量上解除锁的线程安全编程 原子变量类相当于一种泛化的 volatile 变量,能够支持原子的和有条件的读 ...

  2. java并发:AtomicInteger 以及CAS无锁算法【转载】

    1 AtomicInteger解析 众所周知,在多线程并发的情况下,对于成员变量,可能是线程不安全的: 一个很简单的例子,假设我存在两个线程,让一个整数自增1000次,那么最终的值应该是1000:但是 ...

  3. CAS无锁算法与ConcurrentLinkedQueue

    CAS:Compare and Swap 比较并交换 java.util.concurrent包完全建立在CAS之上的,没有CAS就没有并发包.并发包借助了CAS无锁算法实现了区别于synchroni ...

  4. (转载)java高并发:CAS无锁原理及广泛应用

    java高并发:CAS无锁原理及广泛应用   版权声明:本文为博主原创文章,未经博主允许不得转载,转载请注明出处. 博主博客地址是 http://blog.csdn.net/liubenlong007 ...

  5. 无锁的同步策略——CAS操作详解

    目录 1. 从乐观锁和悲观锁谈起 2. CAS详解 2.1 CAS指令 2.3 Java中的CAS指令 2.4 CAS结合失败重试机制进行并发控制 3. CAS操作的优势和劣势 3.1 CAS相比独占 ...

  6. CAS无锁技术

    前言:关于同步,很多人都知道synchronized,Reentrantlock等加锁技术,这种方式也很好理解,是在线程访问的临界区资源上建立一个阻塞机制,需要线程等待 其它线程释放了锁,它才能运行. ...

  7. CAS无锁实现原理以及ABA问题

    CAS(比较与交换,Compare and swap) 是一种有名的无锁算法.无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(N ...

  8. 探索CAS无锁技术

    前言:关于同步,很多人都知道synchronized,Reentrantlock等加锁技术,这种方式也很好理解,是在线程访问的临界区资源上建立一个阻塞机制,需要线程等待 其它线程释放了锁,它才能运行. ...

  9. [数据库锁机制] 深入理解乐观锁、悲观锁以及CAS乐观锁的实现机制原理分析

    前言: 在并发访问情况下,可能会出现脏读.不可重复读和幻读等读现象,为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念.数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务 ...

随机推荐

  1. 学习Acegi应用到实际项目中(7)- 缓存用户信息

    在默认情况下,即在用户未提供自身配置文件ehcache.xml或ehcache-failsafe.xml时,EhCache会依据其自身Jar存档包含的ehcache-failsafe.xml文件所定制 ...

  2. Spring RestTemplate get post 请求 携带 headers

    RestTemplate 1.我用RestTemplate请求时 我把他注入到容器里  这样可以 什么用什么时候拿 2.也可以new出来 不过我不喜欢 所以就没有用new的 下面我自己的方法   先注 ...

  3. python_flask 基础巩固 (DEBUG模式)

    默认情况下flask不会开启DEBUG模式,开启DEBUG模式后,flask会在每次保存代码的时候自动的重新载入代码,并且如果代码有错误,会在终端进行提示. 开启DEBUG模式有四种方式: 1.直接在 ...

  4. 高斯混合模型的EM算法

    高斯混合模型的EM算法 混合高斯模型 高斯混合模型的概率分布可以写成多个高斯分布的线形叠加,即 \[ p(\mathbf x) = \sum_{k=1}^{K}\pi_k\mathcal N(\mat ...

  5. 基于UML网络教学管理平台模型的搭建

    一.基本信息 标题:基于UML网络教学管理平台模型的搭建 时间:2013 出版源:网络安全技术与应用 领域分类:UML:网络教学管理平台:模型 二.研究背景 问题定义:网络教学管理平台模型的搭建 难点 ...

  6. Educational Codeforces Round 61 Editorial--C. Painting the Fence

    https://codeforces.com/contest/1132/problem/C 采用逆向思维,要求最大的覆盖,就先求出总的覆盖,然后减去删除两个人贡献最少的人 #include<io ...

  7. Eclipse下用NDK编译生成so文件

      我们在安装环境的时候安装了NDK,可以在eclipse下直接生成so文件.NDK的压缩包里面自带了一些sample工程,NDK的文件直接解压到某个目录下即可. 第一次生成so文件的时候,我们先使用 ...

  8. 只要一行代码求一串字符中某字符(串)出现次数,c#

    这里只要一行代码就行. static void Main(string[] args) { string str = "qwerwqr;sfdsfds;fdfdsf;dfsdfsdf;dsf ...

  9. dell T130服务器加内存

    需求:客户一台dell T130塔式服务器,由于本机只有一条8G内存,系统运行比较慢,需要再增加一条8G内存. 增加过程:第一次增加时由于没有注意机器上内存频率是2133的,所以新增加的一条2400频 ...

  10. 管道/FIFO

    管道: #include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<string.h&g ...