深入理解JVM-并发、线程、锁

1,603 阅读19分钟

概述

在正式引入本章之前,我想先引入X86现代处理器的多核心结构,如图:

image.png

L1作为数据和指令缓存,L2作为每个核心独有的缓存,速度快,容量小;而L3作为核心共享的缓存,速度慢一些;它们都比主存快。

为什么这么设计,则和当代计算机架构的多级存储相关,在此不再表述,只需要知道,每个CPU都有自己的小缓存,它们彼此之间互不相通就行了。

image.png 使用多线程,一般OS会把不同的线程分配到不同的核心上,同时为了性能,会优先在高速缓存上分配空间,那么问题来了,怎么保证每个核心的高速缓存保存的某一共享变量是一致的呢?此时就需要高速缓存一致性协议,关于这个协议有很多,大致原理就是:如果某个变量被存放在多个L2缓存中,那么当某一个核修改了值后,可以通过这个协议高速其他CPU,让他们的缓存过期,进而可以读到最新的值。

这是我们的知识基础,现在来看Java的内存模型。

Java内存模型

主内存与工作内存

Java内存模型的主要作用是定义程序中各种变量的访问规则,即如何从内存取值和写入到内存这样的细节。Java内存模型所指出的变量一般指的是实例变量,静态变量和数组元素这些可能被多线程访问的变量,至于那些线程私有的则不关心。

Java内存模型对线程读写访问变量有如下要求:

  • 1⃣️所有变量必须存储在主存中。
  • 2⃣️每个线程拥有自己的工作内存,对变量的读取,写入必须先在工作内存中进行。无法直接操作主存。
  • 3⃣️每个线程的工作内存为线程私有,其他线程无法访问。

image.png

内存间交互操作

对于如何在主内存和工作内存之间进行内存交互,JVM规定了8种行为:

操作作用域说明
lock主存把一个变量标识为某个线程独占的状态
unlock主存解除线程对于这个变量的锁定
read主存把变量值从主存传输到工作内存
load工作内存把read读到变量值存放到工作内存副本中
use工作内存把工作内存中的变量值传递到执行引擎中供代码使用
assign工作内存把执行引擎得到的值赋给工作内存中的变量
store工作内存把工作内存中的值传输到主存中
write主存把store传输的值写入到主存中

JVM要求,read先于load,store先于write,这里仅仅是先后关系,而不是顺序先后,它们之间可以穿插别的操作。

除此之外,还有如下规则:

  • 1⃣️不允许read,load;store,write单独出现。
  • 2⃣️不允许一个线程丢弃它最近的assign操作。
  • 3⃣️不允许一个线程在没有assign的情况下把工作内存同步回主存。
  • 4⃣️一个新的变量只能诞生自主存,不允许在工作内存中直接使用一个未被初始化的变量。
  • 5⃣️lock仅支持单个线程进行,且支持锁重入。
  • 6⃣️对某个变量执行lock会清空执行线程的工作内存对应变量的值。
  • 7⃣️unlock必须有一个lock在前,且无法unlock其他线程锁定的变量。
  • 8⃣️执行unlock之前,必须先同步变量。

对volatile的特殊规则

volatile(翻译:易失的)。Java对volatile变量单独抽出了一套规则。

先来看看一个变量被定义成volatile之后,它拥有了哪些特性?

  • 对所有线程可见:意味着任何一个线程对它更改后,其他线程都可以立刻看到这个更新。
  • 禁止指令重排序:意味着对于此变量的重排序优化被禁止,它不仅在一个线程内呈现串行性,在多线程中亦是如此(如果不理解可以看后述)。

说一下第一个,因为工作内存的存在,所以每个线程读取变量时会从工作内存读取,写入时也是先写工作内存;但是我们知道普通变量的读/写操作的三步之间可以被穿插别的操作,所以可能读/写的不及时,或者干脆直接读/写自己的工作内存,而没有去读/写主存,这样就无法及时看到/实现更新操作。

volatile类型的变量就是强制read->load->use;assign->store->write之间不允许出现别的操作,且必须没有等待,连在一起完成。这样就可以实现可见性原则了。

