当前位置: 首页 > 科技观察

为什么是原子引用?

时间:2023-03-13 07:29:02 科技观察

我们之前学习过AtomicInteger、AtomicLong、AtomicBoolean等原子工具类。下面继续了解java.util.concurrent.atomic包下的工具类。AtomicInteger、AtomicLong、AtomicBoolean的相关信息,请参考AtomicXXX的神奇之旅。关于AtomicReference等JDK工具类理解的文章比较枯燥,不代表文章质量下降,因为我要打造一整套bestJavaer的全方位解析,势在必行离不开对JDK工具的了解。请记住:技术是长期的。AtomicReference的基本使用这里先说说老生常谈的账户问题,通过个人银行账户问题逐步介绍AtomicReference的使用。先看个人账户基础类publicclassBankCard{privatefinalStringaccountName;privatefinalintmoney;//构造函数初始化accountName和moneypublicBankCard(StringaccountName,intmoney){this.accountName=accountName;this.money=money;}//不提供任何set方法给修改个人账户,只提供get方法publicStringgetAccountName(){returnaccountName;}publicintgetMoney(){returnmoney;}//重写toString()方法打印BankCard@OverridepublicStringtoString(){return"BankCard{"+"accountName='"+accountName+'\''+",money='"+money+'\''+'}';}}个人账户类只有两个字段:accountName和money。这两个字段代表账户名称和账户金额。账户名称和账户金额一经设置,将无法修改。现在假设有多个人分别向这个账户赚钱,每次都存入一定数量的钱,在理想状态下,每个人每次赚到钱后,账户的金额会不断增加。下面我们来验证一下。看看这个过程。publicclassBankCardTest{privatestaticvolatileBankCardbankCard=newBankCard("cxuan",100);publicstaticvoidmain(String[]args){for(inti=0;i<10;i++){newThread(()->{//首先读取全局引用finalBankCardcard=bankCard;//构造一个新账户并存入一定金额ofmoneyBankCardnewCard=newBankCard(card.getAccountName(),card.getMoney()+100);System.out.println(newCard);//最后把新账户的reference赋给原账户bankCard=newCard;try{TimeUnit.MICROSECONDS.sleep(1000);}catch(Exceptione){e.printStackTrace();}}).start();}}}在上面的代码中,我们首先声明了一个全局变量BankCard,这个BankCard是通过volatile修改,目的是改变其引用后对其他线程可见,然后每个付款人存入一定金额后,输出账户金额发生变化,我们可以观察输出。可以看出,我们预计最后的结果应该是1100元,但是最后只存入了900元,那么这200元去哪了呢?我们可以得出结论,上面的代码不是线程安全的操作。哪里有问题?虽然每个volatile都可以保证每个账户的金额是最新的,但是由于上面几个步骤的组合操作,即获取accountreference和changeaccountreference,虽然每个单独的操作都是Atomic的,但是组合在一起就可以了不是原子的。所以最后的结果会有偏差。我们可以用下面的线程切换图来表示这个过程中的变化。可以看出最终的结果可能是因为线程t1获取到最新的账户变化后,线程切换到t2,t2也获取到最新的账户信息,然后切换到t1,t1修改引用,线程切换到t2,t2修改了引用,所以账户引用的值被修改了两次。那么如何保证获取引用和修改引用之间的线程安全呢?最简单粗暴的方式就是直接使用synchronized关键字加锁。使用synchronized保证线程安全使用synchronized保证共享数据的安全,代码如下10;i++){newThread(()->{synchronized(BankCardSyncTest.class){//先读取全局引用finalBankCardcard=bankCard;//构造一个新账户并存入一定数量的钱BankCardnewCard=newBankCard(card.getAccountName(),card.getMoney()+100);System.out.println(newCard);//最后将新账户的reference赋给原账户bankCard=newCard;try{TimeUnit.MICROSECONDS.sleep(1000);}catch(Exceptione){e.printStackTrace();}}}).start();}}}与BankCardTest相比,BankCardSyncTest增加了同步锁。运行BankCardSyncTest后,我们发现可以得到正确的结果。将BankCardSyncTest.class修改为一个bankCard对象,我们发现线程安全也能得到保证,因为在这个程序中,只会改变bankCard,不会有其他的共享数据。如果还有其他共享数据,我们需要使用BankCardSyncTest.clas来保证线程安全。另外java.util.concurrent.atomic包下的AtomicReference也可以保证线程安全。我们先来认识一下AtomicReference,然后用AtomicReference重写上面的代码。理解AtomicReference使用AtomicReference保证线程安全我们重写上面的例子publicstaticvoidmain(String[]args){for(inti=0;i<10;i++){newThread(()->{while(true){//使用AtomicReference.get获取finalBankCardcard=bankCardRef.get();BankCardnewCard=newBankCard(card.getAccountName(),card.getMoney()+100);//使用CAS乐观锁进行非阻塞更新if(bankCardRef.compareAndSet(card,newCard)){System.out.println(newCard);}try{TimeUnit.SECONDS.sleep(1);}catch(Exceptione){e.printStackTrace();}}}).start();}}}在上面的示例代码中,我们使用AtomicReference封装了BankCard引用,然后使用get()方法获取原子引用,然后使用CAS乐观锁进行非阻塞更新。更新标准是如果使用bankCardRef.get()获取的值等于内存值,则银行卡账户中的资金+100,我们观察一下输出结果。可以看到有些输出是乱序执行的。原因很简单。可以在输出结果之前进行线程切换,然后打印后面线程的值,再切换线程返回输出,但是可以看到,没有出现银行卡金额相同的情况。AtomicReference源码分析了解了上面的例子之后,我们来看看AtomicReference的用法。AtomicReference与AtomicInteger非常相似。它们在内部都使用了以下三个属性。unsafe是sun.misc包下的一个类。AtomicReference主要依赖于sun.misc.Unsafe提供的一些原生方法来保证操作的原子性。Unsafe的objectFieldOffset方法可以得到成员属性在内存中的地址相对于对象内存地址的偏移量。这个偏移量也是valueOffset。简单点说就是找到这个变量在内存中的地址,这样以后就可以直接通过内存地址来操作了。value是AtomicReference中的实际值,因为volatile,这个值实际上是内存值。不同的是AtomicInteger是对整数的封装,而AtomicReference对应的是普通的对象引用。也就是说,当你修改对象引用时,它可以保证线程安全。get和set先来看最简单的get和set方法:get():获取当前AtomicReference的值set():设置当前AtomicReference的值get()可以原子读取AtomicReference中的数据,set()可以原子地设置当前值,因为get()和set()最终都是作用于value变量,而value是通过volatile修改的,所以get和set相当于读取和设置内存。你知道lazySet方法volatile有内存屏障吗?什么是内存屏障?内存屏障,又称内存屏障、内存壁垒、屏障指令等,是同步屏障指令的一种,是CPU或编译器用来随机访问内存操作中的一个同步点,使得所有的读和在可以执行此点之后的操作之前执行此点之前的写操作。它也是一种使CPU处理单元中的内存状态对其他处理单元可见的技术。CPU用了很多优化,缓存,指令重排等等,最终目的还是为了性能。也就是说,当一个程序执行时,只要最终结果相同,指令是否重新排列都无所谓。所以指令的执行时序并不是按顺序执行的,而是乱序的,这样会造成很多问题,这也促使了内存屏障的出现。从语义上讲,内存屏障之前的所有写操作都必须写入内存;内存屏障之后的读操作可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,可以在写操作之后和读操作之前插入内存屏障。内存屏障的开销很轻,但是再小也是有开销的。LazySet就是这样做的。它以普通变量的形式读写变量。也可以说:懒得设置屏障getAndSet方法自动设置为给定值并返回旧值。其源代码如下。它将在不安全的情况下调用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内存不要超过32G,为了节省空间,在JDK1.6版本之后,我们可以在64位JVM中启用指针压缩(UseCompressedOops)来压缩我们对象指针的大小,帮助我们节省内存空间。在JDK8中,该指令默认启用。如果不开启指针压缩,64位JVM会使用8个字节(64位)来存储真实内存地址,相比之前使用4个字节(32位)压缩存储地址带来的问题:增加GC开销:64位对象的引用需要占用更多的堆空间,留给其他数据的空间会减少,从而加速GC的发生,更频繁地执行GC。降低CPU缓存命中率:64位对象引用增加,CPU可以缓存的oops更少,从而降低CPU缓存的效率。因为内存地址64位存储会带来这么多问题,所以程序员发明了指针压缩技术,让我们可以用前面的4个字节来存储指针地址,扩大内存存储空间。可以看出atomic_compare_exchange_oop方法底层也是使用Atomic:cmpxchg方法进行CAS交换,然后对旧值进行解码返回(本人有限的C++知识只能在这里解析,如果看懂这段代码的请指教)告诉我,让我问一波)weakCompareAndSet方法weakCompareAndSet:靠,我很仔细的看了好几遍,发现JDK1.8的这个方法和compareAndSet方法一模一样,骗我。..但事实真的如此吗?不会,JDK源码博大精深,不会设计重复的方法。仔细想想,JDK团队不会犯这么低级的团队,但这是什么原因呢?《Java 高并发详解》这本书给了我们答案。总结本文主要介绍了AtomicReference的产生背景,AtomicReference的使用场景,介绍了AtomicReference的源码和关键方法的源码分析。这篇AtomicReference文章基本涵盖了网上所有关于AtomicReference的内容。不幸的是,cpp源代码可能没有很好地分析。这需要足够的C/C++编程知识。如果有读者有最新的研究,请及时告诉我结果。本文转载自微信公众号“JavaBuilder”,可通过以下二维码关注。如需转载本文,请联系Java开发者公众号。