在解析JMM
之前,我们首先要明确,java并发编程说到底就是为了处理两个关键问题:
我们先简要概述一下,在彻底了解了java内存模型之后,我们可以往更深层次进行探究,那么开始:
synchronized
关键字和lock
锁或者volatile
关键字,我们需要在代码中明确写出该在哪里进行同步,从而让线程互斥执行java的并发采用内存模型,并且java的线程通信对我们程序员来说是透明的,在开发中可能会遇到各种问题,因此需要弄清楚java的内存模型
在《Java虚拟机规范》
中,定义了一种Java内存模型(Java Memory Model) 来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的访问效果,在JDK5
之后,Java内存模型才终于成熟完善
java内存模型的主要目的是:
我们这里说的变量和我们在程序代码里写的变量不同,这里的变量指实例变量,静态字段,和构成数组对象的元素
(文章后面提到的变量都值这里所说的变量),但是不包括局部变量和方法参数,因为前面的变量都是线程之间共享,存储在堆空间中(JKD8),被线程共享,而后面的变量都是线程私有的,jvm创建线程时会为每个线程创建一个栈
,也叫虚拟机栈
,这些变量存储在线程的局部变量表中,是线程私有的,有疑惑的同学请看我的这篇
Java内存模型规定了所有的变量都存储主内存,注意这里的主内存,是依附在物理上的内存,jvm
就运行在物理内存上(我的电脑是16G),所以这里的主内存就是虚拟机的一部分,除此之外,每条线程还有自己的工作内存(本地内存),工作内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化线程的,工作内存中保存了被该线程使用的在主内存中的变量副本,线程对变量的读取赋值等操作都必须在工作内存中进行,不能直接在主内存中进行读写,如图所示:
经过上面的介绍,我们可以模拟出两个线程通信的方式,线程A和线程B通信:
将上面两步更细致的划分,就可以探索接下来的一部分: 主内存和工作内存的交互协议,这个协议将主内存与工作内存的变量交互定义为了以下8中操作:
我们通过一张图来详细描述这个过程,包括手写的lock和unlock:
Java内存模型对这八种操作制定了以下规则:
需要注意的是:read和load ,store和write必须按照顺序来,但不要求连续,也就是这两组操作可以插入其他指令,比如主内存中有a,b两个变量,可以是这种顺序:read a,read b,load b,load a;
在2.3中通过图示描述了工作内存和主内存是如何进行变量交互的,这种模式下很可能出现问题:
比如主内存存在一个变量num = 1
,线程A对其操作,复制其副本到工作内存,此时线程B也对num
进行操作比如num = 2
,并且成功,但此时A线程还是原先num
的值,这就出现了问题,导致线程不安全,这时候Java虚拟机提供了解决方案:提供了volatile
来解决,它是一个轻量级的同步机制
,在进行volatile
详细讲解前,先通俗的说一下它的作用:
write
进主内存,然后B再修改,而且无法保证安全性这里需要提出一个问题:虽然被volatile
修饰的变量对所有线程可见,对volatile
变量的修改都能立刻反应到其他线程中,但是却无法保证基于volatile
变量的运算在并发条件下是安全的,怎么理解这句话呢,我们通过一个例子来说明:
public class Demo {
private volatile static int num = 0;
public static void add(){
num++;
}
public static void main(String[] args) {
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 0; j < 1000 ; j++) {
add();
}
}).start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num);
}
}
输出结果:main 19210
我们这段程序的目的是开通20个线程对同一资源进行每个线程1000次的自增操作,按道理结果应该是20000
,为什么结果会变小?答案是volatile
虽然保证了变量在程序间的可见性,但是并不能保证该变量的运算的原子性,要解决这个问题可以使用另外更加重量级的同步机制: synchronized
或者lock
,我们来看一下为什么num++
为什么不是一个原子性操作,我们反编译刚才的代码,我们取出add()
方法的字节码文件:
public static void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field num:I
3: iconst_1
4: iadd
5: putstatic #2 // Field num:I
8: return
LineNumberTable:
line 13: 0
line 14: 8
我们可以清楚的看到一个++操作对应的是5行字节码命令,我们可以使用原子类操作来进行代替++操作,对原子类不了解的同学可以看一下:
JUC并发-CAS原子性操作和ABA问题及解决
最后总结一句话:volatile
是一个轻量级的同步机制
,保证可见性不保证原子性,可以用同步方法或者原子类来解决原子性问题
并发的三个特征分别是原子性,可见性,有序,而Java内存模型就是围绕着在并发过程中如何处理这三个特征来建立的,本来这些概念不需要再说的,但在了解了java内存模型止后再来看确实有更好的理解:
原子性是指不可再分的最小操作指令,即单条机器指令,原子性操作任意时刻只能有一个线程,因此是线程安全的。
Java内存模型中通过read、load、assign、use、store和write这6个操作保证变量的原子性操作。
long和double这两个64位长度的数据类型java虚拟机并没有强制规定他们的read、load、store和write操作的原子性,即所谓的非原子性协定,但是目前的各种商业java虚拟机都把long和double数据类型的4中非原子性协定操作实现为原子性。所以java中基本数据类型的访问读写是原子性操作。
对于大范围的原子性保证需要通过lock和unlock操作以及synchronized同步块来保证。
可见性是指当一个线程修改了共享变量的值,其他线程可以立即得知这个修改。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。Java中通过volatile
、final
和synchronized
这三个关键字保证可见性:
final
文章后部分会有专门讨论有序性是指:在线程内部,所有的操作都是有序执行的,而在线程之间,因为工作内存和主内存同步的延迟,操作是乱序执行的。Java通过volatile和synchronized关键字确保线程之间操作的有序性。
注意:
synchronized
都满足以上三个特征,看似是一种万能的解决方案,但是注意使用它的时候会出现性能问题
前面提到了volatile禁止指令重排序优化实现有序性。
什么是指令重排,简单的来说:你写的程序,计算机并不是按照你写的那样去执行的。
在java代码执行的时候,编译器和处理器常常会对指令做重排序,这些排序可分为三种类型:
它们之间的顺序:
关于指令重排,我们知道有这个概念就行了,也无法深入,涉及到计算机底层,来看一段代码:
int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4
这些语句我们希望的执行顺序是从上到下依次执行,但是在经过一系列指令重排之后,它的顺序可能是1324或者2134,但是对最终的结果没有影响,但在多线程条件下,指令重排可能会导致一些问题:
出现问题的原因是:由于两个线程中的代码没有数据依赖关系
,所以在经过指令重排过后,代码顺序发生改变,导致最终结果也发生改变
数据依赖分为以下三个类型
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a=1;b=1 | 写一个变量后,再读这个变量 |
写后写 | a=1;a=2 | 写一个变量后,再写这个变量 |
读后写 | a=b;b=1; | 读一个变量后,再写这个变量 |
针对以上三种类型,重排序必定会导致结果发生变化
Happens-Before
是Java内存模型中一个非常重要的概念,happens-before是判断数据是否存在竞争、线程是否安全的重要依据,想要一个操作执行的结果需要对另一个操作可见,那么你们可以使用 happens-before 规则,我们先来看一段代码:
i = 1//线程A中执行
j = i//线程B中执行
i = 2//线程C中执行
在解释这段代码前,我们要对先行发生原则(Happens-Before)
有一个大概的了解:
Happens-Before
关系,那么第一个操作将对第二个操作可见,且第一个操作的顺序要在第二个操作之前我们回到代码,假如线程A和线程B存在Happens-Before
关系,那么A的操作i = 1就先发生于B的j = i,于是我们就可以确定j的值为1,这个时候来了一个线程C,而线程C的操作有不确定性,在A和B的先行关系不变的情况下,假如C在A和B之间发生,这时候B执行操作完了,其j的值是多少?1和2都有可能,因为B和C没有确定先行发生规则,这就不具备多线程的安全性
上面是我们假设的一种情况,这里列举几个常见的Java“天然的”happens-before关系,这些关系没有任何同步器协助就已经存在,可以直接在编码中使用
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作(也就是说你写的操作,如果是单线程执行,那么前面的操作[程序逻辑上的前]就会happens-before于后面的操作)
监视器锁规则: 一个unlock操作先行发生于后面对同一个锁的lock操作,是针对同一个锁,后面是针对时间上的概念
volatile变量规则: 对一个 volatile域的写操作,先行发生于于任意后续对这个volatile域的读操作
传递性:如果 A happens-before B,且 B happens-before C,那么A happens-before C
线程start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens before 线程B中的操作。
线程join()规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
Java语言无需任何同步手段保障就能成立的先行发生规则就只有上面这些,这里需要多提一下happend-before和指令重排序的关系,但是我们之前也提到了,指令重排序可能会使程序结果发生改变,虽然这个几率很小,而volatile
可以避免指令重排序,而volatile
是满足写后读的happens-before
规则,那么volatile是怎么做到的避免指令重排呢?
给大家画个图:
本文参考书籍:《深入理解Java虚拟机》《Java并发编程的艺术》