java-lock

Java 锁机制

背景知识


指令流水线

CPU的基本工作是知性存储的指令序列。程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程。

几乎所有的冯·诺伊曼型计算机的CPU,其工作都可以分为5个阶段:取指令、指令译码、指令执行、访存取数和结果写回。

现代处理器的体系结构中,采用了流水线的处理方式对指令进行处理。指令包含了很多阶段,对其进行拆解,每个阶段由专门的硬件电路、寄存器来处理,就可以实现流水线处理。实现更高的CPU吞吐量,但是由于流水线本身的额外开销,可能会增加延迟。

CPU多级缓存

在计算机系统中,CUP高速缓存(CPU Cache,简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

当处理器发出内存访问请求时,会先查看缓存内是否由请求数据。如果存在(缓存命中),则不经过访问内存直接返回该数据;如果不存在(缓存失效),则先要把内存当中的数据读到缓存当中,再将其返回处理器。

缓存之所以有限,主要是由于程序运行时对内存的访问呈现局限性的特征(Locality)特征。这种局部性既包括空间局部性(Space Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。

问题引入


原子性

一个或多个操作,要么做,要么都不做,且执行的过程不会被任何因素打断 示例方法 i++ 该语句主要有三个过程组成 + 读取变量i的值 + 对其进行 plus 1 操作 + 将新的值赋给i 如果对实例变量i的操作不做任何控制,那么多个线程同时调用就会出现覆盖现象,丢失部分更新。 另外,如果再考虑上工作内存和主存之间的交互,可以细分为以下几个操作: + read 从主存读取到工作内存(非必须) + load 赋值给工作内存的变量副本(非必需) + use 工作内存变量的值传给执行引擎 + 执行引擎执行+1操作 + assign 把从执行引擎接收到的值赋给工作内存的变量 + store 把工作内存中的一个变量的值传给主内存 + write 把工作内存中的变量的值写到主内存中的变量

可见性

可见性是指多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立刻看到修改的值 存在可见性问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知到其他线程对于共享变量的更改,导致读取的值不是最新的。

顺序性

程序执行的顺序按照代码的先后顺序执行 由于编译重排序和指令重排序的存在,使得程序真正执行的顺序不一定是跟代码的顺序一致,这种情况在多线程的情况下会出现问题。
1
2
3
4
5
if(inited == false){
context=loadContext();//sentence 1
inited=true;//sentence 2
}
doSomethingWithConfig(context);//sentence 3
由于sentence1和sentence2没有依赖性,sentence1和sentence2之间可能并行执行或者sentence2先于sentence1执行,如果这段代码有两个线程同时执行,线程1执行了sentence2,而sentence1还没有执行完,这时候线程2判断inited为true,则执行sentence3,但由于context没有初始化完成,则会导致出现未知的异常。

JMM内存模型


> Java虚拟机规范定义了Java内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果(C/C++则直接使用OS和物理机的内存模型,使得程序须针对特定平台编写),它在多线程的情况下尤其重要。

内存划分

JMM的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存中取出变量这样的细节。这里的变量是指共享变量,存在竞争问题的变量,如实例字段、静态字段、数组对象元素等,不包括线程私有的局部变量、方法参数等,因为私有变量不存在竞争问题。可以认为JMM包括内存划分、变量访问操作与规则两部分 分为主内存和工作内存,每个线程都有自己的工作内存,他们共享主内存。 + 主内存(Main Memory)存储所有共享变量的值 + 工作内存(Working Memory) 存储线程使用到的共享变量在主内存的值的副本拷贝 线程对贡献变量的所有读写操作都在自己的工作内存进行,不能直接读写主内存的变量。 不同的线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存完成。 > 这种划分与Java内存区域中堆、栈方法区的划分,两者基本没有任何关系。硬要联系的话,大致上主内存对应JAVA堆中对象的实例数据部分、工作内存对应栈的部分区域;从更低层次上来讲,主内存对应物理内存、工作内存对应寄存器和高速缓存。

内存间交互规则

关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存中的实现细节。Java内存模型定义了8种原子操作来完成 + lock:将一个变量标识为被一个线程独占状态 + unlock:将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定 + read:将一个变量的值从主内存传输到工作内存当中 + load:把read操作从主内存中的变量值放入到工作内存当中的副本当中 + use:把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用变量的指令时都会使用该指令 + assign:把一个从执行引擎接收到的值赋给工作内存当中的变量,每当虚拟机遇到一个给变量赋值的指令时,都要使用该操作 + store:把工作内存中的一个变量的值传递给主内存 + write:把store操作从工作内存中得到的变量的值写到主内存中的变量

定义原子操作的使用规则

  1. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中

  2. 一个新的变量只能从主内存中诞生,不允许在工作内存当中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前必须先assign和load操作。

  3. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock操作后,只有执行相同次数的unlock操作后,变量才会被解锁。lock和unlock必须成对出现。
  4. 如果对一个变量执行lock操作,会清空工作内存当中此变量的值,在执行引擎使用该变量前会重新进行load或assign操作初始化变量的值。
  5. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  6. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

从上面可以看出,把变量从主内存复制到工作内存需要顺序执行read和load操作,从工作内存同步回主内存需要顺序执行stroe、write操作。总结:

  • read、load、use必须成对顺序出现,但不要求连续出现。assign、store、write同之
  • 变量诞生和初始化:变量只能从主内存诞生,且先初始化后才能使用,即必须先load/assign才能use/store
  • lock一个变量后会清空工作内存该变量的值,使用前必须先初始化;unlock前须将变量同步回主内存
  • 一个变量同一时刻只能被同一线程lock,lock几次就必须unlock几次;未被lock的变量不允许被执行unlock,一个线程不能去unlock其他线程lock的变量。

    long和double类型的特殊规则


    JMM要求前述8个操作有原子性,但对于64位的数据类型long和double,在模型中特别定义了一条宽松的规定;允许虚拟机将没有被volatile修饰的64位数据的读写操作分为两次32位的操作来进行。即未被volatile修饰时线程对其读取的read操作不是原子的,可能只读到半个变量的值。虽然如此,商用虚拟机几乎都把64位数据的读写实现为原子操作。

先行发生原则

Java内存模型具备一些先天的“有序性”,即不通过任何同步手段(volatile,synchronized)即可保证有序性,这个通常被称为happens-before原则。

如果两个操作次序不符合先行原则且无法从happens-before原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意的对他们进行重排序。

  1. 程序次序规则(Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作。
  2. 锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。后面指“时间上的先后顺序”
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后边对这个变量的读操作。“后面”指时间上的先后顺序。
  4. 传递规则(Transitivity):如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可得出操作A先行发生于操作C的结论。
  5. 线程启动规则(Thread Start Rule):Thread对象的start方法先于该线程的每一个动作。
  6. 线程中断规则(Thread Interruption Rule):对线程interrput()方法的调用先于被中断线程的代码检测到中断事件的发生(通过Thread.interrupted()检测。
  7. 线程中止规则(Thread Termination Rule):线程中所有的操作先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
  8. 对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于他finalize()方法的开始。

问题解决



原子性

  • 由JMM直接保证的原子性变量操作包括read、load、use、assign、stroe、write
  • 基本数据类型的读写(工作内存)是原子性的

    由JMM的lock、unlock、可实现更大范围的原子性保障,但是这是JVM需要实现支持的功能,对于开发者则是由synchronizend关键字或者Lock读写锁来保证原子性。

    可见性


    volatile变量被一个线程修改后会立刻同步回主内存、变量值被其他内存读取💰立即从主内存刷新值到工作内存。即read、load、use三者连续执行,assign、stroe、write连续执行
    synchronized/lock由lock和unlock的使用规则保证

    • 对一个变量执行lock操作之前必须先把此变量同步回主内存当中(执行store和write操作)
    • 如果对一个变量执行lock操作,将会清空工作内存中这变量的值,在执行引擎中使用这个变量之前需要重新执行load或assign操作初始化变量的值。

    final  修饰的字段在构造器中一旦初始化完成,且构造器没有把”this“的引用再传递出去,则其他线程可立即看到final字段的值。

顺序性


volatile 禁止指令重排序
synchronized 一个变量在同一个时刻只允许一条线程对其进行lock操作

开发篇



volatile 

被volatile修饰的变量能保证其顺序性和可见性

顺序性 

  • 对一个volatile变量的写操作永远先行发生于后面对这个变量的读操作。后面指时间上的先后顺序

可见性

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

volatile相对于synchronized/Lock是非常轻量级的,但是使用场景是有限制的:

  • 对变量的写入操作不依赖于当前值,即仅仅是读取和单纯的写入,比如操作完成,中断或者状态之类的标志。
  • 禁止对volatile变量操作指令的重排序

实现原理

volatile底层是通过CPU提供的内存屏障指令来实现的。硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障来实现的

内存屏障由两个作用:

  • 阻止屏障两侧的指令重排序
  • 强制把写缓冲区/高速缓存中的脏数据写回主内存,让缓存中相应的数据失效

final

对于final域的内存语义,编译器和处理器要遵循两个重排序规则(内部实现也是使用内存屏障):

  • 写final域的重排序规则:在构造函数内对一个final域的写入,与随后把这个被构造函数对象引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 读final域重排序规则:初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FinalExample{
int i;//普通域
final int j;//final域
static FinalExample obj;

public FinalExample(){
i=1;//写普通域,对普通域的写操作可能会被重排序到构造函数之外
j=2;//写final域,对final域的写操作不会被重排序到构造函数之外
}

//写线程A执行
public static void writer(){
obj=new FianlExample();
}

//写线程B执行
public static void reader(){
FianlExample object=obj;//读对象引用
int a=object.i//读普通域,可能会看到结果为0(由于赋值指令可能会被重排序到构造函数之外)
int b=object.j;//读final域,保证能够看到结果为2
}

}

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

对于final域是引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:

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

synchronized

synchronized用于修饰普通方法、修饰静态方法、修饰代码块

  • 确保代码的同步执行(即不同的线程间的互斥)(原子性)
  • 确保对共享变量的修改能够及时可见(可见性)
  • 有效解决指令重排序的问题(顺序性)

实现原理

使用对象监听器 (Monitor,也有叫管程的)进行控制

  • 进入/加锁时执行字节码指令MonitorEnter
  • 退出/解锁时执行字节码指令MonitorExit
  • 当执行代码有异常退出方法/代码块时,会自动解锁

使用哪个对象的监视器:

  • 修饰对象方法时,使用当前对象的监视器
  • 修饰静态方法时,使用类类型(Class的对象)监视器
  • 修饰代码块时,使用括号中的对象的监视器
  • 必须为Object类或者其子类的对象

MonitorEnter(加锁)

  • 每个对象都有个一关联的监视器。
  • 对象被锁住,当且仅当它有属主(Owner)时。
  • 线程执行MonitorEnter就是为了成为Monitor的属主。
  • 如果Monitor对象的记录数(Entry Count,拥有它的线程的重入次数)为0,将其置为1,线程将自己置为Monitor对象的属主。
  • 如果Monitor的属主为其他线程,当前线程会阻塞,直到记录数为0,才会去竞争属主权。

MonitorExit(解锁)

  • 执行MonitorExit的线程一定是这个对象所关联的监视器的属主
  • 线程将Monitor对象的记录数-1。
  • 如果Monitor对象的记录数为0,线程就会执行推出动作,不再是属主。
  • 此时其他阻塞的线程就被允许竞争属主。

对于MonitorEnter、MonitorExit来说,有两个基本参数:

  • 线程
  • 关联监视器的对象

关联结构

在JVM中,对象在内存的布局分为三个部分:对象头、实例数据、对齐填充,如下:

实例变量

  • 存放类的属性数据信息,包括父类的属性信息
  • 如果数组的实例变量,还包括数组的长度
  • 这部分内存按4字节对齐

填充数据

  • 由于虚拟机要求对象起始地址必须为8字节的整数倍
  • 填充数据仅仅是为了字节对齐
  • 保障下一个对象的起始地址为8的整数倍
  • 长度可能为0

对象头(Object Header)

  • 对象头由Mark Word、Class Metadata Address(类元数据地址)和数组长度(对象为数组时)组成
  • 在32位和64位的虚拟机中,Mark Word分别占32字节和64字节,因此称其为Word

Mark Word存储的并非是对象的实际业务数据(如对象的字段值),属于额外的存储成本。为了节约存储空间,MarkWord被设计为一个非固定的数据结构以便在尽量晓得空间中存储尽量多的数据,它会根据对象的状态,变换自己的数据结构,从而复用自己的存储空间。

锁的状态有4种:无锁、偏向锁、轻量级锁、重量级锁。随着竞争的增加,锁的使用情况如下:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

其中偏向锁和轻量级锁是从JDK6时引入的,在JDK6中默认开启。锁的升级(锁膨胀,inflate)是单向的。只能从低向高。不会出现锁的降级。

偏向锁

当锁对象第一次被线程获取时,虚拟机将会把对象头中的标志位设置为“01”(可偏向),即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在Mark Word之中,如果CAS操作成功,持有偏向锁的线程每次进入到这个锁相关的同步模块时,虚拟机都可以不再进行任何同步操作。

当有另一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为01,不可偏向)或 轻量级锁定(标志位为00)的状态,后续的同步就进入到轻量锁流程。

轻量级锁

进入到轻量级锁说明不止一个线程尝试获取锁,这个阶段会通过自适应自旋CAS的方式获取锁。如果获取失败,则进行膨胀,进入到重量级锁流程,线程阻塞。


重量级锁

重量级锁是通过系统的线程互斥锁来实现的,代价最昂贵

ContentionList,CXQ,存放最近竞争锁的线程

  • LIFO,单向链表
  • 很多线程都可以请求锁的线程放入到队列当中
  • 但只有一个线程能将线程出队

EntryList,表示胜者组

  • 双向链表
  • 只有拥有锁的线程才可以访问或变更EntryList
  • 只有拥有锁的线程在释放锁时,并且在EntryList为空、ContentionList不为空的情况下,才能将ContentionList中的线程全部出队,放入到EntryList中

WaitSet,存放处于等待状态的线程

  • 将进行wait()调用的线程放入WaitSet
  • 将进行notify()、notifyAll()调用时,会将线程放入到ContentionList或EntryList队列中

注意

  • 对于一个线程而言,在任何时候最多处于三个集合中的一个
  • 处于三个集合中的线程均处于BLOCKED状态,底层使用互斥量来进行阻塞

当一个线程成功获取到锁时对象监视器的owner字段从NULL变为非空,指向此线程必须将自己从ContentionList或EntryList中出队

竞争型的锁传递机制 线程释放锁时,不保证后继进程一定可以获取到锁,而是后继线程去竞争锁

OnDeck,表示准备就绪的线程,保证任何时候都只有一个线程来直接竞争锁

  • 在获取锁时,如果发生竞争,则使用自旋锁来争用,如果自旋后仍然得不到,则放入上述队列中。
  • 自旋可以减少ContentionList和EntryList上出队入队的操作,也就是减少了内部维护的这些锁的争用。

OS 互斥锁

重量级锁是通过操作系统的线程互斥锁来实现的,在Linux下,锁所使用的技术是pthead_mutex_lock/pthead_mutex_unlock,即线程间的互斥锁。

线程互斥锁是给予futex(Fast Userspace Mutex)机制实现的。常规的操作系统同步机制如IPC等,调用时都需要陷入到内核执行,即使没有竞争也要执行一次陷入操作(int 0x80,trap)。而futex则是内核态和用户态的混合,无竞争时,获取锁和释放锁都不需要陷入内核。

初始分配

首先在内存中分配futex共享变量,对线程而言,内存是共享的,分配(malloc)即可,为整数类型,初始值为1.

获取锁

使用CAS对futex变量-1,观察其结果:

  • 如果由1变为0,表示无竞争,继续执行
  • 如果小于0,表示有竞争,调用futex(..,FUTEX_WAIT,…)使当前线程休眠

释放锁

使用CAS操作给futex变量+1

  • 如果futex变量由0变为1,表示无竞争,继续执行
  • 如果futex变量变化前为负值,表示有竞争,调用futex(…,FUTEX_WAKE,…) 唤醒一个或多个等待线程。