JAVA Lock与CAS 分析

原子(atomic),本意是指“不能被进一步分割的粒子”。原子操作意味着“不可被中断的一个或一系列操作”。在Java中通过循环CAS的方式实现原子操作。

机制保证了只有获得锁的线程才能操作锁定的内存区域,在JDK 5之前Java语言是靠synchronized关键字保证同步的,synchronized可以保证方法或代码块在运行时,同一时刻只有一个线程可以进入到临界区(互斥性),同时它还保证了共享变量的内存可见性。

Java中的每个对象都可以作为锁。

  • 普通同步方法,锁是当前实例对象。

  • 静态同步方法,锁是当前类的class对象。

  • 同步代码块,锁是括号中的对象。

我们先来看一下等待/通知机制:

import java.util.concurrent.TimeUnit;

public class WaitNotify {

   static boolean flag = true;
   static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread A = new Thread(new Wait(), "wait thread");
        A.start();
        TimeUnit.SECONDS.sleep(2);
        Thread B = new Thread(new Notify(), "notify thread");
        B.start();
    }

    static class Wait implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                while (flag) {
                    try {
                        System.out.println(Thread.currentThread() + " flag is true");
                        lock.wait();
                    } catch (InterruptedException e) {
                    }
                }
                System.out.println(Thread.currentThread() + " flag is false");
            }
        }
    }

    static class Notify implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                flag = false;
                lock.notifyAll();
                try {
                    TimeUnit.SECONDS.sleep(7);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

等待/通知机制相关方法在java.lang.Object上定义,线程A在获取锁后调用了对象lock的wait方法进入了等待状态,线程B调用对象lock的notifyAll()方法,线程A收到通知后从wait方法处返回继续执行,线程B对共享变量flag的修改对线程A来说是可见的。

整个运行过程需要注意一下几点:

  • 使用wait()、notify()和notifyAll()时需要先对调用对象加锁,调用wait()方法后会释放锁。

  • 调用wait()方法之后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列中。

  • notify()或notifyAll()方法调用后,等待线程不会立刻从wait()中返回,需要等该线程释放锁之后,才有机会获取锁,之后从wait()返回。

  • notify()方法将等待队列中的一个等待线程从等待队列中移动到同步队列中;

  • notifyAll()方法则是把等待队列中的所有线程都移动到同步队列中,被移动的线程状态从WAITING变为BLOCKED。

  • 从wait()方法返回的前提是,该线程获得了调用对象的锁。

那么,它是如何实现线程之间的互斥性和可见性?

互斥性
我们通过一段代码解释:

public class SynchronizedDemo {
    private static Object object = new Object();
    public static void main(String[] args) throws Exception{
        synchronized(object) {
   //同步代码块
        }
    }
    public static synchronized void m() {}
    //同步方法
}

上这段代码中,使用了同步代码块和同步方法,

  • 同步代码块使用了 monitorenter 和 monitorexit 指令实现。

  • 同步方法中依靠方法修饰符上的 ACC_SYNCHRONIZED 实现。

无论哪种实现,本质上都是对指定对象相关联的monitor的获取,这个过程是互斥性的,也就是说同一时刻只有一个线程能够成功,其它失败的线程会被阻塞,并放入到同步队列中,进入BLOCKED状态。

锁的内部机制

通常情况下锁有4种状态:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
在进一步了解锁之前,我们需要了解两个概念:对象头和monitor。

什么是对象头?
锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等。
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果当前对象是数组)


在hotspot虚拟机中,对象在内存的分布分为3个部分:对象头,实例数据,和对齐填充。
mark word 被分成两部分,lock word和标志位。
Klass ptr指向Class字节码在虚拟机内部的对象表示的地址。
Fields表示连续的对象实例字段。

JAVA Lock与CAS 分析_第1张图片

mark word 被设计为非固定的数据结构,以便在极小的空间内存储更多的信息。比如:在32位的hotspot虚拟机中:如果对象处于未被锁定的情况下。mark word 的32bit空间中有25bit存储对象的哈希码、4bit存储对象的分代年龄、2bit存储锁的标记位、1bit固定为0。而在其他的状态下(轻量级锁、重量级锁、GC标记、可偏向)下对象的存储结构为:

JAVA Lock与CAS 分析_第2张图片

monitor

monitor是线程私有的数据结构,每一个线程都有一个可用monitor列表,同时还有一个全局的可用列表,先来看monitor的内部:

JAVA Lock与CAS 分析_第3张图片

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;

  • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor而失败的线程。

  • RcThis:表示blocked或waiting在该monitor上的所有线程的个数。

  • Nest:用来实现重入锁的计数。

  • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。

  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

