• 极客专栏正式上线!欢迎访问 https://www.jikewenku.com/topic.html
  • 极客专栏正式上线!欢迎访问 https://www.jikewenku.com/topic.html

Java并发编程与高并发学习笔记(四)JAVA内存模型、并发的优势与风险

极客笔记 极客文库 9个月前 (07-22) 268次浏览 已收录 0个评论 扫描二维码
文章目录[隐藏]

Java内存模型(Java Memory Model,JMM)

Java内存模型即Java Memory Model,简称JMM。

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。

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

JVM对Java内存模型的实现

在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区。

  • JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。
  • 线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。
  • 所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。
  • 堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。

下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:

  • 一个本地变量如果是原始类型,那么它会被完全存储到栈区。
  • 一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
  • 对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。
  • 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。
  • Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

堆中的对象可以被多线程共享。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。

硬件内存架构

CPU Registers(寄存器)

是CPU内存的基础,CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器速度远大于主存。

CPU Cache Memory(高速缓存)

由于计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高级缓存,来作为内存与处理器之间的缓冲。

将运算时所使用到的数据复制到缓存中,让运算能快速的进行。

当运算结束后,再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。

RAM-Main Memory(主存/内存)

计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比缓存和寄存器大很多。

运作原理

通常情况下当一个CPU需要读取主存的时候,他会将主存中的部分读取到CPU缓存中,甚至可能将缓存中的部分内容读到他的内部寄存器里面,然后在寄存器中执行操作。

当CPU需要将结果回写到主存的时候,他会将内部寄存器中的值刷新到缓存中,然后在某个时间点从缓存中刷回(flush)主存。

Java内存模型和硬件架构之间的桥接

如图可以看出,Java内存模型和硬件内存架构是有差异的。硬件内存架构中并没有区分栈和堆,对于硬件而言,所有的栈和堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:

当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:

  • 1、共享对象的可见性
  • 2、竞争现象

当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线程不可见。

想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。

最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。

下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。

但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中。

要解决共享对象可见性这个问题,我们可以使用java volatile关键字。

volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。

volatile原理是基于CPU内存屏障指令实现。

竞争现象

如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。

如下图所示,线程A和线程B共享一个对象obj。

假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。

此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。

然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。

要解决上面的问题我们可以使用java synchronized代码块。

synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

支撑Java内存模型的基础原理

指令重排序

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

但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

  • 1、编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 2、指令级并行的重排序:如果不存l在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 3、内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性

如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。

编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

AS-IF-SERIAL语义

不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

内存屏障(MEMORY BARRIER)

通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。

内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

  • 1、保证特定操作的执行顺序。
  • 2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

HAPPENS-BEFORE原则

从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。

happens-before规则如下:

  • 1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
  • 2、监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
  • 3、volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
  • 4、传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。

注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。

Java内存模型中线程和主内存的抽象关系

Java内存模型抽象结构图:

线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  • 1、首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  • 2、然后,线程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程序员提供内存可见性保证。

Java内存模型中同步的操作与规则

同步操作

  • lock(锁定) :作用于主内存变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁) : 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取) : 作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入) :作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用) :作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
  • assign(赋值) : 作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。
  • store(存储) : 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write操作。
  • write(写入) :作用于主内存的变量中,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

同步规则

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

并发的优势与风险


丨极客文库, 版权所有丨如未注明 , 均为原创丨
本网站采用知识共享署名-非商业性使用-相同方式共享 3.0 中国大陆许可协议进行授权
转载请注明原文链接:Java并发编程与高并发学习笔记(四)JAVA内存模型、并发的优势与风险
喜欢 (0)
[247507792@qq.com]
分享 (0)
极客文库
关于作者:
一只程序猿

您必须 登录 才能发表评论!

  • 精品技术教程
  • 编程资源分享
  • 问答交流社区
  • 极客文库知识库

客服QQ


QQ:2248886839


工作时间:09:00-23:00