Java 并发编程 ④ - Java 内存模型

原文地址: Java 并发编程 ④ - Java 内存模型

转载请注明出处!java

往期文章:编程

前言

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各类硬件和操做系统的访问差别的,保证了Java程序在各类平台下对内存的访问都能获得一致效果的机制及规范数组

JMM与Java内存区域是两个容易混淆的概念,这二者既有差异又有联系:缓存

  • 区别

二者是不一样的概念层次Java 内存模型是抽象的,他是用来描述一组规则,经过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。多线程

  • 联系

都存在私有数据区域和共享数据区域。通常来讲,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;一样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。并发

在学习Java 内存模型时,咱们常常会提到3个特性:函数

  • 可见性 - Visibility
  • 原子性 - Atomicity
  • 有序性 - Ordering

Java内存模型就是围绕着在并发过程当中如何处理这3个特性来创建的。本文也会按照这三个特性讲述。性能

1、Java 共享变量的内存可见性问题

在讨论以前,须要先重温一下,JVM运行时内存区域:学习

线程私有变量不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,这一块的数据也称为共享变量,内存可见性问题针对的就是共享变量。优化


好了,弄清楚问题的主体以后,咱们再来思考一个问题。

为何堆上的变量会存在内存可见性的问题呢?

JMM对硬件层面缓存访问的抽象

其实,这就要涉及到计算机硬件的缓存访问操做了。

现代计算机中,处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

Java的内存访问操做与上述的硬件缓存具备很高的可比性:

Java内存模型中,规定了:

  • 全部的变量都存储在主内存中。
  • 每一个线程还有本身的工做内存,存储了该线程以读、写共享变量的副本。
  • 本地内存(或者叫工做内存)是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。
  • 线程只能直接操做工做内存中的变量,不一样线程之间的变量值传递须要经过主内存来完成。

从抽象的角度来讲,JMM定义了线程和主内存之间的抽象关系

按照上述对于JMM的描述,当一个线程操做共享变量时,它首先从主内存复制共享变量到本身的工做内存,而后对工做内存里的变量进行处理,处理完后将变量值更新到主内存。

Cache(工做内存)的存在就会带来共享变量的内存不可见的问题(也能够叫作缓存一致性问题),具体能够看下面的例子:

  • 假设如今主内存中有共享变量X=0;
  • 线程A首先获取共享变量X的值,因为Cache中没有命中,因此去加载主内存中变量X的值,把X=0的值缓存到工做内存中,线程A执行了修改操做X++,而后将其写入工做内存中,而且刷新到主内存中。
Thread-A工做内存中 X=1
  主内存中           X=1
  • 线程B开始获取共享变量,因为Cache没有命中,因此去加载主内存中变量X的值,把X=1的值缓存到工做内存中。而后线程B执行了修改操做X++,而后将其写入工做内存中,而且刷新到主内存中。
Thread-B工做内存中 X=2
  Thread-A工做内存中 X=1
  主内存中           X=2

明明线程B已经把X的值修改成了2,为什么线程A获取的仍是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

如何保证内存的可见性

那么如何保证内存的可见性,主要有三种实现方式:

  • volatile 关键字

    该关键字能够确保对一个变量的更新对其余线程立刻可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其余地方,而是会把值刷新回主内存

  • sychronized 关键字

    一个线程在获取到监视器锁之后才能进入 synchronized 控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,所以 synchronized 代码块中对于共享变量的读取须要从主内存中从新获取,也就能获取到最新的值

    退出代码块的时候,会将该线程写缓冲区中的数据刷到主内存中,因此在 synchronized 代码块以前或 synchronized 代码块中对于共享变量的操做随着该线程退出 synchronized 块,会当即对其余线程可见(固然前提是线程会去主内存读取最新值)。

  • final 关键字

    在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其余线程能够访问到的地方(不要让引用在构造函数中逸出)。若是这个条件知足,当其余线程看到这个对象的时候,那个线程始终能够看到正确初始化后的对象的 final 属性。(final 字段所引用的对象里的字段或数组元素可能在后续还会变化,若没有正确同步,其它线程也许不能看到最新改变的值,但必定能够看到彻底初始化的对象或数组被 final 字段引用的那个时刻的对象字段值或数组元素。)

    final 的场景比较偏,通常就是前面两种方式

    延伸连接:JSR-133:JavaTM 内存模型与线程规范

volatile 和 sychronized 是我认为比较重要的内容,会有单独的章节来说。

2、原子性

JMM 内存交互操做

