Java并发01---JMM模型、Volatile、CAS操作、自旋锁、ABA问题

本章内容将介绍Java并发编程的基础知识,包括JMM模型、Volatile、CAS操作、自旋锁、ABA问题

JMM(Java Memory Model)

首先要明确的是JMM与JVM内存结构不是同一个概念,记的时候不要记混。

我们先来回顾一下JVM内存结构,其包括了堆、方法区、虚拟机栈、程序计数器、本地方法区,其中前二者为线程共享,后三者为线程私有。

其中的线程共享/私有就与我们要介绍的JMM有关。

JMM采用了一种共享数据模型,即JVM在内存中会有一块主内存,并且每一个线程也会有自己的线程内存。这不与JVM内存结构中线程共享/私有的概念恰好一致吗,实际上也是这样的,JMM主内存即为堆,线程内存即为虚拟机栈、程序计数器和本地方法区。

每个线程在执行的时候首先会将数据从主内存中加载至私有内存然后再进行操作。

Volatile修饰

知道JMM模型的主要内容之后,让我们想象一个Java使用场景:

  1. 首先线程A创建对象Integer a = 10;此时JVM会为a在堆中分配一块内存,此时线程A运行结束。
  2. 线程B,线程C被创建,这两者都要对a进行修改,所以JVM会将a复制到B和C的栈中,BC两者分别都对a进行a = a + 1操作,在B中此时a=2,操作结束之后a=3,在C中此时a=2,操作结束之后a=3,操作结束之后这两个线程会将a写回堆内存中。此时问题就发生了,a虽然进行了两次+1操作但是结果仍然是3。

为了解决这个问题我们可以对a进行volatile修饰,volatile关键字会使被修饰的变量有两个特点:

  1. 变量在内存中的可见性
    (1) 当有线程对volatile变量进行修改的时候JVM会强制将该变量在主内存中数据的进行刷新。
    (2) 这种刷新会让别的线程已被加载的该变量无效化。

  2. 禁止指令重排序

这样子似乎就能解决并发问题了,可是volatile虽然能保证变量在内存中的可见性,但是却无法保证原子性。

让我们再想象一个使用场景:

  1. 首先我们有volatile Integer a = 10;
  2. 线程A要进行以下操作a++;
  3. 线程B要进行以下操作a = 0;
  4. A与B并发执行,由于a++这个操作并不是原子操作,在实际执行的时候会先获得a值再进行自加,所以实际操作的过程中获得a值之后可能会被中断。理想的结果是A中的a最后等于11,但是假如A执行一半被中断了,转换为B去执行,B执行完之后A继续,那么此时结果就变成了1。

我们可以发现,volatile虽然很好,但是还不够好。为了解决上面的问题,我们可以对A线程加锁,使用sychronized对该代码段进行修饰,但是sychronized是一种阻塞式的独占锁悲观锁,在这里介绍一种非阻塞的乐观锁,i.e. 自旋锁,所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。在自旋锁内部里有一个非常重要的概念:CAS方法

CAS(Compare And Swap)

  1. 首先CAS是一种思想。

  2. 其次CAS是一种CPU的原子指令。

  3. 最后在Java的concurrent包中有一系列基于CAS的native方法,如compareAndSet

那么什么是CAS?

顾名思义,CAS(Compare And Swap)先要比较内存中实际的数值与预期数值是否相等,若相等则将内存中的实际值设置为新值。

来看代码示例:

使用Java的concurrent包中有原子类如AtomicInteger,其实现了CAS的操作方法。

AtomicInteger a = new AtomicInteger(10);
System.out.println(a.compareAndSet(10, 20) + " " + a);
System.out.println(a.compareAndSet(10, 30) + " " + a);

// 输出结果:
// true 20
// false 20

首先a为10,第一步compareAndSet预期值为10,实际值为10,可以成功将a设置为新值20。
第二步compareAndSet预期值为10,实际值为20,设置新值失败。

通过CAS操作,我们就可以避免上面提到的并发问题。

下面是一个基于CAS方法的乐观锁自增实现,摘自Java乐观锁的实现原理和典型案例

public class Counter {
    private AtomicInteger value = new AtomicInteger(0);//实际值

    public void increment() {
        int expect;
        int update;

        do {
            expect = value.get(); // 预期值
            update = expect + 1; // 新值
 // 比较value与expect是否相等,相等则使用update更新value
        } while (!value.compareAndSet(expect, update));
    }
}

在上述代码中如果在do代码块中被中断并且value被修改,则value不会被更新,继续循环,直到do代码块不被中断,则value可以被更新,循环结束。

不难看出,这段代码实际上就是一个乐观锁的实现,并且具有以下特点。

  1. 不会阻塞线程。i.e. 非阻塞
  2. 总是假设value没有被更新,若检测到value被更新了(线程冲突),则更新失败。i.e. 乐观
  3. 若更新失败则进入循环,i.e. 自旋

所以说,CAS与自旋并不是同一个概念,自旋的过程每次都是一个CAS操作。

但是CAS会导致一些问题:

  1. do代码块一直被中断,这个锁就会陷入死循环。(Java使用锁的膨胀来解决此问题)
  2. do代码块被中断但是到最后别的线程又把值给改回了预期值,CAS会认为没有线程冲突而成功进行更新的操作,i.e. ABA问题

ABA问题

CAS 的使用流程通常如下,摘自面试必问的CAS,你懂了吗?

  1. 首先从地址 V 读取值 A;
  2. 根据 A 计算目标值 B;
  3. 通过 CAS 以原子的方式将地址 V 中的值从 A 修改为 B。

若在第一步之后有别的线程修改V,然后在第三步之前又将V改回了A这个值,这就产生了ABA问题。

为了解决这个问题,可以使用Java中的AtomicStampedReference,这个类可以为变量添加一个版本号,在修改的时候可以再比较一下版本号是否相同,若相同才能成功进行修改操作。

JUC包中的StampedLock类就使用了这种思想:

StampedLock是一种乐观读写锁stampedLock.tryOptimisticRead()方法会获取一个乐观的读锁,实际上返回值是一个long格式的版本号,如果有地方显式地获取了写锁,这个版本号会被更新,如stampedLock.writeLock()方法。

stampedLock.validate(long stamp)方法可以将传入的版本号与锁内部的版本号进行比较,返回布尔值,我们可以据此编写业务代码。详见廖雪峰的官方网站

实际操作的时候建议明确ABA问题是否会导致程序逻辑错误,若不会导致逻辑错误可以选择使用原子类,否则不如直接加一个互斥锁更方便一点。

  • 17
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LXTTTTTTTT

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值