0
点赞
收藏
分享

微信扫一扫

线程三大特性。

guanguans 2022-02-04 阅读 104

关于线程三大特性的影响

前言
在并发编程中 Java 内存模型是最晦涩难懂的部分,但它又十分的重要。本文将从线程三大特性的方向切入,从简入繁 介绍 JMM、Happens-Before、硬件内存模型等等,希望能对小伙伴们的学习有所帮助。

线程三大特性
可见性: Visibility
有序性: Ordering
原子性: Atomicity
线程三大特性我想大家都并不陌生, 而 Java 内存模型(JMM) 是围绕着并发过程中如何处理原子性、可见性和有序性这三个特征来定义的。我们下面就来理解这些问题的本质,而这些有跟我们底层操作系统和硬件设备有关。

CPU、内存和IO设备是一台计算机的核心组成部分,三者都不断迭代,不断变快,但又存在一个主要矛盾:三者之间的速度存在着量级上的差异。

如果我们把CPU、内存和IO设备的速度比作现实生活中的交通工具,那么CPU就像是火箭,内存就像是汽车而IO设备的速度只能算是步行了。

为了合理利用CPU,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现如下:

CPU添加高速缓存:来平衡与内存的速度差异。
分时复用思想:操作系统支持多线程、多进程,用分时复用的思想使用CPU从而平衡CPU与I/O设备的速度差异。
指令执行优化:编译程序优化指令执行次序,使得缓存能够更加合理的利用。
可见性
可见性就是指当一个线程修改了共享变量时,其他线程能够立即得知这个修改。但是由于CPU缓存的存在,可见性往往会存在一些问题

如下图所示:

多CPU下的线程可见性问题
在单核CPU架构下,所有的线程都是在一颗CPU上执行,因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

但是在多核CPU时代,每颗CPU都有自己的缓存,当多个线程在不同CPU上执行时,这些线程操作的时不同的CPU缓存。

线程A对CPU1缓存中的数据进行了修改,线程B不能立马可见,因为线程B操作的时CPU2的缓存,这就带来了多个线程操作共享变量的数据不一致问题。

原子性
分时复用导致的原子性问题:

无论是单核还是多核CPU,从宏观上我们都可以并发执行多个进程,从微观的角度看实际上是操作系统给每个进程分配一个时间片,多个进程分时复用CPU。他带来了诸多好处,让某个进程不会因为等待IO而浪费CPU资源。然而不同的进程是不共享内存空间的,所以在做任务切换时就要切换内存映射地址,这种切换是一种重量级的切换,而现在的操作系统普遍都基于更轻量的线程来调度,进程内的所有线程共享一个内存空间,所以线程的切换成本更低。

这样的切换被称为上下文切换(上下文切换是并发编程中十分重要的一个概念,但不是今天的重点,就不展开讲了),但上下文切换也为我们带来了原子性的问题,我们拿java中的代码来说:

//假设i的初始值为0
i += 1
这段代码在底层至少需要三条CPU指令

1:把变量i的值从内存 load 到CPU寄存器

2:在CPU中执行+1

3:将结果 store到内存,当然有可能只存到缓存(更严谨的说应该是写缓冲区),并没有刷新到主存中。

虽然每条指令具备原子性,但在进行上下文切换,可能发生在任意一条CPU指令执行完之后(注意时CPU指令级别),这对于高级编程语言来说就会在多线程并发时造成原子性问题,如下图所示:

线程切换带来的原子性问题
有序性
有序性,顾名思义就是程序按照代码的先后顺序来执行。有些小伙伴会疑惑?这难道不是理所当然的么?但事实并非如此。

处理器乱序执行:

为了使处理器内部的运算单元能尽量被充分利用,处理器可能对输入代码进行乱序执行(Out-Of-Order Execution)优化,也就是说处理器可能会次序颠倒的执行命令。数据可能在寄存器,处理器缓冲区和主存以不同的次序移动,而不是按照程序指定的顺序。

但为何这种乱序执行在平常的开发中无感呢?

as-if-serial语义:

不管怎么重排序(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

但as-if-serial语义只对(单线程)代码行的执行顺序进行了约束,却无法约束多线程和单条代码指令的有序性。

下面我们来看一段代码:

public class NewObj {
int i = 1;

public static void main(String[] args) {
    NewObj obj = new NewObj();
}

}
对应的字节码如下

0 new #3 <com/gigigi/spring/test/NewObj>
3 dup
4 invokespecial #4 <com/gigigi/spring/test/NewObj. : ()V>
7 astore_1
8 return
过程大致可分为这么几步:

分配一块内存
在内存空间上初始化对象
将内存空间的地址赋值给引用变量
事实上步骤2和步骤3在某些时刻执行顺序可能会颠倒

在 Java 中最经典的案例就是双重锁校验创建单例对象了

public class Singleton {
//构造私有
private Singleton() {

}

//这里不加 volatile 会造成一些问题
static Singleton instance;

static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

}
被优化的指令可能变成这样:

分配一块内存M
将M的地址赋值给instance变量
在内存M上初始化Singleton对象
这会导致线程A在进行new创建对象时,已经到第二个指令处了,此时线程B来到了第一个执行处,发现instance已经不为null了,然后将其返回,导致线程B拿到一个未初始化的完成的对象造成空指针。

我们都知道三大特性问题,那Java又是如何解决的呢?下面就到了今天的主题JMM。

JMM(Java Memory Model)
定义 Java 内存模型并非一件容易的事,这个模型必须定义的足够严谨,才能让 Java 的并发内存访问操作不会产生歧义。我们不能单纯的禁用CPU缓存和编译优化,这样会严重影响程序性能。所以定义必须足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来取得更好的执行速度。

经过长时间的验证和修补,直至JDK5 (JSR-133)发布后,Java 内存模型才终于成熟、完善起来

JSR-133 :Java Memory Model and Thread Specification Revision (Java 内存模型和线程规范修订)

主内存与本地内存
JMM主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的地层细节,也是决定一个线程对共享变量的写入何时对另一个线程可见。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,而每个线程都有一个私有的本地内存(工作内存),本地内存中存储了该线程以读/写共享变量的副本。

JMM
注:本地内存是一个抽象概念,并不真实存在。它包含了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

主内存:

JMM规定了所有的变量都存储在主内存中(此处的主内存与物理硬件的主内存名字一样,两者也可以“类比”,但物理上它仅是虚拟机内存的一部分)

本地内存(工作内存):

每条线程都有自己的工作内存(Working Memory,可以和处理器高速缓存“类比”),线程的工作内存中保存了该线程使用的变量的主内存副本。

注: 这里必须注意,并不是把整个对象复制一次,而是复制这个对象的引用、对象中某个在线程访问到的字段。(我个人对这段的理解,应该和JVM中标量替换的实现类似,即聚合量拆分为标量,如果有误请指正。)

线程对变量的所有操作(读、写)都必须在工作内存中进行,而不能直接读写主内存中的数据。(根据Java 虚拟机规范,即使是volatile也不例外,volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,看起来如同直接到主存中读写访问一般)。

内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了8种操作来完成。Java虚拟机实现时必须保证下面提及的每个操作都是原子性的、不可再分的(但对于 double 和 long 类型来说允许有例外,这个不是今天的重点,所以不在本文中详细阐述)

JMM定义了这8种操作,并对他们进行了规范,而这些规范是解决上文种提到的三特性问题的根本

JMM的8种操作
主内存中的操作:

lock(锁定):把一个变量标记为一条线程的独占状态。
unlock(解锁):把一个处于锁定状态的变量释放出来。
read(读取):它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
write(写入):它把 store 操作从工作内存中得到的变量值放入主内存的变量中。
工作内存中的操作:

load(载入): 它把 read 操作从主内存中得到的值放入工作内存的变量副本中。
use(使用):它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):它把一个从执行引擎收到的赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):它把工作内存中一个变量的值传送到主内存中,以便后续的 write 操作使用。
JMM 还规定了上述 8 种基本操作,需要满足以下规则:

不允许 read 和 load 、store 和 write 操作单一出现。
不允许一个线程丢弃它最近的 assign 操作,及工作内存值被改变之后必须同步回主内存。
不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存
一个新变量只能从主存中”诞生“,也就是说 user 、store 操作之前,必须先执行 assign 和 load 操作。
一个变量在同时只能被一个线程 lock ,但可以被同一个线程多次 lock ,之后只有执行相同次数的 unlock 才能被解锁。
如果一个变量执行了 lock 操作,将会清空工作内存中的此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量值。
如果一个变量没有被 lock ,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程 lock 的变量。
对第一个变量执行 unlock 之前,必须把此变量同不回主内存中(执行 store 、 write 操作)。
基于理解难度和严谨性考虑,最新的JSR-133文档中, 已经放弃了采用8种操作去定义 Java 内存模型的访问协议,缩减为4种 (read 、write 、lock 、unlock)但这仅是描述上的改变,Java内存模型并没有改变。

回到三特性的问题上
从上文我们可以看出一些 java 内存模型是如何实现三特性的端倪,但具体是如何做到的呢?我们先从原子性的保证开始

原子性保证:

当我们的代码需要原子性保证时,可以使用 lock 和 unlock 操作来满足要求, 尽管虚拟机并未把 lock 和 unlock 操作直接开放给用户使用,但却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式的使用这两个操作,而这两个字节码指令反映到 Java 代码中就是同步代码块 —— synchronized 关键字。

我们来看一下这段代码:

public void test01(){
    synchronized (this){
        int i = 1;
    }
}

他的字节码如下:

0 aload_0
1 dup
2 astore_1
3 monitorenter
4 iconst_1
5 istore_2
6 aload_1
7 monitorexit
8 goto 16 (+8)
11 astore_3
12 aload_1
13 monitorexit
14 aload_3
15 athrow
16 return
其中 3、7、13 便对应的是synchronized代码块了

注: 这里为什么会出现两次 monitorexit ?实际上 monitorexit 并没有执行两次,这里是异常表对安全释放锁的一种保证,类似我们平时开发中的try-finally。synchronized 并不是我们今天的主角,在这里就不展开讲了。后边有时间会单独写一篇来介绍 synchronized 。

可见性保证:

volatile

提到可见性保证就不得不提到 java 关键字 volatile 。volatile 保证了变量的可见性和有序性(volatile 在 JDK 1.5 之后才禁止了指令重排),而 JMM 有对 volatile 变量定义了哪些特殊规则呢?

假设 T 表示一个线程, V 和 W分别表示两个 volatile 型变量,那么在进行 read 、 load、 use 、assign 、store 、write 操作时需要满足一下规则:

只有当线程T对变量V执行的前一个动作时 load 时,线程T才能对变量V执行 use 动作;并且,只有当线程T对变量V执行的最后一个动作时 use 时,线程T才能对变量V执行 load 动作。线程T对变量V的 use 动作和 load 、read 动作是相关联的,必须连续且一起出现。这条规则保证了T线程对其他线程修改的变量的可见性
只有当线程T对变量V执行的前一个动作是 assign 时,线程T才能对变量V执行 store 动作;并且,只有当线程T对变量V执行最后一个动作是 store 时,线程T才能对变量V执行 assign 动作。 线程T对变量V的 assign 动作和 store 、 write 动作是相关联的,必须连续且一起出现。这条规则确保了其他线程对T线程修改的变量的可见性
如果把线程T对变量V实施的 use 或 assign 动作比作 A ,对变量W实施的 use 或 assign 比作B。对变量V实施的 load 或 store 动作比作 F,对变量W实施的 load 或 store 比作G。对变量V实施的 read 或 write 动作比作 P,对变量W实施的 read 或 write 动作比作Q。那么如果A先于B,那么P先于Q。这条规则保证了volatile修饰的变量不会被指令重排序优化。
内存屏障

我们说 volatile 能禁止指令重排,他的底层到底如何实现的呢?下面我们从不同的层面来看这个问题。

字节码层面

