volatile 和 synchronized

[toc]

1、概述

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节 码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和 CPU的指令。

2、volatile

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

1、计算机的重排序和可见性

1)CPU流水线

CPU 中一个指令周期分为多个阶段,每个阶段可以对应一个电路单元,每一个时钟周期每一个电路逻辑单元得到前一个电路逻辑单元的输出状态(从寄存器获取)并基于现有状态计算出新状态并输出到寄存器。

CPU 分为5个阶段:

  • F保存程序计数器的预测值,稍后讨论。

  • D位于取指和译码阶段之间。它保存关于最新取出的指令的信息,即将由译码阶段进行处理。

  • E位于译码和执行阶段之间。它保存关于最新译码的指令和从寄存器文件读出的值的信息,即将由执行阶段进行处理。

  • M位于执行和访存阶段之间。它保存最新执行的指令的结果,即将由访存阶段进行处理。它还保存关于用于处理条件转移的分支条件和分支目标的信息

  • W位于访存阶段和反馈路径之间,反馈路径将计算出来的值提供给寄存器文件写, 而当完成ret指令时,它还要向PC选择逻辑提供返回地址

以下是一个 CPU 逻辑组合电路示意图的例子:

image-20200421214256554

一条指令的完整流程就是 FDEMW,下面是CPU三条指令的执行示意图:

image-20200421214723065

可以看到这时未流水线化的处理器,三条指令串行执行,在第一条指令完成 W 之前,第二条指令的 F 都不能开始,则显然有问题,在第一条指令完成 F 之后,紧着的下一个时钟周期 F 逻辑单元其实就可以接着执行下一条指令的预取指令动作了,而不是白白消耗4个时钟周期!

看下面流水线化之后的示意图,效率明显提高:

image-20200421215232300

2)流水线冒险

流水线有这样一种情况,在下一个时钟周期中下一条指令不能执行。这种情况称为冒险(hazard)。我们将介绍三种流水线冒险。

1> 结构冒险

结构冒险第一种冒险叫作结构冒险( structural hazard)。即硬件不支持多条指令在同一时钟周期执行。例如看上图,假设该流水线结构只有一个存储器,第2个时钟周期正在访问存储器,而第5个时钟周期正在预取指令,存储器只能满足一个操作,这就是结构冒险。如果发生上述情况,那我们精心构筑起来的流水线就会受到破坏。

结构冒险:因缺乏硬件支持而导致指令不能在预定的时钟周期内执行的情况。

2> 数据冒险与指令重排

数据冒险( data hazard)发生在由于一条指令必须等待另一条指令的完成而造成流水线暂停的情况下。假设你在折叠衣服时发现有一只短袜找不到与之配对的另一只。你可能做的是下楼到你的房间,在衣橱中找,看是否能找到另一只。很明显,当你在找的时候,已经烘干且正需要折叠的衣服以及已经洗完且正需要烘干的衣服不得不搁置一边

数据冒险:也称为流水线数据冒险( pipeline data hazard),即因无法提供指令执行所需数据而导致指令不能在预定的时钟周期内执行的情况。

在计算机流水线中,数据冒险是由于一条指令依赖于更早的一条还在流水线中的指令造成的(这是一种在洗衣店例子中不存在的情况)。

例如,假设有一条加法指令,它之后紧跟着一条减法指令,而减法指令要使用加法指令的和($s0):

add $s0, $to, $tl 

sub $t2, $s0, $t3 

在不做任何干涉的情况下,这一数据冒险会严重地阻碍流水线。加法指令直到第五步才能写回它的结果,这就意味着在流水线中浪费了3个时钟周期。
虽然可以试图通过编译器来避免这种数据冒险的发生,但实际上这种努力很难令人满意。
因为这种冒险的发生过于频繁而且导致的延迟太长,因此不可能指望编译器把我们从这种困境当中解脱出去。
一种最基本的解决方法是基于以下发现:在解决数据冒险问题之前不需要等待指令的执行结束。对于上述的代码序列,一且ALU生成了加法运算的结果,就可以将它用作减法运算的个输人项。从内部资源中直接提前得到缺少的运算项的过程称为前推( forwarding)或者旁路( bypassing)。

前推:也称为旁路。一种解决数据冒险的方法,具体做法是从内部寄存器而非程序员可见的寄存器或存储器中提前取出数据。

image-20200421220725413

上图表示了把add指令执行后的$s0中的值作为sub指令执行的输人的旁路连接。只有当目标步骤在时间上晚于源步骤时旁路的路径才有效。例如,从前一条指令存储器访问的输出至下一条指令执行的输人就不能实现旁路,因为那样的话将意味着时间的倒流。
旁路可以工作得很好。然而它并不能够避免所有流水线阻塞的发生。例如,假设第一条指令不是add而是装载$s0寄存器的内容, 正如上图所描述的那样,由于数据间的依赖,所需要的数据只有在前一条指令流水线的第四级完成之后才能生效,这对于sub指令的第三级输入来说就太迟了。因此,如下图所示, 即使采用了旁路机制, 在遇到 取数一使用型数据冒险(load- use data hazard) 时,流水线不得不阻塞一个步骤。
图中显示了一个重要的流水线概念,正式的叫法是流水线阻塞( pipeline stal),但是它经常被呢称为气泡( bubble)(其实就是插入一个空指令,本次时钟周期什么也不做,发生了一次空转)。我们经常会在流水线中看到阻塞的发生。

  • 取数一使用型数据冒险。一类特殊的数据冒险,指当装载指令要取的数还没取回来时其他指令就需要使用的情况。
  • 流水线阻塞:也称为气泡。为了解决冒险而实施的一种阻塞。

image-20200421220841398

上面举了一个流水线阻塞的例子,下面则是一个流水线指令重排的例子。注意,指令重排就是通过旁路实现的,下面的指令重排在可以使用 WH 到 IF 的一个旁路实现。

image-20200421221824346

3> 控制冒险

第三种冒险叫作控制冒险( control hazard)。这种冒险会在下面的情况下出现:决策依赖于一条指令的结果,而其他指令正在执行中。

控制冒险:也称为分支冒险( branch hazard)。因为取到的指令并不是所需要的(或者说指令地址的变化并不是流水线所预期的)而导致指令不能在预定的时钟周期内执行。

3)指令重排

上面通过举例说明了现代处理器是会出现指令重排的情况,大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待3。通过乱序执行的技术,处理器可以大大提高执行效率。

除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。

