转载

同步的三种方式:volatile、锁、final

1.1 介绍

关键字volatile是Java虚拟机提供的最轻量级的同步机制。

当一个变量定义为volatile时,它将具备两种特性: (1)可见性;(2)禁止指令重排序。

  1. 可见性
    当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即获得的,但是基于volatile变量的操作并不是安全的(如自增操作),不能保证原子性。
  2. 禁止指令重排序
    不允许对volatile操作指令进行重排序。

1.2 volatile 的 happens – before 关系

在volatile变量与happens – before 之间是什么关系呢,我们通过一个示例说明:

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
        }
    }
}

说明:假定线程A先执行writer方法,线程B后执行reader方法,那么根据happens – before关系,我们可以知道:

  1. 根据程序顺序规则,1 happens before 2; 3 happens before 4。
  2. 根据 volatile变量规则,2 happens before 3。
  3. 根据 happens before 的传递性,1 happens before 4。

具体的happens – before图形化如下:

同步的三种方式:volatile、锁、final

1.3 volatile 读写内存语义

  1. 读内存语义 。当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程之后将从主内存中读取共享变量。
  2. 写内存语义 。当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。这样就保证了volatile的内存可见性。

volatile读写内存语义总结为如下三条:

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

1.4 volatile 内存语义的实现

前面讲到,volatile变量会禁止编译器、处理器重排序。下面是volatile具体的排序规则表:

同步的三种方式:volatile、锁、final

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

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(对volatile写、普通读写实现为不允许重排序,可能会影响性能)。
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(普通读写、volatile读实现为不允许重排序,可能会影响性能)。

下面通过一个示例展示volatile的内存语义:

class VolatileBarrierExample { 
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;      // 第一个 volatile 读
        int j = v2;      // 第二个 volatile 读
        a = i + j;       // 普通写
        v1 = i + 1;      // 第一个 volatile 写
        v2 = j * 2;      // 第二个 volatile 写
    }
}

根据程序和插入屏障的规则,最后的指令序列如下图所示:

同步的三种方式:volatile、锁、final

说明:编译器、处理器会根据上下文进行优化,并不是完全按照保守策略进行插入相应的屏障指令。

2、锁

2.1 介绍

锁是Java并发编程中最重要的同步机制。

锁除了让 临界区 互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

2.2 锁的 happens – before 关系

下面一个示例展示了锁的使用:

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关系如下:

  1. 根据程序顺序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
  2. 根据监视器锁规则,3 happens before 4。
  3. 根据传递性,2 happens before 5。

图形化表示如下:

同步的三种方式:volatile、锁、final

2.3 锁的内存语义

1. 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中,以确保之后的线程可以获取到最新的值。

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

锁的释放与获取总结为如下三条:

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

###2.4 锁内存语义的实现

锁的内存语义的具体实现借助了volatile变量的内存语义的实现。

3.final

3.1 介绍

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

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

如下面示例展示了final两种重排序规则:

public final class FinalExample {
    final int i;
    public FinalExample() {
        i = 3;     // 1
    }
    
    public static void main(String[] args) {
        FinalExample fe = new FinalExample();    // 2
        int ele = fe.i;                          // 3
    }
}

说明: 操作1与操作2符合重排序规则1,不能重排,操作2与操作3符合重排序规则2,不能重排。

由下面的示例我们来具体理解final域的重排序规则。

public class FinalExample {
    int i;                      // 普通变量
    final int j;                // final变量
    static FinalExample obj;    // 静态变量

    public void FinalExample () { // 构造函数 
        i = 1;    // 写普通域
        j = 2;    // 写final域
    }

    public static void writer () { // 写线程A执行 
        obj = new FinalExample();
    }

    public static void reader () {   // 读线程B执行
        FinalExample object = obj;   // 读对象引用
        int a = object.i;            // 读普通域
        int b = object.j;            // 读final域
    }
}

说明:假设线程A先执行writer()方法,随后另一个线程B执行reader()方法。下面我们通过这两个线程的交互来说明这两个规则。

3.2 写 final 域 重排序规则

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

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

writer方法的obj = new FinalExample();其实包括两步,首先是在堆上分配一块内存空间创建FinalExample对象,然后将这个对象的地址赋值给obj引用。假设线程 B 读对象引用与读对象的成员域之间没有重排序,则可能的时序图如下:

同步的三种方式:volatile、锁、final

说明:写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误的读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则 “限定”在了构造函数之内,读线程 B 正确的读取了 final 变量初始化之后的值。写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程 B “看到”对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域 i 的写操作被重排序到构造函数外,此时初始值 2 还没有写入普通域 i)。

3.3 读 final 域 重排序规则

读 final 域的重排序规则如下:

在一个线程中,“初次读对象引用”与”初次读该对象包含的 final 域”,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。

编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器。

reader方法包含三个操作: ① 初次读引用变量 obj。② 初次读引用变量 obj 指向对象的普通域 i。③ 初次读引用变量 obj 指向对象的 final 域 j。

假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

同步的三种方式:volatile、锁、final

3.4 如果 final域 是引用类型

上面我们的例子中,final域是基本数据类型,如果final与为引用类型的话情况会稍微不同。 对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

3.5 final 逸出

写 final 域 的重排序规则可以确保:

在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。

其实要得到这个效果,还需要一个保证:

在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。

转载:http://www.cnblogs.com/leesf456/p/5291484.html

来源: https://blog.csdn.net/hbtj_1216/article/details/76407826

原文  http://www.iigrowing.cn/tong_bu_de_san_zhong_fang_shi_volatile_suo_final.html
正文到此结束
Loading...