type
status
date
slug
summary
tags
category
icon
password
一、Java并发问题
1、什么是线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
为什么不把所有操作都做成线程安全的?
实现线程安全是有成本的,比如线程安全的程序运行速度会相对较慢、开发的复杂度也提高了,提高了人力成本。
2、并发问题是什么
并发问题就是线程不安全。表现为当多线程同时读写一个变量时,因为原子性、缓存可见性、指令重排序等原因,导致变量的实际执行结果和预期不一致。
如上代码,
inc()
和inc2()
都不是线程安全的。- 当两个线程持有 Test 类的同一个实例对象,同时执行
inc()
方法时,且各自执行 1 万次时,得到的结果并不是 m 和 n 都等于 2 万,而是都是 1 万多。
- 当两个线程同时执行
Test.inc2()
1 万次时,得到的结果也不是 m 等于 2 万,而是 1 万多。
3、并发问题产生的原因
那么什么情况下会引发并发问题呢?首先我们先了解并发的三大特性:
可见性
:一个线程对共享变量的修改,另外一个线程能够立刻看到。
原子性
:一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
有序性
:程序执行的顺序按照代码的先后顺序执行。
只要破坏这三个特性其中之一,就会导致并发问题。
现代计算机上 CPU 和外设 IO、主存之间存在巨大的速度差距,为了提高 CPU 计算能力的利用率,提升整体系统性能,做了诸多改进。但这些系统的优化机制,导致了多线程同时读写一个变量时,会引发并发问题。
- 为了解决 CPU 和主存速度差异问题,并提升 CPU 计算能力,引用了多内核 CPU,并且每个内核都有各自的独立缓存,由此引发了缓存可见性问题。
- 为了解决外设 IO 速度慢的问题,引入了进程和线程等任务的切换功能,由此引发了操作的原子性问题。
- 为了提升 CPU 单个内核流水线的利用效率,编译器和内核都对指令的执行顺序进行了重排序。
1)可见性问题:CPU缓存引起
由于 CPU 执行速度远远快于内存存取速度,所以为了提高 CPU 利用率,系统在 CPU 和内存之间添加了高速缓存 cache。
- 当 CPU读取数据时,先访问 cache,如果 cache命中,就不再访问主存,直接返回 cache 中的数据。
- 当 CPU 写数据到内存时,会先写到cache,通常不会马上写回主存,只有 cache 要被替换或者 cache 无效时,才会写回主存。
单核 CPU 不会有不一致问题,这个问题只会出现在多核 CPU 上,现代处理器大部分都是多核心 CPU。
在多核CPU上,每个内核都有自己的独立缓存。当多线程读写同一变量时,如果线程运行在不同内核上,那么它们对同一变量的读写操作就分别在不同的 cache 中执行。每个 cache 都是独立的,互相不可见,单个 cache 中对变量缓存的操作不会影响别的 cache,也不知道别的 cache 中的数据,这样就会导致最终结果的不可控。
假若有两种线程,执行线程 1 的是 CPU1,执行线程 2 的是 CPU2。
- 当线程 1 执行
i = 10
,把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为10,那么在 CPU1 的高速缓存当中 i 的值变为 10了,此时还没有写入到主存当中。
- 此时线程 2 执行
j = i
,它会去主存读取 i 的值并加载到 CPU2 的缓存当中,但内存当中 i 的值还是0,那么就会使得 j 的值为 0,而不是 10。
2)原子性问题:线程切换引起
原子性是指一个操作要么全部执行完毕,要么全部不执行。并发读写同一变量的线程之间任务切换时,如果对变量的读写操作不是原子性的,就会导致并发问题。
i += 1
这行代码的执行并不是原子性的,而是需要三条 CPU 指令,- 将变量 i 从内存读取到 CPU 寄存器;
- 在 CPU 寄存器中执行 i + 1 操作;
- 将最后的结果 i 写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
假如 i 的初始值是 1,考虑下面的情况:
- 线程 1 执行第一条指令,从内存读到的 i 值是 1,还没开始执行第二条指令,发生了线程切换,切换到线程 2 执行。
- 线程 2 从内存中读到的 i 值还是 1, 执行三条指令后写入内存,此时内存的 i 值是 2。
- 切换到线程 1 执行后续两条指令,最后写到内存中的 i 值是 2 而不是预期的 3。
3)有序性问题:重排序引起
为提升内核自身执行速度,内核内部使用流水线并行执行多条指令。指令之间通常有因数据相关、名称相关、控制相关造成的依赖关系,这导致流水线中后面的指令需要等待前面的指令完成后才能执行,大大降低了流水线的并行度。
为提高流水线并行性能,编译器和内核通常会对指令进行静态和动态调度,在不影响单线程执行结果的前提下,把无关的指令插入到指令空闲的位置提前执行,以提升流水线性能。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序::
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
上面代码,如果单线程按顺序执行,打印的值必定是8。不过
init()
经过重排序后,因为语句 1 和语句 2 没有相关性,可能重排序后的顺序为语句 2 在前,语句 1 在后。如果是这样的话,当多线程同时访问时,线程 1 执行完语句 2 后,线程 2 执行getValue()
方法,那么此时线程 2 打印的结果就可能是错误的 0。二、如何解决并发问题
1、顺序一致性模型
顺序一致性模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。
为了理解这两个特性,我们举个例子,假设有两个线程 A 和 B 并发执行,线程 A 有 3 个操作,他们在程序中的顺序是
A1->A2->A3
,线程 B 也有 3 个操作 B1->B2->B3
。假设正确使用了同步,A 线程的 3 个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上有序,并且两个线程都只能看到这个执行顺序。
假设没有使用同步,那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见。
2、JMM 模型
JMM 简单来说是一种虚拟机规范的内存模型,主要作用有两个:
- 提供一套统一内存模型用于屏蔽掉各种硬件和操作系统的内存访问差异。
- 规定并发编程的规范。规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,例如一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
JMM 模型并没有完全实现顺序一致性模型。所以JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。
JMM 的具体实现原则是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门。JMM 对于正确同步多线程程序的内存一致性做了以下保证:
- 如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。这里的同步包括使用
volatile
、final
、synchronized
等关键字实现的同步。
- 如果程序是未正确同步的,JMM 不会有内存可见性的保证,很可能会导致程序出错。此时 JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。
3、JMM 提供的统一内存模型
现代操作系统的硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在 CPU 缓存中和 CPU 内部的寄存器中。整体结构如下图所示。
JMM 为了屏蔽硬件和操作系统的差异,抽象了主内存和本地内存的概念。
- 主内存:所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。从更低的层次可以理解为硬件的内存。
- 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。从更低的层次可以理解 CPU 的寄存器和高速缓存的抽象。
线程间通信必须要经过主内存。线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
Java 内存结构和 JMM 有何区别?
- JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
- Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
4、JMM 定义的并发编程的规范
一方面,我们开发者需要 JMM 提供一个强大的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
为此从 JDK 5开始,Java使用新的 JSR-133 内存模型,引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
1)happens-before原则
happens-before 原则的设计思想很简单: 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 JSR-133 对 happens-before 原则的定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
例如下面这段代码:
- 1 happens-before 2
- 2 happens-before 3
- 1 happens-before 3
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。
happens-before 的规则就 8 条,重点了解下面列举几条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。
- 程序顺序规则:同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
- synchronized 规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile 规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
- 线程启动规则:线程的start() 方法 happen-before 该线程所有的后续操作。
- join 规则:线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
happens-before 与 JMM 的关系如下图所示
我们可以得出以下结论:
- 对于开发者而言,我们只关心 happens-before 规则,不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就行了。
- 对于 JMM 而言,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。对于不同性质的重排序,JMM 采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。
2)as-if-serial 语义(了解即可)
as-if-serial 的语义:无论如何重排序,程序的最终执行结果是不能变的。
happens-before 关系本质上和 as-if-serial 语义是一回事。as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。
3)内存屏障(了解即可)
内存屏障,又称内存栅栏,是一个 CPU 指令。插入一条内存屏障会告诉编译器和 CPU,
不管什么指令都不能和这条 Memory Barrier 指令重排序
。happens-before 原则的实现是通过内存屏障来保证内存可见性和禁止某种类型的重排序规则。具体做法是:- 对于编译器重排序,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
- 对于处理器重排序,由于常见的处理器内存模型比 JMM 要弱,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。 |
下面是常见处理器允许的重排序类型的列表:
\ | Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 |
sparc-TSO | N | N | N | Y | N |
x86 | N | N | N | Y | N |
ia64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
由此可见:
- 常见的处理器都不允许对存在数据依赖的操作做重排序。
- 常见的处理器都支持
StoreLoad
重排序。StoreLoad
会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令,是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。
三、Java 实际解决并发问题
JMM 和 happens-before 原则只是定义了一些规范来解决并发问题。对于 Java 开发者而言,可以不需要了解这些底层原理,直接使用并发相关的一些关键字和类即可开发出线程安全的程序。
volatile
synchronized
final
1、volatile
Java 并发编程中常用到 volatile 关键字,一般用来修饰某个变量。例如 Java 代码如下:
那这个关键字是怎么发挥作用的呢?volatile 的实现原理是:加入 volatile 关键字后,生成的汇编代码前面多出一个 lock 前缀指令,这个 lock 前缀指令就是关键。主要有两部分作用:
保证可见性
:lock 前缀指令会禁止线程本地内存缓存。每个线程在写变量的时候,先当前处理器缓存行的数据写回到系统内存,然后使在其他 CPU 里缓存了该内存地址的数据无效,从而保证不同线程之间的内存可见性。
保证有序性
:lock 前缀指令本身就起到一个内存屏障的作用。确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面,即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
注意 volatile 不保证原子性:对 volatile 变量的单次读/写操作可以保证原子性的,但是并不能保证i++
这种操作的原子性,因为本质上i++
是读、写两次操作。对于复合操作,可以:
- 同步块技术(锁)
- Java concurrent包(原子操作类等)
使用 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
这段汇编代码的执行过程是:
- 对总线和缓存上锁。
- 强制所有 lock 信号之前的指令,都在此之前被执行,并同步相关缓存。
- 执行 lock 前缀后的指令(这里是
movl
)。
- 释放对总线和缓存上的锁。
- 强制所有 lock 信号之后的指令,都在此之后被执行,并同步相关缓存。
lock 前缀在锁缓存的时候有两种方案:
- 锁总线(lock bus):在 Pentium 和早期的 IA-32 处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存。锁总线的开销比较大。
- 锁缓存(lock cache):在新的处理器中,Intel 使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低 lock 前缀指令的执行开销。这里锁缓存(Cache Locking)就是用了
Ringbus + MESI
协议。
上面提到 JMM 提供了四种内存屏障。不同的内存屏障本质是通过 lock 后面跟不同的指令来实现。例如
storeload
屏障通过 lock addl $0x0,(%rsp)
指令来实现。查看 jdk 源码四种内存屏障的实现如下:JSR-133 严格限制 volatile 变量与普通变量的重排序,针对编译器制定 volatile 重排序规则如下:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
由此可以看出,volatile 写操作是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
2、synchronized
synchronized 是并发编程常用的同步锁,一般用来锁住方法或者代码块。synchronized 的实现原理是:
- 首先每个 Java 对象有一个内置监视器锁(monitor),这些内置锁是 Java 开发者看不到的。当 monitor 被占用时就会处于锁定状态。每一个对象在同一时间只与一个 monitor 相关联,而一个 monitor 在同一时间只能被一个线程获得。
- synchronized 的作用就是自动获取和释放 monitor 的使用权。在某个线程进入 synchronized 代码块的时候会自动获取 monitor 的使用权;在退出 synchronized 代码块的时候会自动释放 monitor 的使用权。
从 JMM 的实现原理来看,synchronized 是通过
monitorenter
和 monitorexit
字节码来实现:- monitorenter:尝试获取 monitor 锁的所有权。
- 如果 monitor 的进入数为0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者。
- 如果这个 monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加。
- 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为0,再重新尝试获取 monitor 的所有权。
- monitorexit:释放 monitor 锁的所有权。
- 释放过程很简单,就是 monitor 的计数器减1。
- 如果减完以后,计数器不是 0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权。
- 如果计数器变成 0,则代表当前线程不再拥有该 monitor 的所有权,即释放锁。
从更底层的操作系统原理来看,JMM 中 的
monitorenter
和 monitorexit
字节码依赖于底层的操作系统的 Mutex Lock
来实现的,简单了解即可。synchronized 为并发编程提供了以下特性:
保证原子性
:也称为互斥性,即在同一时间只允许一个线程持有某个对象锁,其他线程必须等待该线程释放锁后才能获取该锁。这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。因为可以锁住代码块,所以 synchronized 相较于 volatile 关键字,可以提供原子性的保证。
保证可见性
:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。
1)synchronized的用法
synchronized 有三种用法:
- 修饰普通实例方法,锁对象为 this
- 修饰静态方法,锁对象为当前类的 class 对象
- 修饰代码块,分两种情况。
- synchronized(this):锁对象为 this。效果等同于上面的修饰普通实例方法。
- synchronized(指定类.class):锁对象为指定类的 class 对象。如果指定类是当前类,效果等同于上面的修饰静态方法。
2)synchronized的优化
同步方法是运行在单线程环境或者无锁竞争环境下,如果每次都调用
Mutex Lock
那么将严重的影响程序的性能。jdk1.6 为了减少获取锁和释放锁的性能消耗,引入 偏向锁
和 轻量级锁
的概念,优化后的 synchronied 同步锁一共有四种状态:无锁
偏向锁
轻量级锁
重量级锁
另外提供了其他的锁优化手段:
适应性自旋
锁消除
锁粗化
那么这些锁是怎么保存和升级的呢?首先我们要知道,Java 对象保存在内存中时,是由以下三部分组成的:
- 对象头
- 实例数据
- 对齐填充字节
而 Java 对象头又包含三部分信息:
Mark Word
用于存储自身的运行时数据,如:HashCode、GC分代年龄、锁标记、偏向锁线程ID等。
类型指针
即对象指向它的类元信息,虚拟机通过这个指针来确定这个对象是哪个类的实例。
数组长度(可选)
如果java对象是一个数组,那么对象头中还必须有一块用于记录数组长度的数据。
synchronized 用的锁存在于 Java 在对象头中的
Mark Word
。在 32 位虚拟机中,Mark Word
存储结构如下图所示:在 64 位虚拟机中,
Mark Word
存储结构如下图所示,可以看到 64 位虚拟机其实是浪费了一部分空间的,JVM支持通过 -XX:+UseCompressedOops
参数来进行指针压缩。不管是 32 位虚拟机还是 64 位虚拟机,最后 2bit 存放的是锁状态的标志位,用来标志当前对象的状态,对象所处的状态,决定了 markword 存储的内容。其中无锁和偏向锁的锁标志位都是
01
,只是在前面的 1bit 区分了这是无锁状态还是偏向锁状态。可以看出所谓的 synchronized 加锁,其实就是修改对象头里面的Mark Word
数据,所以 synchronized 可以对任何一个对象加锁。
锁会随着竞争情况发生膨胀,锁膨胀方向:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
。级别越高对性能的消耗越大,锁可以升级但是不可以降级。整个锁膨胀的过程如下图所示:锁膨胀过程中锁对象的
Mark Word
的变化过程:3)无锁
默认情况下:
- JVM 在启动之后,某个对象处于
无锁态
,这时对象的Mark Word
中后三位已经是001
。
- 如果经过 4 秒都没有发生线程竞争,那么对象会进入
无锁可偏向
状态,也称为匿名偏向
(Anonymously biased)状态。这时对象的Mark Word
中后三位已经变成101
,但是 threadId 指针部分仍然全部为 0,它还没有向任何线程偏向。
为什么要设置 4 秒的偏向锁开启的延迟时间呢。因为 JVM 内部的代码有很多地方也用到了 synchronized,明确在这些地方存在线程的竞争,如果还需要从偏向状态再逐步升级,会带来额外的性能损耗,所以 JVM 设置了一个偏向锁的启动延迟,来降低性能损耗。如果我们让偏向锁在程序启动时立刻启动 ,可以设置参数
-XX:BiasedLockingStartupDelay=0
来关闭偏向锁启动延迟。如果我们确定代码中同步资源一直是被多线程访问的,其实就没必要从偏向锁开始升级了,因为开启反而会因为偏向锁撤销操作而引起更多的资源消耗。可以设置
-XX:-UseBiasedLocking
关闭偏向锁。如果关闭偏向锁,那么某个对象从被创建直到有线程获取这个锁对象之前,会一直处于无锁不可偏向状态,Mark Word
后三位为 001
。在无锁不可偏向状态下,如果有线程试图获取锁,那么将跳过升级偏向锁的过程,直接使用轻量级锁。这个过程可以用下面图来表示:
4)偏向锁
在偏向锁开启的情况下,当共享资源对象首次被某个线程访问的时候,会把该对象头中的线程 ID 设置为当前线程 ID,后续当前线程再次访问这个共享资源时,会根据偏向锁标识跟线程 ID 进行比对是否相同,比对成功则直接获取到锁,起到锁重入的功能。
一旦出现其它线程竞争锁资源,偏向锁就会被撤销。偏向锁适用于同一线程多次申请同一个锁的场景。
偏向锁获取的具体过程如下::
- 首先获取锁对象头中的
Mark Word
,判断当前对象是否处于可偏向状态(即偏向锁打开,且当前没有其他对象获得偏向锁)。
- 如果是可偏向状态,则通过 CAS 原子操作,尝试把当前线程的 ID 写入到
Mark Word
,如果 CAS 成功,表示获得偏向锁成功。
- 如果是不可偏向状态,检查
Mark Word
中的 ThreadID 是否和自己相等,如果相等则不需要再次获得锁,可以直接执行同步代码块,如果不相等,说明当前偏向的是其他线程,需要撤销偏向锁并升级到轻量级锁。
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是直接把被偏向的锁对象升级到被加了轻量级锁的状态。偏向锁的撤销需要等待全局安全点 Safe Point(安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的安全状态,在这个状态上会暂停所有线程工作)。偏向锁撤销的具体过程如下:
- 在这个安全点会挂起获得偏向锁的线程。
- 在暂停线程后,会通过遍历当前 JVM 的所有线程的方式,检查持有偏向锁的线程状态是否存活:
- 如果线程还存活,且线程正在执行同步代码块中的代码,则升级为轻量级锁。
- 如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向。
- 不允许重偏向,则撤销偏向锁,将
Mark Word
升级为轻量级锁,进行 CAS 竞争锁 - 允许重偏向,设置为匿名偏向锁状态,CAS 将偏向锁重新指向新线程
- 完成上面的操作后,唤醒暂停的线程,从安全点继续执行代码。
偏向锁不一定会只升级成轻量级锁,下面情况偏向锁会直接升级成重量级锁。
- 调用了对象的
wait()
方法后,直接从偏向锁升级成了重量级锁,并在锁释放后变为无锁态.
- 调用
hashCode
方法时也会使偏向锁直接升级为重量级锁。
5)轻量级锁
如果出现多个线程获取共享资源,但这些线程处于交替执行,交替获取共享资源的状态,没有同时抢锁(这点很关键),处于一种比较和谐的状态,这时候就可以使用轻量级锁。所以轻量级锁适用于线程交替执行同步块的情况。
这里涉及到到四部分的内容:
1、轻量级锁的加锁过程:
- 某个线程在访问同步资源时,如果锁对象处于无锁不可偏向状态,JVM 首先将在当前线程的栈帧中创建一条锁记录(
Lock Record
),主要包含两部分内容: Displaced Mark Word
:存放锁对象的Mark Word
的拷贝,这里的Mark Word
包括 hash、age 等信息。注意这里只是暂存 ,后面在解锁的时候还要把Displaced Mark Word
的内容再复制回到锁对象的Mark Word
。Owner指针
:存放锁对象的指针,以此来确定这个 Lock Record 属于哪个锁。在拷贝Mark Word
阶段暂时不会处理它。
- 挂起当前线程。JVM 使用 CAS 操作尝试将锁对象的
Mark Word
中的Lock Record
指针指向刚才创建的线程栈帧中的Lock Record
拷贝,并将Lock Record
拷贝中的Owner指针
指向锁对象的Mark Word
。整个过程如下图所示(注意锁对象对象头的变化)。 - 如果 CAS 替换成功,表示竞争锁对象成功,则将锁标志位设置成
00
,表示对象处于轻量级锁状态。 - 如果 CAS 替换失败,则判断锁对象的
Mark Word
是否指向当前线程的栈帧: - 如果是则表示当前线程已经持有对象的锁,执行
轻量级锁重入的过程
,可以直接执行同步代码块。 - 否则说明该其他线程已经持有了该对象的锁,执行
轻量级锁自旋和升级的过程
。如果在自旋一定次数后仍未获得锁,那么轻量级锁需要升级为重量级锁,将锁标志位变成10
,后面等待的线程将会进入阻塞状态。
用流程图对上面的过程进行描述:
2、轻量级锁重入:
我们知道,
synchronized
是可以锁重入的,在轻量级锁的情况下重入也是依赖于栈上的 Lock Record
完成的。以下面的代码中 3 次锁重入为例:轻量级锁的每次重入,都会在栈中生成一个
Lock Record
,但是保存的数据不同:- 首次分配的
Lock Record
,Displaced Mark Word
复制了锁对象的Mark Word
,Owner指针
指向锁对象。
- 之后重入时在栈中分配的
Lock Record
中的Displaced Mark Word
为 null,只存储了指向对象的Owner指针
。
重入的次数等于该锁对象在栈帧中
Lock Record
的数量,这个数量隐式地充当了锁重入机制的计数器。这里需要计数的原因是每次解锁都需要对应一次加锁,只有最后解锁次数等于加锁次数时,锁对象才会被真正释放。在释放锁的过程中,如果是重入则删除栈中的 Lock Record
,直到没有重入时则使用 CAS 替换锁对象的 Mark Word
。3、轻量级锁自旋和升级
当轻量级锁已被某个线程获取,正在竞争的线程会进入自旋等待获得该轻量级锁。
jdk1.6 以前,默认轻量级锁自旋次数是 10 次,可以通过
-XX:PreBlockSpin
参数修改。jdk1.6 以后加入了
自适应自旋锁
,自旋的次数不再固定,由 JVM 自己控制,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:- 对于某个锁对象,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而允许自旋等待持续相对更长时间
- 对于某个锁对象,如果自旋很少成功获得过锁,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
如果自旋超过设定次数或自旋线程数超过 CPU 核数的一半,就会升级为重量级锁。
4、轻量级锁解锁
当获取轻量级锁的线程执行完后,就开始执行执行轻量级锁的解锁操作。
由上面可以知道,此时持有轻量级锁的线程的栈帧
Lock Record
的 Displaced Mark Word
部分存放的是锁对象的 Mark Word
的拷贝(理解这点很重要)。轻量级锁解锁操作就是持有锁的线程使用 CAS 操作尝试将把当前线程的栈帧的 Displaced Mark Word
部分复制回锁对象的 Mark Word
那里。这时需要检查锁对象的
Mark Word
中 Lock Record
指针是否指向当前线程的Lock Record
。- 如果是,则表示没有竞争发生,锁被成功释放,当前线程的整个同步过程就完成了。
- 如果否,则表示当前锁资源存在竞争,有可能其他线程在这段时间里尝试过获取锁失败并多次自旋后,这时候锁对象已经升级为重量级锁(
Mark Word
的内容已经发生了变化),这种情况当前线程就会走重量级锁的解锁流程后。
6)重量级锁
从上面分析可知,锁对象在两种情况下会升级为重量级锁:
- 偏向锁对象被调用
wait()
或hashCode()
方法。
- 轻量级锁对象发生资源竞争。
对上面的图再完善一下流程,整个锁升级的过程如下所示:
当锁对象升级为重量级锁后,系统开销非常大。重量级锁是依赖对象内部的
monitor
来实现的,任意一个对象都有自己的 monito
r,而 monitor
又依赖于操作系统底层的 Mutex Lock
实现。使用重量级锁之后,被阻塞的线程便进入内核(Linux)调度状态,系统切换线程需要在用户态与内核态之间来回切换,这本身就消耗很多时间,有可能比用户执行代码的时间还要长。下面简单了解
monitor
中的核心概念:- owner:标识拥有该 monitor 的线程,初始时和锁被释放后都为 null
- cxq (ConnectionList):竞争队列,所有竞争锁的线程都会首先被放入这个队列中
- EntryList:候选者列表,当 owner 解锁时会将 cxq 队列中的线程移动到该队列中
- OnDeck:在将线程从 cxq 移动到 EntryList 时,会指定某个线程为 Ready 状态(即OnDeck),表明它可以竞争锁,如果竞争成功那么称为 owner 线程,如果失败则放回 EntryList 中
- WaitSet:因为调用 wait() 或 wait(time) 方法而被阻塞的线程会被放在该队列中
- count:monitor 的计数器,数值加 1 表示当前对象的锁被一个线程获取,线程释放 monitor 对象时减 1
- recursions:线程重入次数
用图来表示线程竞争的的过程:
当线程调用
wait()
方法,将释放当前持有的 monitor,将 owner
置为 null,进入 WaitSet
集合中等待被唤醒。当有线程调用 notify()
或 notifyAll()
方法时,也会释放持有的 monitor,并唤醒 WaitSet
的线程重新参与 monitor 的竞争。重量级锁加锁的过程:
当升级为重量级锁的情况下,锁对象的
Mark Word
中的指针不再指向线程栈中的 Lock Record
,而是指向堆中与锁对象关联的 monitor
对象。当多个线程同时访问同步代码时,这些线程会先尝试获取当前锁对象对应的 monitor
的所有权。- 获取成功,判断当前线程是不是重入,如果是重入那么 recursions+1。
- 获取失败,当前线程会被阻塞,等待其他线程解锁后被唤醒,再次竞争锁对象。
7)其他锁优化(简单了解)
锁消除:这属于编译器对锁的优化,JIT 编译器在动态编译同步块时,会使用逃逸分析技术,判断同步块的锁对象是否只能被一个对象访问,以及不会逃逸出去从而被其他线程访问到。如果确认没有逃逸,JVM 就把它们当作栈上数据对待,认为这些数据是线程独有的。JIT 编译器就不会生成 synchronized 对应的锁申请和释放的机器码,就消除了锁的使用。
锁粗化:JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程反复申请、释放同一个锁所带来的性能开销。
3、final
final 关键字修饰的变量表示表示最终的,不可改变。一般有三种用法:
- 修饰类:一个类如果被 final 修饰,当前类不能有子类,而且其中的所有成员方法都无法进行覆盖重写。
- 修饰方法:当 final 关键字用来修饰一个方法的时候,这个方法就是最终方法,无法被覆盖重写。
- 修饰变量:当变量被final修饰,一次赋值终生不变。对于基本类型来说,不可变说的是变量当中的数据不可变;对于引用类型来说,不可变说的是变量当中的地址值不可变。
这里只讨论 final 修饰变量的这种情况。final 声明的变量必须提前赋值。
- final 修饰的局部变量,只能在声明语句里面赋值。
- final 修饰的成员变量,可以在声明语句赋值或构造函数里面赋值。
- static 和 final 修饰的成员变量,可以在声明时赋值或声明后在静态代码块中赋值。
对于 final 域来说,编译器和处理器都需要遵从如下的两条规则:
- 任意构造函数中对一个 final 域的写入,与随后把这个构造对象的引用赋值给另一个引用变量,这两个操作不能重排序。原理是编译器会在 final 域写之后,构造函数 return 之前,插入一个
StoreStore
屏障,禁止处理器把 final 域的写重排序到构造函数之外。
- 初次读一个包含final域对象的引用,与之后初次读这个final域,这两个操作之间不能重排序。原理是处理器会在读 final 域操作的前面插入一个
LoadLoad
屏障,确保在读一个对象的 final 域之前,一定会先读这个包含这个 final 域的对象的引用。
四、实际应用
来看一下经典的 double check 的单例模式。下面代码在创建对象前使用
synchronized
对当前类加锁,保证最终只产生一个实例。看似天衣无缝,但是大家思考下这段代码有没什么问题?问题就出在这一步。
上面有提到,对象的赋值并不是一个原子的操作,其实是分为下面三个指令步骤:
JVM 出于性能考虑,可能会进行指令重排序,有可能会把第 2 步和第 3 步调换,重排序后的顺序如下:
这段代码在单线程情况下不会影响程序执行的结果,但是在多线程情况下就不一样了。
- 线程 A 执行到第 2 步
instance = memory
的时候,发生了线程切换,注意此时对象还没有被初始化。
- 线程 B 判断 intance 不为 null,直接把 instance 返回了,得到的是一个没有被初始化的对象。
这就是这段代码的风险。解决办法就是使用 volatile 修饰 instance 变量。被 volatile 修饰的变量会禁止指令重排序。上面的代码修改如下:
- Author:mcbilla
- URL:http://mcbilla.com/article/87ce69ab-6a0b-404a-a7a0-faa5a6ddc552
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts