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

1、volatile

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图形化如下:

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 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,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 写
    }
}

根据程序和插入屏障的规则,最后的指令序列如下图所示:
这里写图片描述
说明:编译器、处理器会根据上下文进行优化,并不是完全按照保守策略进行插入相应的屏障指令。

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。

图形化表示如下:

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 读对象引用与读对象的成员域之间没有重排序,则可能的时序图如下:

说明:写普通域的操作被编译器重排序到了构造函数之外,读线程 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 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

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

--------

JAVA之锁-volatile

锁是JAVA多线程关键,也是面试中必问的, 在此好好总结一下。

(先要从进程和线程说起,此处先欠下,回头专门说一下操作系统是怎么管理进程和线程的)

说到多线程就要说说JAVA的内存模型:图片来自于网络。

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如上图所示。

这就有一个内存可见性的概念了。

如果变量在a线程中改变了,b线程要立马感知到,就要通过volatile关键字了。 其作用就是 b线程要用到此变量的时候,将本地副本抛弃掉,然后重新从主存去读取。

volatile 解决了线程共享变量的可见性,synchronized 也可以做到。

volatile 修饰变量 但是他解决不了原子性, 比如,volatile  int  count  ;     方法中对其   count++  , 在多个线程对其操作,最终预期值会是错误的。解决方法可以加synchronized ,CAS。

来源:https://www.cnblogs.com/xiaokaige/p/10754394.html

-------------

单例模式下synchronize加锁后为什么要用volatile
原创章绍龙 发布于2019-07-06 08:00:40 阅读数 176 收藏
展开
解决并发共享变量问题需要解决三个问题:原子性、可见性、有序性

synchronize:保证原子性、可见性、有序性(多线程顺序执行)

volatile:保证可见性、有序性(禁止指令重排序)

public class Singleton {
private static volatile Singleton s;//1,volatile修饰的必要性
private Singleton(){};//必须是private
public static Singleton getInstance() {
if(s == null) {//2
synchronized (Singleton.class) {//3
if(s == null) {//4
s = new Singleton();//问题关键:步骤5分为三步,存在指令重排可能性
}
}
}
return s;//6
}
}
位置5可分为三个步骤:

1、memory=allocate();// 分配内存 相当于c的malloc
2、ctorInstanc(memory) //初始化对象
3、s=memory //设置s指向刚分配的地址

由于synchronize不保证单个线程内部指令的顺序,比如线程A在位置5的执行顺序有可能是1-2-3,也有可能是1-3-2,如果是1-3-2的情况,线程A执行完步骤3给s对象分配了地址;此时来个线程B,那么在位置2判断s就不为null,但是此时线程A还没有初始化对象,就会导致线程B拿到的s出错,线程A不会出错。

所以位置3加锁后,只能保证多线程串行执行加锁部分的代码块,不能保证位置5里面的指令执行顺序,所以对象s用volatile修饰 ,禁止指令重排序,从而保证了代码执行的正确性。

总结:步骤1用volatile修饰对象,就是为了步骤5禁止指令重排
————————————————
版权声明:本文为CSDN博主「章绍龙」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/shaolong1013/article/details/94824574