转载

JAVA锁原理之 CAS原子操作篇

原子操作(atomic operation)指的是由 多步操作组成的一个操作 。如果该操作不能原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。

现代操作系统中,一般都提供了原子操作来实现一些同步操作,所谓原子操作,也就是一个独立而不可分割的操作。在单核环境中,一般的意义下原子操作中线程不会被切换,线程切换要么在原子操作之前,要么在原子操作完成之后。更广泛的意义下原子操作是指一系列必须整体完成的操作步骤,如果任何一步操作没有完成,那么所有完成的步骤都必须回滚,这样就可以保证要么所有操作步骤都未完成,要么所有操作步骤都被完成。

例如在单核系统里,单个的机器指令可以看成是原子操作(如果有编译器优化、乱序执行等情况除外);在多核系统中,单个的机器指令就不是原子操作,因为 多核系统里是多指令流并行运行 的, 一个核在执行一个指令时,其他核同时执行的指令有可能操作同一块内存区域 ,从而出现数据竞争现象。多核系统中的原子操作通常使用 内存栅障 (memory barrier)来实现,即一个CPU核在执行原子操作时,其他CPU核必须停止对内存操作或者不对指定的内存进行操作,这样才能 避免数据竞争问题

CAS是什么?

CAS全程为 Compare and Swap即比较再交换

jdk5增加了 并发包java.util.concurrent简称JUC ,其下面的类使用 CAS算法 实现了区别于synchronized同步锁的一种 乐观锁 。JDK 5之前Java语言是靠 synchronized 关键字保证同步的,这是一种 排斥锁也是悲观锁

线程独享和线程共享及java的锁种类

某个资源 只给一个线程享用 称之为线程独享 反之为线程共享

在日常开发时,需要确定好哪些资源是线程共享的,共享的场景是什么.才能更好的去使用不同的 锁策略,保证对资源操作的原子性 .

java的锁种类分为8种

  • 公平锁/非公平锁
  • 可重入锁
  • 独享锁/共享锁
  • 互斥锁/读写锁
  • 乐观锁/悲观锁
  • 分段锁
  • 偏向锁/轻量级锁/重量级锁
  • 自旋锁

不具备原子性操作的例子

资源类Resources

class Resources {
    
    private volatile int i = 0;
    
    public void add() {
        i++;
    }
    
    public int getI() {
        return i;
    }
}
复制代码

测试类Demo

public class Demo {

    public static void main(String[] args) {
        // 资源类实例
        final Resources resources = new Resources();

        // 定长线程池-线程数10个
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        // 循环打印-启动10个线程
        for (int i = 0; i < 10; i++) {
            executorService.execute(new Runnable() {
                public void run() {
                    // 每个线程对资源类的i进行+1
                    for (int i1 = 0; i1 < 1000; i1++) {
                        resources.add();
                    }
                }
            });
        }

        // 阻塞主线程 - 等待线程池执行完毕
        executorService.shutdown();
        while (!executorService.isTerminated()) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 打印i的值,预期值应该是 10*1000*1 = 10000 才算合理
        System.out.println("I的值打印为:"+resources.getI());
    }
}
复制代码

运行结果为:I的值打印为:8939

运行结果中得到一个结论 ,多线程对Resources资源类中的add()方法进行i++,是 线程不安全 的。

有的小伙伴就很奇怪了,只是 一行i++代码 为什么会是不安全的呢?不是说 多步操作 由于CPU多核同时运行才会不安全吗? 可i++明明就一行代码啊

其实我一开始也是这么认为的,为什么一行代码会出现线程不安全的问题,于是带着疑问反编译了java代码后发现 i++是一行代码但是却不是一步操作 .反编译命令: javap -c Resources.class

反编译后的Resources代码如下:

class com.cas.Resources {
  com.cas.Resources();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2                  // Field i:I
       9: return

  public void add();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field i:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field i:I
      10: return

  public int getI();
    Code:
       0: aload_0
       1: getfield      #2                  // Field i:I
       4: ireturn
}
复制代码

仔细观察add方法翻译JVM指令如下

  • aload_0 : 从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶
  • dup : 表示数据重复定义,也就是复制操作数
  • getfield : 获取I存放在堆的值
  • iconst_1 : 当int取值 1~5 时,JVM采用 iconst 指令将常量压入栈中
  • iadd : 计算i的值,说白了就是i+1
  • putfield : 将计算I的结果写到堆内存中
  • return : 结束方法

从字节码指令看出,i++实际上是经过3个主要步骤

  • 从堆内存中获取到i的值并且 复制一份到当前线程的操作数栈中
  • 从当前线程的操作数栈中去计算i的值
  • 计算后结果写回到堆内存中的i去

那么基本上可以确定 不安全的点 在于每个线程预先保留好从堆内存中获取到的i值到操作数栈( 线程临时存储区 ),然后将操作数栈的值计算后再写回到堆内存中。这样的过程就会发生数据脏读的问题了

如下图:

JAVA锁原理之 CAS原子操作篇

从图中展示如果两个线程都对 当前线程的操作数栈中的变量i进行+1 ,导致明明两个线程共加了两次结果却只加了1

JUC中的原子性操作类

  • AtomicInteger
  • AtomicLong
  • AtomicBoolean
  • AtomicReference

这里就举例一些常用的几个类,想要了解更多的朋友们可查看 JDK中java.util.concurrent.atomic包下的类,此包下所有的类都是原子性操作的

JAVA锁原理之 CAS原子操作篇

本文将使用 AtomicInteger 这个号称保证线程安全的int类型原子包装类进行一次测试

资源类稍微做下修改,将int i 声明为AtomicInteger i

资源类

class Resources {

    private volatile AtomicInteger i = new AtomicInteger(0);

