【Java 多线程并发】 Java内存模型基础知识

Metadata

title: 【Java 多线程并发】 Java内存模型基础知识
date: 2023-07-03 13:55
tags:
  - 行动阶段/完成
  - 主题场景/程序
  - 笔记空间/KnowladgeSpace/ProgramSpace/BasicsSpace
  - 细化主题/Java/多线程并发
categories:
  - Java
keywords:
  - Java/多线程并发
description: 【Java 多线程并发】 Java内存模型基础知识

概述

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

JMM
JMM(Java内存模型Java Memory Model)本身是一种抽象的概念

JMM 的组成

  1. 主内存:共享的信息,也就是图中的内存区域,它也就对应着JVM内存区域中的堆
  2. 工作内存:私有信息,也就是图中的工作空间,基本数据类型,直接分配到工作内存,引用的地址存放在工作内存,引用的对象存放在堆中。局部变量,主内存数据的副本都会被存到工作内存。它对应JVM中的Java栈,每一个线程都有一个私有的工作空间,工作空间的空间大小分配和Java栈是一起的,都是根据需要代码需要在编译期间就确定好的,
  3. 工作方式:
    • A线程修改私有数据,直接在工作空间修改
    • B线程修改共享数据,把数据复制到工作空间中去,在工作空间中修改,修改完成以后,刷新内存中的数据

JMM 的 八种同步操作

happens-before

判断数据是否存在竞争、线程是否安全的依据

  • 次序规则
  • 锁定规则
  • volatile变量规则
  • 传递规则
  • 线程启动规则
  • 线程终止规则
  • 对象终结规则

三大特性

  • 可见性
    • volatile
    • synchronized
    • JUC中Lock的lock
  • 原子性
    • Synchronized:同步代码块
    • JUC中Lock的lock:加锁
  • 有序性
    • volatile之前的代码不能调整到它的后面
    • volatile之后的代码不能调整到它的前面
    • 霸道(位置不变化)volatile实现可见性的硬件基础就是cache line

并发编程模型的两个关键问题

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下表所示:

如何通信 如何同步
消息传递并发模型 线程之间没有公共状态,线程间的通信必须通过发送消息来显示进行通信。 发送消息天然同步,因为发送消息总是在接受消息之前,因此同步是隐式的。
共享内存并发模型 线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。 必须显式指定某段代码需要在线程之间互斥执行,同步是显式的。

在Java中,使用的是共享内存并发模型。

Java内存模型的抽象结构

什么是JMM(JMM的作用)

JMM(Java内存模型Java Memory Model)本身是一种抽象的概念并不真实存在,它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的。

能干吗?

  • 通过JMM来实现线程和主内存之间的抽象关系
  • 屏蔽各个硬件平台和操作系统的内存访问差异以实现让Java程序再各种平台下都能达到一致性的内存访问效果。

JMM的组成

  1. 主内存:共享的信息,也就是图中的内存区域,它也就对应着JVM内存区域中的堆
  2. 工作内存:私有信息,也就是图中的工作空间,基本数据类型,直接分配到工作内存,引用的地址存放在工作内存,引用的对象存放在堆中。局部变量,主内存数据的副本都会被存到工作内存。它对应JVM中的Java栈,每一个线程都有一个私有的工作空间,工作空间的空间大小分配和Java栈是一起的,都是根据需要代码需要在编译期间就确定好的,
  3. 工作方式:
    • A线程修改私有数据,直接在工作空间修改
    • B线程修改共享数据,把数据复制到工作空间中去,在工作空间中修改,修改完成以后,刷新内存中的数据

运行时内存的划分

先谈一下运行时数据区,下面这张图相信大家一点都不陌生:

对于每一个线程来说,栈都是私有的,而堆是共有的。
也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。而在堆中的变量是共享的,本文称为共享变量。
所以,内存可见性是针对的共享变量。

既然堆是共享的,为什么在堆中会有内存不可见问题?

这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。

线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

Java线程之间的通信由Java内存模型(简称JMM)控制,从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:

从图中可以看出:

  1. 所有的共享变量都存在主内存中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 如果线程A与线程B之间要通信的话,

必须经历下面2个步骤:

  1. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。
注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。

所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

JMM与Java内存区域划分的区别与联系

上面两小节分别提到了JMM和Java运行时内存区域的划分,这两者既有差别又有联系:
区别
两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。
联系
都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

硬件内存架构与java内存模型

硬件架构

像上面这种图就是多核的机器,每一个CPU都对应着一个cache,cache中的数据是对应CPU私有的,内存中的数据是所有CPU共享的。

