浅谈volatile

前言

提到volatile关键字的原理,我这里不想赘述,其实我也讲不好,因为这不是一篇简短的文章和我这个水平就能讲的清楚的,我建议你如果想了解一下volatile的原理,可以去看一下《Java并发编程的艺术》这本书中的2-1,3-4章节,认真通读一遍,你就会了解volatile设计到了哪些cpu指令,是如何保证的共享变量的可见性,以及使用volatile来进行线程之间通信的原理,volatile关键字相较于synchronized的优势等等,相信你会有所收获。
这里给大家推荐一个电脑上看电子书的软件–kindel,电子书的话可以去亚马逊官网去买,十几块钱一本,主要是排版比网上的盗版pdf要人性化,画质也比较好。网上也有盗版的kinde专用格式的电子书,不过阅读时不能复制,推荐大家支持正版。

volatile

这篇文章我主要和大家分享我当初学习volatile时比较疑惑的问题,建议在阅读过书籍内容后再来看这篇文章,学习不是一蹴而就的,要享受学习的过程。水平有限,文章有误的地方也请不吝指正,共同学习。

volatile一写多读是线程安全的,多写多读是线程不安全的,为什么。

可能大家在很多博客里都看到过这么一句话,有的博客可能就一笔带过。今天我来分享一下这个问题。在这个问题之前,我们来明确几条定义:
每个线程都有自己的变量副本,变量的读写就是对自己线程变量副本的读写
对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
第二点可以简单理解为,一个volatile变量的读,总是会读取主内存变量的最新值
一个volatile变量的写,总是会把变量刷新到主内存

  1. 我们先来讨论一写一读,一个线程A修改变量之后,另一个线程B去读取变量。按照上面的定义,是可以读到线程A修改后的值,这个没问题
  2. 一写多读,其实和一写一读是一样的。每一个读取线程都可以拿到写线程最后修改的值
  3. 多写多读,如果是对单个volatile变量的读或者写,其实仍是线程安全的。出现线程不安全的地方是类似volatile++这种非原子性操作。举个例子,有一个共享变量 volatile a = 1;线程A和线程B分别对变量a执行a++操作。++操作不是一个原子操作,可以分解为三步:
		int temp = a; // 第一步
        temp = temp + 1; // 第二步
        a = temp; // 第三步

线程A执行++操作,在执行完第二步的时候,线程A的本地副本变量里,a = 1;temp = 2;
此时线程A交出时间片,线程B执行++操作,执行完毕把a的值修改成了2,由于变量a是volatile变量,线程B把a=2同步到主内存。线程A继续执行,此时会刷新自己变量副本的变量a值,此时线程A的本地副本变量里,a = 2;temp = 2, 继续执行第三步,就变成了2 = 2;执行完毕把变量a刷回主内存,此时变量a还是2,不是预期的3。
这里我简单写个demo模拟了一下

public class Demo {
    private volatile int num;

    public void add() {
        ExecutorService executorService = Executors.newCachedThreadPool();
        try {
            for (int i = 0; i < 1000; i++) {
                executorService.execute(() -> num++);
            }
            System.out.println("num的值:" + num);
        } finally {
            executorService.shutdown();
        }
    }

    public static void main(String[] args) {
        Demo1 demo1 = new Demo1();
        demo1.add();
    }
}

运行结果,运行5次,发现都不一样。

num的值:972
num的值:990
num的值:984
num的值:983
num的值:985

如何解决这个问题

可以用cas来解决这个问题。比如上面那个问题,如果线程A在最后更新副本变量a的时候,使用cas来判断一下旧的值是否符合我们的预期,也就是说变量a是不是等于1,发现不等于1了,重新执行++操作,最后变量a的结果就是正确的3。
我这里也写了个cas的demo,大家可以参考一下。

