本篇内容包括:进程与线程&并行与并发的基本概念,Java内存模型中的内存划分、内存交互、内存交互,以及JMM的相关概念,包括了 CPU 和缓存一致性、重排序、处理器重排序与内存屏障指令、JMM 的重排序屏障、数据依赖性、as-if-serial 语句、happens-before 规则,还有JMM三大特征(原子性、可见性、有序性)。
进程是静态的概念,进程是资源(CPU、内存等)分配和调度的基本单位,它拥有自己的资源空间,每启动一个进程,系统就会为它分配地址空间;
线程是动态的概念,线程是程序执行的基本单位,它既可以由操作系统内核来控制调度,也可以由用户程序进行控制调度;
线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如:程序计数器,一组寄存器和栈),多个线程共享同一进程内的资源,使用相同的地址空间。
进程线程区别:
并行(parallel)指在同一时刻,有多条指令在多个处理器上同时执行,偏重点在于"同时执行",是物理上的同时发生
并发(concurrency)指在同一时段,有多条指令在多个处理器上同时执行,偏重点在于"多个任务交替执行",是逻辑上的同时发生(simultaneous),而多个任务之间有可能还是串行的
并行并发区别:
JMM(Java Memory Model)即 Java 内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
JMM 定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存。
我们常说的 JVM 内存模式指的是 JVM 的内存分区;而 Java 内存模型(JMM)是一种虚拟机规范。
原始的 Java 内存模型存在一些不足,因此 Java 内存模型在 Java1.5 时被重新修订。这个版本的 Java 内存模型在 Java8 中仍然在使用。
JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与 Java 编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。
JMM 规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟 JVM 内存划分(堆、栈、方法区)是在不同的层次上进行的。如果非要对应起来,主内存对应的是 Java 堆中的对象实例部分,工作内存对应的是栈中的部分区域。从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。
JMM 在设计时候考虑到,如果 Java 线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存。JMM 中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面讲的处理器的高速缓存类比)。线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因此 JMM 制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。
线程、主内存和工作内存的交互关系:
JMM 中规定内存交互操作有 8 种,每种操作都有自己作用的的区域,具体操作如下:
JMM 中的 8 种操作规定了线程对主内存的操作过程,隐式的规定:线程之间要通信必须通过主内存,JMM 的线程通信如下:
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面 2 个步骤:
要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。
Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
源代码
>
>
编译器优化冲排序
>
>
指令级并行的重排序
>
>
内存系统的重排序
>
>
最终执行指令序列
源代码>>编译器优化冲排序>>指令级并行的重排序>>内存系统的重排序>>最终执行指令序列
源代码>>编译器优化冲排序>>指令级并行的重排序>>内存系统的重排序>>最终执行指令序列
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。
这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
例子:假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 a=b=0 的结果。具体的原因如下图所示:
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 a=b=0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。
从 Java 源代码到最终实际执行的指令序列,会经过三种重排序。但是,为了保证内存的可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量后,再读这个位置 |
写后写 | a = 1;b = 2; | 写一个变量后,再写这个变量 |
读后写 | a = b;b = 1; | 读一个变量后,再写这个变量 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
重排序也不能毫无规则,否则语义就变得不可读, as-if-serial 语句 给重排序戴上紧箍咒,起到约束作用。
as-if-serial语句规定重排序要满足以下两个规则:
as-if-serial 语句下重排序既没有改变单线程下程序运行的结果,又没有对存在依赖关系的指令进行重排序。
从 Jdk5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。换句话说,操作1 happens-before 操作2,那么 操作1 的结果是对 操作2 可见的。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。如果不满足这个要求那就不允许这两个操作进行重排序。
happens-before 规则如下:
Ps:JSR-133 规则中只有以上 6 条,但是网上目前流传最多的则是 8 条的版本,即包括下面 2 条:
happens-before 与 JMM的关系如下图所示:
在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurrent包等解决原子性、有序性和可见性三大问题。
其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。
线程切换带来的原子性问题:我们把一个或者多个操作在CPU执行的过程中不能被中断的特性称之为原子性,这里说的是CPU指令级别的原子性。
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码,在Java中对应的关键字就是synchronized。
因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
缓存导致的可见性问题:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称之为可见性。
JMM 是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 volatile 来保证多线程操作时变量的可见性。
除了 volatile,Java 中的 synchronized 和 final 两个关键字也可以实现可见性。
编译优化带来的有序性问题:有序性指的是程序要按照代码的先后顺序执行,编译器为了优化性能,有时候会改变程序中语句的先后顺序。
在 Java 中,可以使用 synchronized 和 volatile 来保证多线程之间操作的有序性。实现方式有所区别:
volatile 关键字会禁止指令重排。synchronized 关键字保证同一时刻只允许一条线程操作。
好了,这里简单的介绍完了 Java 并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized 关键字是万能的,它可以同时满足以上三种特性,这其实也是很多人滥用 synchronized 的原因。
但是 synchronized 是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。