    public void add() {
        i.getAndAdd(1);
    }

    public int getI() {
        return i.get();
    }
}
复制代码

运行结果为:I的值打印为:10000

很显然,当我们使用AtomicInteger进行增加的时候, i在多线程的操作下准确的计算到了10000 ,这个值是正确的。但是 为什么AtomicInteger能让i线程安全呢 ?会不会是使用了 synchronized关键字 呢?让我们深入一探究竟

JAVA锁原理之 CAS原子操作篇
JAVA锁原理之 CAS原子操作篇

点进去发现只用了一行代码搞定,而且还没有锁相关代码,但是调用了一个方法,不会是那个方法加了锁吧,来猜测观察一下调用getAndAddInt方法的三个传参是什么意思

unsafe.getAndAddInt(this, valueOffset, delta)
// this 当前对象
// valueOffset 是什么鬼?
// delta 需要增加的值
// unsafe 这个又是什么鬼?这个类似乎没见过啊
复制代码
JAVA锁原理之 CAS原子操作篇

那我们再点进去看一下unsafe.getAndAddInt(this, valueOffset, delta)究竟做了什么,即使是直接操作内存那也不能保证线程安全啊

JAVA锁原理之 CAS原子操作篇

看完吓一跳,写个where循环在里面调用compareAndSwapInt(var1, var2, var5, var5 + var4)方法,那能猜想到的应该是这个线程去尝试着抢锁,有可能抢不到,然后挂起当前线程不停去自旋抢锁直到抢到锁并且增加成功为止

先分析这段代码

// var1 当前对象
// var2 当前对象值的位移量
// var4 需要增加的值
// var5 声明他作甚?
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

// 再看看这行代码
// var5 = this.getIntVolatile(var1, var2); 根据当前对象和当前对象值的位移量 获取内存中最新的值
// 再往下看看这行代码
// compareAndSwapInt(var1, var2, var5, var5 + var4) 再想想cas的全称即比较再交换

// var1 当前对象
// var2 当前对象值的位移量
// var5 当前对象在内存中最新的值
// (var5 + var4) 换成计算后的值
复制代码

原来如此,这像不像mysql中innodb的行锁特性

update stu set status=2 where id = 1 and status=1
// 通过ID确定好索引
// 通过索引锁定数据再判断status=1 
// 才将id为1的行修改成status=2
复制代码

得出结论,这就是cas实现乐观锁的方式,先比较再进行赋值

CAS算法的好处

CAS是一种无锁算法,通过硬件层面上对先后操作内存的线程进行排队处理, CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS(比较并交换)是CPU指令级的操作, 只有一步原子操作,所以非常快 。而且避免了 请求操作系统来裁定锁的问题,不用麻烦操作系统 ,直接在CPU内部就搞定了

CAS算法的弊端

刚刚看到一个问题,如果增加不成功,那while会一直尝试着去增加,会不会产生死锁或导致线程耗时太久的一系列问题发生啊?

  • 高并发且自旋不成时

    自旋如果长时间不成功,会带来很大的性能开销。如果变更操作很耗时,同时变更很频繁,就可能导致自旋长时间不成功,带来大量的性能开销

手写一个简单的锁

public class MyLock implements Lock {

    /**
     * 锁的拥有者
     */
    private AtomicReference<Thread> atomicReference = new AtomicReference();

    /**
     * 线程等待队列
     */
    private LinkedBlockingQueue<Thread> linkedBlockingQueue = new LinkedBlockingQueue<Thread>();

    /**
     * 加锁
     */
    public void lock() {
        if (!tryLock()) {
            // 如果抢锁失败,将线程进入等待队列,并挂起当前线程
            linkedBlockingQueue.offer(Thread.currentThread());

            // 挂起当前线程方式有 suspend park wait
            // suspend已被弃用了,wait必须配合synchronized才能使用,所以合适我们的也只有park了

            // 线程之间的唤醒有可能是伪唤醒,所以需要写死循环
            while (true) {
                // 只有队列头部的线程等于当前线程才进行抢锁,否则挂起
                if (linkedBlockingQueue.peek() == Thread.currentThread()) {
                    // 抢不到锁就挂起,抢到锁出队列并且退出lock方法
                    if (!tryLock()) {
                        LockSupport.park();
                    } else {
                        linkedBlockingQueue.poll();
                        return;
                    }
                } else {
                    LockSupport.park();
                }
            }
        }
    }

    /**
     * 尝试抢锁
     */
    public boolean tryLock() {
        // 如果锁的拥有者为null,那就将他设为当前线程
        return atomicReference.compareAndSet(null, Thread.currentThread());
    }

    /**
     * 释放锁
     */
    public void unlock() {
        // 尝试释放锁成功后-唤醒队列头部线程
        if (atomicReference.compareAndSet(Thread.currentThread(), null)) {
            Thread peek = linkedBlockingQueue.peek();
            if (peek != null) {
                LockSupport.unpark(peek);
            }
        }
    }


    public void lockInterruptibly() throws InterruptedException {

    }

    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    public Condition newCondition() {
        return null;
    }
}
复制代码

测试:资源类稍微做下修改,将AtomicInteger i 声明为int i

class Resources {
    /**
     * 自定义锁
     */
    private MyLock myLock = new MyLock();

    /**
     * 非原子操作的int类型
     */
    private volatile int i = 0;

    public void add() {
        myLock.lock();
        try {
            i++;
        } finally {
            myLock.unlock();
        }
    }

    public int getI() {
        return i;
    }
}
复制代码

运行结果为:I的值打印为:10000

运行结果是对的,通过 MyLock简单实现CAS的例子,应该对CAS算法的理解更加深刻

原文  https://juejin.im/post/5e1c42f15188254dfb215cc1
正文到此结束
Loading...