在说明Java并发特性之前,先简单了解一下物理计算机中的并发问题,这二者有不少相似之处。物理机对并发的处理方案对于虚拟机也有很大的参考意义。

“并发”在计算机领域内,一直是比较头疼。因为并发不仅仅是计算的事情,也是存储的事情。我们在处理并发时,不可能只靠CPU就能完成,也需要与内存交互,比如读取运算数据,存储运算结果等。

但是,由于CPU的处理效率和内存的处理效率差了几个数量级,计算机不得不引入高速缓存作为内存和CPU之间的缓冲,将运算需要使用的数据复制到缓存中,减少I/O瓶颈,加速运算,当运算完成之后,再将数据从缓存同步回内存中,这样能够提升不少处理的效率。

不过,在引入高速缓存的同时,也带来了另外一个问题——缓存一致性。每个处理器都有自己的高速缓存,而他们又共享同一主内存,当多个处理器任务都是涉及到同一块主内存区域时,就会出现缓存数据不一致的问题。

同时,为了解决一致性的问题,高速缓存就需要遵守一些一致性协议(MSI等协议)来规范对数据的读写。

具体示意图,如下:

注:引入物理计算机并发的概念,主要是为了提供一种思路,实际上的实现远比描述的要复杂。

CPU缓存的一致性问题:并发处理的不同步。比如CPU1将共享数据1改成2,如果CPU1修改完了CPU2再去读取就会读取到2,如果CPU1没有修改完CPU2读取到的就是1,有可能造成脏读的问题,这就是并发处理不同步。

解决方案

  1. 总线加锁,直接在总线上锁定该资源只能同时被一个CPU使用,这样就解决了CPU数据同步问题。但是这会降低CPU的吞吐量,降低计算机效率缓存上的一致性协议(MESI协议,用来保证Cache一致性)
  2. 当CPU在cache中操作数据时,如果该数据是共享变量,数据从cache读到寄存器中,进行新修改,同时将该数据的cache line标志位置为无效,然后更新内存数据。因为修改完数据之后,该数据以前在其他CPU的cache中就失效了,不能再读取了,将标志置为无效是为了让其他CPU直接去内存中读取最新的该数据,然后再更新自己的cache数据,这样就解决了不同步问题。

JMM和硬件内存结构的工作方式很相似,JMM中的工作空间对应的就是cache和寄存器,JMM中的主内存对应的就是硬件中的内存。

Java线程与硬件处理器

整个流程就是用户指定任务交给线程池,由线程池去分配进程用来执行这些任务,一个任务也就对应着一个线程,每一个Java线程是需要映射到一个真实的操作系统线程,通过操作系统线程完成任务的,将Java线程映射到操作系统线程,对线程的挂起或唤醒,这些操作都需要操作系统线程来完成,这就需要请求OS内核的帮助,需要操作系统由用户态转变成内核态,给每一个线程分配一个内核线程,然后内核线程被交给CPU进行操作。

模型分为主内存和工作内存,所有的变量(局部变量除外,局部变量都是线程私有的,不存在并发问题)都存储在主内存中。每条线程具有自己的工作内存,其中工作内存中保存了线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接操作主内存中的变量。不同线程之间是无法访问对方的工作内存,线程间变量值的传递均需要通过主内存来完成,示意图如下:

Java内存模型与硬件内存架构的关系

由上面的讲解可知,工作空间和内存都对应着硬件系统的寄存器,cache和内存。这种交叉的关系也就造成了在多线程并发的环境下很容易出现数据不同步的问题。

注:这里提到的主内存和工作内存,实际上和我们常说的Java内存分为堆、栈、方法区等并不是同一层次的划分,二者基本上没有直接联系。如果一定要勉强对应的话,那主内存主要对应于Java堆中的对象实例部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存直接对应于物理硬件的内存,而工作内存可能优先存储于高速缓存中。

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从 工作内存同步回主内存这一类的实现细节,Java内存模型中定义了8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说, load、store、read和write操作在某些平台上允许有例外。

Java内存模型的必要性

Java内存模型(JMM)的作用:规范内存数据和工作空间数据的交互。来解决数据不同步的问题

关于主内存与工作内存之间具体的交互协议,也就是说,一个变量如何从主内存拷贝到工作内存,又是如何从工作内存同步回到主内存的。Java定义了8种操作来实现的,并且虚拟机保证每一种操作都是原子的。

8种操作分别是lock、unlock、read、load、use、assign、store、write.

