Java并发必知必会第三弹:用积木讲解ABA原理

简介: Java并发必知必会第三弹:用积木讲解ABA原理

Java并发必知必会第三弹:用积木讲解ABA原理

封面图

号外:可落地的 Spring Cloud项目:PassJava

本篇主要内容如下

本篇主要内容

一、背景

4个A

上一节我们讲了程序员深夜惨遭老婆鄙视,原因竟是CAS原理太简单?,留了一个彩蛋给大家,ABA问题是怎么出现的,为什么不是AAB拖拉机,AAA金花,4个A炸弹 ?这一篇我们再来揭开ABA的神秘面纱。

二、面试连环炮

面试的时候我们也经常遭遇面试官的连环追问:

  • CAS概念?
  • Unsafe类是干啥用的?
  • CAS底层实现是怎么样的
  • ABA问题什么场景下会出现?
  • ABA有什么危害?
  • 原子引用更新是啥?
  • 如何避免ABA问题?

面试连环炮

三、用积木讲解ABA问题

案例:甲看见一个三角形积木,觉得不好看,想替换成五边形,但是乙想把积木替换成四边形。(前提条件,只能被替换一次)

用积木讲解ABA过程

可能出现的过程如上图所示:

  • 第一步:乙先抢到了积木,将三角形A积木替换成五角星B1
  • 第二步:乙将五角星B1替换成五边形B2
  • 第三步:乙将五边形B2替换成棱形B3
  • 第四步:乙将棱形B3替换成六边形B4
  • 第五步:乙将六边形B4替换成三角形A
  • 第六步:甲看到积木还是三角形,认为乙没有替换,甲可以进行替换
  • 第七步:甲将三角形V替换成了五边形B

讲解:第一步道第五步,都是乙在替换,但最后还是替换成了三角形(即时不是同一个三角形),这个就是ABA,A指最开始是三角形,B指中间被替换的B1/B2/B3/B4,第二个A就是第五步中的A,中间不论经过怎么样的形状替换,最后还是变成了三角形。然后甲再将A2和A1进行形状比较,发现都是三角形,所以认为乙没有动过积木,甲可以进行替换。这个就是比较并替换(CAS)中的ABA问题。

小结:CAS只管开头和结尾,中间过程不关心,只要头尾相同,则认为可以进行修改,而中间过程很可能被其他人改过。

四、用原子引用类演示ABA问题

AtomicReference:原子引用类

  • 1.首先我们需要定义一个积木类
/**
 积木类
 * @author: 悟空聊架构
 * @create: 2020-08-25
 */
class BuildingBlock {
    String shape;
    public BuildingBlock(String shape) {
        this.shape = shape;
    }
    @Override
    public String toString() {
        return "BuildingBlock{" + "shape='" + shape + '}';
    }
}
  • 2.定义3个积木:三角形A,四边形B,五边形D
static BuildingBlock A = new BuildingBlock("三角形");
// 初始化一个积木对象B,形状为四边形
static BuildingBlock B = new BuildingBlock("四边形");
// 初始化一个积木对象D,形状为五边形
static BuildingBlock D = new BuildingBlock("五边形");
  • 初始化原子引用类
static AtomicReference<BuildingBlock> atomicReference = new AtomicReference<>(A);
  • 4.线程“乙”执行ABA操作
