深入理解java内存模型04-volatile,锁,final

深入理解java内存模型 -学习笔记
深入理解java虚拟机
JSR133
转载自并发编程网 本文链接地址: 深入理解Java内存模型

volatile

volatile的特性
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,当一个变量被定义为volatile类型之后,它将具备两种特性:

  1. 可见性:另一种说法是:对于volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写。(当一个线程修改了这个变量的值,新值对被其他线程来说是可以立即得到的)
  2. 使用volatile的第二个语义是:禁止指令重排

volatile写-读的内存语义

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

volatile变量的写-读可以实现线程的相互通信:
这里写图片描述

由于volatile变量只能保证可见性,在不符合下面两条规则的场景中,我们仍要通过加锁来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够保证只有一个线程修改变量的值;(典型的如volatile++情景)
  • 变量不需要与其他的状态变量共同参与不变约束。
public class VolatileTest extends Thread {
    private static volatile boolean stop = false;//如果不定义为volatile ,会陷入死循环中。

    public void stopMe(){
        stop = true;
    }

    @Override
    public void run(){
        int i = 0;
        while(!stop) {
            i++;
        }
        System.out.println("Stop Thread");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest thread = new VolatileTest();
        thread.start();
        Thread.sleep(1000);
        thread.stopMe();

        Thread.sleep(1000);
    }
}

volatile内存语义的实现
为了实现volatile内存语义,JMM会限制volatile变量的编译器和处理器重排序,禁止重排序的规则如下:
这里写图片描述
举例来说:在程序顺序中,当第一个操作是普通变量的读或写,第二个变量为volatile写,则编译器不允许这两个操作重排序。

从上表我们可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

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

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
    这里写图片描述
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
    这里写图片描述

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

jsr-133对volatile语义增强
旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制了volatile变量与普通变量重排序,使volatile的写-读与后续的锁的释放-获取具有相同的语义。



锁释放-获取的内存语义
锁是java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中; 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。保证了共享变量在同步程序中的各个线程间的可见性

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

这里写图片描述
对于锁释放-获取的内存语义做个总结:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。


锁内存语义的实现
借助ReentrantLock的源代码,来分析锁内存语义的具体实现机制,下面请看代码示例:

public 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的实现依赖于java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,马上我们会看到,这个volatile变量是ReentrantLock内存语义实现的关键。 下面是ReentrantLock的类图(仅画出与本文相关的部分):
这里写图片描述

ReentrantLock分为公平锁和非公平锁(默认非公平锁)。
公平锁
公平的锁:线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序。
公平锁lock()的方法调用轨迹:

//1.ReentrantLock.lock()
  public void lock() {
       sync.lock();
   }

//2.ReentrantLock.FairSYnc.lock();
 final void lock() {
    acquire(1);
 }