Java 内存模型定义了 8 个操做来完成主内存和工做内存的交互操做

  • read:把一个变量的值从主内存传输到线程的工做内存中
  • load:在 read 以后执行,把 read 获得的值放入线程的工做内存的变量副本中
  • use:把线程的工做内存中一个变量的值传递给执行引擎
  • assign:把一个从执行引擎接收到的值赋给工做内存的变量
  • store:把工做内存的一个变量的值传送到主内存中
  • write:在 store 以后执行,把 store 获得的值放入主内存的变量中
  • lock:做用于主内存的变量,把一个变量标识成一条线程独占的状态
  • unlock: 做用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才能够被其余线程锁定。
JMM关于内存交互的定义规则很是的严谨和繁琐,为了方便理解,Java设计团队将Java内存模型的操做简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。

JMM 对于原子性的规定

所谓原子性操做,是指执行一系列操做时,这些操做要么所有执行,要么所有不执行,不存在只执行其中一部分的状况。

Java 内存模型保证了 readloaduseassignstorewritelockunlock 操做具备原子性,例如对一个 int 类型的变量执行 assign 赋值操做,这个操做就是原子性的。可是 Java 内存模型容许虚拟机将没有被 volatile 修饰的 64 位数据(longdouble)的读写操做划分为两次 32 位的操做来进行,也就是说基本数据类型的访问读写是原子性的,除了longdouble是非原子性的,loadstoreread write 操做能够不具有原子性。 在《深刻理解Java 虚拟机》书中提醒咱们只须要知道有这么一回事,真的要用到这个知识点的场景十分罕见。

共享变量的原子性问题

这里放一个很经典的例子,并发条件下的计数器自增。

/**
 * 内存模型三大特性 - 原子性验证对比
 *
 * @author Richard_yyf
 */
public class AtomicExample {

    private static AtomicInteger atomicCount = new AtomicInteger();

    private static int count = 0;

    private static void add() {
        atomicCount.incrementAndGet();
        count++;
    }

    public static void main(String[] args) {
        final int threadSize = 1000;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executor.execute(() -> {
                add();
                countDownLatch.countDown();
            });
        }
        System.out.println("atomicCount: " + atomicCount);
        System.out.println("count: " + count);

        ThreadPoolUtil.tryReleasePool(executor);
    }
}

输出结果:

atomicCount: 1000
count: 997

能够看到,虽然有1000个线程执行了count++操做,最终获得的结果却不是预期的1000。

至于缘由呢,就是由于count++这行代码,并非一个原子性操做。能够借助下图帮助理解。

count++这个简单的操做根据上面的原理分析,能够知道内存操做实际分为读写存三步;由于读写存这个总体的操做,不具有原子性,count被两个或多个线程读入了一样的旧值,读到线程内存当中,再进行写操做,再存回去,那么就可能出现主内存被重复set同一个值的状况,如上图所示,两个线程进行了count++,实际上只进行了一次有效操做。

如何保证原子性

想要保证原子性,能够尝试如下几种方式:

  • CAS:使用基于CAS实现的原子操做类(例如AtomicInteger)
  • synchronized 关键字:可使用synchronized 来保证限定临界区内操做的原子性。它对应的内存间交互操做为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit
前者是乐观锁(读多写少场景),后者是悲观锁(读少写多场景)

3、有序性

重排序

计算机在执行程序时,为了提升性能,编译器和处理器会对指令作重排。

重排序由如下几种机制引发:

  • 编译器优化重排

    编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。

  • 指令并行重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。若是不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器能够改变语句对应的机器指令的执行顺序。

  • 内存系统重排

    因为处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操做看上去多是在乱序执行,由于三级缓存的存在,致使内存与缓存的数据同步存在时间差。

如何保证有序性

Java内存模型容许编译器和处理器对指令重排序以提升运行性能,而且只会对不存在数据依赖性的指令重排序。意思就是说,在Java内存模型的规定下,对编译器和处理器来讲,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。 在单线程下,能够保证重排序优化以后最终执行的结果与程序顺序执行的结果一致(咱们常说的as-if-serial语义),可是在多线程下就会存在问题。

重排序在多线程下会致使非预期的程序执行结果,想要保证可见性,能够考虑如下实现方式:

  • volatile

    volatile产生内存屏障,禁止指令重排序

  • synchronized

    保证每一个时刻只有一个线程进入同步代码块,至关因而让线程顺序执行同步代码。

小结

Java内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征创建。归根究底,是为实现共享变量的在多个线程的工做内存的数据一致性,是的在多线程并发、指令重排序优化的环境中程序能如预期运行。

本文介绍了Java内存模型,以及其围绕的有序性、内存可见性以及原子性相关的知识。不得不说,关于Java内存模型,真的要深究估计能够写出一本小书,有兴趣的读者能够参阅其余资料作更深的了解。

上文中提到的valotilesynchronized,是比较重要的内容,会有单独的章节。

参考

  • 《Java 并发编程之美》
  • 《深刻理解Java虚拟机》
  • JSR133中文
相关文章
相关标签/搜索