zoukankan      html  css  js  c++  java
  • java内存模型之volatile和锁的内存语义

    1.volatile的内存语义

    1.1 volatile的特性

    可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。如下面的例子:

    class VolatileFeaturesExample {
    	volatile long vl = 0L; // 使用volatile声明64位的long型变量
    	public void set(long l) {
    		vl = l; // 单个volatile变量的写
    	}
    	public void getAndIncrement () {
    		vl++; // 复合(多个)volatile变量的读/写
    	}
    	public long get() {
    		return vl; // 单个volatile变量的读
    		}
    	}

    假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。

    class VolatileFeaturesExample {
    	long vl = 0L; // 64位的long型普通变量
    	public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
    		vl = l;
    	}
    	public void getAndIncrement () { // 普通方法调用
    		long temp = get(); // 调用已同步的读方法
    		temp += 1L; // 普通写操作
    		set(temp); // 调用已同步的写方法
    	}
    	public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
    		return vl;
    	}
    }

    其实就是对volatile变量的读写方法进行加锁同步。

    锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

    锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

    简而言之,volatile变量自身具有下列特性:

    • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

    • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

    1.2 volatile写-读建立的happens-before关系

    从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

    1.3 volatile写-读的内存语义

    volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

    volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

    对volatile写和volatile读的内存语义总结:

    • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

    • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

    • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

    1.4 volatile内存语义的实现

    下面来看看JMM如何实现volatile写/读的内存语义。重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下表是JMM针对编译器制定的volatile重排序规则表。

    第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

    从上表可以看出:

    • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

    • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

    • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

    为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

    • 在每个volatile写操作的前面插入一个StoreStore屏障。

    • 在每个volatile写操作的后面插入一个StoreLoad屏障。

    • 在每个volatile读操作的后面插入一个LoadLoad屏障。

    • 在每个volatile读操作的后面插入一个LoadStore屏障。

    这个要对应上面的屏障指令去看,比如第一个的话,就相当于:StoreStore这个屏障在volatile写操作前面,所以在volatile写之前的数据都会对其他处理器可见(即刷新到主内存)。

    上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图,如图:

    上图StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

    volatile写后面的StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

    下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图,如图:

    上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

    上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

    class VolatileBarrierExample {
    	int a;
    	volatile int v1 = 1;
    	volatile int v2 = 2;
    	void readAndWrite() {
    		int i = v1; // 第一个volatile读
    		int j = v2; // 第二个volatile读
    		a = i + j; // 普通写
    		v1 = i + 1; // 第一个volatile写
    		v2 = j * 2; // 第二个 volatile写
    	}
    	…
        // 其他方法
        }

    针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。

    注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。

    上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。

    2 锁的内存语义

    2.1 锁的释放-获取建立的happens-before关系

    锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

    2.2 锁的释放和获取的内存语义

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

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

    对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。总结如下:

    • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。

    • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。

    • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

    2.3 锁内存语义的实现

    通过ReentranLock来辅助说明。

    在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键。

    部分类图如下:

    ReentrantLock分为公平锁和非公平锁,我们首先分析公平锁。使用公平锁时,加锁方法lock()调用轨迹如下。

    1)ReentrantLock:lock()。

    2)FairSync:lock()。

    3)AbstractQueuedSynchronizer:acquire(int arg)。

    4)ReentrantLock:tryAcquire(int acquires)。

    在第4步真正开始加锁,下面是该方法的源代码:

    /**
     * Fair version of tryAcquire.  Don't grant access unless
     * recursive call or no waiters or is first.
     */
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();		// 获取锁的开始,首先读volatile变量state
        if (c == 0) {
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }

    从上面源代码中我们可以看出,加锁方法首先读volatile变量state。在使用公平锁时,解锁方法unlock()调用轨迹如下。

    1)ReentrantLock:unlock()。

    2)AbstractQueuedSynchronizer:release(int arg)。

    3)Sync:tryRelease(int releases)。

    在第3步真正开始释放锁,下面是该方法的源代码。

    protected final boolean tryRelease(int releases) {
        int c = getState() - releases;
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c);		// 释放锁的最后,写volatile变量state
        return free;
    }
    从上面的源代码可以看出,在释放锁的最后写volatile变量state。公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

    非公平锁的释放和公平锁完全一样,所以这里只分析非公平锁的获取。使用非公平锁时,加锁方法lock()调用轨迹如下。

    1)ReentrantLock:lock()。

    2)NonfairSync:lock()。

    3)AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。

    在第3步真正开始加锁,下面是该方法的源代码。

    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

    该方法以原子操作的方式更新state变量,其实就是CAS,如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。

    分别从编译器和处理器的角度来分析,CAS如何同时具有volatile读和volatile写的内存语义。编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。

    下面分析在常见的intel X86处理器中,CAS是如何同时具有volatile读和volatile写的内存语义的。

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

    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    可以看出这是一个本地方法,即由非Java语言实现的,对应的intel X86处理器的源代码片段如下:

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

    ​intel的手册对lock前缀的说明如下:

    1)确保对内存的读-改-写操作原子执行。

    在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4、Intel Xeon及P6处理器开始,Intel使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的执行开销。

    2)禁止该指令,与之前和之后的读和写指令重排序。

    3)把写缓冲区中的所有数据刷新到内存中。

    上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义。

    现在对公平锁和非公平锁的内存语义做个总结:

    • 公平锁和非公平锁释放时,最后都要写一个volatile变量state。

    • 公平锁获取时,首先会去读volatile变量。

    • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

    由此看出,锁释放-获取的内存语义的实现至少有下面两种方式:

    1)利用volatile变量的写-读所具有的内存语义。

    2)利用CAS所附带的volatile读和volatile写的内存语义。

    5.4 concurrent包的实现

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

    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包的源代码有通用化的实现模式:

    • 首先,声明共享变量为volatile。

    • 然后,使用CAS的原子条件更新来实现线程之间的同步。

    • 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

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

    参照:《Java并发编程的艺术》 

  • 相关阅读:
    grid列的值格式化
    页面记载给绑定query的grid加filter
    页面加载后从后面带数据到前台
    waf2控件名
    通讯框架选型
    C# 访问修饰符和const、readonly
    ZooKeeper典型应用场景一览
    ZooKeeper典型使用场景一览
    摘的一段关于原型的介绍
    D3.js和three.js
  • 原文地址:https://www.cnblogs.com/baichendongyang/p/13235469.html
Copyright © 2011-2022 走看看