以下是一个测试例子,指令重排可能会导致输出 x=0, y=0。 理论上是不可能出现这种情况的,要么是(1,0),(0,1),(1,1)中的一种,但是可能其中一个线程发生了指令重排。例如:one 线程x=b行排到a=1上面了,此时执行x=b得到 x=0,然后线程中断,执行 other 线程,执行到y=ay=0,然后输出结果得到(0,0)。

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(new Runnable() {
                public void run() {
                    //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                    shortWait(100000);
                    a = 1;
                    x = b;
                }
            });

            Thread other = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();other.start();
            one.joilin();other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }


    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do{
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

4)as-if-serial 语义

As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。

int a = 1;
int b = 2;
int c = a + b;

将上面的代码编译成Java字节码或生成机器指令,可视为展开成了以下几步动作(实际可能会省略或添加某些步骤)。

  1. 对a赋值1
  2. 对b赋值2
  3. 取a的值
  4. 取b的值
  5. 将取到两个值相加后存入c

在上面5个动作中,动作1可能会和动作2、4重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序,动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系,一旦重排,as-if-serial语义便无法保证。

为保证as-if-serial语义,Java异常处理机制也会为重排序做一些特殊处理。例如在下面的代码中,y = 0 / 0可能会被重排序在x = 2之前执行,为了保证最终不致于输出x = 1的错误结果,JIT在重排序时会在catch语句中插入错误代偿代码,将x赋值为2,将程序恢复到发生异常时应有的状态。这种做法的确将异常捕捉的逻辑变得复杂了,但是JIT的优化的原则是,尽力优化正常运行下的代码逻辑,哪怕以catch块逻辑变得复杂为代价,毕竟,进入catch块内是一种“异常”情况的表现。

public class Reordering {
    public static void main(String[] args) {
        int x, y;
        x = 1;
        try {
            x = 2;
            y = 0 / 0;    
        } catch (Exception e) {
        } finally {
            System.out.println("x = " + x);
        }
    }
}

5)内存可见性

计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。
有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。
这种内存可见性问题也会导致上面示例代码即便在没有发生指令重排序的情况下的执行结果也还是(0, 0)。

6)内存访问重排序与Java内存模型

Java的目标是成为一门平台无关性的语言,即Write once, run anywhere. 但是不同硬件环境下指令重排序的规则不尽相同。例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。从Java 5开始,Java内存模型成为Java语言规范的一部分。
根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。

  • **程序次序法则:**线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • **监视器锁法则:**对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • **volatile变量法则:**对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • **线程启动法则:**在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • **线程终结法则:**线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • **中断法则:**一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • **终结法则:**一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • **传递性:**如果A happens-before于B,且B happens-before于C,则A happens-before于C

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见

Happens-before关系只是对Java内存模型的一种近似性的描述,它并不够严谨,但便于日常程序开发参考使用,关于更严谨的Java内存模型的定义和描述,请阅读JSR-133原文或Java语言规范章节17.4。

除此之外,Java内存模型对volatile和final的语义做了扩展。对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(的前提是没有this引用溢出)。

为了保证final的新增语义。JSR-133对于final变量的重排序也做了限制。

  • 构建方法内部的final成员变量的存储,并且,假如final成员变量本身是一个引用的话,这个final成员变量可以引用到的一切存储操作,都不能与构建方法外的将当期构建对象赋值于多线程共享变量的存储操作重排序。例如对于如下语句
    x.finalField = v; … ;构建方法边界sharedRef = x;
    v.afield = 1; x.finalField = v; … ; 构建方法边界sharedRef = x;
    这两条语句中,构建方法边界前后的指令都不能重排序。

  • 初始读取共享对象与初始读取该共享对象的final成员变量之间不能重排序。例如对于如下语句
    x = sharedRef; … ; i = x.finalField;
    前后两句语句之间不会发生重排序。由于这两句语句有数据依赖关系,编译器本身就不会对它们重排序,但确实有一些处理器会对这种情况重排序,因此特别制定了这一规则。

7)内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU原子指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型

  • **LoadLoad屏障:**对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • **StoreStore屏障:**对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • **LoadStore屏障:**对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • **StoreLoad屏障:**对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
    • 控制重排序:在取指之后如果指令中包含了内存屏障标识,会根据情况禁止当前指令之后的指令放到当前指令之前执行,也会禁止将当前指令放置到需要后置执行的指令之后执行。
    • 内存可见性:包含内存屏障标识的指令中所操作的数据在写回主存的时候会通过缓存一致性将其他处理器中缓存了该数据的缓存行全部置失效。

内存屏障是一种标准,不同的处理器厂商和操作系统可能会采用不同的实现。以X86 的内存屏障指令为例:包括读屏障、写屏障、全屏障。

屏障类型 示例 说明
读屏障 Read1; 读屏障; Read2; 确保读操作Read1,先于 Read2及其后所有读操作。 对于屏障前后的写操作并无影响。
写屏障 Store1; 写屏障; Store2;Read; 确保写操作Store1刷新数据到内存(使数据对其他处理器可见)的操 作,先于写屏障后的Store2及其后所有Read指令的执行。
全 屏障 Read1;Store1; 全屏障; Read2;Store2; 确保屏障之前的所有内存访问操作(包括Store和Read)完成之后,才 执行屏障之后的内存访问操作。 全能型屏障,会屏蔽屏障前后所有指令的重排。

那Java开发时,我们如何进行内存屏障呢?Java作为一种一次编写,多处运行的语言,开发者是不需 要考虑平台相关性的,同样这个问题也不需要也不应该由程序员来关心。在需要确保代码执行顺序时, JVM会在适当位置插入一个内存屏障命令,用来禁止特定类型的重排序。所以,Java虚拟机封装了底层内存屏障的实现,提供了四种内存屏障指令(在后面有说明),编译器会根据底层的计算机架构,将内存屏障替换为相应的CPU指令。有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。

8) Intel 64/IA-32架构下的内存访问重排序

Intel 64和IA-32是我们较常用的硬件环境,相对于其它处理器而言,它们拥有一种较严格的重排序规则。Pentium 4以后的Intel 64或IA-32处理的重排序规则如下。