那么Monitor和对象头又是如何工作的呢?

在 java 虚拟机中,线程一旦进入到被synchronized修饰的方法或代码块时,指定的锁对象通过某些操作将对象头中的LockWord指向monitor 的起始地址与之关联,同时monitor 中的Owner存放拥有该锁的线程的唯一标识,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码。

接下去,我们可以深入了解下在锁各个状态下,底层是如何处理多线程之间对锁的竞争。

偏向锁

下述代码中,当线程访问同步方法method1时,会在对象头(SynchronizedDemo.class对象的对象头)和栈帧的锁记录中存储锁偏向的线程ID,下次该线程在进入method2,只需要判断对象头存储的线程ID是否为当前线程,而不需要进行CAS操作进行加锁和解锁(因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟)。

public class SynchronizedDemo {
    private static Object lock = new Object();
    public static void main(String[] args) {
        method1();
        method2();
    }
    synchronized static void method1() {}
    synchronized static void method2() {}
}

轻量级锁

利用了CPU原语Compare-And-Swap(CAS,汇编指令CMPXCHG)。线程可以通过两种方式锁住一个对象:

  • 通过膨胀一个处于无锁状态(状态位001)的对象获得该对象的锁;

  • 对象处于膨胀状态(状态位00),但LockWord指向的monitor的Owner字段为NULL,则可以直接通过CAS原子指令尝试将Owner设置为自己的标识来获得锁。

获取锁(monitorenter)的大概过程:

  • 对象处于无锁状态时(LockWord的值为hashCode等,状态位为001),线程首先从monitor列表中取得一个空闲的monitor,初始化Nest和Owner值为1和线程标识,一旦monitor准备好,通过CAS替换monitor起始地址到LockWord进行膨胀。如果存在其它线程竞争锁的情况而导致CAS失败,则回到monitorenter重新开始获取锁的过程即可。

  • monitor中的Owner指向当前线程,这是重入锁的情况(reentrant),将Nest加1,不需要CAS操作,效率高。

  • monitor中的Owner为NULL,此时多个线程通过CAS指令试图将Owner设置为自己的标识获得锁,竞争失败的线程则进入第4种情况。

  • 同时Owner指向别的线程,在调用操作系统的重量级的互斥锁之前自旋一定的次数,当达到一定的次数如果仍然没有获得锁,则开始准备进入阻塞状态,将rfThis值原子加1,由于在加1的过程中可能被其它线程破坏对象和monitor之间的联系,所以在加1后需要再进行一次比较确保lock word的值没有被改变,当发现被改变后则要重新进行monitorenter过程。同时再一次观察Owner是否为NULL,如果是则调用CAS参与竞争锁,锁竞争失败则进入到阻塞状态。

释放锁(monitorexit)的大概过程:

  • 检查该对象是否处于膨胀状态并且该线程是这个锁的拥有者,如果发现不对则抛出异常。

  • 检查Nest字段是否大于1,如果大于1则简单的将Nest减1并继续拥有锁,如果等于1,则进入到步骤3。

  • 检查rfThis是否大于0,设置Owner为NULL然后唤醒一个正在阻塞或等待的线程再一次试图获取锁,如果等于0则进入到步骤4。

  • 缩小(deflate)一个对象,通过将对象的LockWord置换回原来的HashCode等值来解除和monitor之间的关联来释放锁,同时将monitor放回到线程私有的可用monitor列表。

public class SynchronizedDemo implements Runnable {

    private static Object lock = new Object();

    public static void main(String[] args) {
        Thread A = new Thread(new SynchronizedDemo(), "A");
        A.start();

        Thread B = new Thread(new SynchronizedDemo(), "B");
        B.start();
    }

    synchronized static void method1() {
    }

    synchronized static void method2() {
    }

    @Override
    public void run() {
        method1();
        method2();
    }
}

重量级锁

当锁处于这个状态下,其他线程试图获取锁都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程。

内存可见性

  • 线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

  • 线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁机制存在以下问题:

(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

(2)一个线程持有锁会导致其它所有需要此锁的线程挂起。

(3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。

独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁用到的机制就是CAS,Compare and Swap。

CAS

什么是CAS

CAS,Compare and Swap。

在java语言之前,并发就已经广泛存在并在服务器领域得到了大量的应用。所以硬件厂商在很早之前就在芯片中加入了大量支持并发操作的原语,从而在硬件层面提升效率。比如:intel在其CPU中,使用cmpxchg指令。

在Java发展初期,java语言是不能够利用硬件提供的这些便利来提升系统的性能的。但随着java不断的发展,Java本地方法(JNI)的出现,使得java程序能够越过JVM直接调用本地方法。CAS也成为java.util.concurrent的基石,CAS实现了区别于synchronouse同步锁的一种乐观锁。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。

类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。

CAS的目的

利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个java.util.concurrent都是建立在CAS之上的,因此对于synchronized阻塞算法,java.util.concurrent在性能上有了很大的提升。

下面我们通过java.util.concurrent中的AtomicInteger来理解基于CAS实现的非阻塞算法 :

import java.io.Serializable;
import java.util.function.IntBinaryOperator;
import java.util.function.IntUnaryOperator;
import sun.misc.Unsafe;

public class AtomicInteger extends Number implements Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;
    private volatile int value;
    public AtomicInteger(int var1) {
        this.value = var1;
    }
    public AtomicInteger() {
    }
    public final int get() {
        return this.value;
    }
    public final void set(int var1) {
        this.value = var1;
    }
    public final void lazySet(int var1) {
        unsafe.putOrderedInt(this, valueOffset, var1);
    }
    public final int getAndSet(int var1) {
        return unsafe.getAndSetInt(this, valueOffset, var1);
    }
    public final boolean compareAndSet(int var1, int var2) {
        return unsafe.compareAndSwapInt(this, valueOffset, var1, var2);
    }
    public final boolean weakCompareAndSet(int var1, int var2) {
        return unsafe.compareAndSwapInt(this, valueOffset, var1, var2);
    }
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    public final int getAndDecrement() {
        return unsafe.getAndAddInt(this, valueOffset, -1);
    }
    public final int getAndAdd(int var1) {
        return unsafe.getAndAddInt(this, valueOffset, var1);
    }
    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
    public final int decrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, -1) - 1;
    }
    public final int addAndGet(int var1) {
        return unsafe.getAndAddInt(this, valueOffset, var1) + var1;
    }
    public final int getAndUpdate(IntUnaryOperator var1) {
        int var2;
        int var3;
        do {
            var2 = this.get();
            var3 = var1.applyAsInt(var2);
        } while(!this.compareAndSet(var2, var3));

        return var2;
    }
    public final int updateAndGet(IntUnaryOperator var1) {
        int var2;
        int var3;
        do {
            var2 = this.get();
            var3 = var1.applyAsInt(var2);
        } while(!this.compareAndSet(var2, var3));

        return var3;
    }
    public final int getAndAccumulate(int var1, IntBinaryOperator var2) {
        int var3;
        int var4;
        do {
            var3 = this.get();
            var4 = var2.applyAsInt(var3, var1);
        } while(!this.compareAndSet(var3, var4));

        return var3;
    }
    public final int accumulateAndGet(int var1, IntBinaryOperator var2) {
        int var3;
        int var4;
        do {
            var3 = this.get();
            var4 = var2.applyAsInt(var3, var1);
        } while(!this.compareAndSet(var3, var4));
        return var4;
    }

    public String toString() {
        return Integer.toString(this.get());
    }
    public int intValue() {
        return this.get();
    }
    public long longValue() {
        return (long)this.get();
    }
    public float floatValue() {
        return (float)this.get();
    }
    public double doubleValue() {
        return (double)this.get();
    }
    static {
        try {
            valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception var1) {
            throw new Error(var1);
        }
    }
}

一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。

现代的CPU提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。

  private volatile int value;

首毫无疑问,在没有锁的机制下可能需要借助volatile原语,保证线程间的数据是可见的(共享的)。

这样才获取变量的值的时候才能直接读取。

  public final int get() {
            return value;
        }

然后来看看++i是怎么做到的。

   public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }

在这里采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。

而compareAndSet利用JNI来完成CPU指令的操作。

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

整体的过程就是这样子的,利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。

CAS的原理

CAS通过调用JNI的代码实现的。JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。而compareAndSwapInt就是借助C来调用CPU底层指令实现的。

下面从分析比较常用的CPU(intel x86)来解释CAS的实现原理。

下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);

可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intel x86处理器的源代码的片段:

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

三、CAS存在的问题

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

  • ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  • 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

  • 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

concurrent包的实现

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

  1. A线程写volatile变量,随后B线程读这个volatile变量.
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量.
  4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

首先,声明共享变量为volatile;
然后,使用CAS的原子条件更新来实现线程之间的同步;
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:

JAVA Lock与CAS 分析_第4张图片

你可能感兴趣的:(JAVA)