并发编程艺术笔记:Java并发机制、Java内存模型

本文深入探讨了Java并发编程的关键概念,包括上下文切换、volatile的使用与优化、synchronized的锁升级机制以及原子操作的实现原理。文章详细讲解了Java内存模型的基础,如重排序、顺序一致性内存模型以及volatile的内存语义,强调了锁的内存语义以及final域的重要性。此外,还讨论了happens-before原则及其在多线程程序中的作用。
摘要由CSDN通过智能技术生成

目录

Java并发机制

上下文切换

Volatile

Synchronized

原子操作的实现

Java内存模型

1、Java内存模型的基础

2、重排序

3、顺序一致性内存模型

4、volatile的内存语义

5、锁的内存语义

6、final域的内存语义

7、happens-before

8、类的初始化过程


Java并发机制

上下文切换

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片(一般是几十毫秒)后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。

任务从保存到再加载的过程就是一次上下文切换。切换会影响多线程的执行速度。

减少上下文切换的方法:

  • 无锁并发编程:多线程竞争锁,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS算法:Java的Atomic包使用CAS算法来更新数据,不需要加锁。
  • 使用最少线程:避免创建不需要的线程,如任务很少,但创建了很多线程来处理,这会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

Volatile

volatile是轻量级的synchronized,保证了可见性。volatile不会引起线程上下文的切换和调度。

// java代码
instance = new Singleton(); // instance是volatile变量
// 转变成汇编代码                           Lock前缀指令
0x01a3de1d: movb $0×0,0×1104800(%esi);    0x01a3de24: lock addl $0×0,(%esp);

为了提高处理速度,处理器不直接和内存直接通信,而是先将系统内存的数据读到内部缓存后再进行操作,操作完不知何时再写到内存。但如果对声明了volatile变量进行写操作,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。处理器实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,若发现缓存行对应的内存地址被修改,则将缓存行状态设置为无效状态,当处理器需要对这个数据进行修改操作时,重新从系统内存中读取最新值。

volatile两条实现原则:

  • Lock前缀指令会引起处理器缓存回写到内存
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

volatile的使用优化

JDK 7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

追加字节优化性能?

LinkedTransferQueue使用一个内部类类型来定义队列的头节点(head)和尾节点(tail),而内部类就是将共享变量追加到64字节。一个对象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个字节。

为什么追加64字节能够提高并发编程的效率?

有些处理器的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着如果队列的头尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,在一致性协议作用下,会导致其他处理器不能访问自己高速缓存行的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,因此在多处理器的情况下会严重影响到队列的入队和出队效率。使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。

使用volatile变量不需要使用追加字节的场景:

  • 缓存行非64字节宽的处理器
  • 共享变量不会被频繁地写:多字节带来性能消耗,如变量不频繁写则没必要通过追加字节来避免相互锁定

这种追加字节的方式在Java 7下可能不生效,因为Java 7变得更加智慧,它会淘汰或重新排列无用字段,需要使用其他追加字节的方式。

Synchronized

JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter monitorexit 指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

锁的升级与降级:

上图转自:https://www.processon.com/view/5c25db87e4b016324f447c95

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

原子操作的实现

1、相关术语

  • 缓存行:缓存的最小操作单位
  • 比较和交换:在操作期间先比较旧值,若无变化则换成新值,发生变化则不交换
  • CPU流水线:由CPU中5~6个不同功能的电路单元组成一条指令处理流水线,一条指令能由这些电路单元分别执行,提高CPU的运算速度
  • 内存顺序冲突:一般由假共享(指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效)引起,当出现内存顺序冲突时,CPU必须清空流水线

2、处理器实现原子操作

1、使用总线锁保证原子性

总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,该处理器就可以独占共享内存。

2、使用缓存锁保证原子性

在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销大,目前处理器在某些场合下用缓存锁定代替总线锁定来进行优化。

频繁使用的内存会缓存在处理器高速缓存里,原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,而使用缓存锁定。“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

3、两种情况下不会使用缓存锁定:

  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,则处理器会调用总线锁定。
  • 有些处理器不支持缓存锁定。

3、Java实现原子操作