在单CPU系统中

  • 读操作不与其它读操作重排序。
  • 写操作不与其之前的写操作重排序。
  • 写内存操作不与其它写操作重排序,但有以下几种例外
  • CLFLUSH的写操作
  • 带有non-temporal move指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)的streaming写入。
  • 字符串操作
  • 读操作可能会与其之前的写不同位置的写操作重排序,但不与其之前的写相同位置的写操作重排序。
  • 读和写操作不与I/O指令,带锁的指令或序列化指令重排序。
  • 读操作不能重排序到LFENCE和MFENCE之前。
  • 写操作不能重排序到LFENCE、SFENCE和MFENCE之前。
  • LFENCE不能重排序到读操作之前。
  • SFENCE不能重排序到写之前。
  • MFENCE不能重排序到读或写操作之前。

在多处理器系统中

  • 各自处理器内部遵循单处理器的重排序规则。
  • 单处理器的写操作对所有处理器可见是同时的。
  • 各自处理器的写操作不会重排序。
  • 内存重排序遵守因果性(causality)(内存重排序遵守传递可见性)。
  • 任何写操作对于执行这些写操作的处理器之外的处理器来看都是一致的。
  • 带锁指令是顺序执行的。

值得注意的是,对于Java编译器而言,Intel 64/IA-32架构下处理器不需要LoadLoad、LoadStore、StoreStore屏障,因为不会发生需要这三种屏障的重排序。

2、volatile定义

Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言 提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。也就是上面提到的**volatile变量法则:**对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。

3、volatile 解决指令有序性和内存可见性

问题

前面提到,重排序只会保证单线程内的访问语义,但是多线程下,重排序有可能会造成不可预知的问题,那有没有什么办法来禁止重排序呢?

  1. 首先在 JVM 层面对于字节码指令的重排序由 JVM 自己可以进行控制,即识别到 volatile 关键字的时候,JVM 禁用字节码指令重排序。
  2. JVM 自己本身的代码又会经过编译器的编译称为机器指令,这个过程可能会重排序。
  3. 编译之后的机器指令在 CPU 执行的时候也会可能发生重排序。
  4. 另外,由于本地高速缓存的引入也会导致多线程环境下多个核心缓存的主存数据不一致问题。

可以看到,第一点是由 JVM 自身保证的,而问题3和4可以利用我们上面提到的内存屏障解决,后三者则需要依赖底层技术的实现。而在 Java 中,提供了 volatile 关键字来解决这些问题。

volatile 实现原理
  1. 下载源码

    OpenJDK的源码是托管在 Mercurial代码版本管理平台上的,可以使用Mercurial的代码管理工具直接 从远程仓库(Repository)中下载获取源码。

    我们选用的项目是OpenJDK 8u,代码远程仓库地址:https://hg.openjdk.java.net/jdk8u/jdk8u/。

    因为下载源码过程中需要执行脚本文件get_source.sh,所以需要在Linux平台下下载。 大家可以在安装了Linux环境的机器上下载,或者在自己的机器上安装Linux虚拟机后进行下载。 获取源代码过程如下:

    #需先安装代码版本管理工具 
    yum -y install mercurial
    #安装成功后,可以用以下命令查看hg 版本信息 
    hg --version
    #从远程仓库下载源码
    hg clone https://hg.openjdk.java.net/jdk8u/jdk8u/
    #赋予get_source.sh文件可执行权限 
    chmod 755 get_source.sh
    #执行文件获取源码 
    ./get_source.sh
    

    OpenJDK 目录结构

    image-20200422073943486

  2. Hsdis工具查看机器指令

    使用hsdis可以查看 Java 编译后的机器指令,使用方法:把编译好的 hsdis-amd64.dll放在 $JAVA_HOME/jre/bin/server 目录下。就可以使用如下命令,查看程序的机器指令。

    java-XX:+UnlockDiagnosticVMOptions-XX:+PrintAssembly,类名
    

    在类VolatileFieldTest中属性volatileField为volatile变量。

    public class VolatileFieldTest {
        private  volatile int volatileField;
        public int getVolatileField() {
            return volatileField;
    		}
        public void setVolatileField(int volatileField) {
            this.volatileField = volatileField;
        }
        public static void main(String[] args) {
            VolatileFieldTest volatileFieldTest=new VolatileFieldTest();
            volatileFieldTest.setVolatileField(2);
        } 
    }
    

    在 Linux中执行

    java-XX:+UnlockDiagnosticVMOptions-XX:+PrintAssembly,VolatileFieldTest
    

    对于 volatile 修饰的变量进行写操作,在生成汇编代码中,会有如下的指令:

    // 内存屏障
    lockaddl$0×0,(%esp); 
    

    上面的操作就相当于一个内存屏障。

    • lock指令:会使紧跟在其后的指令变成原子操作,lock指令是一个汇编层面的指令,作为前缀加在其他汇编指令之前,可以保证其后汇编操作的原子性。在多CPU环境中, LOCK指令可以确保一个处理器能独占使用共享内存。

      在计算机中每个CPU拥有自己的寄存器,缓冲区和高速缓存,所有CPU共享主内存。如果是单 核CPU环境,所有线程都会运行相同CPU上,使用的是相同存储空间不存在一致性问题,就不需要内存屏障;如果是多核 CPU环境中,线程可能运行在不同的CPU内核上, 共享主内存,就需用内存屏障保障一致性。

    • addl指令:加法操作指令。addl $0,0(%%esp)表示将数值0加到rsp寄存器中。esp寄存器指向栈顶的内存单元,加上一个0,esp寄存器的数值依然不变。addl $0,0(%%esp)使用此汇编指令再配合lock指令,实现了CPU的内存屏障。

    通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情:

    1. 将当前处理器缓存行的数据写回到系统内存。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。在8.1.4节有详细说明锁定操作对处理器缓存的影响,对于Intel486和 Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

    2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统 内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的 缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理 器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

      MESI 协议:

    状态 说明 描述
    M Modify,修改 Cache line 数据有效,但数据被修改了,和主存中不一致,数据只存在本 cache 内。
    E Exclusive,独占 Cache line 数据有效,数据和主存中一致,数据只存在于本 cache 内
    S Share,共享 Cache line 数据有效,数据和主内存中一致,数据存在于2个以上 Cache 内
    I Invalid,无效 Cache line 数据无效
volatile 源码

