2024年最新Java架构师必备技术:Java并发编程之JMM-&-volatile详解,文末领取面试资料

最后

针对最近很多人都在面试,我这边也整理了相当多的面试专题资料,也有其他大厂的面经。希望可以帮助到大家。

image

上述的面试题答案都整理成文档笔记。 也还整理了一些面试资料&最新2021收集的一些大厂的面试真题(都整理成文档,小部分截图)

image

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。

本文已被CODING开源项目:【一线大厂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屏障。

那么存汇编指令的角度,CPU是怎么识别到不同的内存屏障的呢:

**(1)sfence:**实现Store Barrior 会将store buffer中缓存的修改刷入L1 cache中,使得其他cpu核可以观察到这些修改,而且之后的写操作不会被调度到之前,即sfence之前的写操作一定在sfence完成且全局可见。

**(2)lfence:**实现Load Barrior 会将invalidate queue失效,强制读取入L1 cache中,而且lfence之后的读操作不会被调度到之前,即lfence之前的读操作一定在lfence完成(并未规定全局可见性)。

**(3)mfence:**实现Full Barrior 同时刷新store buffer和invalidate queue,保证了mfence前后的读写操作的顺序,同时要求mfence之后写操作结果全局可见之前,mfence之前写操作结果全局可见。

(4)lock:用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果。

所以可以发现我们上述分析到的"lock addl"指令也是可以实现内存屏障效果的。

四、volatile 拓展

4.1 滥用 volatile 的危害

经过上述的总结我们可以知道volatile的实现是根据MESI缓存一致性协议实现的,而这里会用到CPU的嗅探机制,需要不断对总线进行内存嗅探,大量的交互会导致总线带宽达到峰值。因此滥用volatile可能会引起总线风暴,除了volatile之外大量的CAS操作也可能会引发这个问题。所以我们使用过程中要视情况而定,适当的场景下可以加锁来保证线程安全。

4.2 如何不用 volatile 不加锁禁止指令重排?

指令重排的示例中我们既然已经知道了插入内存屏障可以解决重排问题,那么用什么方式可以手动插入内存屏障呢?

JDK1.8之后可以在Unsafe魔术类中发现新增了插入屏障的方法。

/**

  • Ensures lack of reordering of loads before the fence
  • with loads or stores after the fence.
  • @since 1.8
    */
    public native void loadFence();

/**

  • Ensures lack of reordering of stores before the fence
  • with loads or stores after the fence.
  • @since 1.8
    */
    public native void storeFence();

/**

  • Ensures lack of reordering of loads or stores before the fence
  • with loads or stores after the fence.
  • @since 1.8
    */
    public native void fullFence();

(1)loadFence()表示该方法之前的所有load操作在内存屏障之前完成。

(2)storeFence()表示该方法之前的所有store操作在内存屏障之前完成。

(3)fullFence()表示该方法之前的所有load、store操作在内存屏障之前完成。

可以看到这三个方法正式对应了CPU插入内存屏障的三个指令lfence、sfence、mfence。

因此我们如果想手动添加内存屏障的话,可以用Unsafe的这三个native方法完成,另外由于Unsafe必须由bootstrap类加载器加载,所以我们想使用的话需要用反射的方式拿到实例对象。

分享

首先分享一份学习大纲,内容较多,涵盖了互联网行业所有的流行以及核心技术,以截图形式分享:

(亿级流量性能调优实战+一线大厂分布式实战+架构师筑基必备技能+设计思想开源框架解读+性能直线提升架构技术+高效存储让项目性能起飞+分布式扩展到微服务架构…实在是太多了)

其次分享一些技术知识,以截图形式分享一部分:

Tomcat架构解析:

算法训练+高分宝典:

Spring Cloud+Docker微服务实战:

最后分享一波面试资料:

切莫死记硬背,小心面试官直接让你出门右拐

1000道互联网Java面试题:

Java高级架构面试知识整理:

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

g-npeG21v4-1715146826692)]

Java高级架构面试知识整理:

[外链图片转存中…(img-HK0LGev4-1715146826692)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值