并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

1. Java 内存模型到底是什么玩意?

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的CPU 有不同的内存模型。

Java 作为跨平台语言,肯定要屏蔽不同CPU内存模型的差异,构造自己的内存模型,这就是Java 的内存模型。实际上,根源来自硬件的内存模型。

还是看这个图片,Java 的内存模型和硬件的内存模型几乎一样,每个线程都有自己的工作内存,类似CPU的高速缓存,而 java 的主内存相当于硬件的内存条。

Java 内存模型也是抽象了线程访问内存的过程。

JMM(Java 内存模型)规定了所有的变量都存储在主内存(这个很重要)中,包括实例字段,静态字段,和构成数据对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的。不会被共享。自然就没有竞争问题。

什么是工作内存呢?每个线程都有自己的工作内存(这个很重要),线程的工作内存保存了该线程使用到的变量和主内存副本拷贝,线程对变量的所有操作(读写)都必须在工作内存中进行。而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。

总结一下,Java 内存模型定义了两个重要的东西,1.主内存,2.工作内存。每个线程的工作内存都是独立的,线程操作数据只能在工作内存中计算,然后刷入到主存。这是 Java 内存模型定义的线程基本工作方式。

2. Java 内存模型定义了哪些东西?

实际上,整个 Java 内存模型围绕了3个特征建立起来的。这三个特征是整个Java并发的基础。

原子性,可见性,有序性

原子性(Atomicity)
什么是原子性,其实这个原子性和事务处理中的原子性定义基本是一样的。指的是一个操作是不可中断的,不可分割的。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

我们大致可以认为基本数据类型的访问读写是具备原子性的(但是,如果你在32位虚拟机上计算 long 和 double 就不一样了),因为 java 虚拟机规范中,对 long 和 double 的操作没有强制定义要原子性的,但是强烈建议使用原子性的。因此,大部分商用的虚拟机基本都实现了原子性。

如果用户需要操作一个更到的范围保证原子性,那么,Java 内存模型提供了 lock 和 unlock (这是8种内存操操作中的2种)操作来满足这种需求,但是没有提供给程序员这两个操作,提供了更抽象的 monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。

可见性(Visibility)
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改,Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实习那可见性的。无论是普通变量还是 volatile 变量都是如此。他们的区别在于:volatile 的特殊规则保证了新值能立即同步到主内存,以及每次是使用前都能从主内存刷新,因此,可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了 volatile 之外, synchronized 和 final 也能实现可见性。同步块的可见性是由 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存种(执行 store, write 操作)。

有序性(Ordering)
有序性这个问题我们在最上面说硬件的时候说过,CPU 会调整指令顺序,同样的 Java 虚拟机同样也会调整字节码顺序,但这种调整在单线程里时感知不到的,除非在多线程程序中,这种调整会带来一些意想不到的错误。

Java 提过了两个关键字来保证多个线程之间操作的有序性,volatile 关键字本身就包含了禁止重排序的语义,而 synchronized 则是由 “一个变量同一时刻只允许一条线程对其进行 lock 操作”这个规则获得的。这条规则决定了同一个锁的两个同步块只能串行的进入。

好了,介绍完了 JMM 的三种基本特征。不知道大家有没有发现,volatile 保证了可见性和有序性,synchronized 则3个特性都保证了,堪称万能。而且 synchronized 使用方便。但是,仍然要警惕他对性能的影响。

3. Java内存模型引出的 Happen-Before 原则是什么?

说到有序性,注意,我们说有序性可以通过 volatile 和 synchronized 来实现,但是我们不可能所有的代码都靠这两个关键字。实际上,Java 语言已对重排序或者说有序性做了规定,这些规定在虚拟机优化的时候是不能违背的。 
1. 程序次序原则:一个线程内,按照程序代码顺序,书写在前面的操作先发生于书写在后面的操作。 
2. volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性。 
3. 锁规则:解锁(unlock) 必然发生在随后的加锁(lock)前。 
4. 传递性:A先于B,B先于C,那么A必然先于C。 
5. 线程的 start 方法先于他的每一个动作。 
6. 线程的所有操作先于线程的终结。 
7. 线程的中断(interrupt())先于被中断的代码。 
8. 对象的构造函数,结束先于 finalize 方法。

