【并发编程的艺术】内存语义分析:volatile、锁与CAS

简介: 几个理解下面内容的关键点:cpu缓存结构、可见性、上一篇文章中的总线工作机制。通过系列的前面几篇文章,我们可以初步总结造成并发问题的原因,一是cpu本地内存(各级缓存)没有及时刷新到主存,二是指令重排序造成的执行乱序导致意料之外的结果,归根结底是对内存的使用不当导致的问题。

系列文章:

【并发编程的艺术】JVM 体系与内存模型

【并发编程的艺术】JAVA 并发机制的底层原理

【并发编程的艺术】JAVA 原子操作实现原理

【并发编程的艺术】JVM 内存模型

【并发编程的艺术】详解指令重排序与数据依赖

【并发编程的艺术】Java 内存模型的顺序一致性

一 volatile

   几个理解下面内容的关键点:cpu缓存结构、可见性、上一篇文章中的总线工作机制。通过系列的前面几篇文章,我们可以初步总结造成并发问题的原因,一是cpu本地内存(各级缓存)没有及时刷新到主存,二是指令重排序造成的执行乱序导致意料之外的结果,归根结底是对内存的使用不当导致的问题。

1.1 volatile变量特性

1)可见性

   对一个volatile变量的读,(对任意线程来说)总是能看到这个volatile变量的最后写入。

2)原子性

   对任意单个volatile变量的读/写具有原子性(这里包括前面提到过的64位变量类型 long 和 double,因为分为高32位和低32位两步操作导致的可能),但类似于volatile ++这种复合操作不具有原子性

1.2 可见性实现分析

   从JSR-133(即JDK5)开始,volatile变量的写-读可以实现线程之间的通信(写-读建立的happens-before关系)。

1.2.1 写-读内存语义

   从内存语义的角度来说,volatile的写-读,与锁的释放-获取有相同的内存效果。也就是说,volatile写与锁的释放有相同的内存语义,volatile读与锁的获取有相同的内存语义。

1.2.1.1 happens-before规则

看一个代码示例:

public class VolatileExample {
    int a=0;
    volatile boolean flag = false;
    public void writer(){
        a = 1;  // 1
        flag = true;    // 2
    }
    public void reader(){
        if(flag){   // 3
            int i=a;    // 4
//            ... other operation
        }
    }
}

假设有两个线程,A调用writer()方法后,B执行reader方法,根据happens-befores规则,这个过程可能的happens-before可能有三种:

1)代码顺序,1->2, 3->4

2)根据volatile规则(特性),2->3

3)根据happens-before传递性,1->4

箭头方向表示happens-before。

图形表示如下:

1.2.1.2 内存语义

一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。这里涉及两个问题:1、写操作所在的线程要及时更新值到内存;2、其他读该共享变量的线程,要感知到本地内存中的共享变量副本失效。

共享变量的状态变化如下图所示:

1.2.1.3 volatile的内存语义实现

在JMM中,为了实现volatile的内存语义,会分别限制在编译器和处理器中的重排序类型。规则如下表所示:

   先介绍一个概念,内存屏障(memory barrier/memory fence)。内存屏障也称为内存栅栏、内存栅障、屏障指令等,是一种同步屏障指令,CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

   编译器在生成字节码时,会在指令序列中插入内存屏障,来禁止某些指定类型的处理器重排序。由于几乎不可能找到一个最优设置来最小化插入内存屏障的总数,所以JMM采用保守策略:

1)每个volatile写操作的前面插入一个StoreStore屏障

2)每个volatile写操作后面插入一个StoreLoad屏障

3)每个volatile读操作的后面插入一个LoadLoad屏障

4)每个volatile读操作后面插入一个LoadStore屏障

保守策略考虑的基本原则:先确保正确,然后再追求效率。在这种策略下,volatile写插入内存屏障后生成的指令序列执行的示意图如下:

1.2.1.4 JSR-133为什么要增强volatile的内存语义

   JSR-133之前的Java内存模型中,虽然不允许volatile变量之间的重排序,但允许volatile变量与普通变量重排序。例如下面示例:

   因为操作1和2没有依赖关系,所以可能被重排序(3、4类似)。结果就是线程B执行4时不一定能看到A在执行1时对共享变量的修改。

   为了提供一种比锁更轻量级的线程之间通信机制,JSR-133决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

   但还是不要忘记,volatile仅仅保证对单个volatile变量的读/写具有原子性

二 锁

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

2.1 锁的释放-获取

与volatile的分析相同,我们还是先分析happens-before关系。示例代码:

public class MonitorExample {
    int a = 0;
    public synchronized void writer(){  // 1
        a++;                            // 2
    }                                   // 3
    public synchronized void reader(){  // 4
        int i = a;                      // 5
//        ...
    }                                   // 6
}

两个线程A、B,还是A执行writer(),然后B执行reader()。 这个过程中包含的happens-before关系如下:

1)根据程序次序(代码顺序)规则, 1_>2, 2->3,4->5,5->6

2)根据监视器锁规则,3->4

3)根据传递性,2->5

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

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

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

对比volatile的写-读内存语义,可以看出锁释放=volatile写,锁获取=volatile读。锁的内存语义总结如下:

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

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

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

2.3 锁内存语义的实现

这里,我们借助ReentrantLock的源码来分析具体实现机制。

import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
    int a=0;
    ReentrantLock lock = new ReentrantLock();
    public void writer(){
        lock.lock();
        try{
            a++;
        }finally {
            lock.unlock();
        }
    }
    public void reader(){
        lock.lock();
        try{
            int i=a;
//            ...
        }finally {
            lock.unlock();
        }
    }
}