下面我们通过OpenJDK源码,来看看JVM是怎么实现volatile赋值的。

  1. 查看volatile 字段的字节码

    通过javap命令(javap -v -p VolatileFieldClass.class)可以看到volatile的属性的字节码flags标识中有个关键字ACC_VOLATILE。

    private volatile int commonField;
       descriptor: I
       flags: ACC_PRIVATE, ACC_VOLATILE
    
  2. is_volatile方法:判断一个变量是否 volatile 类型

    在不同的计算机架构(操作系统和CPU架构)下,内存屏障的实现也会不同,所以对应的JVM的 volatile底层实现在不同的平台上也是不同的。下面我们以linux_x86为例,来一层层解开volatile 的面纱。

    通过全局搜索关键字ACC_VOLATILE,我们可以定位到JVM源文件vm\utilities\accessFlags.hpp文件,代码如下(看中文注释代码):

    class AccessFlags VALUE_OBJ_CLASS_SPEC {
      friend class VMStructs;
     private:
      jint _flags;
    
     public:
      AccessFlags() : _flags(0) {}
      explicit AccessFlags(jint flags) : _flags(flags) {}
    
      // Java access flags
      bool is_public      () const         { return (_flags & JVM_ACC_PUBLIC      ) != 0; }
      bool is_private     () const         { return (_flags & JVM_ACC_PRIVATE     ) != 0; }
      bool is_protected   () const         { return (_flags & JVM_ACC_PROTECTED   ) != 0; }
      bool is_static      () const         { return (_flags & JVM_ACC_STATIC      ) != 0; }
      bool is_final       () const         { return (_flags & JVM_ACC_FINAL       ) != 0; }
      bool is_synchronized() const         { return (_flags & JVM_ACC_SYNCHRONIZED) != 0; }
      bool is_super       () const         { return (_flags & JVM_ACC_SUPER       ) != 0; }
      //可以看到is_volatile函数,这个函数是判断变量字节码中是否有 ACC_VOLATILE这个flag。
      bool is_volatile    () const         { return (_flags & JVM_ACC_VOLATILE    ) != 0; }
      bool is_transient   () const         { return (_flags & JVM_ACC_TRANSIENT   ) != 0; }
      bool is_native      () const         { return (_flags & JVM_ACC_NATIVE      ) != 0; }
      bool is_interface   () const         { return (_flags & JVM_ACC_INTERFACE   ) != 0; }
      bool is_abstract    () const         { return (_flags & JVM_ACC_ABSTRACT    ) != 0; }
      bool is_strict      () const         { return (_flags & JVM_ACC_STRICT      ) != 0; }
      ... ...
    
  3. Java 中 volatile 变量赋值,C++实现

    Java字节码是通过bytecodeInterpreter解释器来执行,我们在bytecodeInterpreter.cpp文件 根据关键字is_volatile搜索,可以看到如下代码:

    //
    // 变量修改后,存储变量值
    //
    int field_offset = cache->f2_as_index();
      if (cache->is_volatile()) {//判断变量是否是volatile变量
        if (tos_type == itos) {//变量类型为int
          obj->release_int_field_put(field_offset, STACK_INT(-1));
        } else if (tos_type == atos) {
          VERIFY_OOP(STACK_OBJECT(-1));
          obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
        } else if (tos_type == btos) {
          obj->release_byte_field_put(field_offset, STACK_INT(-1));
        } else if (tos_type == ztos) {
          int bool_field = STACK_INT(-1);  // only store LSB
          obj->release_byte_field_put(field_offset, (bool_field & 1));
        } else if (tos_type == ltos) {
          obj->release_long_field_put(field_offset, STACK_LONG(-1));
        } else if (tos_type == ctos) {
          obj->release_char_field_put(field_offset, STACK_INT(-1));
        } else if (tos_type == stos) {
          obj->release_short_field_put(field_offset, STACK_INT(-1));
        } else if (tos_type == ftos) {
          obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
        } else {
          obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
        }
        OrderAccess::storeload();
      } else {
    

    在这段代码中,cache->is_volatile()这段代码,cache代表变量在常量池缓存中的实例(本例中为volatileField),这段代码的作用是判断变量是否被 volatile修饰。接着,根据当前变量的类型来赋值,会先判断volatile变量类型(tos_type变量),后面有不同的基础类型的调用,比如int 类型就调用release_int_field_put。

    release_int_field_put这个方法的实现在文件oop.inline.hpp中

    inlinevoidoopDesc::release_int_field_put(intoffset,jintcontents) { OrderAccess::release_store(int_field_addr(offset), contents); }
    

    赋值动作int_field_addr外面包装执行了OrderAccess::release_store方法。
    我们看看 OrderAccess::release_store做了什么,它的定义在 vm\runtime\orderAccess.hpp中

    staticvoidrelease_store();
    

    linux_x86中实现在os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp

    inline void OrderAccess::release_store(volatile jint* p, jint v) { *p = v; }
    

    可以看到volatile操作,第一步是实现了C++的volatile变量的原生赋值实现。

    C/C++中的volatile关键字,用来修饰变量,表明变量可能会通过某种方式发生改变,被 volatile声明的变量,会告诉编译器与该变量有关的运算,不要进行编译优化;且变量的值都必须直接写入内存地址或从内存地址读取。(可以看到这里解决了第2个问题

  4. volatile 使用内存屏障

    赋值操作完成以后,我们可以看到最后一行执行的语句是

    OrderAccess::storeload();
    

    这是JVM的storeload一个内存屏障。
    在 JMM 中,也把内存屏障指令分为了四类,可以在vm\runtime\orderAccess.hpp找到对应的实现方法:

    static void loadload();//读读 
    static void storestore();//写写
    static void loadstore();//读写
    static void storeload();//全屏障
    

    内存屏障的解释也可以在orderAccess.hpp该文件中看到:

    屏障类型 示例 说明
    LoadLoad Fence Load1;LoadLoad;Load2 确保装载动作Load1,先于 Load2及其后所有 Load操作。 对于屏障前后的Store操作并无影响。
    StoreStore Fence Store1;StoreStore;Store2 确保Store1刷新数据到内存(使数据对其他处 理器可见)的操作,先于Store2及其后所有 Store指令的执行。 对于屏障前后的Load操作并无影响。
    LoadStore Fence Load1;LoadStore;Store2 确保屏障指令之前的所有Load操作,先于屏障之后所有Store操作(刷新数据到主存)。
    StoreLoad Fence Store1;StoreLoad;Load2 确保屏障之前的所有内存访问操作(包括Store 和Load)完成之后,才执行屏障之后的内存访 问操作。 全能型屏障,会屏蔽屏障前后所有指令的重排。

    其中StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障,执行该屏障开销会很昂贵。

    以linux_x86为例,我们可以在os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp 看到它们的实现:

    inline void OrderAccess::loadload()   { acquire(); }
    inline void OrderAccess::storestore() { release(); }
    inline void OrderAccess::loadstore()  { acquire(); }
    inline void OrderAccess::storeload()  { fence(); }
    

    当调用storeload屏障时,会调用fence()方法

    inline void OrderAccess::fence() {
    if (os::is_MP()) {//判断系统是否是多核CPU,在多核CPU才需要使用内存屏障。
        // always use locked addl since mfence is sometimes expensive
    #ifdef AMD64
        __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
    #else
        __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
    #endif
    } }
    

    在上面的代码中,我们看到了熟悉的汇编指令 lock; addl $0,0(%%esp) 该指令可以解决第3个问题,至此 volatile 保证了对于共享变量访问的指令的有序性以及该共享变量的可见性。

    • os::is_MP(),会判断当前环境是否是多核。单核不存在一致性问题,多核CPU才需要使用内存屏 障。 cc代表的是寄存器,memory代表是内存。
    • 用”cc”和”memory”,会通知编译器和处理器 volatile变量在内存或者寄存器内的值已经发生了修改,要重新加载,需要直接从主内存中读取。

    到此,我们就彻底的揭开了volatile 的面纱,现在我们知道 volatile 保证有序性和可见性的原理了。

一些术语
术语 英文单词 术语描述
内存屏障 memory barries 一组处理器的指令,用于实现对内存操作的顺序限制
缓冲行 cache line 缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读指令周期
原子操作 atomic operations 不可中断的一个或一系列操作
缓存行填充 cache line fill 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1、L2、L3的或所有)
缓存命中 cache hit 处理器按照寻址公式获取到的地址在缓存中有效,则处理器直接从缓存取指令而不是主存
写命中 write hit 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回内存,这个操作被称为写命中
写缺失 write misses the cache 一个有效的缓存行被写入到不存在的内区区域

3、volatile注意点

有序性

所谓 volatile 保证了有序性仅仅是保证操作了其声明的变量的指令不会先于其前面的指令执行,其后面的指令不会重排到在其之前执行,而其前面的这些指令或者后面的指令集合自己的顺序它是不管的,也管不了。

可见性

可见性即保证声明变量在当前处理器的缓存中进行了修改写入主存的时候其他处理器缓存的可见性,很好理解。

原子性

volatile 声明的指令本身是原子指令

像下面这种经典操作其实包含了多条指令,其中有包含取出和写入 volatile 变量 a 到主存的动作,这些指令是原子的。但是 a = a+1 这条指令不是原子的:

volatile int a = 1;
a++;
  1. 线程 A 从内存中取出变量 a=1存储到缓存并加载到寄存器中,然后计算 a=a+1得到2并存到处理器的寄存器中,并更新了自己的缓存, 然后线程 A 在将数据写入到主存之前被中断了。
  2. 线程 B 开始执行,同样从内存中取出变量 a=1存储到缓存并加载到寄存器中,然后计算 a=a+1得到2并存到处理器的寄存器中,并更新了自己的缓存,然后将 a 写回到主存,触发缓存一致性将 A 中缓存置失效。线程完成。
  3. 线程 A 重新执行,继续将寄存器中的变量 a 写入主存。此时 a=2。实际上应该是3。

以上问题是因为在从内存取出 a 之前就应该是临界区起始点,应该对线程进行同步,但是并没有。

4、volatile使用场景举例

经典场景:DCL(Double Check Lock)
原始问题:懒汉单例模式
public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
          instance = new Singleton();
    }
    return instance;
  }
}