new Thread(() -> {// 初始化一个积木对象A,形状为三角形
           atomicReference.compareAndSet(A, B); // A->B
           atomicReference.compareAndSet(B, A); // B->A
        }, 
  • 5.线程“甲”执行比较并替换
new Thread(() -> {// 初始化一个积木对象A,形状为三角形
           try {
               // 睡眠一秒,保证t1线程,完成了ABA操作
               TimeUnit.SECONDS.sleep(1);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
           // 可以替换成功,因为乙线程执行了A->B->A,形状没变,所以甲可以进行替换。
           System.out.println(atomicReference.compareAndSet(A, D) + "\t" + atomicReference.get()); // true    BuildingBlock{shape='五边形}
       }, "甲").start();

输出结果:true BuildingBlock{shape='五边形}

小结:当线程“乙”执行ABA之后,线程“甲”比较后,发现预期值和当前值一致,将三角形替换成了五边形。

五、那ABA到底有什么危害?

我们看到乙不管怎么进行操作,甲看到的还是三角形,那甲当成乙没有改变积木形状 又有什么问题呢?

出现的问题场景通常是带有消耗类的场景,比如库存减少,商品卖出。

1.我们想象一下生活中的这个喝水场景:

ABA喝水场景

(1)一家三口人,爸爸、妈妈、儿子。

(2)一天早上6点,妈妈给儿子的水杯灌满了水(水量为A),儿子先喝了一半(水量变成B)。

(3)然后妈妈把水杯又灌满了(水量为A),等中午再喝(妈妈执行了一个ABA操作)。

(4)爸爸7点看到水杯还是满的(不知道是妈妈又灌满的),于是给儿子喝了1/3(水量变成D)

(5)那在中午之前,儿子喝了1/2+1/3= 5/6的水,这不是妈妈期望的,因为妈妈只想让儿子中午之前喝半杯水。

这个场景的ABA问题带来的后果就是本来只用喝1/2的水,结果喝了5/6的水。

2.我们再想象一下电商中的场景

(1)商品Y的库存是10(A)

(2)用户m购买了5件(B)

(3)运营人员乙补货5件(A)(乙执行了一个ABA操作)

(4)运营人员甲看到库存还是10,就认为一件也没有卖出去(不考虑交易记录),其实已经卖出去了5件。

那我们怎么解决原子引用的问题呢?

可以用加版本号的方式来解决两个A相同的问题,比如上面的积木案例,我们可以给两个三角形都打上一个版本号的标签,如A1和A2,在第六步中,形状和版本号一致甲才可以进行替换,因形状都是三角形,而版本号一个1,一个是2,所以不能进行替换。

ABA问题的解决方案

在Java代码中,我们可以用原子时间戳引用类型:AtomicStampedReference

六、带版本号的原子引用类型

1.我们看一看这个原子类AtomicStampedReference的底层代码

比较并替换方法compareAndSet

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}

expectedReference:期望值

newReference:替换值

expectedStamp:期望版本号

newStamp:替换版本号

先比较期望值expectedReference和当前值是否相等,以及期望版本号和当前版本号是否相等,如果两者都相等,则表示没有被修改过,可以进行替换。

2.如何使用AtomicStampedReference?

代码示例的原理图

(1)先定义3个积木:三角形A,四边形B,五边形D

// 初始化一个积木对象A,形状为三角形
BuildingBlock A = new BuildingBlock("三角形");

// 初始化一个积木对象B,形状为四边形,乙会将三角形替换成四边形
BuildingBlock B = new BuildingBlock("四边形");

// 初始化一个积木对象B,形状为四边形,乙会将三边形替换成五边形
BuildingBlock D = new BuildingBlock("五边形");

(2)创建一个原子引用类型的实例 atomicReference

 // 传递两个值,一个是初始值,一个是初始版本号
 AtomicStampedReference<BuildingBlock> atomicStampedReference = new AtomicStampedReference<>(A, 1);

(3)创建一个线程“乙”执行ABA操作

new Thread(() -> {
    // 获取版本号
    int stamp = atomicStampedReference.getStamp();
    System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);
    // 暂停线程“乙”1秒钟,使线程“甲”可以获取到原子引用的版本号
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    /*
    * 乙线程开始ABA替换
    * */
    // 1.比较并替换,传入4个值,期望值A,更新值B,期望版本号,更新版本号
    atomicStampedReference.compareAndSet(A, B, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
    System.out.println(Thread.currentThread().getName() + "\t 第二次版本号" + atomicStampedReference.getStamp()); //乙     第一次版本号1
    // 2.比较并替换,传入4个值,期望值B,更新值A,期望版本号,更新版本号
    atomicStampedReference.compareAndSet(B, A, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1); // 乙     第二次版本号2
    System.out.println(Thread.currentThread().getName() + "\t 第三次版本号" + atomicStampedReference.getStamp()); // 乙     第三次版本号3
}, "乙").start();

1)乙先获取原子类的版本号,第一次获取到的版本号为1

2)暂停线程“乙”1秒钟,使线程“甲”可以获取到原子引用的版本号

3)比较并替换,传入4个值,期望值A,更新值B,期望版本号stamp,更新版本号stamp+1。A被替换为B,当前版本号为2

4)比较并替换,传入4个值,期望值B,更新值A,期望版本号getStamp(),更新版本号getStamp()+1。B替换为A,当前版本号为3

(4)创建一个线程“甲”执行D替换A操作

new Thread(() -> {
     // 获取版本号
     int stamp = atomicStampedReference.getStamp();
     System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp); // 甲   第一次版本号1
     // 暂停线程“甲”3秒钟,使线程“乙”进行一次ABA替换操作
     try {
     TimeUnit.SECONDS.sleep(3);
     } catch (InterruptedException e) {
         e.printStackTrace();
     }
     boolean result = atomicStampedReference.compareAndSet(A,D,stamp,stamp + 1);
     System.out.println(Thread.currentThread().getName() + "\t 修改成功否" + result + "\t 当前最新实际版本号:" + atomicStampedReference.getStamp()); // 甲     修改成功否false     当前最新实际版本号:3
     System.out.println(Thread.currentThread().getName() + "\t 当前实际最新值:" + atomicStampedReference.getReference()); // 甲     当前实际最新值:BuildingBlock{shape='三角形}

}, "甲").start();

