java并发编程(十三)----(JUC原子类)引用类型介绍(CAS和ABA的介绍)

时间:2023-01-12 20:51:57

这一节我们将探讨引用类型原子类:AtomicReference, AtomicStampedRerence, AtomicMarkableReference。AtomicReference的使用非常简单,根据API我们就可以知道如何用,但是后两个从名字上看起来感觉是很难的样子,其实只是他的样子长得有点吓人,并且确实发挥了很大的作用(解决了ABA问题)。所以并没有那么可怕,就让我们一起来克服困难吧。

1.AtomicReference简介

AtomicReference的使用非常简单,首先我们来看一下他的方法:

构造函数:
AtomicReference() //使用 null 初始值创建新的 AtomicReference。
AtomicReference(V initialValue) //使用给定的初始值创建新的 AtomicReference。

方法:
boolean compareAndSet(V expect, V update) //如果当前值 == 预期值,则以原子方式将该值设置为给定的更新值。
V get() //获取当前值。
V getAndSet(V newValue) //以原子方式设置为给定值,并返回旧值。
void lazySet(V newValue) //最终设置为给定值。
void set(V newValue) //设置为给定值。
String toString() //返回当前值的字符串表示形式。
boolean weakCompareAndSet(V expect, V update) // 如果当前值 == 预期值,则以原子方式将该值设置为给定的更新值。

下面我们看一个例子来了解下使用方法:

public class AtomicReferenceTest {
public static void main(String[] args) {
AtomicReference atomic1 = new AtomicReference();
atomic1.set("aaa");
atomic1.set(new StringBuffer("str"));
Map map = new HashMap();
map.put("name","xiaoming");
atomic1.set(map);
System.out.println(atomic1.get());

String[] s = {"aa","bb","cc"};
AtomicReference atomic2 = new AtomicReference(s);
System.out.println(atomic2.get());
atomic2.set(atomic1);
System.out.println(atomic2.get());
}
}

输出结果:

{name=xiaoming}
[Ljava.lang.String;@766e119d
{name=xiaoming}

Process finished with exit code 0

由上面程序我们可以看到:AtomicReference可以set任何类型的值并且都是以原子的形式操作的。

2.CAS和ABA

在介绍AtomicStampedRerence, AtomicMarkableReference之前我们的先谈一谈CAS和ABA的问题,因为这两个类的设计就是为了避免CAS操作中的ABA问题而设计的.

2.1 CAS 原子操作的基石

我们知道之前我们学过的Synchronized是一种独占锁。一个线程获得该锁,那么其余的线程只能等待该线程释放锁才能获得。这其实是一种悲观锁的形式。那么乐观锁是如何实现的呢?乐观锁是每次都不加锁,假设完成某项任务没有冲突。如果因为冲突失败那就重试,直到成功为止。

今天我们要说的CAS就是乐观锁的实现机制。可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。

CAS:Compare and Swap,这个操作用C语言来描述就是下面这个样子(代码来自Wikipedia的Compare And Swap词条):

int compare_and_swap (int* reg, int oldval, int newval)
{
int old_reg_val = *reg;
if (old_reg_val == oldval)
*reg = newval;
return old_reg_val;
}

意思就是说,看一看内存*reg里的值是不是oldval,如果是的话,则对其赋值newval。

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

在使用上,通常会记录下某块内存中的旧值,通过对旧值进行一系列的操作后得到新值,然后通过CAS操作将新值与旧值进行交换。如果这块内存的值在这期间内没被修改过,则旧值会与内存中的数据相同,这时CAS操作将会成功执行 使内存中的数据变为新值。如果内存中的值在这期间内被修改过,则一般来说旧值会与内存中的数据不同,这时CAS操作将会失败,新值将不会被写入内存。

2.2 原子操作

虽然我们是在用java语言去执行原子操作,但是最终还是对应到处理器上去执行。那么在处理器上是如何执行原子操作的呢?

32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。

第一个机制是通过总线锁保证原子性。。如果多个处理器同时对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致,举个例子:如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2。

原因是有可能多个处理器同时从各自的缓存中读取变量i,分别进行加一操作,然后分别写入系统内存当中。那么想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。

第二个机制是通过缓存锁定保证原子性。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

上面说了cpu的原子操作是如何实现的,那么在java中一定也有相应的方法去驱动处理器来实现原子操作。JUC包中的原子类都是基于CAS来实现的,我们不妨以AtomicInteger为例来跟踪一下,看看到底是如何实现原子操作的。

首先我们能看到在AtomicInteger中的value值是这样定义的:

    private volatile int value;

我们再来看他的get方法:

public final int get() {
return value;
}

直接返回value值,说明在无锁的情况下,通过volatile来做控制,保证值是可见的。

下面我们接着看一下i++在原子类中是怎么实现的:

public final int getAndSet(int newValue) {
for (;;) {
int current = get();
if (compareAndSet(current, newValue))
return current;
}
}

能看到,首先把volatile修饰的内存中的原始值赋值给当前变量,为了下面compareAndSet方法拿内存中的值和新值比较进行CAS操作。所以关键就在于这个compareAndSet()方法,我们接着进入这个方法:

public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

我们看到compareAndSet方法中返回的是unsafe类的方法,我们知道java不能直接访问操作系统底层,而是通过本地方法来访问。Unsafe类提供了硬件级别的原子操作,这就与硬件相挂钩了。由此我们从java到处理器的通道就打通了。

具体的compareAndSwapInt()方法的实现属于JDK底层的实现,我们在此不多做说明,有兴趣的可以查阅相关资料,看java的底层c语言源码。大致的过程我们可以说明如下:CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。” Java并发包(java.util.concurrent)中大量使用了CAS操作,涉及到并发的地方都调用了sun.misc.Unsafe类方法进行CAS操作

2.3 ABA问题

上面我们结合着处理器对原子操作的处理机制一起讲了java对原子操作的处理方式,那么难道这种方式就一定是完美的吗。下面我们举出一种情况大家分析看看:

  1. 进程P1在共享变量中读到值为A
  2. P1被抢占了,进程P2执行
  3. P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占。
  4. P1回来看到共享变量里的值没有被改变,于是继续执行。

你觉得这会出现什么问题呢?我们知道java底层代码都是用c语言实现的,虽然java中没有指针,但不代表java底层代码中没有使用。虽然P1以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free 的算法中的,CAS首当其冲,因为CAS判断的是指针的地址(c语言的特性)。如果这个地址被重用了呢,问题就很大了。

现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:

java并发编程(十三)----(JUC原子类)引用类型介绍(CAS和ABA的介绍)

在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再pushD、C、A,此时堆栈结构如下图,而对象B此时已经出栈:

java并发编程(十三)----(JUC原子类)引用类型介绍(CAS和ABA的介绍)

在CAS中c语言的堆栈实现过程大致如下:

push(node):
curr := head
old := curr
node->next = curr
while (old != (curr = CAS(&head, curr, node))) {
old = curr
node->next = curr
}

pop():
curr := head
old := curr
next = curr->next
while (old != (curr = CAS(&head, curr, next))) {
old = curr
next = curr->next
}
return curr

假如,在pop函数中,next = curr->next 和 while之间,线程被切换走,此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,即while此时还没有执行呢,所以此时的情况变为:

java并发编程(十三)----(JUC原子类)引用类型介绍(CAS和ABA的介绍)

其中堆栈中只有B元素的引用地址,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。

如果这个例子你没有看懂的话,我可以再举出一个生活中的例子:

你拿着一个装满钱的手提箱在飞机场,此时过来了一个火辣性感的美女,然后她很暖昧地挑逗着你,并趁你不注意的时候,把用一个一模一样的手提箱和你那装满钱的箱子调了个包,然后就离开了,你看到你的手提箱还在那,于是就提着手提箱去赶飞机去了。

以上就是ABA问题。

3 解决ABA问题之道

因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。 为了避免CAS过程中的ABA问题,并发包提供了两个类,AtomicStampedReference和AtomicMarkableReference。前者相当于一个[引用,integer]的二元组,后者相当于一个[引用,boolean]的二元组。
AtomicStampedReference可用来作为带版本号的原子引用,而AtomicMarkableReference可用于表示已删除的节点。

这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

我们先来看一下AtomicStampedReference的方法,AtomicStampedReference 维护带有整数“标志”的对象引用,可以用原子方式对其进行更新:

构造方法:
AtomicStampedReference(V initialRef, int initialStamp) //创建具有给定初始值的新 AtomicStampedReference

方法:
boolean attemptStamp(V expectedReference, int newStamp) //如果当前引用 == 预期引用,则以原子方式将该标志的值设置为给定的更新值。
boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) //如果当前引用 == 预期引用,并且当前标志等于预期标志, 则以原子方式将该引用和该标志的值设置为给定的更新值。
V get(int[] stampHolder) //返回该引用和该标志的当前值。
V getReference() //返回该引用的当前值。
int getStamp() // 返回该标志的当前值。
void set(V newReference, int newStamp) // 无条件地同时设置该引用和标志的值。
boolean weakCompareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) //如果当前引用 == 预期引用,并且当前标志等于预期标志,则以原子方式将该引用和该标志的值设置为给定的更新值。