上图所示,是两组操作,一组是读取,一组是写入。
值得注意的是,Java模型只要求这两个操作必须是顺序执行,但并没有保证是连续执行,这点区别是非常大的。
也就是说,read和load之间、store和write之间是可以插入其他指令的。

JMM 的 八种同步操作

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

JMM规范下多线程先行发生原则之happens-before

在JVM中,如果一个操作执行的结果需要对另一个操作可见或者代码重排序,那么这两个操作之间必须存在happens-before(先行发生)原则,逻辑上的先后关系。

先行并发原则说明

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将变得非常罗嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。
我们没有时时、处处、次次,添加volatile和synchronized来完成程序,这是因为Java语言中JMM原则下,有一个“先行发生”(happens-before)的原则限制和规矩,给你理好了规矩!
这个原则非常重要:它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型晦涩难懂的底层编译原理之中。

happens-before总原则

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
  • 如果两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

happens-before之8条

从JDK 5开始,Java使用新的JSR-133内存模型,提供了 happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:

  1. 次序规则:一个线程内,按照代码的顺序,写在前面的操作先行发生于写在后面的操作,也就是说前一个操作的结果可以被后续的操作获取(保证语义串行性,按照代码顺序执行)。比如前一个操作把变量x赋值为1,那后面一个操作肯定能知道x已经变成了1
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作(后面指时间上的先后)。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的后面同样指时间上的先后
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
  5. 线程启动规则(Thread start Rule):Thread对象的start()方法先行发生于此线程的每一个动作
  6. 线程中断规则(Thread Interruption Rule):
    a. 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    b. 可以通过Thread.interrupted()检测到是否发生中断
    c. 也就是说你要先调用interrupt()方法设置过中断标志位,我才能检测到中断发生
  7. 线程终止规则(Thread Termination Rule):线程中的所有操作都优先发生于对此线程的终止检测,我们可以通过isAlive()等手段检测线程是否已经终止执行。
  8. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始——->对象没有完成初始化之前,是不能调用finalized()方法的

happens-before小总结

  • 在Java语言里面,Happens-before的语义本质上是一种可见性
  • A happens-before B ,意味着A发生过的事情对B而言是可见的,无论A事件和B事件是否发生在同一线程里
  • JVM的设计分为两部分:
    • 一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解happens-before规则,就可以编写并发安全的程序了
    • 另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束从而提升性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序,我们只要关注前者就好了,也就是理解happens-before规则即可,其他繁杂的内容由JMM规范结合操作系统给我们搞定,我们只写好代码即可。

JMM规范下三大特性

可见性

是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存中。

系统中主内存共享变量数据修改被写入的时机是不确定的,多线程并发下很可能出现“脏读”,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在线程自己的工作内存中进行,而不能够直接写入主内存中的变量,不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

线程只能操作自己工作空间中的数据,对其他线程的工作空间不可见。但是并发编程中一个线程对共享变量进行了修改,另一个线程要能立刻看到被改后的最新值。

线程1对共享变量的修改如果要被线程2及时看到,需要经过2个步骤:

  1. 把工作内存1中更新过的共享变量值刷新到主内存中
  2. 把主内存中最新的共享变量的值更新打工作内存2中

线程脏读

  1. 主内存中有变量X,初始值为0
  2. 线程A要将X加1,先将X=0拷贝到自己的私有内存中,然后更新X的值
  3. 线程A将更新后的X值回刷到主内存的时间是不固定的
  4. 刚好在线程A没有回刷x到主内存时,线程B同样从主内存中读取X,此时为0,和线程A一样的操作,最后期盼的X=2就会变成X=1

原子性

所谓原子性,是指在一次操作或多次操作中,要么所有的操作全部执行,并不会受到人任何元素的干扰而中断,要么所有的操作都不执行,中间不会有任何上下文切换(context switch)。比如:A给B转账100,A账户扣除100,B账户账户收入100,这两个操作必须符合原子性,要么都成功,要么都失败。所以并发编程就需要将应该是原子操作的一系列操作封装成一个原子操作。

有序性

对于一个线程的执行代码而言,我们总是习惯性地认为代码的执行总是从上到下,有序执行。但为了提升性能,编译器和处理器通常会对指令序列进行重新排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序话执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序

程序中的顺序不一定就是执行的顺序,因为系统会对代码进行一次重排序,重排序的作用就是提高效率。但是虽然指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性,所以并发编程就要保证重排序之后的有序性,执行结果不能因为重排序而出错。重排序有三种:

  1. 编译器优化的重排序(编译期间):编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序(运行期间):现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