以上是一个懒汉(懒加载)单例模式的写法,但是存在线程安全问题,当第一个进入第4行的线程被切换就会出现问题。

优化:解决线程安全
public class Singleton {
  static Singleton instance;
  static synchronized Singleton getInstance(){
    if (instance == null) {
          instance = new Singleton();
    }
    return instance;
  }
}

以上代码直接在原方法基础上使用synchronized关键字进行加锁,这样确实解决了线程安全问题,但是引入的却是性能的问题,所有尝试获取该单例的线程都会被要求先获得这把锁,如果锁的竞争激烈,synchronized会升级会重量级锁(具体看下面章节)。对性能的影响非常大!

优化:性能问题
public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

为了解决性能上的问题,我们将获取锁的位置后移,在获取单例之前先判断单例是否已经被初始化:

  • 如果有则直接返回,不走取锁流程;

  • 否则竞争锁。竞争到锁之后我们不能立即实例化单例对象,因为虽然我们进入取锁流程的大前提是因为当前线程发现单例未实例化,但是前面判断单例是否实例化是非线程安全的,即可能会出现当前线程判断单例未实例化之后准备争锁前的一瞬间被切换了,另外一个线程开始执行并发现单例未实例,并开始取锁,对象实例化,释放锁等一系列动作且成功了,然后切换回当前线程,此时当前线程接着进行对象实例化,单例失败。

    所以我们需要进入同步块之后还需要对单例对象进行是否实例化的判断。

以上代码解决了性能上的问题,并不是所有获取单例的线程一旦调用该方法都需要先获取锁,而是先进行纳秒级的一个判断,再获取锁,虽然可能会出现已经获得锁准备进行实例化的线程被中断了导致当前线程会阻塞在锁之外的情况,但是这种情况的概率很少,其性能影响可以忽略。

虽然以上代码解决了性能上的问题,但是其实又引入了一个新的问题,就是我们前面提到的内存可见性的问题。这个问题可能由两个原因诱导:

  1. 本地缓存引发的问题

    线程 A 进入方法调用,从主存加载Singleton 的Class对象的静态成员变量instance的地址及其内容进入处理器,并缓存到高级缓存,然后送入寄存器并进行算术逻辑单元的计算,发现并未实例化,然后准备进入下面的运算,即获取锁然后继续后续流程,但是 CPU 时间片刚好用完了,线程切换。

    此时线程 B 进入方法调用,重复线程 A的动作且未被切换然后获取锁,然后再判断单例是否确实没有被实例化还是在实例化中,发现确实没有被实例化,然后实例化单例,然后将单例对象指针引用回写到主存中Singleton 对象的instance变量,释放锁,线程完成,释放CPU资源。

    切换回线程 A ,继续后续流程,判断单例是否实例化,注意此时是从本地缓存获取单例的内容,还是之前写入缓存的 null,所以后续动作将和线程 B 一模一样。单例失败!

    以上问题就是前面提到的本地缓存和主存数据不一致的问题。

  2. 指令重排引发的问题

    线程 A 进入方法调用,发现单例未实例化,竞争锁判断还是未实例化,此时开始实例化对象,以下是正常的实例化流程:

    • 分配一块内存 M;

    • 在内存 M 上初始化 Singleton 对象;

    • 然后 M 的地址赋值给 instance 变量。

    • 释放锁

    但是实际上却可以是

    • 分配一块内存 M;

    • 将 M 的地址赋值给 instance 变量。

    • 最后在内存 M 上初始化 Singleton 对象;

    • 释放锁

    这将导致线程 A 在完成对象实例化之前,线程 B进入方法,发现当前 Class的静态成员变量已经有值了,就是上面第二步所赋值的指针,便直接返回该指针。然后获取到该"单例"的业务正常使用该单例,结果在使用的时候 JVM 发现该单例内容未实例化,直接空指针。

    注意,以上不会出现在线程 A 释放锁之后第三步实例化对象的动作还未完成的情况,因为我们前面提到的 JMM 内存模型的关于锁的 happen-before 原则保证了锁释放之前需要完成所有临界区内的动作。

