并发编程与高并发解决方案(二):三大问题

平衡 CPU > 内存 > IO 三者

  1. CPU 增加多级缓存,平衡内存的慢
  2. CPU 能者多劳,通过分时复用,平衡 IO 的速度差异
  3. 优化编译指令
    伴随着产生新的可见性,原子性,和有序性的问题

可见性

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性

JAVA 内存模型(JMM)

JMM规定,将所有的变量都存放在 主内存中,当线程使用变量时,会把主内存里面的变量 复制 到自己的工作空间或者叫作 私有内存 ,线程读写变量时操作的是自己工作内存中的变量。
JMM
当多个线程同时操作同一个共享对象时,由于工作内存(是CPU的寄存器和高速缓存的一个抽象的描述)这个中间层的出现,线程A和线程B必然存在延迟的问题

竞争现象

多个线程同时修改一个共享对象,这就产生竞争现象

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

如下图所示,线程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操作。
在这里插入图片描述

线程A与线程B之间如要通信的话,必须要经历下面2个步骤才能保证结果正确:

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

在实际实现中,线程的工作内存是这样的:
在这里插入图片描述
为了平衡内存/IO 短板,会在 CPU 上增加缓存,每个核都只有自己的一级缓存,甚至有一个所有 CPU 都共享的二级缓存

  • 在 Java 中,所有的实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享「共享变量」
  • 局部变量,方法定义参数和异常处理器参数不会在线程之间共享(不会有内存可见性的问题)

一句话,要想解决多线程可见性问题,所有线程都必须要刷取主内存中的变量
要解决共享对象可见性这个问题,我们可以使用volatile关键字。

Volatile保证两件事:
线程A工作内存中的变量更新会强制立即写入到主内存;
线程B工作内存中的变量会强制立即失效,这使得线程B必须去主内存中获取最新的变量值。

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

有序性

乱序执行优化

处理器或编译器为提高运算速度而做出违背代码原有顺序的优化。
( Java代码在编译后会变成 Java 字节码, 字节码被类加载器加载到 JVM 里, JVM 执行字节码, 最终需要转化为汇编指令在 CPU 上执行)

重排序遵循原则as-if-serial

  • as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
  • 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

乱序执行优化
但无论哪种执行顺序,最终的结果都是对的。正是因为as-if-serial的存在,我们在编写单线程程序时会觉得好像它就是按代码的顺序执行的,这让我们可以不必关心重排的影响。

示例

双重检查方式实现的单例,编译器 擅自 优化顺序后:
双重检查方式实现的单例
instance = new Singleton();代码转换成了 CPU 的3条指令,new 对象:

  1. 分配一块内存 M
  2. 在内存 M 上初始化 Singleton 对象
  3. 然后 M 的地址赋值给 instance 变量

优化后可能就变成了这样:

  1. 分配一块内存 M
  2. 然后将 M 的地址赋值给 instance 变量
  3. 在内存 M 上初始化 Singleton 对象

编译器优化后的顺序可能导致问题的发生:

  1. 线程 A 先执行 getInstance 方法,当执行到指令 2 时,恰好发生了线程切换
  2. 线程 B 刚进入到 getInstance 方法,判断 if 语句 instance 是否为空
  3. 线程 A 已经将 M 的地址赋值给了 instance 变量,所以线程 B 认为 instance 不为空
  4. 线程 B 直接 return instance 变量
  5. CPU 切换回线程 A,线程 A 完成后续初始化内容
    在这里插入图片描述
    如果线程 A 执行到第 2 步,线程切换,由于线程 A 没有把红色箭头执行完全,线程 B 就会得到一个未初始化完全的对象,访问 instance 成员变量的时候就可能发生 NPE, instance 用 volatile 或者 final 修饰,可避免该问题。

指令重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier(内存屏障)来禁止特定类型的编译器重排序处理器重排序,为上层提供一致的内存可见性保证。
在这里插入图片描述

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

数据依赖性
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。
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(先发生于) 8种规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
  2. 监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
  3. volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。可见性volatile
  4. 传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。
  5. 线程启动/中断/终结;对象终结。
    注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。

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

同步八种操作

在这里插入图片描述

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

同步规则

如果把变量从主内存中复制到工作内存,就需要按顺序的执行read和load操作;
如果把变量从工作内存中同步回主内存,就要按顺序的执行store和write操作。
java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,不允许read和load、store和write操作之一单独出现

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

并发的优势与风险

在这里插入图片描述

补充

JMM

一种规范,Java Memory Model-JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。

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

在这里插入图片描述

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

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

硬件内存架构

硬件内存架构
CPU Registers(寄存器):是CPU内存的基础,CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器速度远大于主存。
CPU Cache Memory(高速缓存):由于计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高级缓存,来作为内存与处理器之间的缓冲。将运算时所使用到的数据复制到缓存中,让运算能快速的进行。当运算结束后,再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
RAM-Main Memory(主存/内存):计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比缓存和寄存器大很多。
当一个CPU需要读取主存的时候,他会将主存中的部分读取到CPU缓存中,甚至他可能将缓存中的部分内容读到他的内部寄存器里面,然后在寄存器中执行操作。当CPU需要将结果回写到主存的时候,他会将内部寄存器中的值刷新到缓存中,然后在某个时间点从缓存中刷回(flush)主存。

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

Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值