ReentrantLock的实现依赖于AQS(AbstractQueuedSynchronizer)。AQS使用volatile变量state来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键。

2.3.1 ReentrantLock源码分析

ReentrantLock有公平锁(FairSync)和非公平锁(NonFairSync)两种实现。这是ReentrantLock的两个静态final内部类,继承自抽象类Sync。首先分析公平锁:

2.3.1.1 公平锁FairSync

公平锁的加锁方法lock,调用路径如下:

1)ReentrantLock:lock()  

2)FairSync:lock()

   源码是sync.lock(); 但因为ReentrantLock在构造方法中会设置持有的sync实例为非公平锁FairSync,所以实际上走的就是FairSync:lock()方法。

public ReentrantLock(boolean fair) {
  sync = fair ? new FairSync() : new NonfairSync();
}

3)AbstractQueueSynchronizer:acquire(int arg)

4)ReentrantLock: tryAcquire(int acquires)

公平锁中tryAcquire方法源码如下:

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            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():

ReentrantLock:unlock->AbstractQueueSynchronizer:release(int arg)->Sync:tryRelease(int releases)

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);
    return free;
}

释放锁的最后写volatile变量state。

   公平锁在释放锁的最后写volatile变量state,在获取锁时先读state。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

2.3.1.2 非公平锁NonFairSync

加锁方法:

ReentrantLock:lock() -> NonfairSync:lock() -> AbstractQueuedSynchronizer: compareAndSetState(int expect, int update)

AQS的cas方法源码:

/**
 * Atomically sets synchronization state to the given updated
 * value if the current state value equals the expected value.
 * This operation has memory semantics of a {@code volatile} read
 * and write.
 *
 * @param expect the expected value
 * @param update the new value
 * @return {@code true} if successful. False return indicates that the actual
 *         value was not equal to the expected value.
 */
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

通过原子操作方式更新state变量。

2.3.1.3 关于CAS

   概念大家都已经了解过,CAS是通过对比当前状态值是否等于预期值来决定是否执行交换。如果当前状态值等于预期值,则以原子方式把同步状态设置为给定的更新值,最重要的是,CAS具有volatile读和写的内存语义

1)编译器角度

编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写之前的任意内存操作重排序。组合这两个条件,意味着同时实现volatile的读和写的内存语义,编译器不能对CAS于CAS前后的任意内存操作做重排序。

sun.misc.Unsafe类的compareAndSwapInt()方法是本地(native)方法,源码如下(intel X86处理器中的):

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

intel手册对lock前缀的说明:

1)确保对内存的读-改-写操作原子执行。在Pentium及以前的处理器中,带有lock前缀的指令会在执行期间锁住总线,使其他处理器暂时无法通过总线访问内存,但这会带来高昂的开销。所以之后的处理器使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定可以大大降低lock前缀指令的执行开销。

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

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

这里2) 和 3)这两点锁具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义。

2.3.2 公平锁和非公平锁的内存语义总结

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

2、公平锁获取时,首先会读volatile变量

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

通过对ReentrantLock的分析可见,锁释放-获取的内存语义的实现至少有以下两种:

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

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

三 总结

   同步实现中使用的volatile关键字和锁,在本文中详细描述了他们的内存语义。包括ReentrantLock的源码分析,和CAS原子性实现原理。

   下一篇中,将分析final域的内存语义、happens-before规则,以及双重检查锁定与延迟初始化,并总结Java内存模型。

相关文章
|
2月前
|
存储 缓存 Java
金石原创 |【JVM盲点补漏系列】「并发编程的难题和挑战」深入理解JMM及JVM内存模型知识体系机制(1)
金石原创 |【JVM盲点补漏系列】「并发编程的难题和挑战」深入理解JMM及JVM内存模型知识体系机制(1)
42 1
|
3天前
|
缓存 Java 编译器
JMM内存模型 volatile关键字解析
JMM内存模型 volatile关键字解析
10 0
|
5月前
|
存储 安全 Java
关于volatile解决内存可见性问题(保证线程安全)
关于volatile解决内存可见性问题(保证线程安全)
|
5月前
|
安全 Java
7.volatile怎么通过内存屏障保证可见性和有序性?
7.volatile怎么通过内存屏障保证可见性和有序性?
30 0
7.volatile怎么通过内存屏障保证可见性和有序性?
|
6月前
|
缓存 算法 安全
从内存可见性看volatile、原子操作和CAS算法
从内存可见性看volatile、原子操作和CAS算法
28 0
|
7月前
|
缓存 Java 编译器
volatile,解决内存可见性引起的问题,wait和notify
volatile,解决内存可见性引起的问题,wait和notify
|
7月前
|
缓存 Java 编译器
volatile的内存语义
volatile的内存语义
32 0
|
8月前
|
缓存 Java 大数据
深入解析JVM调优:解决OutOfMemoryError、内存泄露、线程死锁、锁争用和高CPU消耗问题
深入解析JVM调优:解决OutOfMemoryError、内存泄露、线程死锁、锁争用和高CPU消耗问题
97 0
|
15天前
|
Linux
Linux rsyslog占用内存CPU过高解决办法
该文档描述了`rsyslog`占用内存过高的问题及其解决方案。
40 4
|
1月前
|
移动开发 运维 监控
掌握Linux运维利器:查看CPU和内存占用,轻松解决性能问题!
掌握Linux运维利器:查看CPU和内存占用,轻松解决性能问题!