(1)甲先获取原子类的版本号,版本号为1,因为乙线程还未执行ABA,所以甲获取到的版本号和乙获取到的版本号一致。

(2)暂停线程“甲”3秒钟,使线程“乙”进行一次ABA替换操作

(3)乙执行完ABA操作后,线程甲执行比较替换,期望为A,实际是A,版本号期望值是1,实际版本号是3

(4)虽然期望值和实际值都是A,但是版本号不一致,所以甲不能将A替换成D,这个就避免了ABA的问题。

小结: 带版本号的原子引用类可以利用CAS+版本号来比较变量是否被修改。

总结

本篇分析了ABA产生的原因,然后又列举了生活中的两个案例来分析ABA的危害。然后提出了怎么解决ABA问题:用带版本号的原子引用类AtomicStampedReference。

限于篇幅和侧重点,CAS的优化并没有涉及到,后续再倒腾这一块吧。另外AtomicStampedReference的缺点本篇本没有进行讲解,限于笔者的技术水平原因,并没有一一作答,期待后续能补上这一块的解答。

我是悟空,一只努力变强的码农!我要变身超级赛亚人啦!

相关文章
|
14天前
|
存储 Java 关系型数据库
高效连接之道:Java连接池原理与最佳实践
在Java开发中,数据库连接是应用与数据交互的关键环节。频繁创建和关闭连接会消耗大量资源,导致性能瓶颈。为此,Java连接池技术通过复用连接,实现高效、稳定的数据库连接管理。本文通过案例分析,深入探讨Java连接池的原理与最佳实践,包括连接池的基本操作、配置和使用方法,以及在电商应用中的具体应用示例。
33 5
|
24天前
|
存储 算法 Java
Java HashSet:底层工作原理与实现机制
本文介绍了Java中HashSet的工作原理,包括其基于HashMap实现的底层机制。通过示例代码展示了HashSet如何添加元素,并解析了add方法的具体过程,包括计算hash值、处理碰撞及扩容机制。
|
4天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
4天前
|
Java
Java之CountDownLatch原理浅析
本文介绍了Java并发工具类`CountDownLatch`的使用方法、原理及其与`Thread.join()`的区别。`CountDownLatch`通过构造函数接收一个整数参数作为计数器,调用`countDown`方法减少计数,`await`方法会阻塞当前线程,直到计数为零。文章还详细解析了其内部机制,包括初始化、`countDown`和`await`方法的工作原理,并给出了一个游戏加载场景的示例代码。
Java之CountDownLatch原理浅析
|
6天前
|
Java 索引 容器
Java ArrayList扩容的原理
Java 的 `ArrayList` 是基于数组实现的动态集合。初始时,`ArrayList` 底层创建一个空数组 `elementData`,并设置 `size` 为 0。当首次添加元素时,会调用 `grow` 方法将数组扩容至默认容量 10。之后每次添加元素时,如果当前数组已满,则会再次调用 `grow` 方法进行扩容。扩容规则为:首次扩容至 10,后续扩容至原数组长度的 1.5 倍或根据实际需求扩容。例如,当需要一次性添加 100 个元素时,会直接扩容至 110 而不是 15。
Java ArrayList扩容的原理
|
7天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
12天前
|
存储 Java 关系型数据库
在Java开发中,数据库连接是应用与数据交互的关键环节。本文通过案例分析,深入探讨Java连接池的原理与最佳实践
在Java开发中,数据库连接是应用与数据交互的关键环节。本文通过案例分析,深入探讨Java连接池的原理与最佳实践,包括连接创建、分配、复用和释放等操作,并通过电商应用实例展示了如何选择合适的连接池库(如HikariCP)和配置参数,实现高效、稳定的数据库连接管理。
30 2
|
12天前
|
Java 数据库连接 数据库
如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面
本文介绍了如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面。通过合理配置初始连接数、最大连接数和空闲连接超时时间,确保系统性能和稳定性。文章还探讨了同步阻塞、异步回调和信号量等并发控制策略,并提供了异常处理的最佳实践。最后,给出了一个简单的连接池示例代码,并推荐使用成熟的连接池框架(如HikariCP、C3P0)以简化开发。
30 2
|
15天前
|
Java 数据格式 索引
使用 Java 字节码工具检查类文件完整性的原理是什么
Java字节码工具通过解析和分析类文件的字节码,检查其结构和内容是否符合Java虚拟机规范,确保类文件的完整性和合法性,防止恶意代码或损坏的类文件影响程序运行。
|
12天前
|
算法 Java 数据库连接
Java连接池技术,从基础概念出发,解析了连接池的工作原理及其重要性
本文详细介绍了Java连接池技术,从基础概念出发,解析了连接池的工作原理及其重要性。连接池通过复用数据库连接,显著提升了应用的性能和稳定性。文章还展示了使用HikariCP连接池的示例代码,帮助读者更好地理解和应用这一技术。
27 1