【并发编程的艺术】内存语义分析: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内存模型。

相关文章
|
Java 编译器 开发者
深入理解Java内存模型(JMM)及其对并发编程的影响
【9月更文挑战第37天】在Java的世界里,内存模型是隐藏在代码背后的守护者,它默默地协调着多线程环境下的数据一致性和可见性问题。本文将揭开Java内存模型的神秘面纱,带领读者探索其对并发编程实践的深远影响。通过深入浅出的方式,我们将了解内存模型的基本概念、工作原理以及如何在实际开发中正确应用这些知识,确保程序的正确性和高效性。
|
5月前
|
存储 缓存 Java
【高薪程序员必看】万字长文拆解Java并发编程!(5):深入理解JMM:Java内存模型的三大特性与volatile底层原理
JMM,Java Memory Model,Java内存模型,定义了主内存,工作内存,确保Java在不同平台上的正确运行主内存Main Memory:所有线程共享的内存区域,所有的变量都存储在主存中工作内存Working Memory:每个线程拥有自己的工作内存,用于保存变量的副本.线程执行过程中先将主内存中的变量读到工作内存中,对变量进行操作之后再将变量写入主内存,jvm概念说明主内存所有线程共享的内存区域,存储原始变量(堆内存中的对象实例和静态变量)工作内存。
163 0
|
10月前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
128 0
|
12月前
|
存储 安全 Java
jvm 锁的 膨胀过程?锁内存怎么变化的
【10月更文挑战第3天】在Java虚拟机(JVM)中,`synchronized`关键字用于实现同步,确保多个线程在访问共享资源时的一致性和线程安全。JVM对`synchronized`进行了优化,以适应不同的竞争场景,这种优化主要体现在锁的膨胀过程,即从偏向锁到轻量级锁,再到重量级锁的转变。下面我们将详细介绍这一过程以及锁在内存中的变化。
111 4
|
存储 SQL 缓存
揭秘Java并发核心:深度剖析Java内存模型(JMM)与Volatile关键字的魔法底层,让你的多线程应用无懈可击
【8月更文挑战第4天】Java内存模型(JMM)是Java并发的核心,定义了多线程环境中变量的访问规则,确保原子性、可见性和有序性。JMM区分了主内存与工作内存,以提高性能但可能引入可见性问题。Volatile关键字确保变量的可见性和有序性,其作用于读写操作中插入内存屏障,避免缓存一致性问题。例如,在DCL单例模式中使用Volatile确保实例化过程的可见性。Volatile依赖内存屏障和缓存一致性协议,但不保证原子性,需与其他同步机制配合使用以构建安全的并发程序。
207 0
|
11月前
|
存储 缓存 安全
Java内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
132 2
|
12月前
|
存储 安全 Java
JVM锁的膨胀过程与锁内存变化解析
在Java虚拟机(JVM)中,锁机制是确保多线程环境下数据一致性和线程安全的重要手段。随着线程对共享资源的竞争程度不同,JVM中的锁会经历从低级到高级的膨胀过程,以适应不同的并发场景。本文将深入探讨JVM锁的膨胀过程,以及锁在内存中的变化。
152 1
|
12月前
|
存储 Kubernetes 架构师
阿里面试:JVM 锁内存 是怎么变化的? JVM 锁的膨胀过程 ?
尼恩,一位经验丰富的40岁老架构师,通过其读者交流群分享了一系列关于JVM锁的深度解析,包括偏向锁、轻量级锁、自旋锁和重量级锁的概念、内存结构变化及锁膨胀流程。这些内容不仅帮助群内的小伙伴们顺利通过了多家一线互联网企业的面试,还整理成了《尼恩Java面试宝典》等技术资料,助力更多开发者提升技术水平,实现职业逆袭。尼恩强调,掌握这些核心知识点不仅能提高面试成功率,还能在实际工作中更好地应对高并发场景下的性能优化问题。
|
12月前
|
缓存 Java 编译器
【多线程-从零开始-伍】volatile关键字和内存可见性问题
【多线程-从零开始-伍】volatile关键字和内存可见性问题
140 0
|
安全 Java 编译器
深入Java内存模型:解锁并发编程的秘密
【8月更文挑战第24天】在Java的世界,内存模型是支撑并发编程的基石。本文将深入浅出地探讨Java内存模型(JMM)的核心概念、工作原理及其对高效并发策略的影响。我们将通过实际代码示例,揭示如何利用JMM来设计高性能的并发应用,并避免常见的并发陷阱。无论你是Java新手还是资深开发者,这篇文章都将为你打开并发编程的新视角。
74 2

热门文章

最新文章