当前位置: 首页 > article >正文

《Java 并发编程艺术》笔记(上)

如何减少上下文切换

减少上下文切换的方法有无锁并发编程CAS算法使用最少线程使用协程

  • 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁。如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
  • CAS 算法:Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

volatile 是如何来保证可见性的呢?

让我们在 X86 处理器下通过工具获取 JIT 编译器生成的汇编指令来查看对 volatile 进行写操作时,CPU 会做什么事情。

Java代码如下:

instance = new Singleton(); // instance 是 volatile 变量

转变成汇编代码,如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

volatile 变量修饰的共享变量进行写操作的时候会多出第二行汇编代码

Lock 前缀的指令在多核处理器下会引发两件事:

  • 1)将当前处理器缓存行的数据写回到系统内存。
  • 2)这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1, L2或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

下面来具体讲解 Volatile 的两条实现原则。

  • 1)Lock 前缀指令会引起处理器缓存回写到内存Lock 前缀指令导致在执行指令期间,声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器中,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。在锁操作时,总是在总线上声言LOCK#信号。但在 P6 和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
  • 2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32 处理器和 Intel 64 处理器使用 MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和 Intel 64 处理器能嗅到其他的处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如:在 Pentium 和 P6 family 处理器中,如果通过嗅探一个处理器来监测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

synchronized 是如何实现同步的?

先来看下利用 synchronized 实现同步的基础:Java 中的每一个对象都可以作为锁。具体表现为以下 3 种形式。

  • 对于普通同步方法,锁是当前实例对象;进入同步代码前要获得当前实例的锁;
  • 对于静态同步方法,锁是当前类的Class对象;进去同步代码前要获得当前类对象的锁;
  • 对于同步方法块,锁是synchonized()括号里配置的对象。这需要指定加锁的对象,进入同步代码前要获得指定对象的锁。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁到底存在哪里呢?锁里面会存储什么信息呢?

从 JVM 规范中可以看到 Synchonized 在 JVM 里的实现原理,JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用 monitorentermonitorexit 指令实现的,而方法同步是使用另外一种方式实现的,细节在 JVM 规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit,如表2-2所示。

在这里插入图片描述

Java 对象头里的 Mark Word 里默认存储对象的HashCode分代年龄锁标记位。32 位 JVM 的 Mark Word 的默认存储结构:

在这里插入图片描述

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Work 可能变化为存储以下 4 种数据,如表2-4所示。

在这里插入图片描述

64 位虚拟机下,Mark Word64bit 大小的, 其存储结构如图2-5所示。

在这里插入图片描述

在这里插入图片描述

处理器如何实现原子操作

32 位 IA-32 处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性

  • (1)使用总线锁保证原子性

    第一个机制是通过总线锁保证原子性。处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存

  • (2)使用缓存锁保证原子性

    第二个机制是通过缓存锁定保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

频繁使用的内存会缓存在处理器的 L1,L2 和 L3 高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在奔腾6和最近的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会使缓存行无效。

但是有两种情况下处理器不会使用缓存锁定。

  • 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。
  • 第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

JAVA 如何实现原子操作

在java中可以通过循环 CAS的方式来实现原子操作。

(1)使用循环 CAS 实现原子操作

JVM 中的 CAS 操作正是利用了处理器提供的 CMPXCHG 指令实现的。自旋 CAS 实现的基本思路就是循环进行 CAS 操作直到成功为止,以下代码实现了一个基于 CAS 线程安全的计数器方法safeCount和一个非线程安全的计数器count

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class CASTest {

    private AtomicInteger atomicI = new AtomicInteger(0);
    private int i = 0;

    public static void main(String[] args) throws InterruptedException {
        final CASTest cas = new CASTest();

        List<Thread> ts = new ArrayList<>(600);
        long start = System.currentTimeMillis();

        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    cas.count();
                    cas.safeCount();
                }
            });
            ts.add(t);
        }

        for (Thread t : ts) {
            t.start();
        }

        // 等待所有线程执行完成
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        TimeUnit.SECONDS.sleep(1);

        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);
    }

    /** 使用CAS实现线程安全计数器 */
    private void safeCount() {
        for (;;) {
            int i = atomicI.get();
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }

    /** 非线程安全计数器 */
    private void count() {
        i++;
    }
}