很多人会误以为这可以保证并发(包括我一开始也这么认为)。但是仔细想想就能明白,volatile仅仅保证一个变量在被更新后,任何开始于更新之后的读都可读到这个值,这也就暗示volatile读/写操作是原子性的,任何开始于“写”后面的“读”一定可以“读”到这个“写”的新值

但是这真的可以保证并发吗?以自增为例,自增实际上拆分为三步,读,+1并保存,赋值。如果此时有两个线程同时读,它们读到的值肯定是一样的,且它们的读都先于写,就无法读到另一线程写的新值。而这,就是根源。问题解决了,所以volatile可以保证原子写,但是无法保证并发。

所以我们可以大胆的猜测,volatile要求写变量时,不依赖于变量当前值,事实确实如此。

关于第二个,我们可以设想有如下情况:

boolean flag = false;

// 在线程A运行
public void f() {
    // 干活,干一些耗时的活
    boolean flag = true;
}

// 在线程B运行
public void ff() {
    while (!flag) {
        // 干活,干其他的活
    }
}

上述代码中,ff依赖flag的状态,但是因为指令重排序(指令重排序指的是为了追求流水线满载等原因,会对指令进行重新排序,重排前与重排后对执行结果没有影响,但是这仅限单个线程中),可能flag在耗时工作执行前就被值为true了,这样ff可能压根不会执行。这样的重排序对于线程A肯定没什么影响,但是在多线程中就有影响了。

禁止指令重排序是通过内存屏障实现的,至于怎么实现,就涉及到CPU和硬件方面的知识了,JMM提供了自己的一套内存屏障,并可以被现代处理器所支持。

我们可以大胆猜测一下volatile被JVM赋予的操作变量的特殊规则:

  • 定义为volatile的变量的assign->store->write不可以被穿插read->load->use操作,这点和普通变量不同;这样就能保证每次写入必然写入到了主存。
  • 定义为volatile的变量的read->load->use不可以被穿插assign->store->write操作,这样可以保证每次读取必然是从主存读取的。
  • 对于volatile的write操作,会触发其他线程的工作内存对应变量的无效化,强制它们读取主存。
  • 设置内存屏障使得volatile变量后面的操作不可以被排到前面去。

最后总结一下:

  • 对volatile的赋值操作必须立刻写回到主存。
  • 每次读取volatile的变量时,必须从主存读取,这样可以读取到最新的值。
  • volatile修饰的变量不会被重排序。

在这里多嘴一下内存屏障。

目前可用的内存屏障,主要有四种:

类型使用作用
LoadLoadA LL BA的读取操作完成必须早于B及其之后的读取操作开始,就是完全早于的意思,下同
StoreStoreA SS BA的写入操作完全完成,早于B及其后续的写入操作的开始
LoadStoreA LS BA的读取操作完全完成,早于B及其之后的写入操作的开始
StoreLoadA SL BA的写入操作完全完成,早于B及其之后的读取操作,这是一个万能屏障,它具备其他三种屏障的功能,但也是开销最大的,是volatile的默认实现,也被几乎所有的指令集所支持

如我们上述所说,第四种是默认实现,但开销大,如果我们只是单纯的想要保证LL,SS,LS这种操作有序,可以进行特殊优化,比如美团内部会使用Unsafe进行CAS操作替换volatile,可以提升18%的性能,当然这是极端情况,且比较特例化。

原子性,可见性,有序性

原子性:一般认为对于基本数据类型的读写拥有原子性,此外,synchronized修饰的代码块也具有原子性。

可见性:指某一线程修改变量后即可立刻被其他线程看到,除了volatile,还有final和synchronized修饰的代码块,依旧具有可见性;对于synchronized,想一想前面说的JMM对于unlock操作的要求。

有序性:除了volatile禁止指令重排序带来的有序性,还有synchronized带来的有序性;这里需要说明,synchronized修饰的代码块内部可以重排序,它的有序性和volatile是不同的。synchronized强调线程之间有序进入临界区,volatile强调变量读/写不会被重排序

先行发生原则

如果我们的程序只能通过volatile/synchronized关键词来保证有序性,那程序书写起来未免太麻烦了,于是我们引入了先行发生原则,什么意思呢?就是我们的程序,存在一些“天然的”有序性,这些有序性就是先行发生原则。

