java内存模型

111 阅读12分钟

为什么要有内存模型

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步

  • 通信是指线程之间以何种机制来交换信息。线程之间的通信机制有两种:共享内存和消息传递
    • 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态 进行隐式通信。
    • 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消 息来显式进行通信。
  • 同步是指程序中用于控制不同线程间操作发生相对顺序的机制。
    • 在共享内存并发模型 里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
    • 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。 Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。 JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

CPU和缓存一致性

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

而随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

处理器优化和指令重排

上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化

除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排

可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。

并发编程三大问题

并发问题产生的三大根源是「可见性」「有序性」「原子性」

  • 可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。上文中的缓存一致性问题会导致可见性
  • 有序性:是程序执行的顺序按照代码的先后顺序执行。指令重排问题会导致有序性问题
    • 编译器优化导致重排序(编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序)
    • 指令集并行重排序(CPU原生就有可能将指令进行重排)
    • 内存系统重排序(CPU架构下很可能有store buffer /invalid queue 缓冲区,这种「异步」很可能会导致指令重排)
  • 原子性:Java的一条语句往往需要多条 CPU 指令完成(i++),由于操作系统的线程切换很可能导致 i++ 操作未完成,其他线程“中途”操作了共享变量 i ,导致最终结果并非我们所期待的。处理器优化可能导致原子性问题 不同的CPU实现的架构和优化均不一样,Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果

java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

什么是内存模型(解决上面问题的方法)

所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。 通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:限制处理器优化使用内存屏障

什么是Java内存模型

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

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

image.png

所以,再来总结下,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

Java内存模型实现

原子性

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 操作可以不具备原子性。」

想要保证原子性,可以尝试以下几种方式:

  • 「CAS」:使用基于CAS实现的原子操作类(例如AtomicInteger)
  • 「synchronized 关键字」:可以使用synchronized 来保证限定临界区内操作的原子性。它对应的内存间交互操作为:「lock 和 unlock」,在虚拟机实现上对应的字节码指令为 「monitorenter 和 monitorexit」。 详情可见 Java并发关键字解析-synchronized

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

可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

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

  • 「volatile 关键字」

    该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是 「会把值刷新回主内存」。详情可见 Java并发关键字解析-volatile

  • 「sychronized 关键字」

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

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

  • final 关键字

有序性

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

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

  • 「volatile」

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

  • 「synchronized」

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

先行发生原则

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一个“先行发生”(Happens-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

单一线程原则(程序员顺序规则):在一个线程内,在程序前面的操作先行发生于后面的操作。注意,要考虑分支、循环等结构。

管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。

volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先后。

线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

线程终止规则(Thread Termination Rule):Thread 对象的结束先行发生于 join() 方法返回。

线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。

对象终结规则(Finalizer Rule): 一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。

传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行发生于操作C的结论

参考 再有人问你Java内存模型是什么,就把这篇文章发给他。 - 掘金 (juejin.cn)

Java 并发编程 ④ - Java 内存模型 - 掘金 (juejin.cn)