Java并发编程(四)Java内存模型(下)

 2019-12-10 16:00  阅读(737)
文章分类:Java Core

一、volatile的内存语义

1.1、volatile的特性

理解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变量自身具有下列特性。
(1)可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
(2)原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性

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

从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。
从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

1.3、volatile写-读的内存语义

volatile写的内存语义如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义如下:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见.
下面对volatile写和volatile读的内存语义做个总结。
(1)线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
(2)线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
(3)线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

1.4、volatile内存语意的实现

前文提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下表是JMM针对编译器制定的volatile重排序规则表。
20191210001478\_1.png
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
20191210001478\_2.png
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:
20191210001478\_3.png

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

在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。

二、锁的内存语义

众所周知,锁可以让临界区互斥执行。这里将介绍锁的另一个同样重要,但常常被忽视的功能:锁的内存语义。

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

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

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

假设线程A执行writer()方法,随后线程B执行reader()方法。根据happens-before规则,这个过程包含的happens-before关系可以分为3类。
(1)根据程序次序规则,1 happens-before 2,2 happens-before 3;
4 happens-before 5,5 happens-before 6。
(2)根据监视器锁规则,3 happens-before 4。
(3)根据happens-before的传递性,2 happens-before 5。
20191210001478\_4.png
上图表示在线程A释放了锁之后,随后线程B获取同一个锁。在上图中,2 happens-before5。因此,线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。

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

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结:
(1)线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
(2)线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
(3)线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

2.3、锁内存语义的实现

借助ReentrantLock的源代码,来分析锁内存语义的具体实现机制。

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的实现依赖于Java同步器框AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态。
ReentrantLock分为公平锁和非公平锁,我们首先分析公平锁。使用公平锁时,加锁方法lock()调用轨迹如下。
(1)ReentrantLock:lock()。
(2)FairSync:lock()。
(3)AbstractQueuedSynchronizer:acquire(int arg)。
(4)ReentrantLock:tryAcquire(int acquires)。
在第4步真正开始加锁,下面是该方法的源代码。

protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState(); // 获取锁的开始,首先读volatile变量state
        if (c == 0) {
            if (isFirst(current) &&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) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

该方法以原子操作的方式更新state变量,本文把Java的compareAndSet()方法调用简称为CAS。JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。
下面我们来分析在常见的intel X86处理器中,CAS是如何同时具有volatile读和volatile写的内存语义的:
程序会根据当前处理器的类型来决定是否为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写的内存语义。经过上面的分析,现在我们终于能明白为什么JDK文档说CAS同时具有volatile读和volatile写的内存语义了。
现在对公平锁和非公平锁的内存语义做个总结:
★ 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
★ 公平锁获取时,首先会去读volatile变量。
★ 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
从本文对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。
( 1)利用volatile变量的写-读所具有的内存语义。
( 2)利用CAS所附带的volatile读和volatile写的内存语义。

2.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变量。
如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:
★ 首先,声明共享变量为volatile。
★ 然后,使用CAS的原子条件更新来实现线程之间的同步。
★ 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图:
20191210001478\_5.png

三、happens-before规则

(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

四、双重检查锁定与延迟初始化

在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本文将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。

4.1、双重检查锁定的由来

在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。

public class UnsafeLazyInitialization {
        private static Instance instance;
        public static Instance getInstance() {
            if (instance == null) // 1:A线程执行
                instance = new Instance(); // 2:B线程执行
            return instance;
        }
    }

在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化。
对于UnsafeLazyInitialization类,我们可以对getInstance()方法做同步处理来实现线程安全的延迟初始化。示例代码如下。

public class SafeLazyInitialization {
        private static Instance instance;
        public synchronized static Instance getInstance() {
            if (instance == null)
                instance = new Instance();
            return instance;
        }
    }

由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
在早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。

public class DoubleCheckedLocking { // 1
    private static Instance instance; // 2
        public static Instance getInstance() { // 3
        if (instance == null) { // 4:第一次检查
            synchronized (DoubleCheckedLocking.class) { // 5:加锁
                if (instance == null) // 6:第二次检查
                    instance = new Instance(); // 7:问题的根源出在这里
                } // 8
            } // 9
            return instance; // 10
        } // 11
    }

如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美。

4.2、问题的根源

前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。

memory = allocate(); // 1:分配对象的内存空间
    ctorInstance(memory); // 2:初始化对象
    instance = memory; // 3:设置instance指向刚分配的内存地址

上面3行伪代码中的2和3之间,。2和3之间重排序之后的执行时序如下。

memory = allocate(); // 1:分配对象的内存空间
    instance = memory; // 3:设置instance指向刚分配的内存地址
    // 注意,此时对象还没有被初始化!
    ctorInstance(memory); // 2:初始化对象

时序图如下:
20191210001478\_6.png
B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
(1)不允许2和3重排序。
(2)允许2和3重排序,但不允许其他线程“看到”这个重排序

4.3、基于volatile的解决方案

对于前面的基于双重检查锁定来实现延迟初始化的方案(指DoubleCheckedLocking示例代码),只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。请看下面的示例代码。

public class SafeDoubleCheckedLocking {
        private volatile static Instance instance;
        public static Instance getInstance() {
            if (instance == null) {
                synchronized (SafeDoubleCheckedLocking.class) {
                if (instance == null)
                    instance = new Instance(); // instance为volatile,现在没问题了
                }
            }
            return instance;
        }
    }

当声明对象的引用为volatile后,伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。

4.4、基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

public class InstanceFactory {
        private static class InstanceHolder {
            public static Instance instance = new Instance();
        }
        public static Instance getInstance() {
            return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
        }
    }

假设两个线程并发执行getInstance()方法,下面是执行的示意图:
20191210001478\_7.png
初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。
(1)T是一个类,而且一个T类型的实例被创建。
(2)T是一个类,且T中声明的一个静态方法被调用。
(3)T中声明的一个静态字段被赋值。
(5)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
(6)T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
类初始化的处理过程分为了5个阶段。
第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设Class对象当前还没有被初始化(初始化状态state,此时被标记为state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象。
20191210001478\_8.png
20191210001478\_9.png
第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
20191210001478\_10.png
第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
20191210001478\_11.png
线程B结束类的初始化处理
20191210001478\_12.png

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java并发编程(四)Java内存模型(下)

相关推荐