1、使用循环CAS实现(原子包装类,如AutomicInteger)

CAS实现原子操作的三大问题:

  • ABA问题:解决思路是使用版本号,可使用AtomicStampedReference
  • 循环时间开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销,pause指令执行了一个延迟的操作,提升了自旋等待循环的性能。
  • 只能保证一个共享变量的原子操作:当对多个共享变量操作时,循环CAS无法保证操作的原子性。解决方法可采用AtomicReference类来保证引用对象之间的原子性,即将多个变量放在一个对象里来进行CAS操作

2、使用锁机制

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。除了偏向锁,JVM实现锁的方式都用了循环 CAS,即当一个线程想进入同步块时使用循环CAS的方式来获取锁,当它退出同步块时使用循环CAS释放锁。

Java内存模型

Java内存模型的作用就是用来屏蔽掉不同操作系统中的内存差异性来保持并发的一致性。同时JMM也规范了JVM如何与计算机内存进行交互。简单的来说java内存模型就是Java自己的一套协议来屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性达到最终的"一次编写,到处运行"。

1、Java内存模型的基础

并发编程的两个关键问题:线程间如何通信 及 线程间如何同步

线程之间的两种通信机制:共享内存 和 消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信

在消息传递的并发模型里,线程之间没有公共状态,线程间必须通过发送消息来显式进行通信

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

内存模型的抽象结构

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。下图是JMM的抽象结构:

线程之间的共享变量(实例域、静态域、数组元素)存储在主内存中(Main Memory),每一个线程都有自己的本地内存(Local Memory),本地内存中存储着读、写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

由上图可以看出,线程之间的通信由两个步骤:

  • 线程A把修改后的本地内存中的共享变量更新到主内存中去
  • 线程B到主内存中读取线程A之前更新过的共享变量

从整体上看,这就是线程A在向线程B发送消息,而且这个通信过程必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。重排序分为3种。

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,重新安排语句执行顺序
  • 指令级并行的重排序:将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

问题:重排序可能会导致多线程程序出现内存可见性问题

对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。

对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类:

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)

happens-before简介

从JDK 5开始使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在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关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

happens-before与JMM的关系:

2、重排序

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译期和处理器考虑。

as-if-serial语义

as-if-serial语义即不管怎么重排序,(单线程)程序的执行结果不能被改变。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为重排序会改变执行结果。如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

程序顺序规则

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

这里A happens-before B,但实际执行时B却可以排在A之前执行。如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A 的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens-before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下, 尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义可以看出, JMM同样遵从这一目标。

控制依赖关系

当代码中存在控制依赖关系时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。

以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当flag条件判断为真时,就把该计算结果写入变量i中。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

3、顺序一致性内存模型

Java内存模型规范对数据竞争的定义:在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读、写操作。从上图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读、写操作串行化(即在顺序一致性模型中, 所有操作之间具有全序关系)

假设两个线程A,B并发执行,其中A线程有3个操作,它们在程序中的顺序是:A1-A2-A3。其中B线程也有3个操作,它们在程序中的顺序是:B1-B2-B3。

假设两个线程使用监视器锁来正确同步: 

假设这两个线程没有做同步: 

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。

顺序一致性模型与JMM的区别

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。
  • 顺序一致性模型保证所有线程能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。 
  • JMM不保证对64位long型和double型变量的写操作具有原子性,而顺序一致性模型能保证对所有的内存读、写操作都具有原子性。

总线工作机制

这3个差异与处理器总线的工作机制密切相关。 数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。

总线事务包括读事务和写事务。读事务从内存传递数据到处理器,写事务从处理器传送数据到内存,每个事务会读、写内存中一个或多个物理上连续的字。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。 

假设处理器A,B,C同时向总线发起总线事务,只是总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问,期间不管总线事务是读事务还是写事物。 

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线之中的内存读、写操作的原子性。

在JSR-133之前的旧内存模型中,一个64位long、double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。

从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long、double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

4、volatile的内存语义

volatile变量自身具有下列特性:

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对整个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读、写具有原子性,但类似于volatile++这种复合操作不具有原子性。

