java中的final和volatile详解

  相比synchronized,final和volatile也是经常使用的关键字,下面聊一聊这两个关键字的使用和实现

1.使用

  final使用:

  • 修饰类表示该类为终态类,无法被继承
  • 修饰方法表示该方法无法重写,编译器可以内联编译
  • 修饰对象表示该对象引用一旦初始化后,无法被修改
  • 将参数传递到匿名内部类中,参数需要声明为final,其实外部类对与匿名内部类来说就是一个闭包,而java在匿名内部类中拷贝了一份,没有实现引用同步,所以要求参数不可变(参考:https://www.zhihu.com/question/21395848)

例子:

 

class FinalFieldExample {
  final int x;
  int y;
  static FinalFieldExample f;
  public FinalFieldExample() {
    x = 3;
    y = 4;
  }

  static void writer() {
    f = new FinalFieldExample();
  }

  static void reader() {
    if (f != null) {
      int i = f.x;
      int j = f.y;
    }
  }
}

 

  调用reader方法的线程保证了当f不为null时,x的值一定可以读取到,因为x声明为了final,而y则不一定

 

  volatile使用:

  •  一般修饰对象
  • 包含两个含义:可见性,禁止指令重排

JSR133 FAQ中例子1:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

  上边这个例子中,一个线程调用writer方法,一个线程调用reader发放,当先调用writer方法,后调用reader方法时,由于对象v声明为volatile,具有可见性,也就是一个线程的修改会立即在另一个线程中体现出来,因此reader方法中判定会为true,如果进入该分支后,保证x的值一定为42,因为volatile保证了禁止指令重排,所以writer中第一个赋值一定会在第二个赋值前执行。

JSR133 FAQ中例子2:

private volatile static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();
    }
  }
  return instance;
}

  以上是一个典型double-check locking例子,instance声明为volatile保证了构造Something对象的指令和赋值给instance的指令不会重排,这样的话当其他线程拿到instance的引用不为null时,instance已经初始化完毕了

2.规则和原理 

   在解释下面规则原理之前还是要在说明一下,编译器和处理器为了优化程序执行的速度,会对指令进行重排序,下面通过一个例子来说明:

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

public static void main(String[] args) throws InterruptedException {
    Thread one = new Thread(new Runnable() {
        public void run() {
            a = 1; //1
            x = b; //2
        }
    });

    Thread other = new Thread(new Runnable() {
        public void run() {
            b = 1; //3
            y = a; //4
        }
    });
    one.start();other.start();
    one.join();other.join();
    System.out.println(“(” + x + “,” + y + “)”);
}

  一般可能认为,这个代码的执行结果可能有三种,分别是(1,0),(0,1),(1,1)(虽然这种情况没有跑出来)这三种情况,但是当连续执行10000多次的时候,发现居然有(0,0)这种情况,实际上这是因为指令在执行的时候发生了重排序,也就是说编译器和处理器会根据实际情况优化代码执行的顺序。指令重排序是以as if serial优化的,所以只要保证在单线程下,最后的执行结果一致即可。上面这个例子就是发生了重排序,如果步骤1和步骤2发生重排序,导致实际执行顺序为2->3->4->1,那么就会出现(0,0)

 

  JSR133(JMM)中对final域在重排序方面进行了约束,以保证final的正确使用

  final规则

  当final域为对象的时候,编译器和处理器需要遵循这两个重排序原则:

  1. 在构造函数中对一个final对象的写入,与后面的把构造对象的引用赋值给引用对象,这两个操作不得重排序
  2. 初次读取包含一个final对象的引用,和初次读取这个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域
    }
}

  第一条规则实际上表达的是对final域的写入不可以重排序到构造函数外,这一条本质上包含了下面两条规则:

  1. 针对编译器,编译器不会将构造函数中final域对写入重排序到构造函数外;
  2. 针对处理器,编译器会在构造函数返回结束前,加入一个storestore屏障(后续再详细解释),保证处理器不会将final域的写入重排序到构造函数外

  因此当线程B执行的时候(不考虑读取时候的重排序),当读取object引用时,对象内到final域已经初始化好了,可以正常读取,但是普通域可能没有初始化好

  第二条规则同样也需要在编译器和处理器层面去保证:

  1. 针对编译器,由于读对象的引用和对象引用中的final域,这两个操作存在关联关系,所以编译器不会重排序
  2. 针对处理器,编译器会在读取对象引用中的final域前,插入一个loadload屏障,保证读对象的引用和对象引用中的final域这两个操作不会重排序

  因此当线程B执行的时候,读取对象引用和读取对象中的普通域可能发生重排,而读取对象引用和对象中的final域不会,这样通过和第一条结合时候,对于final域,并发情况下,可以保证final域的正常读取

 

  上面看到对final域对对象其实是基础类型,如果是引用类型呢

