【刨根问底】带你深入理解JUC并发工具类 — volatile和cas

大家好,我是Java不惑(WX公众号同名)。这是专栏的第一篇文章,我将给大家介绍一下计算机体系中的高速缓存以及三大并发问题,在介绍三大并发问题时会介绍一下volatile修饰符在这三个问题中起到的作用,最后介绍了CAS的使用。希望这篇文章让你有所收获!

高速缓存

在计算机体系中,CPU内核处理数据的速度和在内存中读取数据的速度不匹配,为了解决它们之间存在的巨大差异,引入高速缓存。

内核读取数据时,会首先去高速缓存中读取,高速缓存中不存在数据则去内存中读取,读取后会将数据存入高速缓存中。

根据时间局部性原理,被缓存的数据在未来可能会被多次引用,所以使用高速缓存可以提高内核读取数据的速度。

时间局部性(temporal locality) :被引用过一次的存储器位置在未来会被多次引用。
空间局部性(spatial locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。

这里其实也用到了空间局部性原理,当内核读取数据时,不会只读需要的数据,而是会将周围的数据一同读到高速缓存中,而读取的这部分数据就叫做缓存行。

缓存行是高速缓存和内存进行数据交换的最小单位,大小是2的整数幂,Linux系统中缓存行的默认值是64byte。

我们可以通过代码证明一下缓存行的存在:

    int[][] array = new int[64 * 1024][1024];
    long startTime1=System.currentTimeMillis();   //获取开始时间
    // 横向遍历
    for(int i = 0; i < 64 * 1024; i ++)
        for(int j = 0; j < 1024; j ++)
            array[i][j] ++;
    long endTime1=System.currentTimeMillis(); //获取结束时间
    System.out.println("程序运行时间: "+(endTime1-startTime1)+"ms");


    // 纵向遍历
    long startTime2=System.currentTimeMillis();   //获取开始时间
    for(int i = 0; i < 1024; i ++)
        for(int j = 0; j < 64 * 1024; j ++)
            array[j][i] ++;
    long endTime2=System.currentTimeMillis(); //获取结束时间
    System.out.println("程序运行时间: "+(endTime2-startTime2)+"ms");

因为有缓存行的存在,横向遍历比纵向遍历快的多,在我电脑上执行结果如下:

程序运行时间: 176ms
程序运行时间: 2513ms
三级缓存

缓存容量越大,存储数据会变多,但查找速度会变慢。

缓存容量越小,存储数据会变少,但查找速度会变快。

为了提高缓存的性能,在内核和内存之间引入三级缓存。距离内核近的缓存,容量小,速度快;距离内存近的缓存,容量大,速度慢。

从内核到内存分别是L1缓存、L2缓存和L3缓存。

L1缓存和L2缓存是内核专用缓存,一个CPU共享一个L3缓存。

以我现在所用电脑为例:L1缓存:256K,L2缓存1MB,L3缓存6MB。

从L1到L3,缓存越来越大,命中率也越来越高,但是读取缓存的延迟也会变高。

三大并发问题

多核CPU和高速缓存的存在,每个内核读取相同的数据到缓存中,当一个内核修改了缓存的数据,并不会直接同步到内存中,其他内核无法得知数据已经改变,就导致了并发问题。

下面我们看一下三大并发问题,并讨论volatile和三大并发问题的关系。

原子性

指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。

volatile修饰过的变量的读和写是原子性的。读出来再写进去这种复合操作不是原子性的,所以volatile不保证原子性。下面两段代码是等价的:

public class ThreadSafeInteger {
    private int value;

    public synchronized int get() {
        return value;
    }
    public synchronized  void set(int value) {
        this.value = value;
    }
}
public class ThreadSafeInteger {

    private volatile int value;

    public int get() {
        return value;
    }

    public void set(int value) {
        this.value = value;
    }
}
可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

实现如下代码,main线程和thread01线程分别读取了running的变量。main线程对running变量做了修改,但thread01线程不可见。

public class Visibleness {
  private boolean running = true;

  void func() {
    System.out.println("thread start……");
    while (running){}
    System.out.println("thread end……");
  }

  public static void main(String[] args) throws InterruptedException {
    System.out.println("main start……");
    Visibleness object = new Visibleness();
    new Thread(object::func,"thread01").start();
    Thread.sleep(2 * 1000);
    object.running = false;
    System.out.println("main end……");
  }
}

//输出:
main start……
thread start……
main end……

上面的代码,core0和core1读取running到缓存中,并使用了running变量。但后面core0修改running为false,这个修改仅影响了core0的缓存,并没有写入main memory,导致对core1不可见。

思考一下,为什么core1不直接写入main memory?最主要的原因还是性能问题,如果内核不断读取和写入main memory,那么高速缓存就没有存在的意义。

使用volatile修饰running变量,core0修改了running变量后,会告知core1变量已经修改,并写入到main memory中,此时代码中输出:

main start……
thread start……
main end……
thread end……

所以,volatile修饰的变量具有可见性。

有序性

处理器为了提高处理的效率,会对我们的代码进行重排序操作。例如如下语句,两条语句执行顺序并不会影响程序的正常运行。

boolean inited = true;
User user;
user = new User();  //语句1
inited = true;  //语句2

但在多线程环境下,如果其他线程依赖这两条语句,就会导致一些问题。如下代码,处理器如果先执行了语句2,此时user还未初始化,就会导致代码异常。

while (!inited) {
	Thread.sleep(2 * 1000);
}
user.getId();

volatile修饰的变量,通过插入内存屏障来禁止特定处理器的重排序操作。

活学活用

刚刚我们讲过,缓存和内存之间数据交换的最小单位是缓存行,当一个缓存行被多个内核缓存后,一个内核修改缓存行中volatile修饰的数据后,将会通知其他内核修改该缓存行。如果频繁修改缓存行中的数据,将导致性能急剧下降,这就是有名的伪共享问题。

JDK7的并发包里有一个队列集合类LinkedTransferQueue,在使用volatile变量时,采用追加字节的方式优化性能,这样保证一个缓存行中仅存在一个变量,大大提高了性能。

CAS

悲观锁(Pessimistic Lock),顾名思义,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持悲观态度。线程每次修改数据之前,都会认为别的线程会修改,所以拿数据之前会先上锁,别的线程要想使用这个数据必须等待线程释放掉锁。synchronized 就是悲观锁的一种,也被称为独占锁。
乐观锁(Optimistic Lock),顾名思义,它认为数据一般情况下不会造成冲突,所以在数据提交更新的时候,会对数据是否冲突进行检测,如果发现冲突则失败并重试,直到成功为止,可以称为自旋。

CAS过程

乐观锁用到的主要机制就是 CAS。CAS 即(Compare and swap),也就是比较并替换,CAS 有三个操作数分别为:内存值 V,旧的预期值 A,新的值 B。处理过程为:

  • 1、首先获取内存中的值A
  • 2、A经过自增或其他计算后变为B
  • 3、对比当前内存中的V和A是否相同,相同则B替换A
    AtomicLong 的自增就是使用这种方式实现:
public final long incrementAndGet() {
    for (;;) {
        long current = get();//(1)
        long next = current + 1;//(2)
        if (compareAndSet(current, next))//(3)
            return next;
    }
}

public final boolean compareAndSet(long expect, long update) {
    return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}

假如当前值为 1,那么线程 A 和线程 B 同时执行到了(3)时候各自的 next 都是 2,current=1。

假如线程 A 先执行了(3),那么这个是原子性操作,会把档期值更新为 2 并且返回 1,if 判断 true 所以 incrementAndGet 返回 2。

这时候线程 B 执行 (3),因为 current=1 而当前变量实际值为 2,所以 if 判断为 false,继续循环,如果没有其他线程去自增变量的话,这次线程 B 就会更新变量为 3 然后退出。

这里使用了无限循环使用 CAS 进行轮询检查,虽然一定程度浪费了 CPU 资源,但是相比锁来说避免的线程上下文切换和调度。

ABA问题

线程1获取当前内存值为:A,其他线程将内存的值改为B,后又改为A。此时内存中的A已经不是线程1获取的A,这个问题叫做ABA问题。

1、ABA问题无影响的可以不做处理

2、有影响的可以加版本号,每次修改数据都修改版本号,对比值时也对比版本号即可。

volatile修饰变量,对共享变量做读取或者写入操作时,具有原子性;但一个变量赋值给另一个变量是非原子性的。因此volatile可以和CAS结合实现原子操作。

总结

看完文章你一定会好奇volatile怎么实现的可见性?volatile又是怎样实现的有序性?cas为什么是原子性的操作?也希望你能想一下,内核修改数据之后,是怎样向其他内核通信。在专栏的第二篇文章中,我将向大家简单介绍一下volatile和cas的原理。

如果我的文章对你有帮助,请帮我点赞转发,如果文章内容有问题,请在评论区告诉我,谢谢!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值