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

简介: 并发编程之 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博客


相关文章
|
8天前
|
存储 Java 测试技术
滚雪球学Java(18):解密JavaSE中的堆栈:你真的了解Java内存吗?
【4月更文挑战第7天】🏆本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
40 1
滚雪球学Java(18):解密JavaSE中的堆栈:你真的了解Java内存吗?
|
3天前
|
算法 Java Go
Go vs Java:内存管理与垃圾回收机制对比
对比了Go和Java的内存管理与垃圾回收机制。Java依赖JVM自动管理内存,使用堆栈内存并采用多种垃圾回收算法,如标记-清除和分代收集。Go则提供更多的手动控制,内存分配与释放由分配器和垃圾回收器协同完成,使用三色标记算法并发回收。示例展示了Java中对象自动创建和销毁,而Go中开发者需注意内存泄漏。选择语言应根据项目需求和技术栈来决定。
|
1天前
|
存储 机器学习/深度学习 Java
【Java探索之旅】数组使用 初探JVM内存布局
【Java探索之旅】数组使用 初探JVM内存布局
9 0
|
2天前
|
安全 Java 编译器
Java面向对象思想以及原理以及内存图解(下)
Java面向对象思想以及原理以及内存图解(下)
10 0
|
2天前
|
Java
Java面向对象思想以及原理以及内存图解(上)
Java面向对象思想以及原理以及内存图解
10 0
|
2天前
|
算法 安全 Java
Java并发编程基础总结
Java并发编程基础总结
4 0
|
4天前
|
缓存 Java
Java并发编程:深入理解线程池
【4月更文挑战第26天】在Java中,线程池是一种重要的并发工具,它可以有效地管理和控制线程的执行。本文将深入探讨线程池的工作原理,以及如何使用Java的Executor框架来创建和管理线程池。我们将看到线程池如何提高性能,减少资源消耗,并提供更好的线程管理。
|
5天前
|
存储 安全 Java
Java并发编程中的高效数据结构:ConcurrentHashMap解析
【4月更文挑战第25天】在多线程环境下,高效的数据访问和管理是至关重要的。Java提供了多种并发集合来处理这种情境,其中ConcurrentHashMap是最广泛使用的一个。本文将深入分析ConcurrentHashMap的内部工作原理、性能特点以及它如何在保证线程安全的同时提供高并发性,最后将展示其在实际开发中的应用示例。
|
6天前
|
Java API 调度
[AIGC] 深入理解Java并发编程:从入门到进阶
[AIGC] 深入理解Java并发编程:从入门到进阶
|
6天前
|
存储 缓存 Java
[JVM] 浅谈JMM(Java 内存模型)
[JVM] 浅谈JMM(Java 内存模型)