解决:单例对象可见性
public class Singleton {
  static volatile Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    	}
    return instance;
  }
}

可以看到,我们声明了Singleton类对象的静态成员变量instance为一个volatile变量,这就保证其在多缓存下的可见性,以及对于该变量的内存写入的动作的前后有序性(在将单例对象的指针写入到该Singleton类对象的静态成员变量instance变量之前必须保证该单例对象的实例动作都完成了)。

另外,我们可以对该变量定义为final,final 关键字也有其相关的 happen-before原则,也是为了保证其声明的变量的可见性,但是声明了该关键字的话就不是懒汉式了,因为需要立即填充该变量的内容且之后都不能改变。

由DCL 问题引发的思考

以上 DCL 的场景我们可以看到一个现象:在临界区之内的共享变量的访问并不一定是安全的。

那么我们是不是需要对所有可能被并发访问的变量即使是可以通过synchronied保证了变量被访问是同步的情况下也要加上volatile呢?其实不是的,之所以上面会出现这样的情况,是因为我们在进入临界区之前就先从主存加载了这个变量数据,导致本地对其进行缓存,如果我们的加载动作是在临界区内进行,那就没有问题了,所有线程的写入及获取动作都是同步的。

考虑到悲观锁的性能问题,我们需要结合其带来的代价是不是我们的业务不能承受的(例如上面的单例构建方法如果被调用的不多,完全可以到第二步优化线程安全问题就可以了)。如果悲观锁的代价实在太大,可以考虑乐观锁的实现。

另外,对于我们的业务来说,某些共享数据的"短暂"不可见是否是正常的?需要根据实际业务进行考虑。

5、volatile的使用优化

著名的Java并发编程大师Doug lea在JDK 7的并发包里新增一个队列集合类Linked- TransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性 能。LinkedTransferQueue的代码如下。
3729c7403eb7298c0f4b2e7c118d5980

让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的 头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类 AtomicReference只做了一件事情,就是将共享变量追加到64字节。我们可以来计算下,一个对 象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个 字节。

为什么追加64字节能够提高并发编程的效率呢?因为对于英特尔酷睿i7、酷睿、Atom和 NetBurst,以及Core Solo和Pentium M处理器的L1、L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着,如果队列的头节点和尾节点都不足64字节的话,处理器可能会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致 其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使 用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存 行,使头、尾节点在修改时不会互相锁定。

那么是不是在使用volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。

  • 缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个 字节宽。
  • 共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。

不过这种追加字节的方式在Java7下可能不生效,因为Java 7变得更加智慧,它会淘汰或 重新排列无用字段,需要使用其他追加字节的方式。

3、synchronized

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对synchronized进行了各种优化之后,有些情况下它就并不那么重了。 下面详细介绍Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

1. 使用 synchronized 同步的方式

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

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对,即Java在语言层面已经帮助我们对锁进行维护任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

2. synchronized 的锁

由上面可以知道 Java 中所有对象都可以作为 synchronized 的锁(标志位),而synchronized用的锁是存在 Java对象头(不是对象体,仅仅对象头) 里的。如果对象是数组类型,则虚拟机用3个字宽 (Word)(12个字节、96bit)存储对象头,如果对象是非数组类型,则用2字宽(8个字节、64bit)存储对象头。在32位虚拟机中,1字宽 等于4字节,即32bit。
f4b3b5226491637dd876157c9065a151
以上是 Java 对象头结构组成。以32bit 虚拟机为例,可以看到锁相关的信息有:

  1. 23bit 的线程 id
  2. 2bit 的锁标识位
  3. 1bit 的是否偏向锁标识位

一个对象初始化之后默认状态后三位为001,表示偏向锁无锁状态。

3、锁的升级和比较

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态。

image-20200421151815413

这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率.

1. 偏向锁

在 Java6和 Java7中偏向锁是默认启用的,而到了 Java8之后是关闭的(默认是轻量级锁),我们可以通过手动设置参数-XX:-UseBiasedLocking=false来关闭或者启用偏向锁。默认情况下,偏向锁是在程序启动几秒之后才激活,如有必要可以使用参数XX:BiasedLockingStartupDelay=0来关闭延迟。

偏向锁获取

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并 获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁
在线程尝试获取锁的时候,首先检查对象头中 Mark Word 的后三位是否为101,即检查是否是偏向锁:

  • 是:检查对象头 MarkWord 的线程 id 标识位是否为当前线程 id:如果是,将直接执行同步代码块
  • 否:根据当前拥有该偏向锁的线程的状态是否活跃而升级为轻量级锁或者进行偏向锁撤销然后重新偏向当前线程。

可以看到,如果已经拥有偏向锁的线程尝试获取偏向锁,是不用再进行较重量的 CAS 操作的,仅进行了一些标志位的判断,纳秒级的区别。

偏向锁撤销

另外,在一个拥有偏向锁的线程A完成或者异常结束之后,它是不会释放偏向锁的,即回填对象头 markword 中的信息,而是等待下一次其他线程来获取该偏向锁的时候进行撤销。 假设此时一个线程B线程 A获得偏向锁之后最早来获取同一个锁对象( 此时锁对象头 markword 中的后三位为101,偏向锁1bit+锁标识2bit),然后判断获取锁的线程A是否还活跃:

  • 如果还是活跃状态,则需要等到全局安全点,然后暂停该线程A,然后在栈帧中创建一个锁记录空间,并将对象头中的 mark word 复制到锁记录空间中,然后使用 CAS 设置锁记录指针到markword,并将锁标识位设置为00(完成轻量级锁升级),然后恢复该线程。当前线程B以及晚于线程B进入同步代码块的其他线程都进入轻量级锁的获取流程。
  • 如果非活跃状态,线程 B则会使用 CAS 将对象头中 markword 的线程 id设置为当前线程 id 以及偏向锁标识位为1,完成重新偏向。如果 CAS 设置失败,存在锁竞争,触发上面的流程。