//3.AbstractQueuedSynchronizer.acquire(int arg);
public final void acquire(int arg) {
     if (!tryAcquire(arg) &&
         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();

 }

//4.ReentrantLock.FairSYnc.tryAcquire(int arg);
 protected final boolean tryAcquire(int acquires) {
       final Thread current = Thread.currentThread();
       int c = getState(); //读取volatile变量state
       if (c == 0) {
           if (!hasQueuedPredecessors() && //判断当前线程是否在排队队列中的第一位
               compareAndSetState(0, acquires)) { //CAS设置当前线程获取锁
               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;
   }

解锁方法:

//1.ReentrantLock.unlock()
public void unlock() {
   sync.release(1);
 }

//2.AbstractQueuedSynchronizer.release(int arg)
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

//3.ReentrantLock.Sync.tryRelease(int arg)
 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
      return free;
  }

非公平锁
非公平锁就是一种获取锁的抢占机制,是随机获得锁的,它允许插队:当一个线程请求非公平锁时,如果在发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。
非公平锁的释放锁与公平锁的释放锁调用逻辑相同,这里只分析费公平锁的获取:

//1.ReentrantLock.lock()
  public void lock() {
       sync.lock();
   }

//2.ReentrantLock.NonFairSYnc.lock();
final void lock() {
   if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

//3.AbstractQueuedSynchronizer.compareAndSetState(int expect, int update);
 protected final boolean compareAndSetState(int expect, int update) {
       // See below for intrinsics setup to support this
       return unsafe.compareAndSwapInt(this, stateOffset, expect, update);//原子操作更新state的值。
   }

通过对ReentrantLock的分析可以看出,锁释放-获取的内存语义至少具有下面两种方式:

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

concurrent包的实现
由于java的CAS同时具有volatile读和写的内存语义,因此java线程之间的通信现在有了如下四种方式:

  1. A线程写volatile变量,随后B线程读取这个volatile变量
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量
  4. A线程用CAS更新一个volatile变量,随后B线程读取这个volatile变量。

java的CAS会使用现代处理器上提供的原子操作指令,这些方式以原子的方式对内存执行读,改 ,写操作。同时,volatile变量的读、写(可见性)和CAS可以实现线程之间的通信。把这些特性整合到一起,就形成了java的J.U.C包的实现的基石。仔细观察concurrent包的源码实现,会发现一个通用化的实现模式:

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

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:
这里写图片描述



final

对final域的读写更像是普通的变量访问。对于final域,编译器和处理器要遵守两个重排序规则

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。
  • 初次读一个包含final域的对象引用,与随后初次读取这个final域,这两个操作不能重排。
public class FinalExample {
    int i; //普通变量
    final int j; //final变量
    static FinalExample obj;


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

    public static void writer() {
        obj = new FinalExample();
    }

    public static void reader(){
        FinalExample object = obj; //读取对象引用

        int b = object.j; //读取final域
    }
}

假设一个线程A执行writer()方法,随后另外一个线程B执行reader()方法。
写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则实际上包含了下面两个方面:

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

现在分析下writer()方法,writer()方法只包含一行代码:obj = new FinalExample();这行代码包含了两个步骤:

  1. 构造一个FinalExample()对象
  2. 把这个对象的引用赋值给引用变量obj;

假设线程B读对象引用与读对象的成员域之间没有重排序,下图是一种可能的执行时序:
这里写图片描述
在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误的读取了普通变量i未初始化之前的值。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确的读取了final变量初始化之后的值。

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

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

  • 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接yilaiguanxi。由于处理器遵守间接依赖关系,因此编译器不会重新排序这两个操作。大多数处理器也会遵守间接依赖,故大多数处理器不会重排序这连个操作。
reader()方法包含三个操作:

  • 初次读取引用变量obj
  • 初次读取引用变量obj指向对象的普通域i
  • 初次读取引用变量obj指向对象的final域j;

现在假设写线程A没有发生任何重排序,同时程序在不遵守间接排序的处理器上执行。
这里写图片描述
在上图,读对象的普通域的操作被重排序到读对象引用之前。读普通域时,该域还没有被线程A写入,这是一个错误的读取操作。而读final操作域的重排序规则会把读对象final的操作“限定”在读对象引用之后,此时final域已经初始化完毕,这是一个正确的读取操作。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。(在本例中,如果该引用不为null,那么引用对象的final域一定已经被线程A初始化过了。)

如果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域是一个引用类型,它引用了一个int类型的数组对象。对于引用类型,写final域的重排序规则对编译器和处理器有如下限制:

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

对上面的示例程序,我们假设首先线程A执行writeOne())方法,执行完之后线程B执行writeTwo()方法,执行完线程C执行reader()方法。下面可能是一种线程执行顺序:
这里写图片描述
在上图中,1是对final域的写入,2是对final域引用的对象的成员域的写入,3是把构造对象的引用赋值给某个引用变量。这里除了1和3不能重排序,2和3也不能重排序。
JMM可以确保读线程C至少能看到线程A在构造函数中final引用对象的成员域的写入(即C至少能读取到intArray[0]值为1)。而写线程B对数组元素的写入,C线程不一定能看到。JMM不保证线程B的写入对线程C可见,因为线程B和线程C存在数据竞争,此时的执行结果不可知。
如果想确保线程C能看到写线程B对数组元素的写入,写线程B和读线程C之间需要同步原语(lock或volatile)来确保内存可见性。

为什么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后面,执行reader()方法的线程仍然可能无法看到final域被初始化的值,因为操作1和操作2之间可能被重排序。这里写图片描述
从上图可以看出:在构造函数返回之前,被构造对象的引用依旧不能为其他线程可见:因为此时的final域可能还没有被初始化。在构造函数返回之后,任意线程都将保证能看到final域正确初始化之后的值。

final语义在处理中的实现
以x86处理器为例,说明final语义在处理器中的实现。
上面提到,写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前,插入一个StoreStore屏障。读final域的重排序规则要求编译器在读final域的操作面前插入一个LoadLoad屏障。
由于x86处理器不会对写-写操作做重排序,所以在x86处理器中,写final域需要的StoreStore屏障会被省略掉。同样,由于x86处理器不会对存在间接依赖关系的操作重排序,所以在x86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说x86处理器中,final域读/写不会插入任何内存屏障。

JSR-133为什么要增加final语义
在旧的java内存模型中,最严重的一个缺陷就是线程可能看到final域的值会改变。比如,一个线程看到一个整型final域的值是0(未初始化时的默认值),过了一段时间之后这个线程再去读这个final域值时,却发现了变成了1.
为了修复这个漏洞,JSR-133专家组增加了final的语义。通过final域增加写和读重排序规则,可以为java程序员提供初始化保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(lock,volatile),就可以保证任意线程都能看到这个final域在构造函数中被初始化的值

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值