JMM在保证有序性时所使用的两个原则:

  • as-if-seria(单线程遵循):在单线程中,无论如何重排序,程序执行的结果都应该与代码顺序执行的结果一致(java编译器和处理器运行时都会保证在单线程中遵循as-if-serial规则,多线程存在程序交错执行时,则不遵守)
  • happens-before(多线程遵循):在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个是多线程中程序运行遵守的原则,保证在多线程环境下程序运行结果不会出错,后面有对其的详细讲解。重排序也就是遵守这个原则。

优缺点

  • JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令更符合CPU的执行特性,最大限度的发挥机器性能。

  • 但是指令重排可以保证串行语义一致,但没有义务保证多线程的语义也一致(即可能产生“脏读”),简单而言就是两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。

    从源码到最终执行示例图:

  • 单线程环境里确实能够保证程序最终执行结果和代码顺序执行的结果一致
  • 处理器在进行重排序时必须考虑到指令之间的数据依赖性
  • 多线程环境中线程交替执行,由于编译器优化重排的存在,可能出现乱序现象,两个线程使用的变量能否保证一致性是无法确定的,结果无法预测。

JMM对三个特征的保证

一个正确执行的并发程序,必须具备原子性、可见性、有序性。否则就有可能导致程序运行结果不正确,甚至引起死循环。

JMM与原子性

  • y=x 没有原子性
    • 把数据X读到工作空间(原子性)
    • 把X的值写到Y(原子性)
  • i++ 没有原子性
    • 读i到工作空间
    • +1;
    • 刷新结果到内存
  • z=z+1 没有原子性
    • 读z到工作空间
    • +1;
    • 刷新结果到内存
      由上面的这些例子可知多个原子性的操作合并到一起没有原子性

JMM对原子性的保证方式:

  1. Synchronized:同步代码块
  2. JUC中Lock的lock:加锁

JMM与可见性

JMM对可见性的保证方式:

volatile

在JMM模型上实现MESI协议,也就是在变量使用volatile关键字,一个线程将内存中的有volatile关键字标识的变量拿到工作空间进行修改后,就会通知其他线程再访问这个变量的话直接到内存中去找,就不要在自己的工作空间找了,因为数据已经被修改了。

深入来说,是通过加入内存屏障和禁止重排序优化来实现的:

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,会将cpu数据强制刷新到主内存中去
  • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,强制缓存器中的缓存失效,每次使用都要去主内存中重新获取数据

通俗地讲,volatile变量在每次被访问的时候,都强迫从主内存中读取该变量的值,而当该变量在发生变化时,又会强迫变量讲最新的值刷新到主内存中,这样,任意时刻,不同的线程总能看到该变量的最新值。

synchronized

使用synchronized加锁来保证可见性,它会保证两条原则:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新值(注意:加锁与解锁需要是同一把锁)
JUC中Lock的lock

JMM与有序性

JMM对有序性的保证方式:

volatile

被加了volatile关键字的变量不会被重排序。

对于volatile修饰的变量:

  • volatile之前的代码不能调整到它的后面
  • volatile之后的代码不能调整到它的前面
  • 霸道(位置不变化)volatile实现可见性的硬件基础就是cache line
synchronized

被它括起来的代码块内部会进行重排序,但是同步代码块整体在所有代码中的顺序不会改变。

对synchronized同步代码块:

  • 同步代码块之前的代码不能调到它后面
  • 同步代码块之后的代码不能调到它前面

JMM对有序保证按照Happens-before原则

时间上的先后与happens-before的关系

一个操作时间上先发生于另一个操作“并不代表”一个操作happen—before另一个操作。
一个操作happen—before另一个操作“并不代表”一个操作时间上先发生于另一个操作。

JMM规范下多线程对变量的读写过程

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有的地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读写赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到线程自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存存储着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

JMM定义了线程和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存中(从硬件角度讲就是内存条)
  • 每个线程都有一个自己的本地工作内存,本地工作内存中存储了该线程用来读写共享变量的副本(从硬件角度来说就是CPU的缓存)

小总结:

  • 我们定义的所有共享变量都储存在物理主内存中
  • 每个线程都有自己独立的工作内存,里面保证该线程使用到的共享变量的副本(主内存中该变量的一份拷贝)
  • 线程对共享变量所有的操作都必须先在线程自己的工作内存中进行后写回主内存,不能直接从主内存在读写(不能越级)
  • 不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行(同级不能互相访问)。

JMM与多线程通信

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

线程间通信的步骤:

首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

本地内存A和B有主内存中共享变量x的副本。

  • 假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。
  • 当线程A和线程B需要通信时(如何激发?–隐式),线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。
  • 随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。