2. 轻量级锁(自旋锁)

轻量级锁获取

当前线程(所有进入临界代码区的线程)检查到锁状态为00的时候,进入自旋检查锁状态是否为01**(可以为自旋次数设置一个阈值作为锁膨胀升级为重量级锁的条件,-XX:PreBlockSpink可以进行设置)**,直到拥有轻量级锁的线程执行完成或者异常结束后更新锁状态为01,所有在自旋获取锁的线程通过复制 markword 到锁记录空间然后 CAS 设置markword 指向自己的锁记录空间并设置锁状态为00来获得锁。

而当自旋线程CAS 失败的时候,说明存在其他线程同时在自旋获取锁**(可以为自旋线程的个数设置一个阈值作为锁膨胀升级为重量级锁的条件)**。此时向 OS 申请互斥量资源,并使用 CAS 设置互斥量资源指针到对象头 markword 并设置锁标识位为10,锁升级为重量级锁。当前线程以及所有其他线程进入重量级锁的获取流程。

轻量级锁释放

在拥有轻量级锁的线程执行完成或者异常结束之后会扫描当前栈帧中的锁记录,从锁记录中取出 markword 的内容 CAS 设置回对象头以及锁标识为01,锁释放成功。

线程的过度自旋会使得 CPU 的过度空转而效率下降,过度自旋可能是拥有锁的线程执行时间过长,也可能是因为过多线程出现竞争锁等。

自适应旋锁

所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。其大概原理是这样的:

假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数

另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。

3. 重量级锁

重量级锁获取

当前线程(所有进入临界代码区的线程)复制对象头 marword 到当前栈帧,检查到锁状态位:如果是10,直接获取对象头中的互斥量指针向 OS 申请挂起到该互斥量上,陷入内核等待 OS 的唤醒;如果是01,则使用 CAS 设置锁标识位为01来获取锁,获取失败则同样向 OS 申请阻塞到互斥量上。

重量级锁释放

拥有重量级锁的线程在执行完毕或者异常结束之后,CAS 设置锁状态为01,并向 OS 申请唤醒所有在该互斥量上的所有线程。

可以看到竞争重量级失败的锁需要全部陷入内核阻塞,等待操作系统调度,涉及到用户态和内核态之间的转换,这是比较消耗时间的,所以称之为重量级锁。

bc3fae9b2644ebc03b2d4d1d824f01e0

以上内容可以参考以下 HotSpot 源码:对象头源码markOop.hpp、偏向锁源码 biasedLocking.cpp,以及其他源码ObjectMonitor.cpp和BasicLock.cpp、CAS 操作源码 bytecodeInterpreter.cpp

4. 三种锁的对比

image-20200421154117222

4. 可重入

synchronized 拥有强制原子性的内部锁机制,是一把可重入锁。因此,在一个线程使用 synchronized 方法时调用该对象另一个 synchronized 方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。在 Java 中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。 synchronized 锁的对象头的 markwork 中会记录该锁的线程持有者和计数器,当一个线程请求成功后, JVM 会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个 synchronized 方法/块时,计数器会递减,如果计数器为 0 则释放该锁锁。

5.悲观锁(互斥锁、排他锁)

synchronized 是一把悲观锁(独占锁),所有线程在执行同步块之前必须先获得锁,如果获取不到必须在临界区之外等待,直到抢占到锁为止。

有一些文章提到上面的轻量级锁是乐观锁,感觉有问题,不应该是乐观锁。如果是乐观锁,那么操作就应该是当前无论是什么级别的锁,都先简单的 test 一下当前锁是否没有被占用,是的话就直接进入临界区,执行同步块,然后再通过 CAS获取乐观锁(而不是每次都直接 CAS,CAS 可以认为就是一个实际上的加锁动作),如果获取失败就需要回滚临界区中执行的逻辑,获取成功就什么也不做。所以这里的回滚动作需要保存进入临界区之前的状态或者保存临界区的一系列操作进行一步步回退,即多版本控制,参考数据库的 MVCC即为一个经典的"乐观锁"。

如果非要说它是一个乐观锁的话,只能这么解释,每一次都乐观地认为只有一个线程会去获得锁,所以先设置偏向锁或者轻量级锁,如果遇到多个线程竞争锁,再进行升级。(但是这并不是"乐观锁"的定义)。

另外,CAS 本身并不是一个"乐观锁",只是乐观锁的组成部分:

  1. 全局定义一个标识位0表示无锁,1表示有锁,初始化为0
  2. 业务操作
  3. CAS 传入0和1,处理器(硬件)会原子地将标识位现有地值是否和旧值比较:如果相等则设置为传入地新值并返回成功;否则返回失败。
    • 如果 CAS 返回成功,说明无锁,并且拿到了锁,写入业务操作修改地共享变量。然后修改标识位为0。
    • 如果 CAS 返回失败,说明已经有别的线程拿到了锁,回到步骤1重新进行业务操作(基于新地共享变量),直到成功或者超出重试次数。

以上对于 CAS 的应用存在 ABA问题,即当前线程认为标识位是0,即初始版本是0,但是实际上有另一个线程在当前线程访问共享变量之前修改了共享变量,然后在当前线程 CAS 之前先通过 CAS 获得了锁并成功刷新共享变量到主存并释放锁,这对于当前线程是无感知的,它还是傻乎乎地将基于老版本的共享变量地业务操作结果在获得锁之后刷新到主存。

解决方法就是对锁增加版本,方法如下

  1. 全局定义一个标识位0xx表示无锁,1xx表示有锁,xx 表示版本号,初始化为00
  2. 获取标识位,是否0开头:是,截取当前版本号+1拼接成新标识值;否,循环当前代码直到变为1(或者阻塞)
  3. 业务操作
  4. CAS 传入旧地标识位和新的拼接地标识位,处理器(硬件)会原子地将标识位现有地值是否和旧值比较:如果相等则设置为传入地新值并返回成功;否则返回失败。
    • 如果 CAS 返回成功,说明无锁且无人修改新版本,并且拿到了锁,写入业务操作修改地共享变量。然后修改标识位第一位为0。
    • 如果 CAS 返回失败,说明已经有别的线程拿到了锁或者当前线程依赖地版本失效了,回到步骤2重新进行业务操作(基于新地共享变量),直到成功或者超出重试次数。

