Java并发基础知识

1.并行与并发

目标都是最大化CPU的使用率
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行所以无论从微观还是宏观来看,二者都是一起执行的。
在这里插入图片描述

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
在这里插入图片描述
并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)

2.并发三大特性

众所周知的并发三特性:可见性、原子性和有序性

可见性(Visibility)

当一个线程修改了共享变量的值,其他线程能够看到修改的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。

如何保证可见性

  • volatile 关键字 (底层调用内存屏障,再往下是Lock前缀指令)
  • 内存屏障保证 (x86架构底层调用的Lock前缀指令)
  • synchronized 关键字 (底层调用内存屏障,再往下是Lock前缀指令)
  • Lock前缀指令
  • final 关键字(底层疑似调用内存屏障,再往下是Lock前缀指令)
  • Thread.yield()

归类为两种:

  1. JVM层面storeLoad内存屏障 ,在x86架构下用Lock前缀指令替代了mfence
  2. 上下文切换,例如:Thread.yield()

有序性(Ordering)

即程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。

如何保证有序性

  • volatile 关键字
  • 内存屏障
  • synchronized关键字
  • Lock前缀指令

原子性(Atomicity)

一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。

如何保证原子性

  • synchronized 关键字
  • Lock前缀指令
  • CAS

3.Java内存模型(JMM)

3.1 定义

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM 描述的是一种抽象的概念,一组控制程序中各个变量在共享数据区域和私有数据区域的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量是指存在竞争问题的变量,如实例字段、静态字段、数组对象元素等,不包括线程私有的局部变量、方法参数等,因为私有变量不存在竞争问题。可以认为JMM包括内存划分、变量访问操作与规则两部分。

3.2 JMM在硬件内存架构中的位置

Java内存模型与硬件内存架构之间存在差异。硬件内存架构不区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
在这里插入图片描述

3.3 主内存与工作内存

在这里插入图片描述
JMM内存划分为主内存和工作内存,每个线程都有自己的工作内存,它们共享主内存。

  • 主内存(Main Memory):存储所有共享变量的值。主要包括本地方法区和堆。
  • 工作内存(Working Memory):存储该线程使用到的共享变量在主内存值的副本拷贝。主要包括两部分,一个是属于该线程私有栈对主存部分变量拷贝的寄存器(包括程序计数器PC和CPU工作的高速缓存区)

线程对共享变量的所有读写操作都在自己的工作内存中进行,不能直接读写主内存中的变量。
不同线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存完成。

这种划分与Java内存区域中堆、栈、方法区等的划分是不同层次的划分,两者基本没有关系。硬要联系的话,大致上主内存对应Java堆中对象的实例数据部分、工作内存对应栈的部分区域;从更低层次上说,主内存对应物理硬件内存、工作内存对应寄存器和高速缓存。

3.4 内存交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步到主内存之间的实现细节,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操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

3.5 volatile的内存语义

3.5.1 volatile的特性

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性 (基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。 64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。
  • 有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保 volatile的写-读和锁的释放-获取具有相同的内存语义。

volatile写-读的内存语义

  • 一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • 一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量

3.5.2 volatile可见性实现原理

3.5.2.1 JMM内存交互层面实现

volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。

3.5.2.2 硬件层面实现

通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效

3.5.2.3 volatile在hotspot的实现
3.5.2.3.1 字节码解释器实现

JVM中的字节码解释器(bytecodeInterpreter),用C++实现了JVM指令,其优点是实现相对简单且容易理解,缺点是执行慢。
bytecodeInterpreter.cpp
bytecodeInterpreter.cpp

在linux系统x86中的实现
orderAccess_linux_x86.inline.hpp

inline void OrderAccess::storeload() { fence(); } 
inline void OrderAccess::fence() { 
    //只有多核处理器才会有可见性问题 
	if (os::is_MP()) {
		// always use locked addl since mfence is sometimes expensive 
		// 使用lock前缀指令代替mfence写屏障
		#ifdef AMD64 
		__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); 
		#else 
		__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory"); 
		#endif 
	}
}
lock前缀指令的作用
  1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低 lock前缀指令的执行开销。
  2. LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前后的读写指令重排序
  3. LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效
3.5.2.3.2 模板解释器实现

模板解释器(templateInterpreter),其对每个指令都写了一段对应的汇编代码,启动时将每个指令与对应汇编代码入口绑定,可以说是效率做到了极致。
templateTable_x86_64.cpp

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); 
  } 
} 

// 负责执行putfield或putstatic指令 
void TemplateTable::putfield_or_static(int byte_no, bool is_static, Rewrite Control 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); 
}

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();  
	  //在栈顶加一个0元素
	  addl(Address(rsp, offset), 0); // addl $0, $0(%rsp) 
	} 
}
3.5.2.4 汇编层面volatile的实现

添加下面的jvm参数查看之前可见性Demo的汇编指令 :

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp 

在这里插入图片描述
验证了可见性,使用了lock前缀指令

HSDIS(HotSpot disassembler),一个Sun官方推荐的HotSpot虚拟机JIT编译代码的反汇编插件,其实际上就是一个动态库。这里我们直接从网上下载与我们系统对应的编译后文件,然后直接将hsdis-amd64.dll放置到<JAVA_HOME>/jre/bin目录下即可。
转载《在64位Windows上编译hsdis》 https://blog.csdn.net/yizishou/article/details/53423409

3.6 指令重排序

Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
在编译器与CPU处理器中都能执行指令重排优化操作
在这里插入图片描述
volatile重排序规则
在这里插入图片描述
volatile禁止重排序场景

  1. 第二个操作是volatile写,不管第一个操作是什么都不会重排序
  2. 第一个操作是volatile读,不管第二个操作是什么都不会重排序
  3. 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序

3.7 as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

double pi = 3.14; // A 
double r = 1.0; // B 
double area = pi * r * r; // C

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

3.8 happens-before

从JDK 5 开始,JMM使用happens-before的概念来阐述多线程之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。 happens-before和JMM关系如下图:
在这里插入图片描述
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下操作之间是否可能存在冲突的所有问题。下面我们就一个简单的例子稍微了解下happens-before :

i = 1; //线程A执行 
j = i ; //线程B执行

j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后 j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成立。这就是happens-before原则的威力。

happens-before原则定义如下

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

我们来详细看看happens-before每条规则(摘自《深入理解Java虚拟机第12章》):
1.程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
2.锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
3.volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
4.传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C
5.线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
6.线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
7.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:
1.将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
2.将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
3.在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
4.释放Semaphore许可的操作Happens-Before获得许可操作
5.Future表示的任务的所有操作Happens-Before Future#get()操作
6.向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作

这里再说一遍happens-before的概念:
如果两个操作不存在上述(前面8条 + 后面6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。

3.9 JMM内存屏障插入策略

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障
    在这里插入图片描述
    如何充分压榨硬件性能,压榨CPU计算能力,减少CPU等待时间(机械同感)

x86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障仅会对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障
在这里插入图片描述
JVM层面的内存屏障
在JSR规范中定义了4种内存屏障:
1.LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
2.LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
3.StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
4.StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令,其他屏障对应空操作

硬件层内存屏障
硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障:

  1. lfence,是一种Load Barrier 读屏障
  2. sfence, 是一种Store Barrier 写屏障
  3. mfence, 是一种全能型的屏障,具备lfence和sfence的能力
  4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

内存屏障有两个能力:
1. 阻止屏障两边的指令重排序
2. 刷新处理器缓存/冲刷处理器缓存

对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数 据;对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。

Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

paopaodog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值