hi,大家好。我是fancy~
本文是图解并发的第二篇。今天我们来讲讲Java并发编程的基础:Java内存模型。
它非常的重要,是并发编程里面不可绕去的一环,也是面试的重点。本文依然使用图解的方式带大家理解它。
话不多说,列大纲发车~
在讲JMM之前,我们需要先了解一下计算机硬件的体系和计算机内存模型。
我们如今的计算机硬件体系结构,都是以冯诺依曼体系结构为基础的,也就是:输入->计算->存储->输出结构。输入和输出设备形式可以为多样,键盘、显示器、音响等等。而CPU负责计算;内存条和磁盘负责存储。
在当代计算机运算体系中,CPU、存储设备和输入输出设备都是相互协调,相互配合完成一整套工作的。随着芯片技术的发展,CPU的速度越来越快,越来越快。一颗CPU芯片上集成着上亿个半导体和晶体管,并且根据摩尔定律:约每隔18个月便会增加一倍,性能也将提升一倍。
然而,内存和磁盘的发展并没有太大突破。原因是它们是存储设备,而并非计算设备。所以这就导致了CPU的计算和处理速度和内存的读写存储速度差距越来越大。
为了解决信息在CPU计算完后的存储问题,于是硬件架构在冯诺依曼体系的基础上又引入了缓存的概念:在CPU和内存之间加入高速缓存,它处于缓存层。通过添加缓存来解决问题:
在这里,寄存器是嵌在CPU里面的。而高速缓存处于CPU和主内存之间。在现代计算机中,高速缓存又被分为多层,通常有L1,L2,L3等,每个层级之间存在一定的存储速度差异,通过这种添加多级缓存的方式,来最大程度减少CPU和主内存之间的差异。
这里的缓存层依然是一个架构层面的设计,是一种思想。这种思想主要是为了解决存储性能问题。你可以从很多地方看到这种缓存的设计,比如后端架构中用户请求和数据库中间的缓存层。都是为了解决类似的问题。
原本的CPU是直接和内存打交道的,在添加了这个缓存层之后,CPU会先将数据存储在缓存层里:
Java内存模型(Java Memory model),简称JMM。
在这里需要区分一下Java内存模型、JVM内存模型和Java对象模型这三个模型。JVM内存模型指的是JVM虚拟机的内存分区,包括堆、本地方法栈、虚拟机栈、方法区等内容;而Java对象模型指的是Java创建一个对象时堆和栈的引用关系。我们这里讲的Java内存模型,是在以硬件体系下的内存模型为基础,通过Java虚拟机的平台无关性和统一性创建一个机制和规范,用以保证并发场景下数据的安全。
所以,它是一种规范,在多线程环境下各个线程之间相互协调和配合的规范。
为什么要有这个规范?因为Java作为一门高级语言肯定不会直接和CPU、内存这样的硬件结构打交道。而是屏蔽了这些底层细节,通过JMM这个规范,让我们不需要再去关心硬件的高速缓存、寄存器这些内容,而是将它们都抽象成了主内存和本地内存这样的概念,它形成了Java层面的架构:
所以,每个线程都有自己的本地内存,对于我们定义的变量,它们都是共享的。所有的共享变量都存储在主内存中。而每个线程的本地内存是私有的:
所以,你可以将每个线程类比为CPU的核心,而线程私有的本地缓存类比为缓存层:
那么,就像我们刚才说的。每个线程都有自己的本地内存,而共享变量都是存储在主内存中的。所以,当某一时刻,有多个线程去操作同一个共享变量的时候,
必定会存在数据并发问题:
所以,为了避免这种多线程场景下的并发问题,我们的程序就需要去实现并发安全场景的特性,以此来解决并发安全问题。就像实现数据库的隔离级别一样,只有隔离级别,才能去保证对应的线程安全。
那么,实现并发安全的程序存在哪些特性呢?主要是三个:有序性、可见性和一致性。我们一个一个讲。
一段简单的程序:
int a = 1 ;
int b = 2 ;
a = a + 1;
当你看到这段程序的时候,你是否觉得它的执行就跟它的长相一样,平淡无奇、普普通通?
但是这里的执行顺序并不是按照它的代码顺序来执行的。包括这行代码在内,对于Java来说,代码实际执行顺序和代码在Java文件中的顺序并不一致。代码指令并不是严格按照代码语句的顺序来执行的,这就是重排序。
为什么要进行指令重排序?依然就是为了优化性能:
假设如果没有进行重排序,原本的计算指令如下:
但是进行了重排序后,原本在b被赋值之后才进行的a = a + 1操作,会被判定为是对a进行的独立操作,往后的代码对其没有依赖性,所以会进行指令的重排序,将这行代码提到对b的赋值之前,这样子就不需要再进行一次load指令和store指令了:
这样子,少了一次对a的load和store操作,性能一下子就提升了不少。
代码在被执行的哪些时刻会进行重排序呢?主要是两个时间区间:
编译器优化:包括 JVM,JIT 编译器等
CPU 指令重排:就算编译器不发送重排,CPU 也有可能进行重排
这种重排序,优化了性能。但是在多线程场景下会有什么弊端呢?并非所有线程对同一段代码都会进行重排序,假设在保证了可见性的前提下,两个线程交替执行一段代码,那么就可能导致计算的结果不准确。
所以,在并发场景下,我们需要禁止指令重排序,以保证多线程对同一段代码进行操作不会因为指令重排序而出现执行结果不正确的情况。当一个变量加了volatile关键字后,它就会通过内存屏障来禁止重排序,我们会在讲解volatile关键字的篇章中详细介绍。
什么是可见性呢,就是当一个线程修改了处于主内存的共享变量的值时,别的线程能够马上就知道并且更新这个值。因为只有这样,才能够保证多线程场景下同一个数据值的处理是同步和正确的。
如果可见性不能够保证,就会出现可见性问题。
我们来举个例子,现在有一段程序:
i = i++;
线程假设有两个线程,线程A和线程B。它们在两颗不同的CPU之间运行。
i是一个全局变量,默认初始值为0。
那么此时线程A和线程B就会有以下执行过程:
可以看出,线程A和线程B对i这个值的修改,彼此都是不知道的,这就是可见性问题。
为什么会产生可见性问题?因为线程A和线程B在修改变量i的值之后,都没有将这件事通知给对方。所以,想要解决可见性问题,只需要在修改完变量的值之后,将别的线程缓存中对于该变量的值同步成自己最新修改的就行了:
此时,线程A和线程B的执行过程就变成如下:
这样子,可见性问题也就解决了。
那么Java中有哪些方式可以解决可见性问题从而保证可见性呢?有很多。volatile、synchronized都可以解决。synchronized我们都知道,只要加一把锁保证变量是互斥的,那么就可以简单粗暴的解决。volatie则是更加轻量化。
原子性指的是对于一个一系列的操作,要么全部执行成功,要么全部执行失败或者不执行,不存在执行一半的状况。它其实和数据库原子性的概念是一样的。
知道了原子性,我们再来看看原子操作。所谓的原子操作,就是指一段代码只有一个操作步骤。那么Java中什么样的操作会被定义为原子操作呢?我们依然举个例子:
int i = 0; //以下我们定义为操作A
int j = i ; //以下我们定义为操作B
i++; //以下我们定义为操作C
以上三个操作,你觉得哪些是原子操作呢?其实只有操作A是原子操作,其余都不是:
所以,依然以操作C来作为例子。
当我们执行i++的过程时,会发生以下过程:
1.读取i的值,并加载到本地缓存
2.将i的值进行+1操作
3.将+1后的结果赋值给i,即写回本地缓存
4.将最终的结果写回到主内存
那么假如原子性得不到保证,多线程场景下就可能会出现以下问题:
在这里,i的值正确应该为2,由线程A和线程B分别执行完成。但是最终因为线程A的执行过程被中断,原子性得不到满足,导致结果错误。
那么如何解决呢?必须要让线程A的整套操作保证原子性,也就是说在执行i++的这整个过程保证所有操作要么同时成功,要么同时失败回滚,就ok了:
在这里强调一下,volatie是无法保证一套操作的原子性的,因为Java语言并不能像数据库一样将代码回滚。如果想要保证原子性,可以通过synchronized或者Lock这样的锁来让多线程环境下对于资源保证互斥,让一套完整的原子操作执行完毕再释放资源。
本文介绍了计算机系统下的内存模型和Java内存模型的关系,详解了Java内存模型的结构和并发场景下Java线程安全的三大特性。不同的锁形式可以实现不同的特性。那么像volatile、synchronized和Lock这些锁分别能实现哪些特性呢?我在这里总结了一下:
下文,我们就来详细聊聊实现并发安全的利器:volatie。