Java教程

Java架构师必备技术:Java并发编程之JMM-&-volatile详解

本文主要是介绍Java架构师必备技术:Java并发编程之JMM-&-volatile详解,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

(2)再读取L1,如果存在则先把cache行锁住,把数据读取出来,然后解锁

(3)如果L1没有则读取L2,如果存在则先将L2中的cache行加锁,然后将数据拷贝到L1,再执行读L1的过程,最后解锁

(4)如果L2没有则读取L3,同上先加锁,再往上层依次拷贝、加锁,读取到之后依次解锁

(5)如果L3也没有数据则通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

  • 缓存一致性问题:

在多处理器系统中,每个处理器都有自己的缓存,于是也引入了新的问题:缓存一致性。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI等等。

1.3 MESI缓存一致性协议

缓存一致性协议中应用最广泛的就是MESI协议。主要原理是 CPU 通过总线嗅探机制(监听)可以感知数据的变化从而将自己的缓存里的数据失效,缓存行中具体的几种状态如下:

以上图为例,假设主内存中有一个变量x=1,CPU1和CPU2中都会读写,MESI的工作流程为:

(1)假设CPU1需要读取x的值,此时CPU1从主内存中读取到缓存行后的状态为E,代表只有当前缓存中独占数据,并利用CPU嗅探机制监听总线中是否有其他缓存读取x的操作。

(2)此时如果CPU2也需要读取x的值到缓存行,则在CPU2中缓存行的状态为S,表示多个缓存中共享,同时CPU1由于嗅探到CPU2也缓存了x所以状态也变成了S。并且CPU1和CPU2会同时嗅探是否有另缓存失效获取独占缓存的操作。

(3)当CPU1有写入操作需要修改x的值时,CPU1中缓存行的状态变成了M。

(4)CPU2由于嗅探到了CPU1的修改操作,则会将CPU2中缓存的状态变成 I 无效状态。

(5)此时CPU1中缓存行的状态重新变回独占E的状态,CPU2要想读取x的值的话需要重新从主内存中读取。

二、JMM模型

2.1  Java 线程与系统内核的关系

Java线程在JDK1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现。因此,在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪种线程模型来实现。

![](https://upload-images.jianshu.io/upload_images/22932333-af9a9a025b96cd7e.png?imageMogr

《一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》

【docs.qq.com/doc/DSmxTbFJ1cmN1R2dB】 完整内容开源分享

2/auto-orient/strip%7CimageView2/2/w/1240)

**用户线程:**指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态/核心态切换,速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。

**内核线程: **线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。

基于线程的区别,我们可以引出java内存模型的结构。

2.2  什么是 JMM 模型

Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,从某个程度上讲应该包括了JVM中的堆和方法区。多条线程对同一个变量进行访问可能会发生线程安全问题。

工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。所以则应该包括JVM中的程序计数器、虚拟机栈以及本地方法栈。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

2.3 JMM 详解

需要注意的是JMM只是一种抽象的概念,一组规范,并不实际存在。对于真正的计算机硬件来说,计算机内存只有寄存器、缓存内存、主内存的概念。不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

工作内存同步到主内存之间的实现细节,JMM定义了以下八种操作:

如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

  • 同步规则分析