(2)CAS 实现原子操作的三大问题

  • ABA问题:如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化。解决方案:JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决ABA问题
  • 循环时间长开销大:自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销
  • 只能保证一个共享变量的原子操作:我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用

(3)使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM 内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁。

主内存和本地内存结构

Java 线程之间的通信由 Java 内存模型(JMM)控制JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度看,JMM 定义了线程主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本本地内存是 JMM 的一个抽象概念,并不真实存在。本地内存它涵盖了缓存写缓冲区寄存器以及其他的硬件和编译器优化之后的一个数据存放位置。

在这里插入图片描述

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  • 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  • 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

在这里插入图片描述

从源代码到指令序列的重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

在这里插入图片描述

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

happens-before 的定义

从 JDK 5 开始,Java使用新的 JSR-133 内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

JSR-133 对happens-before关系的定义如下

  • 1)如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 2)两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

与程序员密切相关的happens-before规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。(存在依赖关系的代码)
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。(及时刷新主存+原子化一组指令)
  • volatile变量规则:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。(及时刷新主存+禁止与前面的指令重排)
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作;
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始;

一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。

上面的 1)是 JMM 对程序员的承诺。 从程序员的角度来说,可以这样理解 happens-before 关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!

上面的 2)是 JMM 对编译器和处理器重排序的约束原则。 正如前面所言,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序)编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。

as-if-serial语义

as-if-serial语义的意思是:不管怎样重排序(编译器和处理器为了提高并行度), (单线程)程序的执行结果不能被改变。编译器、runtime、和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

  • as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程序是按程序的顺序来执行的。happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。

as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

程序顺序规则

double pi = 3.14;         // A 
double r = 1.0;           // B 
double area = pi * r * r; // C 

根据 happens-before 的程序顺序规则,上面计算圆面积的示例代码存在 3 个 happens-before 关系:

1)A happens-before B。
2)B happens-before C。
3)A happens-before C。

这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。

这里 A happens- before B,但实际执行时B却可以排在A之前执行。如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

顺序一致性内存模型

特性

  • 一个线程中的所有操作必须按照程序的执行顺序来执行
  • 所有的线程都只能看到一个单一的操作执行顺序(不管是否正确同步),每个操作都必须原子执行且立刻对所有线程可见。

在这里插入图片描述

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存的读/写操作。上图中可以看出, 在任意时刻最多只有一个线程可以连接到内存。因此,在多线程并发执行时,图中的开关装置能把所有的内存读/写操作串行化(即在顺序一致性模型中所有操作之间具有全序关系)。

假设线程A和线程B使用监视器锁来正确同步,A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如下所示:

在这里插入图片描述

假设线程A和线程B没有做同步,那么这个未同步的程序在顺序一致性模型中的另一种可能的效果如下所示:

在这里插入图片描述

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但是所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:A1 - B1 - A2 - B2 - A3 - B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见

但是,在 JMM 中就没有这个保证未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本不被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。这种情况就会出现多种运行结果。

JMM 在具体实现上的基本方针为:在不改变(正确同步)程序执行结果的前提下,尽可能为编译器和处理器的优化打开方便大门。

在这里插入图片描述

顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区的代码“逸出”到临界区之外,那样会破坏监视器锁的语义)。JMM 会在进入临界区和退出临界区的关键时间点做一些特殊处理,使得线程在这两个时间点具有顺序一致性模型中相同的内存视图。虽然线程 A 在临界区内做了重排序,但由于监视锁互斥执行的特性,这里线程 B 无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

long 和 double 类型的操作

