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

Richard_Yi 2020年04月24日 110次浏览

往期文章:

前言

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

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

  • 区别

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

  • 联系

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

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

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

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

一、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 是我认为比较重要的内容,会有单独的章节来讲。

二、原子性

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是非原子性的,loadstorereadwrite 操作可以不具备原子性。 在《深入理解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

前者是乐观锁(读多写少场景),后者是悲观锁(读少写多场景)

三、有序性

重排序

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

重排序由以下几种机制引起:

  • 编译器优化重排

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

  • 指令并行重排

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

  • 内存系统重排

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

如何保证有序性

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

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

  • volatile

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

  • synchronized

    保证每个时刻只有一个线程进入同步代码块,相当于是让线程顺序执行同步代码。

小结

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

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

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

参考

  • 《Java 并发编程之美》
  • 《深入理解Java虚拟机》
  • JSR133中文