(1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

(2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。

(3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。

(4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。

(5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

(6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

2.4 JMM 如何解决多线程并发引起的问题

多线程并发下存在:原子性、可见性、有序性三种问题。

  • 原子性:

**问题:**原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。但是当线程运行的过程中,由于CPU上下文的切换,则线程内的多个操作并不能保证是保持原子执行。

**解决:**除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。

  • 可见性

**问题:**之前我们分析过,程序运行的过程中是分工作内存和主内存,工作内存将主内存中的变量拷贝到副本中缓存,假如两个线程同时拷贝一个变量,但是当其中一个线程修改该值,另一个线程是不可见的,这种工作内存和主内存之间的数据同步延迟就会造成可见性问题。另外由于指令重排也会造成可见性的问题。

**解决:**volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。

有序性

**问题:**在单线程下我们认为程序是顺序执行的,但是多线程环境下程序被编译成机器码的后可能会出现指令重排的现象,重排后的指令与原指令未必一致,则可能会造成程序结果与预期的不同。

**解决:**在Java里面,可以通过volatile关键字来保证一定的有序性。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

三、volatile关键字

3.1 volatile 的作用

volatile是 Java 虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用:

  • 保证被volatile修饰的共享变量对所有线程总数可见,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知
  • 禁止指令重排序优化

3.2 volatile 保证可见性

以下是一段多线程场景下存在可见性问题的程序。

public class VolatileTest extends Thread {
private int index = 0;
private boolean flag = false;

@Override
public void run() {
while (!flag) {
index++;
}
}

public static void main(String[] args) throws Exception {
VolatileTest volatileTest = new VolatileTest();
volatileTest.start();

Thread.sleep(1000);

// 模拟多次写入,并触发JIT
for (int i = 0; i < 10000000; i++) {
volatileTest.flag = true;
}
System.out.println(volatileTest.index);
}
}

运行可以发现,当 volatileTest.index 输出打印之后程序仍然未停止,表示线程依然处于运行状态,子线程读取到的flag的值仍为false。

private volatile boolean flag = false;

尝试给flag增加volatile关键字后程序可以正常结束, 则表示子线程读取到的flag值为更新后的true。

那么为什么volatile可以保证可见性呢?

可以尝试在JDK中下载hsdis-amd64.dll后使用参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 运行程序,可以看到程序被翻译后的汇编指令,发现增加volatile关键字后给flag赋值时汇编指令多了一段 “lock addl $0x0,(%rsp)”

说明volatile保证了可见性正是这段lock指令起到的作用,查阅IA-32手册,可以得知该指令的主要作用:

  • 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。
  • lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据。
  • 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序。

3.3 volatile 禁止指令重排

Java 语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

以下是源代码到最终执行的指令集的示例图:

as-if-serial原则:不管怎么重排序,单线程程序下编译器和处理器不能对存在数据依赖关系的操作做重排序。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

下面是一段经典的发生指令重排导致结果预期不符的例子:

public class VolatileTest {

int a, b, x, y;

public boolean test() throws InterruptedException {
a = b = 0;
x = y = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();

if (x == 0 && y == 0) {
return true;
} else {
return false;
}
}

public static void main(String[] args) throws InterruptedException {
for (int i = 0; ; i++) {
VolatileTest volatileTest = new VolatileTest();
if (volatileTest.test()) {
System.out.println(i);
break;
}
}
}
}

按照我们正常的逻辑理解,在不出现指令重排的情况下,x、y永远只会有下面三种情况,不会出现都为0,即循环永远不会退出。

  1. x = 1、y = 1
  2. x = 1、y = 0
  3. x = 0、y = 1

但是当我们运行的时候会发现一段时间之后循环就会退出,即出现了x、y都为0的情况,则是因为出现了指令重排,时线程内的对象赋值顺序发生了变化。

而这个问题给参数增加volatile关键字即可以解决,此处是因为JMM针对重排序问题限制了规则表。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。一个读的操作为load,写的操作为store。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

以上图为例,普通写与volatile写之间会插入一个StoreStore屏障,另外有一点需要注意的是,volatile写后面可能有的volatile读/写操作重排序,因为编译器常常无法准确判断是否需要插入StoreLoad屏障。

则JMM采用了比较保守的策略:在每个volatile写的后面插入一个StoreLoad屏障。

  • 在每个volatile读操作的后面插入一个LoadStore屏障。

[外链图片转存中…(img-0bTIlI3y-1640919603408)]

以上图为例,普通写与volatile写之间会插入一个StoreStore屏障,另外有一点需要注意的是,volatile写后面可能有的volatile读/写操作重排序,因为编译器常常无法准确判断是否需要插入StoreLoad屏障。

则JMM采用了比较保守的策略:在每个volatile写的后面插入一个StoreLoad屏障。

这篇关于Java架构师必备技术:Java并发编程之JMM-&-volatile详解的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!