JMM 内存并发模型及 volatile 原理

4 篇文章 0 订阅

多核 CPU 缓存架构

CPU 核心数和线程数的关系

目前主流 CPU 都是多核的,增加核心数量就是为了增加线程数,因为操作系统是通过线程来执行任务的,一般情况下它们是 1:1 的关系,也就是说 4 核 CPU 一般拥有 4 个线程(即单核 CPU 一次只处理一次线程【注意这里说的是单次】,也就是如果是 4 核 CPU 一次能同时处理 4 个线程)。

但我们在更多情况下发现系统并不仅仅因为 CPU 核心数有多少就只运行多少个线程,而是 1:N 的关系,这实际上是 CPU 时间片轮转机制下达到的效果:

在这里插入图片描述

CPU 时间片轮转机制简单理解就是在一定时间内分割多个时间片,每个时间片单次给一个线程运行,不断切换线程并发运行。即 CPU 在某个时刻一次只处理一个线程,在某段时间内并发处理多个线程

这里需要提及到两个概念:并发与并行。

在这里插入图片描述

  • 并发:一个核处理多件事情,但实际上一次只处理一件事情,这种情况称为并发(情况1)

  • 并行:多个核处理事情,每个核处理一件事情,这种情况称为并行(情况2)

线程它是占用资源的,所以无论是在 Linux 还是 Windows,线程是不能随意开启太多,比如 Linux 单核最大线程只能开启 1024 个线程,Windows 是 2048 个,当然不同硬件参数数值也有所不同。

并发从本质上来讲是串行的,从宏观上是并发运行,从微观上是串行的

CPU 缓存架构

在这里插入图片描述

CPU 和主内存需要进行读写 IO,但 CPU 运算效率频率是很高的,所以就会出现一种情况:内存的处理效率跟不上 CPU 的处理效率,因此 CPU 提供了一个硬件:高速缓冲区

在 CPU 需要从主内存读取数据前,会先将数据读取到高速缓冲区,因为高速缓冲区的运行速度和 CPU 是相差无几的,CPU 能及时的从高速缓冲区读取数据来解决该问题。

在这里插入图片描述

比如在 Windows 任务管理器查看到的 L1、L2、L3 就是高速缓冲区,一般快一些的会有 8MB 或更多。

在这里插入图片描述

上图的 CPU 缓存就是高速缓冲区模型,可以认为是工作内存,CPU 不会直接从主内存中读取写入,而是会经过工作内存将数据读写到主内存。

Java 的内存模型也是仿照的 CPU 这种架构模型。

Java 多线程内存模型解析

在这里插入图片描述

Java 多线程内存模型(JMM 内存模型)跟 CPU 内存模型类似,是基于 CPU 缓存模型来建立的。

或许你会有疑问:为什么 Java 也要建立这样的一个内存模型?CPU 缓存模型不是已经有了吗?

因为不同的 CPU 厂商(intel、AMD、高通)生产的 CPU 是不同的,CPU 是硬件,但还需要对应的软件去识别才能够跑起来用起来,这里的软件也就是驱动,每家厂商的驱动汇编指令是不一样的,所以在上层 Java 通过 JVM(更具体的说是 JVM 指令)做标准化,屏蔽底层计算机的不同,将 Java 内存模型标准化,做到通用不同厂商的 CPU 架构

Java 的内存模型同样也有主内存和工作内存的概念,每个线程都有自己的工作内存,线程需要从主内存读取数据时是需要先拷贝一份副本到工作内存(不是绝对),再从工作内存读取数据,写入也同理

高并发体系下面临的三大问题

我们先分析下面的代码会有怎样的输出:

private static boolean flag = false;

