并发编程
# Java内存模型(JMM)
# JMM存在的必要性
- 简化多线程编程
- 不需要知道底层细节lock、mesi一致性协议、硬件缓存协议
- 保证程序可移植性
依赖于处理器本身的内存一致性模型,但不同的处理器可能差异很大
屏蔽掉不同操作系统中的内存差异性来保持并发的一致性
- 明确处理器层面,过于泛泛的内存模型定义,各个厂商硬件实现不一样
# JMM结构和工作原理
JMM不同于JVM内存区域模型
# 线程,工作内存,主内存工作交互图(JMM规范)
每个线程创建时 JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行
# 主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例 对象是成员变量还是方法中的本地变量(也称局部变量)
# 工作内存
- 工作内存是每个 线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必 须通过主内存来完成。
- 主要存储当前方法的所有本地变量信息,每个线程只能访问自己的工作内存,对其它线程是不可见的,所以存储在工作内存的数据不存在线程安全问题
- 将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝
# 主内存与工作内存的数据存储类型以及操作方式
- 方法中包含本地变量是基本数据类型,直接存储在工作内存的帧栈结构,而不放到主内存中
- 本地变量是引用类型,该引用存工作内存帧栈,对象实例将存储在主内存
- 成员变量,不管它是基本数据 类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到主内存
- 两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作 的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存
# Java内存模型与硬件内存架构的关系
JMM只是一种抽象的概念,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,也有可能存储到CPU缓存或者寄存器中
# 并发编程的可见性,原子性与有序性问题
3大原则:
- 原子性 一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不 会被其他线程影响。
- 可见性 当一个线程修改了某个共享变量 的值,其他线程是否能够马上得知这个修改的值 编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题
- 有序性
- 程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,但是最终执行的结果不变.
# JMM如何解决原子性&可见性&有序性问题
实现3大原则:
- 原子性问题 synchronized和Lock实现原子性
- 可见性问题
- synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
- volatile修改的值立即更新到主存中,同时清空其他线程工作内存的值
- UnsafeFactory.getUnsafe().storeFence(); //内存屏障
- Thread.yield(); // 释放时间片,上下文切换 加载上下文:flag=true
- System.out.println(count); // 内存屏障
- LockSupport.unpark(Thread.currentThread()); // 内存屏障
- Thread.sleep(1); //内存屏障
- 总结: Java中可见性如何保证? 方式归类有两种: jvm层面 storeLoad内存屏障 ; 上下文切换 Thread.yield();
- 有序性问题
- 可以通过volatile关键字来保证一定的“有序性”
- 通过线程同步synchronized和Lock来保证有序性
- 指令重排序
- 目的:: 为了最大限度的 发挥机器性能,适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性。
- 定义::: 只要程序的最终结果 与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
- as-if-serial语义 :: 不管怎么重排序,程序的执行结果不能被改变。
- happens-before 原则
- 多线程间切换
- 之前计算的值从哪来?? 线程切换之前会保存上下文, 再次运行时会还原上下文
- 如何保证从那一行接着执行?? PC计数器记录
# JMM数据同步
# 八大原子操作
- lock(锁定) 作用于主内存的变量,把一个变量标记为一条线程独占状态
- unlock(解锁) 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后 的变量才可以被其他线程锁定
- read(读取) 作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
- load(载入) 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用) 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- assign(赋值) 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
- store(存储) 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存 中,以便随后的write的操作
- write(写入) 作用于工作内存的变量,它把store操作从工作内存中的一个变量的值 传送到主内存的变量中
# 流程
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须连续执行。
# 同步规则分析
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 (load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行 assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重 复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个 变量之前需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去 unlock一个被其他线程锁定的变量。如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去 unlock一个被其他线程锁定的变量。 volatile就是加了lock,来保证可见性的
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write 操作)
# 临界区
一个程序运行多个线程本身是没有问题的。
当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的 私有栈中,因此不具有共享性,不会导致线程安全问题。
问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源
# 竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种手段可以达到目的:
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量cas
注意:
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
# volatile内存语义
# 作用
- volatile是Java虚拟机提供的轻量级的同步机制(也算是可见性吧)
- 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改 了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
- 禁止指令重排序优化。
# volatile的可见性
# volatile无法保证原子性
# 指令重排序
为了充分利用CPU,会通过流水线将指令并行进行。为了能并行执行,又需要将指令进行重排序以便进行并行执行,以确保时间局部性原理,来利用cpu缓存。
# volatile禁止重排优化
这也是单例双重检测必须加他的原因,不加他可能会出现获得的对象属性是空的问题...
# 内存语义的实现
- volatile写之前的操作不会被编译器重排序到volatile写之后
- volatile读之后的操作不会被编译器重排序到volatile读之前。
# 硬件层的内存屏障
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
- Memory Barrier的另外一个作用 是强制刷出各种CPU的缓存数据
# 参考资料
java内存屏障的原理与应用 (opens new window)
volatile实现内存可见性分析:字节码版本 (opens new window)
字节码: putstatic
volatile编译后JVM 会加上ACC_VOLATILE标识 putstatic字节码指令的解释器里面会使用C++的方法自带了编译器屏障的功能,总能拿到内存中的最新值。 在JVM 种通过acquire()方法实现内存屏障,最终还是反应到了内存屏障
关于java单例中使用使用volatile写双重检验锁的一点疑问? (opens new window)
instance = new Singleton()不是原子操作,这段代码可以简单分为下面三步执行:
为 instance 分配内存空间;
初始化 instance;
将 instance 指向分配的内存地址
由于但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 a 执行了 1 和 3,此时 线程 b 调用 getSingleton() 后发现 instance 不为空,因此返回 instance,但此时 instance 还未被初始化,所以就会导致空指针异常。