4、原子操作的实现

前面提到,多线程并发编程旨在提高程序效率,但是却带来了访问共享变量引起的冲突,而我们的解决方案有两大方向:

  • 对业务数据进行 hash,不同的线程处理不同数据,即避免访问共享变量。

  • 对共享访问或者操作的时候进行加"锁"。

前者实现逻辑较为简单,较为具象。我们主要看后者。

1. 原子操作

并发环境下,由于不同线程可能会共享变量,而在一组事务(原子操作)没有完成之前当前线程被打断且共享变量被其他线程进行修改,便可能导致两个线程之间的数据错乱。所以我们需要保证一个临界区之内的只能有一个线程在执行,即在线程到达临界点之后进行线程同步,而在临界区之内的代码即为同步块,同步块中的共享变量为临界资源。

高级语言的所有线程同步都可以抽象为以上描述。而我们对于临界区的线程同步动作可以放在临界区域起始点,也可以放到临界区域结束位置。

  • 对于前者,每个线程进入临界区域之前必须通过竞争获取到临界区的进入条件,然后才可以进入临界区执行代码块并访问临界资源,在到达临界区结尾的时候,释放临界区的进入条件。
  • 对于后者,每个线程直接进入临界区域,但是在访问临界资源的时候复制一个副本进行操作,然后到达临界区域结尾的时候,通过竞争得到临界资源的写入的条件:
    • 如果获取得到,先检查当前临界区域中的这些共享变量的值是否发生了变化:如果没有,直接写入临界资源,然后释放该写入条件;如果发生了变化,释放该条件,然后走竞争失败逻辑。
    • 如果竞争失败,则视具体业务做后续工作:可以放弃这些修改,重新回到临界区域起始点获取临界资源重新执行同步代码;也可以继续竞争写入。

可以看到,前者其实就是悲观锁,后者就是乐观锁。它们的区别是,悲观锁锁定的是同步块中的业务操作;乐观锁锁定的是临界资源刷入内存的动作。所以如果业务操作时间消耗远远大于临界资源刷入内存的动作,如果是悲观锁,临界区锁定时间变长,则总线程等待时间变长;如果使用乐观锁,则总线程等待时间变短;另外如果锁竞争激烈,可能会使用乐观锁维护多版本的代价剧烈上升。

所以,在业务操作时间过长,且锁竞争相对没有那么激烈的情况,使用乐观锁的收益是较高的。

可以看到无论是悲观锁还是乐观锁,都要进行"加锁",所谓加锁,即拥有那么一个标识位,同一时刻只有一个线程可以修改这个标识位,而修改成功则表示这个线程为"锁"的拥有者,其可以对临界区进行访问。所以归根结底,我们需要的是一个原子操作可以原子的修改某个标识位。

"原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在高级语言层面所以具象化的同步操作都是依赖的是操作系统提供的一系列原语(原子指令)来实现。操作系统提供的原语包括有信号量、test_and_set(CAS)、锁、消息传递、栅栏(内存屏障)等操作。而操作系统这些原语的构建又依赖于硬件(门电路逻辑)已有的一些原子操作,包括中断的禁止和启用(关闭线程中断)、内存的加载和写入、test & set(CAS) 等。

当然,硬件提供这些原子操作不是因为它们预见到研究操作系统的人将来有这个需要,而是硬件设计师需要这些原子操作来对其涉及进行各种测试,操作系统对其利用只不过是一个副产品而已。

2、处理器实现原子操作的一些例子

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

锁总线

当多个处理器对多个共享变量进行访问的时候,可能会出现交叉访问的情况,导致数据错误,此时处理器可以使用总线锁来解决这个问题。所谓总线锁就是使用处理器提供的一个 LOCK#信号,LOCK 指令是一个汇编层面的指令,在一些特殊的场景下,作为前缀加在以下汇编指令之前,保证操作的原子性,这种指令被称为 “LOCK前缀指令”。

ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG.

LOCK前缀导致处理器在执行指令时会置上LOCK#信号,于是该指令就被作为一个原子指令(atomic instruction)执行。在多处理器环境下,置上LOCK#信号可以确保任何一个处理器能独占使用任何共享内存。这种方式代价太大。

锁缓存

在Pentium4、Inter Xeon和P6系列以及之后的处理器中,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。

在所有的 X86 CPU 上都具有锁定一个特定内存地址的能力,当这个特定内存地址被锁定后,它就可以阻止其它的系统总线读取或修改这个内存地址。这种能力是通过 LOCK 指令前缀再加上具体操作(如ADD)的汇编指令来实现的。当使用 LOCK 指令前缀时,它会使 CPU 宣告一个 LOCK# 信号,处理器不在总线上声 言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子 性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如CPU A嗅探到CPU B打算写内存地址,且这个地址处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

3、Java 实现原子操作的一个例子

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

(1)使用循环 CAS 实现直接对共享变量的原子操作

JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本 思路就是循环进行CAS操作直到成功为止,参考juc 包下的AtomicInteger类,很多方法都调用了sun.misc.Unsafe#compareAndSwapInt,例如:

public final int updateAndGet(IntUnaryOperator updateFunction) {
        int prev, next;
        do {
            prev = get();
            next = updateFunction.applyAsInt(prev);
        //调用本地方法
        } while (!compareAndSet(prev, next));
        return next;
}
public final boolean compareAndSet(int expect, int update) {
  			//该本地方法直接调用sun.misc.Unsafe#compareAndSwapInt
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

而这个方法是一个 native 方法,底层就是依赖的处理器提供的 CMPXCHG 实现的。

(2)CAS 直接实现原子操作变量地三大问题

Java juc 包下基本都是循环 CAS。

<1>ABA 问题

参考3.5中悲观锁地描述。AtomicStampedReference是 Jdk1.5提供地解决 ABA 问题地。

<2>循环时间长CPU开销大

略。

<3>只能保证一个共享变量地原子操作

用锁;或者合并多个变量到一个变量进行操作,操作完之后再拆解;AtomicReference是 Jdk1.5提供地一个可以对引用类型对象进行 CAS 地一个类,可以实现该操作。

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

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


   转载规则


《volatile 和 synchronized》 阿钟 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录