主要知识点概览
简单的并发场景讨论
我们要考虑并发的主要场景,就是当多个线程同时竞争同一个资源时,我们应该如何解决;
基础知识解释
在java代码执行过程中,创建的对象(也可称之为共享变量)是在堆中的;而方法栈执行区域,即为线程;
在线程中修改的是副本对象的值,而非操作实际对象;副本修改完成以后还是需要回写至堆中内存;
<1>方法(线程)在执行过程中,是需要读(获取变量)写(创建修改)堆中对象的;
假如此时存在并发,咱们在代码中能如何进行控制?而这也是并发核心需要思考并解决的问题;
并发的核心还是对于对象的修改,实际中的对象是长什么样子的哪?
实际对象演示
复杂对象其实就是简单对象的多层嵌套;
线程在获取对象时,实际上是当前的cpu从内存中加载此内存至寄存器(cache_line),当然加载的即为是实际对象的副本(copy);
实际情况模拟,当线程去读(获取变量)写(创建修改)变量时
很有可能会出现如下场景
1.在并发场景中,若两个线程同时获取副本,此时的副本1和2是一样的
同时执行+=操作,有可能是只有一个线程的操作生效!
基础知识解释
线程在操作内存副本时,变量操作 += 或者 ++ 在指令级别上来说都是复合指令
涉及到了读(从主内存中获取,并放置到当前方法栈) 和 写(将当前方法栈中的值回写至主内存) 的指令执行
而当我们需要保证并发的安全性,首先得保证主内存变量的可见性(主内存变量的修改,在所有线程中可见);其次得保证这多个指令在执行期间保持原子性(一组指令同时执行完成)和有序性(指令在执行期间不会重排序);
内存交互操作指令
read(读取):从主内存读取数据
load(载入):将主内存读取到的数据写入工作内存
use(使用):从工作内存读取数据来计算
assign(赋值):将计算好的值从新赋值到工作内存中
store(存储):将工作内存数据写入到主内存
write(写入):将store过去的变量值赋值给主内存中的变量
lock(锁定):将主内存变量加锁,标识为线程独占状态
unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
在java中若要保证方法内部一组操作的原子性,是需要使用锁来保证的;
并发三大特性
要保证并发的安全性必须满足三大特性
可见性、有序性、原子性
可见性
当线程修改了共享变量的值,其他线程能够看到修改的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
有序性
虽然程序执行的顺序按照代码的先后顺序执行,但JVM 存在指令重排,所以存在有序性问题。
原子性
一个或多个指令,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。
可见性
关于可见性的重点解释
基础知识解释
线程实际上获取到副本,其实是存放在cpu缓存(包括寄存器,L1,L2缓存)中用来参与计算的
而堆中的对象是存放在内存中的
可见性问题在多核cpu上的解释
单核CPU由于同一时刻只会有一个线程执行,而每个线程执行的时候操作的都是同一个CPU的缓存,所以,单核CPU不存在可见性问题。
但是在多核CPU上,每个CPU的内核都有自己的缓存。当多个不同的线程运行在不同的CPU内核上时,这些线程操作的是不同的CPU缓存。一个线程对其绑定的CPU的缓存的写操作,对于另外一个线程来说,不一定是可见的,这就造成了线程的可见性问题。
Java中的volatile关键字是通过调用C语言实现的,而在更底层的实现上,即汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。具体的实现原理是在硬件层面上通过:MESI缓存一致性协议 ,多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据 ,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。
mesi协议的简单说明
整个 MESI 的状态可以用一个有限状态机来表示Cache-Line的状态流转。对于不同状态触发的事件操作,可能是来自本地CPU核心发出的广播事件,也可以是来自其他 CPU核心通过总线发出的广播事件。
remote 其他cpu参与处理此数据
local 当前的cpu在处理此数据
这行数据有效 => cache_line有效
基础知识解释
这些状态描述的是,cpu的cache_line中数据状态,cpu读取数据的最小单位为cache_line(寄存器)(64位系统,其大小即为64byte)
有序性
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义(as-if-serial)的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性(data-dependency),处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作可能是在乱序执行。
data-dependency
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性;
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
as-if-serial
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
要遵守as-if-serial,则存在数据依赖的代码,不能改变执行顺序;
重排序的实际案例分析
实际上我们写的代码,更多的是对cpu计算能力的使用,使用cpu进行读和写;
cpu读取内存的速度是快于写的;现代的处理器使用写缓冲区(只对当前cpu可见的)来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作在乱序执行。(再次思考一次)
虽然指令是按照顺序执行,但是由于cpu写的延迟;很可能使得原本执行的 先写后读 变成了 先读后写;
public class ReOrderTest {
// normal-write normal-read causes reorder
// a=1, read b; b=1, read a;
private static /*volatile*/ int x = 0;
private static /*volatile*/ int y = 0;
private static int a = 0;
private static int b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(() -> {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
shortWait(10000);
a = 1;
x = b;
});
Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result + "发送了重排序");
break;
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
常见的处理器都会允许Store-Load重排序(先写后读 变成 先读后写)
如何禁止重排序哪?
在执行指令时加入屏障(Barriers)指令,如针对Store-Load操作,强制cpu执行完成写(Store)的全部过程,才能执行读(Load)指令;
屏障是为了保证指令执行的有序性,实际上在java层面,程序员能使用的指令有volatile和监视器
volatile_store monitor_enter(等价,生成的屏障是相同的)
volatile_load monitor_exit(等价,生成的屏障是相同的)
在此可以看出使用volatile和监视器指令执行时,会有明确的顺序;进而java替我们描述了一种规则,称之为happens-before原则;
happens-before原则
JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的happens-before规则如下:
-
程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
-
监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
-
volatile变量规则:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。
-
传递性:如果A happens- before B,且B happens-before C,那么A happens-before C。
从另一个角度来说,happens-before也保证了监视器锁代码块的原子性;
在执行synchronized代码块时,必须执行完此监视器的解锁,才能进行对此监视器加锁;监视器内部代码块的原子性得以保证;
但是对volatile进行读写时, volatile int x = 0; 执行 x++时,并不能保证两个线程,必定能按照顺序执行操作;
volatile原理
volatile语义使变量具有可见性和有序性
可见性最终是通过硬件层面上通过MESI等缓存一致性协议在多核cpu中保证的
有序性是通过内存屏障保证的,volatile读和写都会生成对应的屏障
有兴趣可以翻阅hotspot源码,观察volatile语义在c++代码层面的实现
CAS原理
cas在java并发中主要的作用就是保证对变量原子性的操作
CAS全称CompareAndSwap,比较交换,主要是通过处理器的指令(如cmpxchg指令)保证操作的原子性。包含三个操作数:
1变量内存地址,用V表示其主内存中实际的值;(通过直接访问主内存地址读取变量值)
2旧的预期值,A表示;
3新值,B表示;
当执行CAS指令时,只有当V=A时,才会用B去更新V,否则不执行更新操作。
CAS缺陷
ABA问题:在CAS更新过程中,当读取到的值为A,然后准备赋值时仍为A,但实际上有可能A的值被改成了B然后又被改回了A,叫做ABA问题。该问题在大部分场景不影响并发的最终效果。