4. Happen-Before 引出的 volatile 又是什么?

我们在前面,说了很多的 volatile 关键字,可见这个关键字非常的重要,但似乎他的使用频率比 synchronized 
少多了,我们知道了这个关键字可以做什么呢?

volatile 可以实现线程的可见性,还可以实现线程的有序性。但是不能实现原子性。

我们还是直接写一段代码吧!

package cn.think.in.java.two;

/**
 * volatile 不能保证原子性,只能遵守 hp 原则 保证单线程的有序性和可见性。
 */
public class MultitudeTest {

  static volatile int i = 0;

  static class PlusTask implements Runnable {

    @Override
    public void run() {
      for (int j = 0; j < 10000; j++) {
//        plusI();
        i++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[10];
    for (int j = 0; j < 10; j++) {
      threads[j] = new Thread(new PlusTask());
      threads[j].start();
    }

    for (int j = 0; j < 10; j++) {
      threads[j].join();
    }

    System.out.println(i);
  }

//  static synchronized void plusI() {
//    i++;
//  }

}

 
我们启动了10个线程分别对一个 int 变量进行 ++ 操作,注意,++ 符号不是原子的。然后,主线程等待在这10个线程上,执行结束后打印 int 值。你会发现,无论怎么运行都到不了10000,因为他不是原子的。怎么理解呢?

i++ 等于 i = i + 1;

虚拟机首先读取 i 的值,然后在 i 的基础上加1,请注意,volatile 保证了线程读取的值是最新的,当线程读取 i 的时候,该值确实是最新的,但是有10个线程都去读了,他们读到的都是最新的,并且同时加1,这些操作不违法 volatile 的定义。最终出现错误,可以说是我们使用不当。

楼主也在测试代码中加入了一个同步方法,同步方法能够保证原子性。当for循环中执行的不是i++,而是 plusI 方法,那么结果就会准确了。

volatile 确实不能保证原子性,但是能保证有序性和可见性。那么是怎么实现的呢?

怎么保证有序性呢?实际上,在操作 volatile 关键字变量前后的汇编代码中,会有一个 lock 前缀,根据 intel IA32 手册,lock 的作用是 使得 本 CPU 的Cache 写入了内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache,别的CPU需要重新获取Cache。这样就实现了可见性。可见底层还是使用的 CPU 的指令

如何实现有序性呢?同样是lock 指令,这个指令还相当于一个内存屏障(大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障),指的是,重排序时不能把后面的指令重排序到内存屏障之前的位置。只有一个CPU访问内存时,并不需要内存屏障;但如果有两个或者更多CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证了。

因此请不要随意使用 volatile 变量,这会导致 JIT 无法优化代码,并且会插入很多的内存屏障指令,降低性能。

5.使用volatile关键字的场景

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

 至于为何需要这么写请参考:《Java 中的双重检查(Double-Check)》  http://www.iteye.com/topic/652440

6. 总结

首先 JMM 是抽象化了硬件的内存模型(使用了多级缓存导致出现缓存一致性协议),屏蔽了各个 CPU 和操作系统的差异。

Java 内存模型指的是:在特定的协议下对内存的访问过程。也就是线程的工作内存和主存直接的操作顺序。

JMM 主要围绕着原子性,可见性,有序性来设置规范。

synchronized 可以实现这3个功能,而 volatile 只能实现可见性和有序性。final 也能是实现可见性。

Happen-Before 原则规定了哪些是虚拟机不能重排序的,其中包括了锁的规定,volatile 变量的读与写规定。

而 volatile 我们也说了,不能保证原子性,所以使用的时候需要注意。volatile 底层的实现还是 CPU 的 lock 指令,通过刷新其余的CPU 的Cache 保证可见性,通过内存栅栏保证了有序性。

volatile关键字内存栅栏有两层意思:

  1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

下面这篇文章从实战角度专门探索 volatile 关键字原理

并发编程之volatile 关键字白话文解读_击水三千里的专栏-CSDN博客

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

数据与后端架构提升之路

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值