public class FinalReferenceExample {
final int[] intArray;                     //final是引用类型
static FinalReferenceExample obj;

public FinalReferenceExample () {        //构造函数
    intArray = new int[1];              //1
    intArray[0] = 1;                   //2
}

public static void writerOne () {          //写线程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 temp1 = obj.intArray[0];       //6
    }
}
}

  对于final域为引用对象的情况,编译器和处理器有下面对重排序限制:

  1. 在构造函数里对final域的引用对象中的成员的写入,和构造对象的引用的赋值操作,这两个操作不得重排序

  我们先执行线程A,再执行线程B、最后执行线程C,由于重排序的限制,步骤3与步骤1,步骤3与步骤2不可重排序,而步骤1和步骤2存在关联关系,因此线程C执行的时候可以正常读取到final域引用对象的成员值。而线程B的修改是否可以在线程C中读取到则不一定了,需要在线程B、C之间需要使用同步原语

  逃逸

  上面我们通过例子说明了一个问题,构造函数中的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
    }
}
}

   上面这个例子中,final域的重排序限制无法限制步骤1和步骤2的重排序,那么就有可能出现逃逸现象,当reader线程执行时,可能无法正常访问到构造对象中final域初始化后的值

  volatile规则

  为了达到java跨平台的语言特性,需要将内存重新抽象,这样就诞生了jsr133,jsr133描述了java内存模型,屏蔽了底层实现的差异,保证相同的代码在不同平台上具有相同的表现。根据java内存模型(java memory model,简称JMM)的规定,可以简化为几个happen-before原则,happen-before前后两个操作不可重排序并且前者对后者内存可见:

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁(monitor)的解锁 happens-before于每一个后续对同一监视器锁的加锁,monitor为同步原语的实现方式。
  • 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

  happen-before原则是对java内存模型对近似描述,更严谨的java模型定义参考jsr133。jsr133对volatile语意进行了扩展,特别是关于重排序这方面,具体限制如下:

重排序示意表

  第二项操作指的是第一项操作后面的所有操作,例如,普通的读写操作不可与之后的volatile变量的写操作重排序,参考上面volatile例子,留白的单元格表示在保证java语意不变的情况下可以重排序,例如,java语意不允许对同一个对象的读写重排序,但是对不同对对象的读写可以

  内存屏障

  内存屏障(memory barrier,也称作内存栏栅)是一种CPU指令,用于控制指令重排序和解决可见性问题

  内存屏障可以被分为以下几种类型

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

  上面的重排序规则可以通过内存屏障指令实现:

内存屏障示意表

  总的来说,内存屏障指令提供了两个方面的功能:

  1. 内存屏障指令前后指令不可重排序,具体重排序限制根据四种内存屏障指令不同而不同,具体含义参考上面的表格
  2. 如果是storeload或者storestore指令,要求volatile对象的写操作写入内存中,同时会导致其他CPU中的缓存行失效

  第一条,我们已经在上面阐明了,对于第二条功能是通过缓存一致性协议达到,缓存一致性协议在单机多核的情况下是通过硬件实现。最为出名的缓存一致性协议是Intel的MESI。

 

3、总结 

  final和volatile语意在jsr133中做了相应扩展,保证了其语意的正确性。正确理解其使用规则和编译器和处理器实现原理对我们日常工作有意义,不管是final还是volatile底层都依赖内存屏障技术,内存屏障技术(指令)最重要的功能就是对指令重排序对限制,对于volatile对语意中可见性语意,通过内存屏障技术和缓存一致性协议实现。

    

参考:

http://www.infoq.com/cn/articles/java-memory-model-6

http://www.cs.umd.edu/users/pugh/java/memoryModel/jsr-133-faq.html

https://tech.meituan.com/java-memory-reordering.html

http://www.cnblogs.com/dolphin0520/p/3920373.html

转载于:https://www.cnblogs.com/blueSkyline/p/8858462.html

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: volatileJava的一个关键字,用于修饰变量。它的作用是告诉编译器,该变量可能会被多个线程同时访问,因此需要特殊处理,以保证线程安全。 具体来说,volatile关键字有以下几个特点: 1. 可见性:当一个线程修改了volatile变量的值,其他线程能够立即看到这个修改。 2. 有序性:volatile变量的读写操作会按照程序的顺序执行,不会被重排序。 3. 不保证原子性:虽然volatile变量能够保证可见性和有序性,但是它并不能保证多个线程同时修改变量时的原子性。 因此,如果需要保证原子性,需要使用synchronized关键字或者Lock接口来进行同步。 总之,volatile关键字是Java用于保证多线程访问变量的安全性的一种机制,它能够保证可见性和有序性,但是不能保证原子性。 ### 回答2: Javavolatile关键字是一种轻量级的同步机制,用于确保多个线程之间的可见性和有序性。它可以用于修饰变量、类和方法。 1. 修饰变量:当一个变量被volatile修饰时,它会被立即写入到主内存,并且每次读取变量时都会从主内存重新获取最新的值。这样可以保证多个线程操作同一个变量时的可见性和一致性。 2. 修饰类:当一个类被volatile修饰时,它的实例变量就会被同步,而且每个线程都会获取最新的变量值。这样可以保证多线程操作同一对象时的可见性和一致性。 3. 修饰方法:当一个方法被volatile修饰时,它的调用会插入内存栅栏(memory barrier)指令,这可以保证方法调用前的修改操作都已经被写入主内存,而方法调用后的读取操作也会重新从主内存读取最新值。这样可以确保多线程之间的调用顺序和结果可见性。 需要注意的是,volatile并不能完全取代synchronized关键字,它只适用于并发度不高的场景,适用于只写入不读取的场景,不能保证复合操作的原子性。 总之,volatile关键字在Java具有广泛的应用,可以保证多线程之间的数据同步和可见性,但也需要谨慎使用,以免造成数据不一致和性能问题。 ### 回答3: Javavolatile关键字意味着该变量在多个线程之间共享,并且每次访问该变量时都是最新的值。简单来说,volatile保证了线程之间的可见性和有序性。下面我们详细解释一下volatile的用法和作用。 1. 线程之间的可见性 volatile关键字保证了对该变量的读写操作对所有线程都是可见的。在没有用volatile关键字修饰变量的情况下,如果多个线程并发访问该变量,每个线程都会从自己的线程缓存读取该变量的值,而不是直接从主存读取。如果一个线程修改了该变量的值,但是其他线程不知道,那么可能导致其他线程获取到的数据不是最新的,从而引发一系列问题。而用了volatile关键字修饰该变量后,每次修改操作都会立即刷新到主存,其他线程的缓存的变量值也会被更新,从而保证了线程之间的可见性。 2. 线程之间的有序性 volatile关键字也保证了线程之间的有序性。多个线程并发访问同一个volatile变量时,JVM会保证每个线程按照程序指定的顺序执行操作。例如,在一个变量被volatile修饰的情况下,多个线程同时对该变量进行读写操作,JVM会保证先执行写操作的线程能够在后续的读操作获取到最新的变量值。这么做的好处是,可以避免出现线程间操作顺序的乱序问题,从而保证了程序的正确性。 需要注意的是,并不是所有的变量都需要用volatile关键字修饰。只有在多个线程之间共享变量并且对变量的读写操作之间存在依赖关系的情况下,才需要使用volatile关键字。此外,volatile关键字不能保证原子性,如果需要保证操作的原子性,需要使用synchronized或者Lock等其他并发工具。 总之,volatile关键字是Java非常重要的关键字之一,它可以在多个线程之间保证可见性和有序性,从而保证了程序的正确性。在开发过程,我们应该根据具体情况来选择是否使用volatile关键字,以及如何使用它。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值