👏作者简介:大家好,我是程序员行走的鱼
🍂博主从本篇正式开始多线程学习,希望小伙伴可以一起探讨
📖 本篇主要介绍和大家一块简单认识下多线程的一些术语、以及并发的三大特性的理解、java内存模型的介绍
以及volatile关键字的保证线程可见性和有序性的原理
本章知识点思维脑图概览:
1. 并行和并发
两者的目的都是最大化压榨CPU的使用率
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)
再举个例子:并发就好比我们每个人都有一个手机,都没电了,此时如果只有一个充电宝,那么同一时刻只能有一个手机充电,其他人就要等待,大家轮流充电,而并行呢就是每个人都带了充电宝,每个人用自己的充电宝进行充电,谁也不打扰谁,这就是并发和并行的区别。
2. 并发的三大特性
并发编程的三大特性分别是可见性、有序性、原子性。
2.1 可见性
当一个线程修改了共享变量的值,其他线程能够立刻看到修改的值。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
保证可见性的方式:
- 通过volatile关键字保证可见性。
- 通过内存屏障保证可见性。
- 通过 synchronized 关键字保证可见性。
- 通过 Lock保证可见性。
- 通过 final 关键字保证可见性 。
下边通过代码演示并发中可见性问题以及保证可见性的方式
public class VisibilityTest {
private boolean flag = true;
private int count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (flag) {
count++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
// 线程threadA模拟数据加载场景
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
// 让threadA执行一会儿
Thread.sleep(1000);
// 线程threadB通过flag控制threadA的执行时间
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
}
运行结果:
这里我们有一个共享变量flag,线程A通过判断flag的值进行业务处理,线程B改变将flag的值从true改为false,如果线程B对共享变量flag的操作对于线程A可见的话,那么线程A会退出循环,但是从结果来看并没有退出循环,这就是并发中涉及到三大特性之一的可见性问题。
以下代码是解决可见性的几种方式
public class VisibilityTest {
//方式一 添加volatile关键字
private volatile boolean flag = true;
//方式二 判断标志的后边的共享变量添加volatile关键字
private int count = 0;
//方式八
//private Integer count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (flag) {
//TODO 业务逻辑
count++;
//方式三 通过内存屏障
//UnsafeFactory.getUnsafe().storeFence();
//方式四 释放时间片,上下文切换 重新加载上下文:flag=true
//Thread.yield();
//方式五 通过synchronized关键字(底层还是使用内存屏障)
//System.out.println(count);
//方式六 底层还是使用内存屏障
//LockSupport.unpark(Thread.currentThread());
//方式七 底层还是使用内存屏障
// try {
// Thread.sleep(1);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
// 线程threadA模拟数据加载场景
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
// 让threadA执行一会儿
Thread.sleep(1000);
// 线程threadB通过flag控制threadA的执行时间
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
public class UnsafeFactory {
/**
* 获取 Unsafe 对象
* @return
*/
public static Unsafe getUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 获取字段的内存偏移量
* @param unsafe
* @param clazz
* @param fieldName
* @return
*/
public static long getFieldOffset(Unsafe unsafe, Class clazz, String fieldName) {
try {
return unsafe.objectFieldOffset(clazz.getDeclaredField(fieldName));
} catch (NoSuchFieldException e) {
throw new Error(e);
}
}
}
针对以上代码可见性问题的出现我们可以看下的图例:
1.首先线程A通过read指令从主内存获取flag的的值为true
2.通过load指令在本地内存生成flag变量的副本
3.通过use命令把flag的值读取到cpu寄存器中
4.1s后线程B执行
5.线程B通过read指令从主内存获取flag的的值为true
6.通过load指令在本地内存生成flag变量的副本
7.通过use命令把flag的值读取到cpu寄存器中,此时调用方法把flag的值改为flase
8.线程B通过assign命令回写到本地内存
9.再通过store命令和write再刷回主存中,主存中的flag的值改为flase
9.但是此时线程A是一直在本地内存中获取flag变量值(和优化有关,while循环的次数太多太快,获取flag变量的间隔很短就会直接从本地内存中一直获取值,而不会从主存中去重新刷新变量的值),无法得知主存中flag的值已经发生变化,导致线程A一直循环 。关于这些指令可以查看下边JMM内存模型进行了解
这里可以延伸出问题
1.什么时候本地内存会失效(会重新从主存中获取值)
一段时间内没有使用共享变量,本地内存会淘汰这个共享变量副本,重新从主存中获取
2.什么时候本地内存的变量会刷回主存中?
线程执行完毕后会刷回到主内存中。
我们可以总结解决可见性问题在Java底层主要通过两种方式,关于底层会在后边的volatile底层实现和其他方式的底层实现讲解
1.通过storeLoad内存屏障
2.通过上下文切换,重新从主存中读取值。
2.2 有序性
即程序执行的顺序按照代码的先后顺序执行,但是JVM 存在指令重排,所以存在有序性问题。
如何保证有序性:
-
通过volatile关键字保证有序性。
-
通过内存屏障保证有序性。
-
通过synchronized关键字保证有序性。
-
通过Lock保证有序性。
先看看最常见的创建单例对象的DCL代码
public class SingletonFactory {
private volatile static SingletonFactory myInstance;
public static SingletonFactory getMyInstance() {
if (myInstance == null) {
synchronized (SingletonFactory.class) {
if (myInstance == null) {
myInstance = new SingletonFactory();
}
}
}
return myInstance;
}
public static void main(String[] args) {
SingletonFactory.getMyInstance();
}
}
思考一个问题:DCL为什么要使用volatile
这是因为myInstance = new SingletonFactory(),这一步骤并不是一个原子操作,当我们new一个对象的时候,操作系统底层会分为三个步骤:
1.开辟空间
2.对象初始化
3.myInstance指向内存空间的地址
但是由于指令重排机制的存在,第二步和第三步可能顺序是相反的,这就意味着,在多线程情况下,第一个线程创建的对象还没有初始化 ,第二个线程会判断这个对象不为空,直接拿着这个半成品对象去使用,这无疑是有问题的。
2.3 原子性
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。
如何保证原子性:
- 通过 synchronized 关键字保证原子性。
- 通过 Lock保证原子性。
- 通过 CAS保证原子性。
启用十个线程对sum进行操作
public class Test {
private volatile static int sum = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 10000; j++) {
sum++;
}
});
thread.start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(sum);
}
}
运行结果:
如果自增是原子操作,得到的结果应该是10000,多次实验得到的结果都不是10000,可以见的volatile关键字不能保证我们的原子性。
3. Java内存模型(JMM)
3.1 JMM定义
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM 描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。
3.2 JMM与硬件内存架构的关系
Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和 CPU内部的寄存器中。如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
内存交互操作:
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。 但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现。
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 (load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过 了load和assign操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
4. Volatile关键字
4.1 Volatile的内存语义
volatile的特性:
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入 。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。 64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。
- 有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。
volatile写-读的内存语义:
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
4.2 Volatile实现可见性原理
- 在Jvm层面:volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。
- 在硬件层面:通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”, 缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
volatile在hotspot的实现
-
字节码解释器实现:JVM中的字节码解释器(bytecodeInterpreter),用C++实现了JVM指令,其优点是实现相对简单且容易理解,缺点是执行慢。
bytecodeInterpreter.cpp:
//添加内存屏障(jvm层面)
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
//是否是多核
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
//lock前缀指令(汇编层面)
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
-
模板解释器实现:其对每个指令都写了一段对应的汇编代码,启动时将每个指令与对应汇编代码入口绑定,可以说是效率做到了极致。
当我们为一个变量赋值的时候,JVM执行的是以下代码
templateTable_x86_64.cpp
// 负责执行putfield或putstatic指令
void TemplateTable::putfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
// ...
// Check for volatile store
__ testl(rdx, rdx);
__ jcc(Assembler::zero, notVolatile);
putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad |
Assembler::StoreStore));
__ jmp(Done);
__ bind(notVolatile);
putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
__ bind(Done);
}
//内存屏障
void TemplateTable::volatile_barrier(Assembler::Membar_mask_bits
order_constraint) {
// Helper function to insert a is-volatile test and memory barrier
if (os::is_MP()) { // Not needed on single CPU
__ membar(order_constraint);
}
}
assembler_x86.hpp
// Serializes memory and blows flags
void membar(Membar_mask_bits order_constraint) {
// We only have to handle StoreLoad
// x86平台只需要处理StoreLoad
if (order_constraint & StoreLoad) {
int offset = -VM_Version::L1_line_size();
if (offset < -128) {
offset = -128;
}
// 下面这两句插入了一条lock前缀指令: lock addl $0, $0(%rsp)
lock(); // lock前缀指令
addl(Address(rsp, offset), 0); // addl $0, $0(%rsp)
}
}
lock前缀指令的作用
- 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
- LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
- LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。
k addl $0, $0(%rsp)
lock(); // lock前缀指令
addl(Address(rsp, offset), 0); // addl $0, $0(%rsp)
}
}
lock前缀指令的作用
- 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
- LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
- LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。