计算机系统中,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,所有文章都将同步在公众号上。