通过先行发生原则,可以很容易解决两个线程之间是否存在冲突的所有问题。如果两个操作之间符合先行发生原则,或者可以推导出,那么这两个操作就是线程安全的。来看看Java中存在的一些“天然的”先行发生原则。

  • 程序次序规则:书写在前面的控制流先行发生于后面的控制流,⚠️这里指的是控制流,而不是指令流,比如if-else,for,while这样的指令控制语句。
  • 管程锁定规则:对同一个锁的操作,先开始的unlock先行发生于后面开始的lock操作,或者这么说,unlock如果比lock先开始,那么一定是unlock完成之后才会触发lock。整个unlock是原子的。
  • volatile规则:对一个volatile的“读”操作先行发生于“写”,即,如果“写”开始早于“读”,那么一定是“写”结束,才会“读”。
  • 线程启动规则:Thread::start先于线程里的其他操作。
  • 线程终止规则:线程里的终止检测后于线程里的任何方法。
  • 线程中断规则:线程的中断调用先于方法中的中断检测。
  • 对象终结规则:对象的初始化完成先于finalize()开始。
  • 传递性:如果A先于B,B先于C,那么A先于C。

Java与线程

Java线程使用内核线程实现,内核线程准确来说不属于某一进程,每个线程都是一个调度实体。但是直接使用内核线程有些代价过大,于是在此之上诞生了一种轻量级进程的mini线程,每个轻量级进程对应一个内核线程,所以有这样的关系:

image.png

因为Java线程实现是内核线程,所以线程调度,分配,唤醒,阻塞这些都是完全丢给OS去做的。

Java定义了线程的六种状态,线程某一时刻仅能处于其中之一的状态:

image.png

这里需要说明的是Waiting,Blocked和TimeWaiting。当调用wait()方法时,会进入Waiting;当获取不到锁时,会进入Blocked;当调用Thread.sleep()或LockSupport.park()时,会进入TimeWaiting(限时等待)环节。

线程安全

首先来看线程安全的定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

Java中的线程安全

在讨论Java中的线程安全之前,我们先给线程之间共享的变量分个类:

  • 不可变。最纯粹最简单的线程安全,final修饰的变量不可被更改,拥有绝对的线程安全。
  • 绝对线程安全。满足这个要求的类可以保证无论调用者怎么调用,都是线程安全的,但是Java中绝大多数的类都是相对线程安全的。
  • 相对线程安全。这个符合Java中绝大多数的类库
  • 线程兼容。这个要求调用者自己确保线程安全,Java中绝大多数未声明线程安全的类库都属于此类,比如HashMap,ArrayList等。
  • 线程对立。无论怎么保证,都无法在多线程中使用,这在Java中极少,同时也是我们应该避免的。

线程安全的实现

互斥同步

互斥是方法,同步是目的。在Java中,最基本的互斥同步就是synchronized关键字,此外,还有重入锁,java.util.lock包下面的一堆工具类。我的另一篇文章有详细使用,可以在主页看看。

它们都属于重量级锁,一般情况下要避免使用。

非阻塞同步(无锁)

互斥同步属于悲观策略,在这种策略下使用的锁都是悲观锁,悲观锁认为每次都会产生竞争,仅此每次都会加锁。那问题来了,如果竞争不那么激烈,或者说压根没有竞争,那加锁岂不是一种负担?有没有什么好的解决方案?

答案就是无锁实现,也就是非阻塞同步。在这里,我们仅讨论Java的CAS(Compare And Set)比较并设置。CAS借助于X86指令实现,需要三个参数,第一个是期望的值的内存地址,第二个是期望的旧值,第三个是想要设置的新值。当且仅当内存地址中的值等于旧值时,才会进行新值替换,当有多个操作同时进行时,只有一个操作会成功,且这个操作是立即返回的,设置成功返回true,否则false(说明没竞争过其他线程)

既然我们知道了这个操作是立即返回的,且仅存在true/false返回值,那么我们可以写一个循环,在返回false时再次尝试,这样就会一直尝试设置新值知道成功。此时线程既没有被阻塞,也实现了单一访问操作。