可以把对volatile变量的单个读、写,看成是使用同一个锁对这些单个读、写操作做了同步。

从JDK 5开始,volatile变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

volatile写-读的内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile写和volatile读的内存语义总结:

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

volatile内存语义的实现

为了实现volatile内存语义,JMM会分别限制编译器和处理器的重排序,volatile重排序规则表如下:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。为此,JMM采取保守策略。

基于保守策略的JMM内存屏障插入策略

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

编译器在生成字节码时可做优化:

5、锁的内存语义

锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁释放与volatile写有相同的内存语义,锁获取与volatile读有相同的内存语义。

锁释放和锁获取的内存语义总结:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质是线程A通过主内存向线程B发送消息

锁内存语义的实现

公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立刻变得对获取锁的线程可见。

非公平锁主要调用了CAS方法,CAS(compareAndSet)方法同时具有volatile读与写的内存语义,因此编译器不能对CAS与CAS前面和后面的任意内存作重排序。该方法为本地方法调用,查看OpenJdk c++源码可知使用了lock前缀,而intel的手册对lock前缀的说明如下:

  • 确保对内存的读-改-写操作原子执行,通过总线锁定、缓存锁定方法
  • 禁止该指令,与之前和之后的读和写指令重排序
  • 把写缓冲区的所有数据刷新到内存中

公平锁和非公平锁的内存语义总结:

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量
  • 公平锁获取时,首先会读volatile变量
  • 非公平锁获取时,首先用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义

因此,锁释放-获取的内存语义实现至少有两种方式:

  1. 利用volatile变量的写-读所具有的内存语义
  2. 利用CAS所附带的volatile读和volatile写的内存语义

concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信有4种方式:

  • A线程写volatile变量,随后B线程读这个volatile变量。
  • A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  • A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  • A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键。同时,volatile变量的读、写和CAS都可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基础。

6、final域的内存语义

final域的重排序规则

对于final域,编译器和处理器遵守两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作不能重排序

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2个方面:

  1. JMM禁止编译器把final域的写重排序到构造函数之外。
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序 (比如alpha处理器),这个规则就是专门用来针对这种处理器的。

final域为引用类型

在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能被重排序。

为什么final引用不能从构造函数内“溢出”

在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。

在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

final语义在处理器中的实现

由于X86处理器不会对写-写操作做重排序,所以在X86处理器中,写final域需要的StoreStore障屏会被省略掉。同样,由于X86处理器不会对存在间接依赖关系的操作做重排序, 所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,在X86处理器中,final域的读/写不会插入任何内存屏障!

JSR-133为什么要增强final的语义

在旧的Java内存模型中,一个最严重的缺陷就是线程可能看到final域的值会改变。比如, 一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间之后这个 线程再去读这个final域的值时,却发现值变为1(被某个线程初始化之后的值)。最常见的例子就是在旧的Java内存模型中,String的值可能会改变。

为了修补这个漏洞,JSR-133专家组增强了final的语义。通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在 构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

7、happens-before

程序员对内存模型的使用:程序员希望内存模型易于理解、易于编程。程序员希望基于 一个强内存模型来编写代码。

编译器和处理器对内存模型的实现:编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

JMM设计

happens-before的定义

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

第一条是JMM对程序员的承诺,为了保证内存可见性。

第二条是JMM对编译器和处理器重排序的约束原则。只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序), 编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

8、类的初始化过程

Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。

第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。

第4阶段:线程B结束类的初始化处理。

第5阶段:线程C执行类的初始化的处理。线程A和B的类初始化处理过程都经历了两次锁获取-锁释放。线程C的类初始化处理只需要经历一次锁获取-锁释放。

对于双重检测锁定与延迟初始化总结:字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果需要对实例字段使用线程安全的延迟初始化,使用基于volatile的延迟初始化的方案;如果需要对静态字段使用线程安全的延迟初始化,使用基于类初始化的方案。

JMM的内存可见性保证

按程序类型,Java程序的内存可见性保证可以分为下列3类:

  • 单线程程序:单线程程序不会出现内存可见性问题。编译器和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序:正确同步的多线程程序的执行将具有顺序一致性。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序:JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、false、null)。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值