public static void main(String[] args) {
	new Thread(() -> {
		System.out.println("begin.....");
		while (!flag) {} // 高频的处理一个变量时会去读取高速缓存
		System.out.println("ending.....");
	}).start();
	
	try {
		Thread.sleep(2000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	
	new Thread(() -> {
		prepare();
	}).start();
}

private static void prepare() {
	System.out.println("prepare assign begin.....");
	flag = true; // 重新赋值
	System.out.println("prepare assign ending.....");
}

执行结果:
begin.....
prepare assign begin.....
prepare assign ending.....

如果按照我们预想的结果是 prepare() 执行后将 flag 的值修改了,线程中的循环将会退出执行后续的代码,但运行后结果和预想的并不一致,这是为什么呢?

我们分析下上面的代码执行步骤是怎样的:

在这里插入图片描述

  • 线程 1 将 flag=false 从主内存读取到工作内存

  • 后续线程 2 也从主内存将 flag=false 读取到工作内存,并且在工作内存中修改变量 flag=true,再重新写回主内存

  • 而此时线程 1 还使用着 flag 的副本,也不知道主内存的 flag 值被修改了,所以不会退出循环

这个问题也验证了工作内存的存在。

那要怎么解决上面的问题?

// 注意添加了关键字 volatile 修饰
private static volatile boolean flag = false;

public static void main(String[] args) {
	new Thread(() -> {
		System.out.println("begin.....");
		while (!flag) {} // 高频的处理一个变量时会去读取高速缓存
		System.out.println("ending.....");
	}).start();
	
	try {
		Thread.sleep(2000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	
	new Thread(() -> {
		prepare();
	}).start();
}

private static void prepare() {
	System.out.println("prepare assign begin.....");
	flag = true; // 重新赋值
	System.out.println("prepare assign ending.....");
}

执行结果:
begin.....
prepare assign begin.....
prepare assign ending.....
ending.....

只需要在变量修饰添加关键字 volatile 就解决了这个问题。volatile 是怎么做到的?

在说明 volatile 之前,需要讲到 并发编程中的三大问题:可见性、原子性、有序性

  • 可见性:多个线程数据通信及同步问题

  • 原子性:多个线程间执行的结果不一致问题

  • 有序性:指令重排会带来的线程间的处理问题

可见性简单理解就是每个线程因为有独立的工作内存,所以假设线程 A 修改了数据,线程 B 是不知道线程 A 修改了数据的,也就是内存可见的概念

原子性简单理解就是线程 A 和线程 B 不会同时对主内存中的变量操作,而是会读取一个变量的副本到工作内存中

有序性简单理解就是本来想通过调整指令提升运行效率,但在多线程情况指令重排却导致逻辑错误

JMM 内存模型 8 大原子操作

Java 内存模型的操作具体分为 8 个:

操作描述
read(读取)从主内存中读取数据
load(载入)将主内存读取到的数据写入工作内存
use(使用)从工作内存读取数据来计算
assign(赋值)将计算好的值重新赋值到工作内存当中
store(存储)将工作内存数据写入主内存
write(写入)将存入的数据变量值赋值给主内存中的共享变量
lock(锁定)将主内存变量加锁
unlock(解锁)将主内存变量解锁

在这里插入图片描述

根据 JMM 的内存模型原子操作,我们将上面的案例用图解的方式说明(下图从左到右说明):

在这里插入图片描述

  • 线程 1(左边) 从主内存读取数据 read 到控制总线,将数据写入到工作内存 load,从工作内存读取数据计算 use

  • 线程 2(右边) 同样也从主内存读取数据 read 到控制总线,将数据写入到工作内存 load,从工作内存读取数据计算 use,并将计算好的值重新赋值到工作内存当中 assign,将工作内存数据写入主内存 store,最后是 write 将修改的结果 flag=true 重新写入主内存

此时线程 1 还操作工作内存的副本 flag=false,所以对线程 2 重新赋值写入主内存是不可知的。

解决可见性问题:总线嗅探机制及 lock 指令

至于为什么 volatile 能实现不同线程可见性的情况,主要基于两个实现:

  • 缓存一致协议(MESI):多个 CPU 从主内存读取同一个数据到各自的高速缓存(也就是线程的工作内存),当其中某个 CPU 修改了缓存里的数据,该数据马上同步回主内存,其他的 CPU 通过总线嗅探机制可以感知到数据的变化从而将自己的缓存失效

  • 缓存加锁:缓存锁的核心机制是遵循于缓存一致性协议,一个处理器的缓存回写到内存会导致其他处理器的缓存失效,IA-32 和 Intel 64 处理器使用 MESI 实现缓存一致性协议,ARM 架构下是 AMBA 协议

总线嗅探机制遵循的缓存一致性协议,更具体的说就是 总线嗅探机制监控的就是 volatile 修饰的变量,当某个线程修改了 volatile 修饰的变量,总线嗅探机制会让其他线程的工作内存中的该变量失效,重新从主内存 read、load

在这里插入图片描述

总线嗅探机制是附带的一种能力,最主要的是 lock 指令在发生作用

volatile 可见性底层实现原理

volatile 缓存可见性底层实现主要通过一条汇编指令 lock 前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存中

查看例子加了 volatile 关键字的代码汇编指令,会发现有添加了 lock 前缀指令:

汇编指令:
lock add dword ptr [rsp],0h   ;*putstatic flag
						  ; -  com.example.test::prepare@9 (line 45)


hotspot虚拟机代码:

bytecodeInterpreter.cpp

// volatile 进行处理时的逻辑
int field_offset = cache->f2_as_index()
if (cache->is_volatie()) {
	....
	OrderAccess::storeload() 
} else {
	....
}

OrderAccess_linux_x86.inline.hpp

inline void OrderAccess::storeload() { fence(); }

inline void OrderAccess::fence() {
	if (os::is_MP()) {
#ifded AMD64
	__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); // 追加 lock 汇编指令
#else
	__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#endif		
	}
}

每种 CPU 架构可能有不同的汇编指令,这里以 intel 的汇编指令说明 lock 指令为什么能做到这样的效果,其开发手册对 lock 指令解释如下:

  • 会将当前处理器缓存行的数据立即写回到系统内存

  • 这个写回内存操作会引起其他 CPU 缓存了该地址的数据无效(MESI)

  • 提供内存屏障功能,使 lock 指令不会进行重排

在说明中提到了两个新概念:内存屏障、指令重排。它们到底是什么?

指令重排

同样还是用一个例子说明什么是指令重排:

public class Main {

    public static void main(String[] args) {
        Test test = new Test();
        try {
            test.start();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static class Test {
        int a = 0;
        int b = 0;
        int x = 0;
        int y = 0;

        void start() throws Exception {
            HashSet<String> set = new HashSet<>();
            for (int i = 0; i < 10000000; i++) {
                a = 0;
                b = 0;
                x = 0;
                y = 0;

                Thread thread1 = new Thread(() -> {
                    a = y;
                    x = 1;
                });

                Thread thread2 = new Thread(() -> {
                    b = x;
                    y = 1;
                });

                thread1.start();
                thread2.start();
                thread1.join();
                thread2.join();

                set.add("a = " + a + ", b = " + b);
                System.out.println(set);
            }
        }
    }
}

上面的代码可能出现的情况:
a = 0, b = 0 // thread1 和 thread2 同时跑,a = y; b = x; 先执行了
a = 1, b = 0 // thread2 先跑完,再跑 thread1
a = 0, b = 1 // thread1 先跑完,再跑 thread2
a = 1, b = 1 // 这种也会出现,为什么??

上面的代码很简单,开启两个线程不断运行并输出可能的结果,会出现四种情况。而会出现上述第四种情况只有一种可能性:发生了指令重排。

Thread thread1 = new Thread(() -> {
	a = y;
	x = 1;
});

Thread thread2 = new Thread(() -> {
	y = 1; // 发生了指令重排,y = 1 先处理了
	b = x;
});

什么情况会出现指令重排?如下:

y = 1; // 可以指令重排,因为调整执行顺序不会对下面的代码有影响
... // 假设是耗时操作
b = x;

// 这种不会做指令重排,因为代码上下有依赖关系
b = x;
y = b;

一般情况下,CPU 和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间存在一定的先后顺序,并发情况下会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。

简单理解就是,指令重排其实就是对运行指令的调整达到提升运行效率的目的,这不是一个 bug。但它只考虑单线程下,多线程出现指令重排可能会导致结果异常。

我们在网上找单例模式代码例子的时候,经常会看到他们这么写:

public class Singleton {
	// 添加了 volatile 关键字
	private static volatile Singleton sSingleton;

	public static Singleton getInstance() {
		if (sSingleton == null) {
			synchronized(Singleton.class) {
				if (sSingleton == null) {
					sSingleton = new Singleton();
				}
			}
		}
		return sSingleton;
	}

	private Singleton() {}
}

上面的单例模式为对象加上了 volatile 关键字,为什么要这么做?

最主要是处理 getInstance() 在类锁内第二次判断 sSingleton 是否为 null 在多线程情况初始化对象时可能会出现的异常。

sSingleton = new Singleton() 在虚拟机看来它一共分为几个指令操作:

1、为对象分配内存空间

2、初始化对象

3、将引用指向对象的内存空间地址

虚拟机执行的时候不一定是按顺序 123 执行,也有可能是 132。这是虚拟机的指令排序引起的,单线程情况下是没有什么 bug,最终都会创建出对象,只是先后顺序不同。

在多线程情况下做指令重排将会出现问题:

线程 1 执行了 sSingleton = new Singleton() 虚拟机是按 132 排序执行,当执行到 3 的时候对象已经不为 null,此时线程 2 执行到第一次判断 if (sSingleton == null) 结果为 false 返回 sSingleton 对象,但是此时对象还没有被初始化完成,因此很有可能会出现 bug。

解决有序性问题:内存屏障

在底层的实现过程当中,volatile 对象不单单是用来处理可见性问题,它也被用来处理有序性问题,因为 lock 指令它会生成内存屏障

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2确保 Load1 数据的装载先于 Load2 及所有后续装载指令的装载
StoreStore BarriersStore1;StoreStore;Store2确保 Store1 数据对其他处理器可见(刷新到内存)先于 Store2 及所有后续存储指令的存储
LoadStore BarriersLoad1;LoadStore;Store2确保 Load1 数据装载于 Store2 及所有后续的存储指令刷新到内存
StoreLoad BarriersStore1;StoreLoad;Load2确保 Store1 数据对其他处理器变得可见(指刷新到内存)先于 Load2 及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

内存屏障有四种类型,其中 volatile 关键字就是使用的 storeLoad,在 hotspot 虚拟机的代码也可以查看:

hotspot虚拟机代码:

bytecodeInterpreter.cpp

// volatile 进行处理时的逻辑
int field_offset = cache->f2_as_index()
if (cache->is_volatie()) {
	....
	OrderAccess::storeload() // 内存屏障
} else {
	....
}

OrderAccess_linux_x86.inline.hpp

inline void OrderAccess::storeload() { fence(); }

inline void OrderAccess::fence() {
	if (os::is_MP()) {
#ifded AMD64
	__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); // 追加 lock 汇编指令,lock 指令是一个标记位,表示在这个指令前后不进行指令重排
#else
	__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#endif		
	}
}

内存屏障实际上就是 CPU 在指令优化时给的一个标记,执行到此位置时不进行指令优化

总结

上面讲解了很多,从 CPU 架构到 Java JMM 内存模型,但最主要的就是引出 volatile 关键字以及为什么它能解决可见性、有序性问题。

volatile 实际上是通过内存屏障(禁止 CPU 指令优化)来防止指令重排解决了有序性问题,禁止 CPU 高速缓存(高速缓存即线程工作内存,有线程将数据写回共享内存,总线嗅探机制下其他线程的工作内存数据副本失效要重新从共享内存读取)来解决可见性问题

lock 指令的作用:

  • 将当前处理缓存行的数据写回到主内存

  • 写回内存的操作会使其他 CPU 里缓存了该内存地址的数据无效

lock 指令,它本意是禁止高速缓存解决可见性问题,但实际上在这里,它表示的是一种内存屏障的功能,也就是说针对当前的硬件环境,JMM 层面采用 lock 指令作为内存屏障来解决可见性问题

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值