public class Demo implements Serializable {
    private static final long serialVersionUID = 7373984972572414691L;
    private volatile int num;
    // Unsafe自己写demo不能直接new,可以用反射去拿,可以去看下Unsafe的源码,判断了ClassLoad
    private static final Unsafe unsafe;
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe =  (Unsafe) field.get(null);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new Error(e);
        }
    }
	// 变量的偏移量,不懂的可以去百度一下,网上大篇文章
    private static final long numOffset;
    static {
        try {
            numOffset = unsafe.objectFieldOffset
                    (Demo.class.getDeclaredField("num"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    public void add() {
    	// 这里使用缓存线程池,比较符合我们的模拟场景需求
        ExecutorService executorService = Executors.newCachedThreadPool();
        try {
            for (int i = 0; i < 1000; i++) {
                executorService.execute(() -> {
                    for (;;) {
                        int expect = num;
                        int update = num+1;
                        // cas的返回值是boolean,可以点到源码里看一下,cas的源码在jdk,不会找的可以百度
                        if (unsafe.compareAndSwapInt(this, numOffset, expect, update)) {
                            break;
                        }
                    }
                });
            }
            System.out.println("A的值:" + num);
        } finally {
            executorService.shutdown();
        }
    }
    public static void main(String[] args) {
        Demo demo = new Demo();
        demo.add();
    }
}

运行了几次,结果都是1000。

A的值:1000
A的值:1000
A的值:1000
A的值:1000

引入CAS引发的ABA问题

ABA问题我知道怎么回事,也晓得用版本号机制可以解决,但是没具体去解决过,在写这篇学习日记的时候,本着求实的原则,学习了一下代码层面的解决方案,记录一下,希望对大家也有所帮助。
先了解一下什么是ABA问题, 举个最简单的例子。
小明用cas想把10改成11,在修改的过程中,小红把10改成了11之后又改成了10, 小明在cas判断的时候,发现数据还是10,就修改成了11,虽然最终的结果并没有问题,但是小明想要修改的10,和小明最终修改的10,并不是同一个,这就是ABA问题。

解决ABA问题

ABA问题,Doug Lea也给出了解决方案,AtomicStampedReference。想了解AtomicStampedReferenc的可以看一下这篇文章。原理就是用一个reference引用和stamp标记来标记一个资源,当你需要更改的时候,先判断这两个引用和标记必须和预期的一样,才会进行更新。更新完毕把stamp进行+1操作。
我也写了个demo,大家可以参考一下

public class AtomicStampedReferenceTest {

    private final static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {
        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + " 标记:" + stamp);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean c = atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " 标记:" + atomicStampedReference.getStamp() + " boolean:" + c);
            boolean b = atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " 标记:" + atomicStampedReference.getStamp() + " boolean:" + b);

        }).start();

        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + " 标记:" + stamp);
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean b = atomicStampedReference.compareAndSet(100, 102, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName() + " value:" + atomicStampedReference.getReference() + " boolean:" + b);

        }).start();
    }
}

运行结果

Thread-0 标记:1
Thread-1 标记:1
Thread-0 标记:2 boolean:true
Thread-0 标记:3 boolean:true
Thread-1 value:100 boolean:false

当我把100换成10086,又会发生什么呢?

public class AtomicStampedReferenceTest {

    private final static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(10086, 1);

    public static void main(String[] args) {
        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + " 标记:" + stamp);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean c = atomicStampedReference.compareAndSet(10086, 10087, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " 标记:" + atomicStampedReference.getStamp() + " boolean:" + c);
            boolean b = atomicStampedReference.compareAndSet(10087, 10086, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " 标记:" + atomicStampedReference.getStamp() + " boolean:" + b);

        }).start();

        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + " 标记:" + stamp);
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean b = atomicStampedReference.compareAndSet(10086, 10010, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName() + " value:" + atomicStampedReference.getReference() + " boolean:" + b);

        }).start();
    }
}

运行结果

Thread-0 标记:1
Thread-1 标记:1
Thread-0 标记:1 boolean:false
Thread-0 标记:1 boolean:false
Thread-1 value:10086 boolean:false

修改全都不成功,大家可以思考下为什么,重点是reference,不懂的可以打个断点去compareAndSet()方法里看一看,我就不讲答案了。理解了这个问题,AtomicStampedReference你就算真正理解了。需要注意一点的是,AtomicStampedReference是支持泛型的。

关于ABA引发的思考

写ABA的时候,我在想有没有这个必要。开发也不少时间了,我没有在我的代码层面写过ABA问题的解决方案。因为我们现在开发的产品大多是注重结果,并不注重过程。比如你的银行卡有100块钱,你使用支付宝去消费50块,支付方式选择银行卡扣除,在你输入密码扣款的过程中,你老婆使用绑定了你这种银行卡的微信支付了100块,又给你这个卡提现了100块。假设这个过程是秒到账的。那么你支付宝扣款50的行为,是正确的且无误的,当然可以操作成功。这个时候使用ABA就没有意义,大多情况下,我们并不关注ABA问题。

ABA问题引申的校验机制

相信大家都写过这样的代码:
update table set a = a1,version = version+1 where version = ***
对于某些可以频繁修改的数据,我们会在数据库里加一个版本号,每次修改的时候判断一下这个版本号,这其实也是一个解决并发的好办法,在冲突并不严重但是你又想杜绝出现这种bug的情况下。
我并不确定版本号机制和AtomicStampedReference谁先谁后,我想表达的是技术都是相通的,学习别人的优秀的解决方案,往往能给自己的项目带来灵感。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值