基础篇:深入JMM内存模型解析volatile、synchronized的内存语义
目录
先介绍下多进程多线程在linux几种通信方式
- 管道:管道的实质是一个内核缓冲区,需要通信的两个进程各在管道的两端,进程利用管道传递信息
- 信号:信号是软件层次上对中断机制的一种模拟,进程不必阻塞等待信号的到达,信号可以在用户空间进程和内核之间直接交互
- 消息队列:消息队列是消息的链表,存放在内存中并由消息队列标识符标识,允许多个进程向它写入与读取消息
- 共享内存:多个进程可以可以直接读写同一块内存空间,是针对其他通信机制运行效率较低而设计的
- 信号量:信号量实质上就是一个标识可用资源数量的计数器,它的值总是非负整数
- 套接字:套接字可用于不同机器之间的进程间通信。有两种类型的套接字:基于文件的和面向网络(socket)
java设计上则是基于共享内存来实现进程,线程的通信
1 java内存模型,JMM(JAVA Memory Model)
- 1.1 线程A需要和线程B交互,则需要更新工作内存的共享变量副本到主存,然后线程B去主存读取更新后的变量
- 1.2 java线程之间的通信是由JMM控制的,JMM决定线程对共享变量的写入何时对另一线程可见。共享变量存在主存,线程拥有自己的工作内存(一个抽象的概念,它覆盖了缓存,写缓冲区,寄存器等)
2 CPU高速缓存、MESI协议
处理器的高速发展,CPU的性能和内存性能差距拉大,为了解决问题,CPU设置多级缓存,例如L1、L2、L3高速缓存(Cache)。
和JMM的内存布局相似,前者是系统级别,解决缓存一致性问题;后者是应用级别的,解决的是内存一致性问题
这些高速缓存一般都是独属于CPU内部的,对其他CPU不可见,此时又会出现缓存和主存的数据不一致现象,CPU的解决方案有两种
- 总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性
- 缓存一致性协议(MESI):缓存一致性协议也叫缓存锁定,缓存一致性协议会阻止两个以上CPU同时修改映射相同主存数据的缓存副本
MESI实现是依靠处理器使用嗅探技术保证它的内部缓存、系统主内存和其他处理器的缓存的数据在总线上保持一致
例:处理器打算回写脏内存地址,而此内存处于共享状态(Share);那么其他处理器会嗅探到,并将使自身的对应的缓存行无效,在下次访问相应内存地址时,刷新该缓存行
缓存数据状态有如下四种(MESI):
缓存状态 描述 M(Modifed) 在缓存行中被标记为Modified的值,与主存的值不同,这个值将会在它被其他CPU读取之前写入内存,并设置为Shared E(Exclusive) 该缓存行对应的主存内容只被该CPU缓存,值和主存一致,被其他CPU读取时置为Shared,被其他CPU写时置为Modified S(Share) 该值也可能存在其他CPU缓存中,但是它的值和主存一致 I(Invalid) 该缓存行数据无效,需要时需重新从主存载入
3 指令重排序和内存屏障指令
- 为提高程序性能,编译器和处理器经常会对指令做重排序,分别是编译器优化的重排序、指令并行级别的重排序,内存系统的重排序。
- 指令并行重排序和内存系统重排序归为处理器重排序
- 编译器级优化重排序,可由JMM规则禁止特定类型的指令重排;对于处理器级别重排序则是插入特定类型的内存屏障指令,以此禁止特定类型的重排序
- CPU的设计者提供内存屏障机制,是将对共享变量读写的高速缓存的强一致性控制权(MESI的机制)交给了程序员或编译器
- 这里介绍两种处理器级别的内存屏障指令
- 写内存屏障:该屏障之前的写操作先于之后的写操作;在指令后插入StoreBarrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
- 读内存屏障:该屏障之前的读操作先于之后的读操作;在指令前插入LoadBarrier,让高速缓存中的数据失效,强制从主内存加载数据
- 内存屏障有两个作用:阻止屏障两侧的指令重排序;强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
- JAVA的内存屏障指令,基本可以理解为在CPU内存屏障指令上二次封装
JAVA内存屏障指令 | 作用描述 |
---|---|
Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存),先于Store2及所有后续存储指令的存储。 |
Load1;LoadStore;Store2 | 确保Load1数据装载先于Store2及所有后续存储指令的存储。 |
Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载。 |
Load1;LoadLoad;Load2 | 确保Load1数据的装载,先于Load2及所有后续装载指令的装载。 |
- 特殊的是StoreLoad,会使该屏障之前的所有内存访问指令(装载和存储指令)完成之后,才执行该屏障之后的内存访问指令;是一个”全能型”的屏障,它同时具有其他三个屏障的效果
- 用一句话描述java内存屏障的目的:把当前工作内存的数据全部刷新到主内存,并且其他工作内存的共享变量全部失效,真正需要用时再读取主存最新的值
4 happen-before原则
- 内存屏障是相对于jvm,cpu级别的内存一致性(内存可见性)的解决方案;为了让java程序员更容易理解,jsr-133使用happens-before的概念来说明不同操作之间的内存可见性
- 程序次序规则:同一个线程,任意一操作happens-before同线程之后的全部操作
- 监视器锁(synchronized)规则:对一个监视器锁的解锁,happens-before随后对这个锁的加锁
- volatile变量规则:对volatile变量的写操作,happens-before该volatile变量之后的任意读操作
- 传递性:如果A先于B;B先于C;则A先于C
- happens-before部分规则是基于内存屏障实现的
5 synchronized内存语义
class Count{
int a = 0;
public synchronized void writer(){// 1
a++; //2
} //3
public synchronized void reader(){// 4
int i = a; //5
} //6
}
- 根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens- before 6。 根据监视器锁规则,3 happens-before 4。根据happens-before的传递性得 2 happens-before 5。执行结果如下图
- 线程释放锁时内存语义:JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
- 线程获取锁时内存语义:JMM会把该线程对应的工作内存置为无效
6 volatile的内存语义
- volatile变量具有可见性,Java线程内存模型确保所有线程看到这个变量的值是最新的,并且单个volatile变量的读/写具有原子性;java编译器对volatile变量处理如下
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的前面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
- 注意i++是复合操作,即使 i 是volatile变量,也不保证i++是原子操作
volatile Object instance;
instance = new Object();
//相应汇编代码
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
- 当volatile变量修饰的共享变量进行写操作的反汇编代码会出现
0x01a3de24: lock addl $0×0,(%esp)
,其实就是插入了内存屏障导致的结果,lock表示volatile变量写时被缓存锁定了(MESI协议),作用如下- 禁止指令重排序
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
int a = 0; volatile boolean v = false; 线程A
a = 1; //1
v = true; //2 线程B
v = true; //3
System.out.println(a);//4
- 根据程序次序规则,1 happens-before 2;3 happens-before 4。根据volatile变量规则,2 happens-before 3。 根据happens-before的传递性规则,1 happens-before 4。程序的执行结果表现如下图
- volatile写的内存语义:写volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
- volatile读的内存语义:读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
- 非基本字段不应该用volatile修饰。其原因是volatile修饰对象或数组时,只能保证他们的引用地址的可见性
7 final内存语义
- final写内存语义:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。保障对象被引用之前,fianl域里的变量都是被初始化的
- 实现原理:编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
public class Example {
int i; //普通类型
final int j; // 引用类型
public Example () { // 构造函数
i = 0; j = 1;
}
public static void writer () { // 写线程A执行
obj = new Example ();
}
public static void reader () { // 读线程B执行
Example object = obj; // 读对象引用
int a = object.i; // 读普通域
int b = object.j; // 读final域
}
}- final只会禁止对其修饰变量的写操作,被重排序到构造函数之外;普通变量 i 的赋值可能会被重排到序构造函数之外
- A线程创建obj,可能让线程B拿到初始化一半的obj;final变量 j 被初始化,而普通变量 i 还没初始化
- 疑问:内存屏障不是会禁止指令重排吗?个人猜想应该是编译器先重排序,此时普通变量已经在构造器外了,再根据final类型插入内存屏障。上面的代码执行可能有如下情况:
- final读内存语义
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
- 实现原理:要求编译器在读final域的操作前面插入一个LoadLoad屏障
- 当使用final修饰引用对象或者数组时,final只保证在构造器返回之前对引用对象的操作先于构造器返回之后的操作
public class Example {
final int[] intArray; // intArray 是引用类型
public Example () { // 构造函数
intArray = new int[1];
intArray[0] = 1; //此操作对获取该对象引用的线程是可见的
}
}
8 synchronized,volatile内存语义的原理梳理
9 应用题:延迟加载双重锁定是否真的安全
public class Instance { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (Instance.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
代码第7行instance=new Singleton();
创建了一个对象。这一行代码可以分解为如下的3行伪代码
memory = allocate(); // A1:分配对象的内存空间
ctorInstance(memory); // A2:初始化对象
instance = memory; // A3:设置instance指向刚分配的内存地址
假如2和3之间重排序之后的顺序如下
memory = allocate(); // A1:分配对象的内存空间
instance = memory; //A3:instance指向刚分配的内存地址,此时对象还没有被初始化
ctorInstance(memory); // A2:初始化对象
- 假如发生A3、A2重排序,线程是不保障赋值和初始化对象两步骤操作结果会一起同步到主存
- 因此第二个线程执行到
if (instance == null);// 4:第一次检查
时,可能会得到一个刚分配的内存而没初始化的对象(此时没有加锁,锁的happens-before规则不适用) - 相应的两个解决方法
- 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:
private volatile static Instance instance;
- 使用类加载器的全局锁,在执行类的初始化期间,JVM会去获取一个锁;这个锁可以同步多个线程对同一个类的初始化,每个线程都会试图获取该类的全局锁去初始化类
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
// 这里将导致InstanceHolder类被初始化
return InstanceHolder.instance ;
}
} - 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:
10 题外话:伪共享(false sharing)
- 伪共享
- 前面介绍到每个CPU都有属于自己的高速缓存,但是缓存数据大小是怎样的呢?
- 这个大小并不是我们需求存多大就存多大的,而是一个固定的大小-64字节,缓存的加载更新都是以连续的64字节内存为单位,称之为缓存行
- 一缓存行是可以存在多个变量的,比如long类型(64位==8字节),可以存入8个
- 假如变量A和变量B是在同一连续的内存,CPU缓存加载A时,B也会被读取;反之亦然,A的脏回写导致在其他CPU相应内存失效的同时,同一缓存行的B内存也被标识为Modified(同舟共渡,一起翻船)
- 设想变量A和B没有关联,却刚好在同一缓存行;然后A被CPU-X处理,B被CPU-Y处理;因为CPU-X对A的缓存更新而导致B的缓存失效;CPU-Y要处理B,则要读取更新后的缓存行(B实际是没被更新),造成没必要的内存读取开销。这就是伪共享
- 伪共享的解决方法:
1- 填充字节,将对应的变量填充到缓存行的大小。如下面定义的类,声明额外的属性public final static class FilledLong {
/**value 加 p1 - p6;加对象头8个字节正好等于一缓存行的大小 */
//markWord + klass (32位机,64位是16字节) 8字节
public volatile long value = 0L; // 8字节
public long p1, p2, p3, p4, p5, p6; //48字节
}2- 使用jdk的注解@Contended修饰变量,jvm会自动将变量填充到缓存行的大小。注意的是需要加入启动参数 -XX:-RestrictContended
关注公众号,一起交流
参考文章
- java并发编程的艺术(书籍)
- Linux进程间通信的几种方式
- java内存屏障
- 搞懂内存屏障-指令与JMM
- 杂谈什么是伪共享
基础篇:深入JMM内存模型解析volatile、synchronized的内存语义的更多相关文章
- JVM内存模型、指令重排、内存屏障概念解析
在高并发模型中,无是面对物理机SMP系统模型,还是面对像JVM的虚拟机多线程并发内存模型,指令重排(编译器.运行时)和内存屏障都是非常重要的概念,因此,搞清楚这些概念和原理很重要.否则,你很难搞清楚哪 ...
- JVM内存模型、指令重排、内存屏障概念解析(转载)
在高并发模型中,无是面对物理机SMP系统模型,还是面对像JVM的虚拟机多线程并发内存模型,指令重排(编译器.运行时)和内存屏障都是非常重要的概念,因此,搞清楚这些概念和原理很重要.否则,你很难搞清楚哪 ...
- Java并发编程:JMM(Java内存模型)和volatile
1. 并发编程的3个概念 并发编程时,要想并发程序正确地执行,必须要保证原子性.可见性和有序性.只要有一个没有被保证,就有可能会导致程序运行不正确. 1.1. 原子性 原子性:即一个或多个操作要么全部 ...
- 全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判.如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 i ...
- Java内存模型与Volatile,Happen-Before原则等
Java的内存模型 Java内存模型(JMM)是一个抽象的模型.决定了线程主要定义了线程和内存间的抽象关系:主内存存放的是线程共享变量,每个线程有自己的工作内存,存放变量的副本,只能对副本进行读写, ...
- Java内存模型:volatile详解
详见:http://blog.yemou.net/article/query/info/tytfjhfascvhzxcyt202 Java内存模型:volatile是干什么用的Volatile字段是用 ...
- Java内存模型与volatile关键字
Java内存模型与volatile关键字 一).并发程序开发 并行程序的开发要涉及多线程.多任务间的协作和数据共享问题. 常用的并发控制:内部锁.重入锁.读写锁.信号量. 二).线程的特点 线程的特点 ...
- JMM 内存模型 与 volatile 关键字
内存模型 线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory). 本地内存中存储了该线程以读/写共享变量的副本. 不同线程之间无法相互 ...
- Java并发编程、内存模型与Volatile
http://www.importnew.com/24082.html volatile关键字 http://www.importnew.com/16142.html ConcurrentHash ...
随机推荐
- Java数据结构——2-3树
定义2-3树是平衡的3路查找树,其中2(2-node)是指拥有两个分支的节点,3(3-node)是指拥有三个分支的节点.B-树是一种平衡的多路查找树,2-3树属于b-树,其也同样具有B-树的性质,如m ...
- 7. Jackson用树模型处理JSON是必备技能,不信你看
每棵大树,都曾只是一粒种子.本文已被 https://www.yourbatman.cn 收录,里面一并有Spring技术栈.MyBatis.JVM.中间件等小而美的专栏供以免费学习.关注公众号[BA ...
- rbac表 ( 5张 )
/* Navicat Premium Data Transfer Source Server : 本地连接 Source Server Type : MySQL Source Server Versi ...
- CF1349A Orac and LCM 题解
题意分析 给出$n$个数,求这$n$个数两两的最小公倍数的最大公约数 思路分析 通过分析样例可以发现,如果要成为这$n$个数两两的最小公倍数的公约数,至少要是这$n$个数中$n-1$个数的约数,否则就 ...
- 阿里面试竟如此轻松,2招带你过关斩将拿下offer
在找工作之前首先是要认清一个问题,虽然这个问题比较俗,但是很现实,就是为什么追求高工资? 这个问题我想不用说大家心里也清楚.大部分人都不是当前城市的本地人,说好听了叫来上班,说的不好听其实叫“外来务工 ...
- Docker 搭建 Redis Cluster 集群环境
使用 Docker 搭建 Redis Cluster,最重要的环节就是容器通信的问题,这一块我们在之前的文章中已经给大家解决了<Docker 网络模式详解及容器间网络通信>,本篇文章主要练 ...
- 20190923-04Linux用户管理命令 000 012
useradd 添加新用户 1.基本语法 useradd 用户名 (功能描述:添加新用户) useradd -g 组名 用户名 (功能描述:添加新用户到某个组) 2.案例实操 (1)添加一个用户 [ ...
- 营销经验总结:如何才能提升h5游戏代入感?
HTML5游戏拥有即点即玩,无需下载,并具备传播性广的特点,这就使得商家看到了无限商机,如何让产品更加深入人心,是游戏推广最为重要的环节.优秀的代入感才是游戏产品宣传的关键,那么有哪些要素的支撑才能确 ...
- poi自动生成Ecxel表格和Chart图表
最近因为业务需求,需要做poi自动导出Ecxel表格和Chart折线图的功能. 所以我在网上找到了一篇关于poi生成Chart图表的博客,代码很详细,但是缺少相关注释说明. 想要将它改造成自己需要的样 ...
- python 模块安装导入
一.定义 python模块就是一个.py文件,一个模块中可以有多个函数,在使用模块时,只需要import下,就可以使用模块中的函数功能.import模块的过程相当于把这个py文件中的所有内容都执行一遍 ...