在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,那么会有非常大的同步开销。为了照顾这种处理器,Java 语言规范中鼓励但不强求 JVM 对 64 位 long 型和 double 类型的变量写操作具有原子性。当 JVM 在这种处理器上运行时,可能会把一个 64 位的 long/double 变量的写操作拆成两个 32 位写操作来执行。这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写操作不具备原子性。

当单个内存操作不具有原子性时,可能会产生意想不到的后果。

在这里插入图片描述

如上图,假设处理器 A 写一个long类型的变量,同时处理器 B 要读这个long类型的变量。处理器 A 中 64 位的写操作被拆分成两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的事务中执行。同时,处理器 B 中 64 位的读操作被分配到单个读事务中执行。当处理器 A 和 B 按照上面的时序来执行时,处理器 B 将看到仅仅被处理器 A “写了一半” 的无效值。

注意:在 JSR-133 的旧内存模型中,一个 64 位的 long/double 变量的读/写操作可以被拆分成两个 32 位的读/写操作来执行。从 JSR-133 内存模型开始(即JDK1.5),仅仅允许把一个 64 位 long/double 变量的操作拆分成两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性。

volatile 写 - 读的内存语义

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

下面对 volatile 写和 volatile 读的内存语义做个总结

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接受了之前某个线程发出的(在写这个volatile变量之前对共享变量所做的修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

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

  • 在每个volatile写操作前面插入一个StoreStore屏障
  • 在每个Volatile写操作后面插进入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入LoadStore屏障

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意程序中都能得到正确的 volatile 内存语义。

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

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

如下图

在这里插入图片描述
在这里插入图片描述

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

下面对锁释放和锁获取的内存语义做个总结:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

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中,调用lock()方法获取锁;调用unlock()方法释放锁。

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

在这里插入图片描述

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

1)ReentrantLocklock()
2)FairLocklock()
3)AbstractQueuedSynchronizeracquire(int arg)
4)ReentrantLocktryAcquire(int acquires)

第四步真正开始加锁,下面是该方法的源码

/**
* 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)ReentrantLockunlock()
2)AbstractQueuedSynchronizerrelease(int arg)
3)SynctryRelease(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); // 释放锁的最后,写 volatile 变量 state
    return free;
}

从上面源码可以看出,在释放锁的最后写 volatile 变量 state

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

现在我们来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法lock()调用轨迹如下

1)ReentrantLocklock()
2)NonFairSynclock()
3)AbstractQueuedSynchronizercompareAndSetState(int expect,int update)

第三步真正开始加锁,下面是该方法源码

/**
* 该方法以原子操作的方式更新state变量,JDK中对该方法的说明:如果当前状态值等于预期值,
* 则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。
*/
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

该方法以原子操作的方式更新state变量,本文把 Java 的 compareAndSet() 方法调用简称为 CAS。JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。

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

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state
  • 公平锁获取时,首先会去读volatile变量,
  • 非公平锁获取时,首先会用 CAS 更新volatile变量,这个操作同时具有volatile读和写的内存语义。

从本文对 ReentrantLock 的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式

  • 1)利用 volatile 变量的写-读所具有的内存语义
  • 2)利用 CAS 所附带的 volatile 读和 volatile 写的内存语义

conurrent包的实现

由于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 变量。

原子方式对内存执行读 - 改 - 写操作,这是在多处理器中实现同步的关键。同时,volatile变量的读/写CAS可以实现线程之间的通信。把这些整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源码,会发现一个通用化的实现模式:

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

AQS、非阻塞数据结构和原子变量类,这些 concurrent 包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。

在这里插入图片描述

final 域的内存语义

final 域的重排规则

对于 final 域,编译器和处理器都要遵守两个重排序规则

  • 1)在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 2)初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

写 final 域重排序规则

final 域的重排序规则禁止把 final 域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:

  • 1)JMM 禁止编译器把 final 域的写重排序到构造函数之外;
  • 2)编译器会在 final 域写之后,构造函数 return 之前,插入一个 storestore 屏障。这个屏障可以禁止处理器把 final 域的写重排序到构造函数之外。