缺点就是如果竞争压力大,或者锁长时间被持有,就会白白浪费CPU,因为不停轮询的过程本身就是一个自旋锁,会空转CPU。

当然,CAS可能导致ABA问题,但是大多数时候ABA问题并不影响使用,就算真的有影响,那还不如乖乖地用同步锁。

无同步方案

最典型就是ThreadLocal,让每个线程独有一份变量。

另一个就是可重入代码,可重入代码指的就是那些,无论调用多少次,只要输入相同,输出就相同,我们就可以说这代码它是可重入的。

锁优化

自旋锁和自适应自旋锁

自旋锁就是空转CPU,忙等待来等着锁的释放,为什么这么做呢?因为有时候可能一个同步代码块执行地很快,一个申请锁的线程可能只需要等几微秒就行了,这是就没必要进行线程阻塞操作等待锁的释放,因为线程调度也是需要成本的;那我就可以让这个线程死循环跑一会,过一会再申请试试。

自适应自旋锁就是在自旋锁的基础上,通过历史信息分析,确定自旋时长,使得自旋等待时间更加“适合”。

锁消除

锁消除主要依赖逃逸分析,如果某个代码或者某个共享变量并不会被其他线程访问,那么对于这个变量的同步操作就可以取消了。

锁粗化

除了尽可能减少同步的范围,有时也可以把多个范围接近的同步块合并为一个大范围的同步块,毕竟加锁解锁也是要成本的。

偏向锁

在竞争不那么激烈的场景下,同一个锁可能会被同一个线程再次获取,这就是偏向锁的理论支持。

偏向锁和轻量级锁以及重量级锁的切换需要了解锁机制的实现原理。再次我们再次引入Java对象头的布局:

  • MarkWord:包含了GC分代年龄,hashCode(JVM负责生成)等信息。
  • 类型指针+数组长度(仅限数组类型):指向这个对象在方法区中的所属类型。

当我们加锁时,实际上是对某个对象的锁定,获得了锁就是获得了这个对象的锁,而记录这个对象锁的信息就在对象头中。

对象头还包含了对这个对象加锁应该使用的锁类型,是否是偏向锁等信息。

image.png

偏向锁在获取资源的时候会在资源对象上记录该对象是偏向该线程的,偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断该资源是否是偏向自己的,如果是偏向自己的则不需要进行额外的操作,直接可以进入同步操作。

偏向锁获取过程:

  • 1⃣️访问Mark Word中偏向锁标志位是否设置成1,锁标志位是否为01——确认为可偏向状态。
  • 2⃣️如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5⃣️,否则进入步骤3⃣️。
  • 3⃣️如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5⃣️;如果竞争失败,执行4⃣️。
  • 4⃣️如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  • 5⃣️执行同步代码。

轻量级锁

轻量级锁的获取过程:

  • 1⃣️在代码进入同步块的时候,如果同步对象锁状态为偏向状态,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。官方称之为 Displaced Mark Word(所以这里我们认为Lock Record和 Displaced Mark Word其实是同一个概念)。这时候线程堆栈与对象头的状态如图所示:

image.png

  • 2⃣️拷贝成功后,虚拟机将使用CAS操作尝试将对象头的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向对象头的mark word。如果更新成功,则执行步骤3⃣️,否则执行步骤4⃣️。
  • 3⃣️如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下所示: image.png
  • 4⃣️如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,现在是重入状态,那么设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。下图为重入三次时的lock record示意图,左边为锁对象,右边为当前线程的栈帧,重入之后然后结束。接着就可以直接进入同步块继续执行。 image.png
  • 5⃣️如果不是说明这个锁对象已经被其他线程抢占了,说明此时有多个线程竞争锁,那么它就会自旋等待锁,一定次数后仍未获得锁对象,说明发生了竞争,需要膨胀为重量级锁。

轻量级锁的解锁过程:

  • 1⃣️通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
  • 2⃣️如果替换成功,整个同步过程就完成了。
  • 3⃣️如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

最后,锁的升级是单向的,可以是这样升级:偏向锁->轻量级锁->重量级锁。

最后,本人能力有限,看到一篇不错的关于锁优化讲解的文章,放在这里,供读者阅读。