看完这篇,还不懂JAVA内存模型(JMM)算我输
欢迎关注专栏【JAVA并发】
更多技术干活尽在个人公众号——JAVA旭阳
前言
开篇一个例子,我看看都有谁会?如果不会的,或者不知道原理的,还是老老实实看完这篇文章吧。
@Slf4j(topic = "c.VolatileTest")
public class VolatileTest {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// do other things
}
// ?????? 这行会打印吗?
log.info("done .....");
});
t.start();
Thread.sleep(1000);
// 设置run = false
run = false;
}
}
main
函数中新开个线程根据标位run
循环,主线程中sleep
一秒,然后设置run=false
,大家认为会打印"done .......
"吗?
答案就是不会打印,为什么呢?
JAVA并发三大特性
我们先来解释下上面问题的原因,如下图所示,
现代的CPU架构基本有多级缓存机制,t线程会将run
加载到高速缓存中,然后主线程修改了主内存的值为false,导致缓存不一致,但是t线程依然是从工作内存中的高速缓存读取run
的值,最终无法跳出循环。
可见性
正如上面的例子,由于不做任何处理,一个线程能否立刻看到另外一个线程修改的共享变量值,我们称为"可见性"。
如果在并发程序中,不做任何处理,那么就会带来可见性问题,具体如何处理,见后文。
有序性
有序性是指程序按照代码的先后顺序执行。但是编译器或者处理器出于性能原因,改变程序语句的先后顺序,比如代码顺序"a=1; b=2;
",但是指令重排序后,有可能会变成"b=2;a=1
", 那么这样在并发情况下,会有问题吗?
在单线程情况下,指令重排序不会有任何影响。但是在并发情况下,可能会导致一些意想不到的bug。比如下面的例子:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程 A、B 同时调用 getInstance()
方法,正常情况下,他们都可以拿到instance
实例。
但往往bug就在一些极端的异常情况,比如new Singleton()
这个操作,实际会有下面3个步骤:
分配一块内存 M;
在内存 M 上初始化
Singleton
对象;然后 M 的地址赋值给
instance
变量。
现在发生指令重排序,顺序变为下面的方式:
分配一块内存 M;
将 M 的地址赋值给 instance 变量;
最后在内存 M 上初始化 Singleton 对象。
优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance()
方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance()
方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance
是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
这就是并发情况下,有序性带来的一个问题,这种情况又该如何处理呢?
当然,指令重排序并不会瞎排序,处理器在进行重排序时,必须要考虑指令之间的数据依赖性。
原子性
如上图所示,在多线程的情况下,CPU资源会在不同的线程间切换。那么这样也会导致意向不到的问题。
比如你认为的一行代码:count += 1
,实际上涉及了多条CPU指令:
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU 指令执行完。假设 count=0
,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1
的操作,但是得到的结果不是我们期望的 2,而是 1。
我们潜意识认为的这个count+=1
操作是一个不可分割的整体,就像一个原子一样,我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。但实际情况就是不做任何处理的话,在并发情况下CPU进行切换,导致出现原子性的问题,我们一般通过加锁解决,这个不是本文的重点。
Java内存模型真面目
前面讲解并发的三大特性,其中原子性问题可以通过加锁的方式解决,那么可见性和有序性有什么解决的方案呢?其实也很容易想到,可见性是因为缓存导致,有序性是因为编译优化指令重排序导致,那么是不是可以让程序员按需禁用缓存以及编译优化, 因为只有程序员知道什么情况下会出现问题 。 顺着这个思路,就提出了JAVA内存模型(JMM)规范。
Java 内存模型是 Java Memory Model(JMM)
,本身是一种抽象的概念,实际上并不存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
默认情况下,JMM中的内存机制如下:
- 系统存在一个主内存(
Main Memory
),Java 中所有变量都存储在主存中,对于所有线程都是共享的 - 每条线程都有自己的工作内存(
Working Memory
),工作内存中保存的是主存中某些变量的拷贝 - 线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量
- 线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成
同时,JMM规范了 JVM 如何提供按需禁用缓存和编译优化的方法,主要是通过volatile
、synchronized
和 final
三个关键字,那具体的规则是什么样的呢?
JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的。
Happens-Before规则
JMM本质上包含了一些规则,那这个规则就是大家有所耳闻的Happens-Before
规则,大家都理解了些规则吗?
Happens-Before
规则,可以简单理解为如果想要A线程发生在B线程前面,也就是B线程能够看到A线程,需要遵循6个原则。如果不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性。
1.程序的顺序性规则
在一个线程中,逻辑上书写在前面的操作先行发生于书写在后面的操作。
这个规则很好理解,同一个线程中他们是用的同一个工作缓存,是可见的,并且多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。
2. volatile
变量规则
指对一个 volatile
变量的写操作, Happens-Before
于后续对这个 volatile
变量的读操作。
怎么理解呢?比如线程A对volatile
变量进行写操作,那么线程B读取这个volatile
变量是可见的,就是说能够读取到最新的值。
3.传递性
这条规则是指如果 A Happens-Before B
,且 B Happens-Before C
,那么 A Happens-Before C
。
这个规则也比较容易理解,不展开讨论了。
- 锁的规则
这条规则是指对一个锁的解锁 Happens-Before
于后续对这个锁的加锁,这里的锁要是同一把锁, 而且用synchronized
或者ReentrantLock
都可以。
如下代码的例子:
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
- 假设 x 的初始值是 8,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁)
- 线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到
x==12
。
5.线程 start()
规则
主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
这个规则也很容易理解,线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before
于线程 B 中的任意操作。
6.线程 join()
规则
线程 A 中,调用线程 B 的 join()
并成功返回,那么线程 B 中的任意操作 Happens-Before
于该 join() 操作的返回。
使用JMM规则
我们现在已经基本讲清楚了JAVA内存模型规范,以及里面关键的Happens-Before
规则,那有啥用呢?回到前言的问题中,我们是不是可以使用目前学到的关于JMM的知识去解决这个问题。
方案一: 使用volatile
根据JMM的第2条规则,主线程写了volatile
修饰的run
变量,后面的t线程读取的时候就可以看到了。
方案二:使用锁
利用synchronized
锁的规则,主线程释放锁,那么后续t线程加锁就可以看到之前的内容了。
小结:
volatile
关键字
- 保证可见性
- 不保证原子性
- 保证有序性(禁止指令重排)
volatile
修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小。volatile
的性能远比加锁要好。
synchronized
关键字
- 保证可见性
- 不保证原子性
- 保证有序性
加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的。
线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中。
总结
本文讲解了JAVA并发的3大特性,可见性、有序性和原子性。从而引出了JAVA内存模型规范,这主要是为了解决并发情况下带来的可见性和有序性问题,主要就是定义了一些规则,需要我们程序员懂得这些规则,然后根据实际场景去使用,就是使用volatile
、synchronized
、final
关键字,主要final关键字也会让其他线程可见,并且保证有序性。那么具体他们底层的实现是什么,是如何保证可见和有序的,我们后面详细讲解。
如果本文对你有帮助的话,请留下一个赞吧
看完这篇,还不懂JAVA内存模型(JMM)算我输的更多相关文章
- 看完您如果还不明白 Kerberos 原理,算我输!
系统环境 操作系统:CentOS 6 或 CentOS 7 JDK 版本:1.8.0_151 Ambari 版本:2.6.1 HDP 版本:2.6.4.0 扩展链接 Kerberos原理--经典对话 ...
- 看完这篇还不懂Redis的RDB持久化,你们来打我!
一.为什么需要持久化 redis里有10gb数据,突然停电或者意外宕机了,再启动的时候10gb都没了?!所以需要持久化,宕机后再通过持久化文件将数据恢复. 二.优缺点 1.rdb文件 rdb文件都是二 ...
- 看完这篇还不懂 MySQL 主从复制,可以回家躺平了~
大家好,我是小羽. 我们在平时工作中,使用最多的数据库就是 MySQL 了,随着业务的增加,如果单单靠一台服务器的话,负载过重,就容易造成宕机. 这样我们保存在 MySQL 数据库的数据就会丢失,那么 ...
- 看完这篇还不会 GestureDetector 手势检测,我跪搓衣板!
引言 在 android 开发过程中,我们经常需要对一些手势,如:单击.双击.长按.滑动.缩放等,进行监测.这时也就引出了手势监测的概念,所谓的手势监测,说白了就是对于 GestureDetector ...
- 全面理解Java内存模型(JMM)及volatile关键字(转载)
关联文章: 深入理解Java类型信息(Class对象)与反射机制 深入理解Java枚举类型(enum) 深入理解Java注解类型(@Annotation) 深入理解Java类加载器(ClassLoad ...
- 全面理解Java内存模型(JMM)及volatile关键字(转)
原文地址:全面理解Java内存模型(JMM)及volatile关键字 关联文章: 深入理解Java类型信息(Class对象)与反射机制 深入理解Java枚举类型(enum) 深入理解Java注解类型( ...
- Java内存模型JMM 高并发原子性可见性有序性简介 多线程中篇(十)
JVM运行时内存结构回顾 在JVM相关的介绍中,有说到JAVA运行时的内存结构,简单回顾下 整体结构如下图所示,大致分为五大块 而对于方法区中的数据,是属于所有线程共享的数据结构 而对于虚拟机栈中数据 ...
- Java内存模型JMM与可见性
Java内存模型JMM与可见性 标签(空格分隔): java 1 何为JMM JMM:通俗地讲,就是描述Java中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这 ...
- 什么是Java内存模型(JMM)
什么是java内存模型 缓存一致性问题 在现代计算机中,因为CPU的运算速度远大于内存的读写速度,因此为了不让CPU在计算的时候因为实时读取内存数据而影响运算速度,CPU会加入一层缓存,在运算之前缓存 ...
- Java内存模型(JMM)详解
在Java JVM系列文章中有朋友问为什么要JVM,Java虚拟机不是已经帮我们处理好了么?同样,学习Java内存模型也有同样的问题,为什么要学习Java内存模型.它们的答案是一致的:能够让我们更好的 ...
随机推荐
- day44-反射03
Java反射03 3.通过反射获取类的结构信息 3.1java.lang.Class类 getName:获取全类名 getSimpleName:获取简单类名 getFields:获取所有public修 ...
- siteServer CMS知识点
1.结构说明 (1) 网站目录说明: a. 一个SitesServer后台只能建立一个主站,但可以建立多个子站,主站目录就是项目的根目录: b. 而子站的目录呢?是在主站目录下建立相应名称的目 ...
- windows下利用_popen,_wpoen创建管道进行系统命令输出数据
转载: https://blog.csdn.net/greless/article/details/72383762 参考: http://www.linuxidc.com/Linux/2011-04 ...
- 使用Java实现haskell-style的list
作为一个haskell这门函数式编程语言的爱好者,我特别喜欢它的list操作和推导功能.与传统面向对象或者过程语言不同的是,函数式语言通常喜欢把它们分为head.tail或者init.last等两部分 ...
- SpringBoot 自定义注解 实现多数据源
SpringBoot自定义注解实现多数据源 前置学习 需要了解 注解.Aop.SpringBoot整合Mybatis的使用. 数据准备 基础项目代码:https://gitee.com/J_look/ ...
- java 入土--集合详解
java 集合 集合是对象的容器,实现了对对象的常用的操作,类似数组功能. 和数组的区别: 数组长度固定,集合长度不固定 数组可以存储基本类型和引用类型,集合只能存储引用类型 使用时需要导入类 Col ...
- 8.-Django应用及分布式路由
一.应用 应用在Django项目中是一个独立的业务模块,可以包含自己的路由.视图.模版.模型,可以看成一个小的mtv 创建步骤 1.项目下用manage.py中的子命令创建应用文件夹 python3 ...
- SQL---ltrim()和rtrim()函数的使用
背景 去除字符串首尾空格大家肯定第一个想到trim()函数,不过在sqlserver中是没有这个函数的,却而代之的是ltrim()和rtrim()两个函数. 看到名字所有人都 知道做什么用的了,ltr ...
- 云原生之旅 - 4)基础设施即代码 使用 Terraform 创建 Kubernetes
前言 上一篇文章我们已经简单的入门Terraform, 本篇介绍如何使用Terraform在GCP和AWS 创建Kubernetes 资源. Kubernetes 在云原生时代的重要性不言而喻,等于这 ...
- day10-Tomcat02
Tomcat02 4.IDEA开发JavaWeb工程 4.1开发javaweb工程&配置Tomcat&启动项目 需求:使用idea开发javaweb工程fishWeb,并将网页部署到f ...