写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域就不具有这个保障。

读 final 域重排序规则

读 final 域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的 final 域,JMM 会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读 final 域操作的前面插入一个 LoadLoad 屏障。实际上,读对象的引用和读该对象的 final 域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。

读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读这个包含这个 final 域的对象的引用。

final 域为引用类型

上面我们看到的 final 域是基础数据类型,如果 final 域是引用数据类型,将会有什么效果?请看下面示例代码。

public class FinalRefrenceExample { 

	final int[] intArray;                    // final 是引用类型
	static FinalReferencExample obj;
	
	public FinalReferenceExample() {         // 构造函数 
		intArray = new int[1];               // 1 
		intArray[0] = 1;                     // 2 
	}
	
	public static void writeOne() {           // 写线程 A 执行 
		obj = new FinalReferenceExample();    // 3
	}
	
	public static void writerTwo() {         // 写线程 B 执行 
		obj.intArray[0] = 2;                 // 4 
	}
	
	public static void reader() {           // 读线程 C 执行 
		if (obj != null) {	                // 5 
			int templ = obj.intArray[0];	// 6 
		}
	}
}

针对引用数据类型,写 final 域针的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个 final 修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。

对上面的示例程序,假设首先线程 A 执行 writerOne() 方法,执行完后线程 B 执行 writerTwo() 方法,执行完后线程 C 执行 reader() 方法。下图是一种可能的线程执行时序。

在这里插入图片描述

在上图中,1是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看得到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步原语(lockvolatile)来确保内存可见性。

为什么 final 引用不能从构造函数中“溢出”

前面提到,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。为了说明这个问题,让我们来看下面的示例代码:

public class FinalReferenceEscapeExample { 
	final int i;
	static FinalReferenceEscapeExample obj; 

	public FinalReferenceEscapeExample() { 
		i = 1;                             // 1 写 final 域 
		obj = this;                        // 2 this 引用在此“逸出”
	}
	
	public static void writer() {
		new FinalReferenceEscapeExample(); 
	}
	
	public static void reader() {
		if (obj != null) {                // 3 
			int temp = obj.i;             // 4
		}
	}
} 

在这里插入图片描述

假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且在程序中操作 2 排在操作 1 后面,执行 read() 方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和 操作 2 之间可能被重排序。实际的执行时序可能如图3-32所示。

从图 3-32 可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。

JSR-133 为什么要增强 final 语义

在旧的 Java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整型 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会发生改变。

为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 Java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化后的值。

双重检查锁定的由来

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()方法做同步处理来实现线程安全的延迟初始化。示例代码如下:

在这里插入图片描述

由于对getInstance()做了同步处理,synchronized将导致性能开销。如果getInstance()被多个线程频繁的调用, 将会导致程序执行性能的下降。反之,如果getInstance()不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。