添加访问标记(acces flag): [volatile]

JVM层面

JVM拿到 volatile 标记的字节码, 它的处理是采用内存屏障来保证屏障两边的指令不可以重排,保证有序性。

下面我们来看JSR内存屏障的规范要求:

StoreLoad Barriers 类型同时具备其他三个屏障的效果,因此也成为全能屏障(mfence属于这种类型),执行此屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)

另外现代的多处理器大多也支持内存屏障的系统原语,比如:

lfence,一种 Load Barrier 读屏障,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存中加载数据。
sfence,一种 Store Barrier 写屏障,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
mfence,一种全能屏障,具备 lfence和 sfence的能力。
HotSpot实现

HotSpot虚拟机在内存屏障的实现上略有不同,并没有采用系统底层支持的内存屏障原语,它是通过一个lock前缀来完成的。

Java代码:

instance = new Singleton();//instance是volatile变量
汇编码:

0x01a3de1d: movb $0x0,0x1104800(%esi);

0x01a3de24: lock addl $0x0,(%esp);
在被 volatile 修饰的变量,在赋值后(movb $0x0,0x1104800(%esi) 这句就是赋值操作)多执行了一个 ”lock addl $0x0,(%esp);“操作,这个操作的作用相当于一个内存屏障,这句命令中“addl $0x0,(%esp)”(把ESP寄存器的值加0)显然是一个空操作,之所以用空操作而不是用空操作专用指令 nop ,是因为 IA32 手册中规定了 lock 前缀不允许配合 nop 指令使用。而这个关键的 lock 前缀的作用就是将本处理器的缓存写入内存,该写入动作也会引起别的处理器或者别的内核无效化其缓存,这种操作相当于JMM中所说的 store 和 write 操作。

Doug Lea 列出的各种处理器框架下的内存屏障指令:

The JSR-133 Cookbook
​gee.cs.oswego.edu/dl/jmm/cookbook.html
synchronized

相比于 volatile ,synchronized 就简单的多,它的可见性是由一个变量执行 unlock 操作之前,必须先把此变量同步回主存中(也就是执行 store 、write 操作)这条规则获得。

final

而关键字 final 的可见性是指: 被 final 修饰的字段在构造器中一旦被初始化,且没有this逃逸,那么其他线程中就能看见 final 字段的值。

有序性保证:
Java 提供了 volatile 和 synchronized 两个关键字来保证线程之间的有序性,上文中提到volatile 本身就包含了禁止指令重排的语义。而 synchronized 则是由一个变量在同一时刻只允许一个线程对其进行 lock 操作这条规则来保证有序性的。

Happens-Before
如果 JMM 中所有的有序性仅依靠 volatile 和 synchronized 来完成,那么很多操作都将变的非常啰嗦,但我们在编写 Java 代码时并没有察觉这一点,这是因为 Java 语言中一个 Happens-Before 原则。它十分十分重要,是本文中第二个重点,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

Happens-Before规则最初是在一篇叫做 Time, Clocks, and the Ordering of

Events in a Distributed System 的论文中提出来的,在这篇论文中,Happens-

Before 的语义是一种因果关系。

http://lamport.azurewebsites.net/pubs/time-clocks.pdf
​lamport.azurewebsites.net/pubs/time-clocks.pdf
而从 JDK 5 开始,java 使用新的 JSR-133内存模型,而JSR-133就是使用 Happens-Before 的概念来阐述操作之间的内存可见性的。

happens-before 与 JMM 的关系如图:

程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作 happens-before 与书写在后的操作。
注意: 这里的程序顺序也可能是重排序的顺序,也就是上文中提到的的 as-if-serial语义。

这里引入一个概念:数据依赖性

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

这个概念也解释了Happens-Before的某些行为。

以下段代码为例:

int a = 1; //A
int b = 2; //B
int c = a * b * b;//C
按照Happens-Before的程序顺序规则,上述代码存在3种Happens-Before关系:

A happens-before B
B happens-before C
A happens-before C
但事实上 B 可以排在 A 之前执行,因为 A 和 B 并不存在数据依赖关系,并且 B 在 A 之前执行并不影响最后的执行结果。 所以 JMM 并不要求 A 一定要在 B 之前执行。这种不影响结果的乱序是 JMM 所允许的。

管程锁定规则:一个 unlock 操作 Happens-Before 于后面对同一个锁的 lock 操作。 这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。这里也顺便简单的解释下管程的意思吧:管程(Monitors监视器)管程是一种通用的同步原语,实现资源互斥访问,java 中的 synchronized 就是对管程的实现。
volatile变量规则:对一个 volatile 变量的写操作 Happens-Before 与后面对这个变量的操作。(具体的实现上文中以阐述的非常清楚)
线程启动规则:Thread 对象的 start()方法 Happens-Before 于此线程的其他每一个动作。
线程终止规则:线程中所有操作都 Happens-Before 此线程的终止检测,我们一般通过 Thread::join()方法是否结束或者Thread::isAlive()的返回值等手段检测线程是否终止执行。
线程中段规则:对线程 interrupt() 方法时 Happens-Before 被中断线程的代码检测到中断事件的发生(中断检测后于中断指令执行),一般可以通过Thread::interupted()方法检测是否中断。
对象终结规则:一个对象的初始化完成 Happens-Before 它的finalize()方法的开始。(finalize() GC前的回调操作)
传递性:如果 A 操作 Happens-Before 于 B 操作,B 操作 Happens-Before 于 C 操作,那么 A 操作 Happens-Before 于 C 操作。
硬件内存模型
这里再讲一讲硬件内存模型,上文中我们频繁引用了CPU缓存、高速缓存、写缓冲区等硬件内存模型的相关概念。下面我们来说说硬件的内存模型。

正确理解硬件的内存模型对于理解 JMM 会很有用。

在文章的开头我们提到了CPU、内存、I/O设备之间的速度差异,而对于现代处理器为了能更充分的利用计算机处理器的效率,不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲;

基于高速缓存的存储交互很好的解决了处理器与内存之间的矛盾,但也为计算机系统带来了更高的复杂度,它引起了一个新的问题: 缓存一致性(Cache Coherence)

在多路处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统,如下图:

那么当多个处理器的运算任务都涉及到同一块主存区域时,该以谁的数据为准呢?

为了解决一致性问题,各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,除了常见的 MESI(Illinois Protocol)协议外,还有MSI、MOSI、Synapse、Firefly 及 Dragon Protocol 等。我们可以理解为在特定的操作协议下,对特定内存或高速缓存进行读写访问的抽象过程,就是内存模型的定义。

高速缓冲存储器一致性_百度百科

不同架构的物理机器可以拥有不一样的内存模型。

那么问题来了,因为不同的平台上的内存模型存在着差异,如果我们直接使用物理硬件和操作系统的内存模型,可能会导致程序在一套平台上并发完全正常,而在另一套平台上并发访问却经常出错(如 C 和 C++ 等,这也导致了需要在某些场景下必须针对不同的平台来编写程序)而JMM就是为屏蔽这种差异而存在的。

注:《Java 虚拟机规范》曾试图定义一种“Java 内存模型”以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果。(《Java 虚拟机规范》第二版之前,专门有一章来描述内存模型,后来由于这部分内容难以把握宽紧限度,被反复修改更正。所以从第三版(JAVA SE 7版)开始索性就被移出规范,独立以 JSR 形式维护。)

在学习 Java 内存模型时,一定有小伙伴会错误的认为 JMM 中定义的工作内存等同于虚拟机栈,而主内存则对应的是堆,或者认为硬件内存模型中的主内存就是 JMM 中的主内存,CPU缓存就是 JMM 中的工作内存。实际上这两种理解都是不准确的。

JMM其实更像是一个桥梁,JMM中所有的概念都是抽象存在的,它既不是硬件内存模型中的概念,也更不能等同与 JVM 中的堆栈。 JMM 的根本其实就是定义某种的规范来屏蔽在各种不同的硬件和操作系统中内存的访问差异。

举报

相关推荐

0 条评论