看来看一下AtomicMarkableReference的方法:

构造方法:
AtomicMarkableReference(V initialRef, boolean initialMark) //创建具有给定初始值的新 AtomicMarkableReference。

方法:
boolean attemptMark(V expectedReference, boolean newMark) //如果当前引用 == 预期引用,则以原子方式将该标记的值设置为给定的更新值。
boolean compareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark) //如果当前引用 == 预期引用,并且当前标记等于预期标记,那么以原子方式将引用和标记的值设置为给定的更新值。
V get(boolean[] markHolder) // 返回该引用和该标记的当前值。
V getReference() //返回该引用的当前值。
boolean isMarked() //返回该标记的当前值。
void set(V newReference, boolean newMark) //无条件地同时设置该引用和标记的值。
boolean weakCompareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark) //如果当前引用 == 预期引用,并且当前标记等于预期标记,那么以原子方式将引用和标记的值设置为给定的更新值。

AtomicMarkableReference类描述的一个(Object,Boolean)的对,可以原子的修改Object或者Boolean的值,这种数据结构在一些缓存或者状态描述中比较有用。这种结构在单个或者同时修改Object/Boolean的时候能够有效的提高吞吐量。

AtomicStampedReference类维护带有整数“标志”的对象引用,可以用原子方式对其进行更新。对比AtomicMarkableReference类的(Object,Boolean),AtomicStampedReference维护的是一种类似(Object,int)的数据结构,其实就是对对象(引用)的一个并发计数。但是与AtomicInteger不同的是,此数据结构可以携带一个对象引用(Object),并且能够对此对象和计数同时进行原子操作。

下面我们就AtomicStampedReference 的使用举一个例子:

public class AtomicStampedReferenceTest {
public static void main(String[] args) {
AtomicInteger atomicInt = new AtomicInteger(100);
atomicInt.compareAndSet(100, 101);
atomicInt.compareAndSet(101, 100);
System.out.println("new value = " + atomicInt.get());
boolean result1 = atomicInt.compareAndSet(100, 101);
System.out.println(result1); // result:true

AtomicInteger v1 = new AtomicInteger(100);
AtomicInteger v2 = new AtomicInteger(101);
AtomicStampedReference<AtomicInteger> stampedRef = new AtomicStampedReference<AtomicInteger>(v1, 0);

int stamp = stampedRef.getStamp();
stampedRef.compareAndSet(v1, v2, stampedRef.getStamp(),
stampedRef.getStamp() + 1);

System.out.println(stampedRef.getStamp());
stampedRef.compareAndSet(v2, v1, stampedRef.getStamp(),
stampedRef.getStamp() + 1);

System.out.println("new value = " + stampedRef.getReference());
boolean result2 = stampedRef.compareAndSet(v1, v2, stamp, stamp + 1);
System.out.println(result2); // result:false
}
}

输出结果为:

new value = 100
true
1
new value = 100
false

Process finished with exit code 0

上面的输出结果可以看到AtomicInteger 执行cas操作成功,AtomicStampedReference执行cas操作失败。我们可以看到在24行compareAndSet(v1, v2, stamp, stamp + 1)中第三个参数期待的标志位值为1,但是经过上面两次的值变更,stampedRef.getStamp()已经是2了,所以此刻期待值与内存中的标志位值不符,操作失败。

AtomicMarkableReference的使用方法也是类似,在此就不另做介绍,大家可以多多使用才会知道这些类所带来的好处。