在早期的 JVM 中,synchronized(甚至是无竞争的synchronized)存在这巨大的性能开销。因此,人们想出了一个“聪明”的技 巧:双重检查锁定(double-checked locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码:

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

  • 在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
  • 在对象创建好之后,执行getInstance()将不需要获取锁,直接返回已创建好的对象。

双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化

问题的根源

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

在这里插入图片描述

上面三行伪代码中的 2 和 3 之间,可能会被重排序。2 和 3 之间重排序之后的执行时序如下:

在这里插入图片描述

根据Java语言规范,所有线程在执行java程序时必须要遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。换句话来说,intra-thread semantics 允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面三行伪代码的 2 和 3 之间虽然被重排序了,但这个重排序并不会违反 intra-thread semantics。这个重排序在没有改变单线程程序的执行结果的前提下,可以提高程序的执行性能。

为了更好的理解intra-thread semantics,请看下面的示意图(假设一个线程A在构造对象后,立即访问这个对象):

在这里插入图片描述

如上图所示,只要保证 2 排在 4 的前面,即使 2 和 3 之间重排序了,也不会违反 intra-thread semantics。

下面,再让我们看看多线程并发执行的时候的情况。请看下面的示意图:

在这里插入图片描述

由于单线程内要遵守 intra-thread semantics,从而能保证 A 线程的程序执行结果不会被改变。但是当线程 A 和 B 按上图的时序执行时,B 线程将看到一个还没有被初始化的对象。

回到本文的主题,DoubleCheckedLocking示例代码的第 7 行(instance = new Singleton();)如果发生重排序,另一个并发执行的线程 B 就有可能在第 4 行判断 instance 不为 null。线程 B 接下来将访问 instance 所引用的对象,但此时这个对象可能还没有被 A 线程初始化!下面是这个场景的具体执行时序:

在这里插入图片描述

这里 A2 和 A3 虽然重排序了,但 Java 内存模型的 intra-thread semantics 将确保 A2 一定会排在 A4 前面执行。因此线程 A 的 intra-thread semantics 没有改变。但 A2 和 A3 的重排序,将导致线程 B 在 B1 处判断出instance不为空,线程 B 接下来将访问instance引用的对象。此时,线程 B 将会访问到一个还未初始化的对象

在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化:

    1. 不允许 2 和 3 重排序;
    1. 允许 2 和 3 重排序,但不允许其他线程“看到”这个重排序。

后文介绍的两个解决方案,分别对应于上面这两点。

基于volatile的双重检查锁定的解决方案

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

在这里插入图片描述

当声明对象的引用为volatile后,“问题的根源”的三行伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行:

在这里插入图片描述

基于类初始化的解决方案

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

基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom):

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

假设两个线程并发执行getInstance(),下面是执行的示意图:

在这里插入图片描述

这个方案的实质是:允许“问题的根源”的三行伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程B)“看到”这个重排序

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化:

  • 1)T 是一个类,而且一个 T 类型的实例被创建;
  • 2)T 是一个类,且 T 中声明的一个静态方法被调用;
  • 3)T 中声明的一个静态字段被赋值;
  • 4)T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
  • 5)T 是一个顶级类(top level class,见java语言规范的§7.6),而且一个断言语句嵌套在 T 内部被执行。

在 InstanceFactory 示例代码中,首次执行 getInstance() 的线程将导致 InstanceHolder 类被初始化(符合情况4)。

由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用getInstance()来初始化InstanceHolder类)。因此,在Java中初始化一个类或者接口时,需要做细致的同步处理

Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。

JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

在这里插入图片描述

通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁

但基于volatile的双重检查锁定的方案有一个额外的优势除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化

延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。

  • 如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于 volatile 的延迟初始化的方案;
  • 如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

http://www.kler.cn/a/160376.html

相关文章:

  • 简述 synchronized 和 java.util.concurrent.locks.Lock 的异同?
  • AI时代的研发生产关系,是否有新的可能?
  • 网页web无插件播放器EasyPlayer.js点播播放器遇到视频地址播放不了的现象及措施
  • Unity资源打包Addressable资源保存在项目中
  • windows C#-LINQ概述
  • 有什么初学算法的书籍推荐?
  • 处理实时视频流:第三方美颜SDK的实时图像处理策略
  • idea开发环境配置
  • C++11改进观察者模式
  • js 将后端返回的对象转换为数组
  • VUEX使用总结
  • spark log4j日志配置
  • Amazon CodeWhisperer 正式可用, 并面向个人开发者免费开放
  • redis应用-分布式锁
  • Java - InetAddress#isReachable 方法解析
  • EPICS modbus 模块数字量读写练习
  • 分类与群组:解析分类和聚类分析技术
  • Kubernetes入门笔记——(2)k8s设计文档
  • java之stringbuf
  • 【9】PyQt对话框
  • Ubuntu 20.04 安装 mysql8 LTS
  • 【AI-ChatGPT-Prompt】什么是Prompt
  • Redis生产实战-热key、大key解决方案、数据库与缓存最终一致性解决方案
  • Centos7如何安装MySQL
  • HBase-架构与设计
  • 面试冲刺 - 算法题 1