了解 AtomicReference
使用 AtomicReference 保证线程安全性
下面我们改写一下上面的那个示例
public class BankCardARTest { private static AtomicReference<BankCard> bankCardRef = new AtomicReference<>(new BankCard("cxuan",100)); public static void main(String[] args) { for(int i = 0;i < 10;i++){ new Thread(() -> { while (true){ // 使用 AtomicReference.get 获取 final BankCard card = bankCardRef.get(); BankCard newCard = new BankCard(card.getAccountName(), card.getMoney() + 100); // 使用 CAS 乐观锁进行非阻塞更新 if(bankCardRef.compareAndSet(card,newCard)){ System.out.println(newCard); } try { TimeUnit.SECONDS.sleep(1); } catch (Exception e) { e.printStackTrace(); } } }).start(); } } }
在上面的示例代码中,我们使用了 AtomicReference 封装了 BankCard 的引用,然后使用 get()
方法获得原子性的引用,接着使用 CAS 乐观锁进行非阻塞更新,更新的标准是如果使用 bankCardRef.get() 获取的值等于内存值的话,就会把银行卡账户的资金 + 100,我们观察一下输出结果。
可以看到,有一些输出是乱序执行的,出现这个原因很简单,有可能在输出结果之前,进行线程切换,然后打印了后面线程的值,然后线程切换回来再进行输出,但是可以看到,没有出现银行卡金额相同的情况。
AtomicReference 源码解析
在了解上面这个例子之后,我们来看一下 AtomicReference 的使用方法
AtomicReference 和 AtomicInteger 非常相似,它们内部都是用了下面三个属性
Unsafe
是 sun.misc
包下面的类,AtomicReference 主要是依赖于 sun.misc.Unsafe 提供的一些 native 方法保证操作的原子性
。
Unsafe 的 objectFieldOffset
方法可以获取成员属性在内存中的地址相对于对象内存地址的偏移量。这个偏移量也就是 valueOffset
,说得简单点就是找到这个变量在内存中的地址,便于后续通过内存地址直接进行操作。
value
就是 AtomicReference 中的实际值,因为有 volatile ,这个值实际上就是内存值。
不同之处就在于 AtomicInteger 是对整数的封装,而 AtomicReference 则对应普通的对象引用
。也就是它可以保证你在修改对象引用时的线程安全性。
get and set
我们首先来看一下最简单的 get 、set 方法:
get()
: 获取当前 AtomicReference 的值
set()
: 设置当前 AtomicReference 的值
get() 可以原子性的读取 AtomicReference 中的数据,set() 可以原子性的设置当前的值,因为 get() 和 set() 最终都是作用于 value 变量,而 value 是由 volatile
修饰的,所以 get 、set 相当于都是对内存进行读取和设置。
lazySet 方法
volatile 有内存屏障你知道吗?
内存屏障是啥啊?
内存屏障,也称
内存栅栏
,内存栅障,屏障指令等, 是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。也是一个让CPU 处理单元中的内存状态对其它处理单元可见的一项技术。
CPU 使用了很多优化,使用缓存、指令重排等,其最终的目的都是为了性能,也就是说,当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。所以指令的执行时序并不是顺序执行的,而是乱序执行的,这就会带来很多问题,这也促使着内存屏障的出现。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
内存屏障的开销非常轻量级,但是再小也是有开销的,LazySet 的作用正是如此,它会以普通变量的形式来读写变量。
也可以说是:懒得设置屏障了
getAndSet 方法
以原子方式设置为给定值并返回旧值。它的源码如下
它会调用 unsafe
中的 getAndSetObject 方法,源码如下
可以看到这个 getAndSet 方法涉及两个 cpp 实现的方法,一个是 getObjectVolatile
,一个是 compareAndSwapObject
方法,他们用在 do...while 循环中,也就是说,每次都会先获取最新对象引用的值,如果使用 CAS 成功交换两个对象的话,就会直接返回 var5
的值,var5 此时应该就是更新前的内存值,也就是旧值。
compareAndSet 方法
这就是 AtomicReference 非常关键的 CAS 方法了,与 AtomicInteger 不同的是,AtomicReference 是调用的 compareAndSwapObject
,而 AtomicInteger 调用的是 compareAndSwapInt
方法。这两个方法的实现如下
路径在 hotspot/src/share/vm/prims/unsafe.cpp
中。
我们之前解析过 AtomicInteger 的源码,所以我们接下来解析一下 AtomicReference 源码。
因为对象存在于堆中,所以方法 index_oop_from_field_offset_long
应该是获取对象的内存地址,然后使用 atomic_compare_exchange_oop
方法进行对象的 CAS 交换。
这段代码会首先判断是否使用了 UseCompressedOops
,也就是指针压缩
。
这里简单解释一下指针压缩的概念:JVM 最初的时候是 32 位的,但是随着 64 位 JVM 的兴起,也带来一个问题,内存占用空间更大了 ,但是 JVM 内存最好不要超过 32 G,为了节省空间,在 JDK 1.6 的版本后,我们在 64位中的 JVM 中可以开启指针压缩(UseCompressedOops)
来压缩我们对象指针的大小,来帮助我们节约内存空间,在 JDK 8来说,这个指令是默认开启的。
如果不开启指针压缩的话,64 位 JVM 会采用 8 字节(64位)存储真实内存地址,比之前采用4字节(32位)压缩存储地址带来的问题:
- 增加了 GC 开销:64 位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少, 从而加快了 GC 的发生,更频繁的进行 GC。
- 降低 CPU 缓存命中率:64 位对象引用增大了,CPU 能缓存的 oop 将会更少,从而降低了 CPU 缓存的效率。
由于 64 位存储内存地址会带来这么多问题,程序员发明了指针压缩技术,可以让我们既能够使用之前 4 字节存储指针地址,又能够扩大内存存储。
可以看到,atomic_compare_exchange_oop 方法底层也是使用了 Atomic:cmpxchg
方法进行 CAS 交换,然后把旧值进行 decode 返回 (我这局限的 C++ 知识,只能解析到这里了,如果大家懂这段代码一定告诉我,让我请教一波)
weakCompareAndSet 方法
weakCompareAndSet
: 妈的非常认真看了好几遍,发现 JDK1.8 的这个方法和 compareAndSet 方法完全一摸一样啊,坑我。。。
但是真的是这样么?并不是,JDK 源码很博大精深,才不会设计一个重复的方法,你想想 JDK 团队也不是会犯这种低级团队,但是原因是什么呢?
《Java 高并发详解》这本书给出了我们一个答案。
总结
此篇文章主要介绍了 AtomicReference 的出现背景,AtomicReference 的使用场景,以及介绍了 AtomicReference 的源码,重点方法的源码分析。此篇 AtomicReference 的文章基本上涵盖了网络上所有关于 AtomicReference 的内容了,遗憾的是就是 cpp 源码可能分析的不是很到位,这需要充足的 C/C++ 编程知识,如果有读者朋友们有最新的研究成果,请及时告诉我。
</div>