Java教程

Java 中的各种锁及其原理

本文主要是介绍Java 中的各种锁及其原理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

概览

在并发编程中,锁是一种常用的保证线程安全的方法。Java 中常用的锁主要有两类,一种Synchronized 修饰的锁,被称为 Java 内置锁或监视器锁。另一种就是在 J2SE 1.5 版本之后的 java.util.concurrent 包(下称 j.u.c 包)中的各类同步器,包括 ReentrantLock(可重入锁),ReentrantReadWriteLock(可重入读写锁),Semaphore(信号量),CountDownLatch 等。这些同步器都是基于 AbstractQueuedSynchronizer(下称 AQS)这个简单的框架来构建的,而 AQS 类的核心数据结构是一种名为 Craig, Landin, and Hagersten locks(下称 CLH 锁)的变体。

Synchronized锁

Synchronized 锁的底层类别

不同锁下对象头中的内容

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/20210321185335.png

这里主要是与锁相关的内容

偏向锁:线程 ID,锁标志为 01

轻量级锁:指向栈帧中锁记录的指针,锁标志为 00

重量级锁:指向互斥量(重量级锁)的指针,锁标志为 10

实现偏向锁两个额外的域,第一个域用于记录线程id,第二个域用于记录锁的状态。每次都是将自己的线程号放到标记字段中,这样每次如果每次线程号都是自己的,那么就不用进行锁处理了。

如果不是自己的,同时状态又是被锁,那么就会进行锁是升级,也就是升级为轻量级锁,以保证只有一个线程能获得锁。

偏向锁

背景:大多数时候都不会遇到锁,并且往往需要锁的都是同一个线程

加锁:当一个线程访问同步块并获得锁时,会在对象的头部和栈帧中记录存储该线程的ID

解锁:
如果存储的ID不是自己的ID,说明其它线程持有了锁,那么就会发起竞争,如果存储的线程没有使用,那么就会释放锁
如果是自己的ID,那么说明没有其他线程持有锁,就可以直接访问数据了

这样只有发生锁竞争的时候才会释放锁,从而提高锁的性能

轻量级锁

通过自旋的方式来对线程进行阻塞

加锁:在线程的栈帧中创建存储锁记录的空间,将对象头的mark word复制到锁记录中,尝试将对象头的mark word替换为指向锁记录的指针,如果成功,说明没有竞争直接获得锁,如果失败,说明锁已经被占用,通过自旋获得锁

解锁:用栈帧中的锁记录替换对象头的mark word,如果成功,说明没有竞争,如果失败,说明有其他线程在尝试获得锁,存在竞争,锁会膨胀为重量级锁,并不再恢复

重量级锁:获得锁失败时就会陷入阻塞,等待被唤醒。

轻量级锁加锁过程

线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,然后将对象头中的 Mark Word 复制到锁记录中。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。

锁记录:用于存储锁对象目前的Mark Word的拷贝(也就是对象自身的 HashCode,分代年龄等信息)

偏向锁则只是将 Mark Word 中的线程 ID 修改为自己的 ID

重量级锁则是让线程进入等待状态,而不是自旋状态

字节码层面

synchronized关键字最主要的三种使⽤⽅式

修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁

修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀ 个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管new了多少个对象,只有 ⼀份)。所以如果⼀个线程A调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程B需要调⽤ 这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前 实例对象锁。

修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/20201226200709.png

synchronized 同步语句块的情况

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置,锁对象是monitor(每个Java对象的对象头中)

synchronized 修饰⽅法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized ⽅法");
    }
}

通过 ACC_SYNCHRONIZED 访问 标志来辨别⼀个⽅法是否声明为同步⽅法

Monitor 对象

Java面试常见问题:Monitor对象是什么? - 知乎 (zhihu.com)

本质上是一个管程:管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

存放的位置:我的理解中是在对象中的,然后线程获取之后,保留一个备份相当于是许可证。

当对象头 Mark Word 字段中的锁状态为重量级锁时,Mark Word 中会记录指向 Monitor 对象的指针

在HotSpot虚拟机中,Monitor是基于C++的ObjectMonitor类实现的,结构体如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  // 约为 _WaitSet 和 _EntryList 的节点数之和
    _waiters      = 0,
    _recursions   = 0;  // 记录重入次数
    _object       = NULL;
    _owner        = NULL; // _owner指向持有ObjectMonitor对象的线程
    _WaitSet      = NULL; // 双向循环链表:存放处于wait状态的线程队列,即调用wait()方法的线程
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 单向链表:多个线程争抢锁,会先存入这个
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 双向循环链表:存放处于等待锁block状态的线程队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

工作机制

(1)当多个线程同时访问一段同步代码时,首先会进入 _EntryList 队列中。

(2)当某个线程获取到对象的Monitor后进入临界区域,并把Monitor中的 _owner 变量设置为当前线程,同时Monitor中的计数器 _count 加1。即获得对象锁。

(3)若持有Monitor的线程调用 wait() 方法,将释放当前持有的Monitor,*owner变量恢复为null,*count自减1,同时该线程进入 _WaitSet 集合中等待被唤醒。

(4)在WaitSet 集合中的线程会被再次放到EntryList 队列中,重新竞争获取锁。

(5)若当前线程执行完毕也将释放Monitor并复位变量的值,以便其他线程进入获取锁。

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/202108201610182.png

锁竞争机制

ObjectMonitor::enter() 和 ObjectMonitor::exit() 分别是ObjectMonitor获取锁和释放锁的方法。线程解锁后还会唤醒之前等待的线程,根据策略选择直接唤醒_cxq队列中的头部线程去竞争,或者将_cxq队列中的线程加入_EntryList,然后再唤醒_EntryList队列中的线程去竞争。

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/202108201623496.jpg

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/202108201625596.jpg

java.util.concurrent 包

AQS(AbstractQueuedSynchronizer,队列同步器)

AQS learning (I) introduction to spin lock principle (why does AQS bottom layer use spin lock queue?)

核心思想:如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

AQS 使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

为什么 AQS 需要一个虚拟 head 节点

因为每个节点都需要自选对前驱节点的状态进行判断,AQS 的虚拟 head 节点是用来给最前面的节点查看的,遇到这个节点说明可以拿到锁了。

为什么 AQS 使用 CLH 锁而不是性能更好的 MCS 锁

因为 AQS 需要对超时、中断等情况进行处理,而 CLH 锁前驱节点的结构能够很好的满足这些情况,而 MCS 则不行。

自旋锁的改进

常规自旋锁

UMA架构与NUMA架构下的自旋锁(CLH锁与MCS锁) - 文章详情 (itpub.net)

因为常规自旋锁是对一个变量(锁状态,堆中的对象头,所有线程共享)的访问,而在 CPU 的架构中,为了加速数据访问,每个 CPU 都有缓存,所以自旋锁对锁状态的访问实际上是对当前 CPU 内部的缓存的数据的访问,当锁的状态发生改变时,因为缓存一致性,所以每个 CPU 内部对锁状态的缓存就会失效,需要浪费很大的时间重新从内存进行读取(缓存同步)。

CLH 锁

并发编程——详解 AQS CLH 锁 - 简书 (jianshu.com)

Java AQS 核心数据结构-CLH 锁-InfoQ

CLH(Craig, Landin, and Hagersten locks):是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋

CLH 锁的核心思想:将众多线程长时间对某资源(比如锁状态)的竞争,通过有序化这些线程将其转化为只需对前驱节点本地变量检测。而唯一存在竞争的地方就是在入队列之前对尾节点tail的竞争,但此时竞争的线程数量已经少了很多了。比起所有线程直接对某资源竞争的轮询次数也减少了很多,这也大大节省了CPU缓存同步的消耗,从而大大提升系统性能。

实现方式:

结构:FIFO队列,构建的时候主要通过移动尾部节点tail来实现队列的排队,每个想获取锁的线程创建一个新节点并通过CAS原子操作将新节点赋给tail,然后让当前线程轮询前一节点的某个状态位。

解锁:执行完线程后只需将当前线程对应的节点状态位置为解锁状态即可,由于下一节点一直在轮询,所以可获取到锁。

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/202108132010598.jpg

缺点:自旋的对象是前驱节点,在NUMA架构下可能会存在性能问题,因为如果前驱节点和当前节点不再同一个本地主存储的话则访问时间会很长,这就会导致性能受影响。

两个缺点:第一是因为有自旋操作,当锁持有时间长时会带来较大的 CPU 开销。第二是基本的 CLH 锁功能单一,不改造不能支持复杂的功能。

AQS 对 CLH 队列锁的改造

针对 CLH 的缺点,AQS 对 CLH 队列锁进行了一定的改造。针对第一个缺点,AQS 将自旋操作改为阻塞线程操作。针对第二个缺点,AQS 对 CLH 锁进行改造和扩展,原作者 Doug Lea 称之为“CLH 锁的变体”。下面将详细讲 AQS 底层细节以及对 CLH 锁的改进。AQS 中的对 CLH 锁数据结构的改进主要包括三方面:扩展每个节点的状态、显式的维护前驱节点和后继节点以及诸如出队节点显式设为 null 等辅助 GC 的优化。正是这些改进使 AQS 可以支撑 j.u.c 丰富多彩的同步器实现。

因为 AQS 的改造,显式的维护前驱节点从而可以处理“超时”和各种形式的“取消”,维护后继节点从而将自旋操作替换为阻塞等待,这样在前面的操作完成之后就可以唤醒后继节点获取锁了。

MCS锁

MCS锁由John Mellor-Crummey和Michael Scott两人发明,它的出现旨在解决CLH锁存在的问题。它也是基于FIFO队列,与CLH锁相似,不同的地方在于轮询的对象不同。MCS锁中线程只对当前节点本地变量自旋,而前驱节点则负责通知其结束自旋操作。这样的话就减少了CPU缓存与主存储之间的不必要的同步操作,减少了同步带来的性能损耗。

每个线程对应着队列中的一个节点。节点内有一个spin变量,表示是否需要旋转。一旦前驱节点使用完锁后,便修改后继节点的spin变量,通知其不必继续做自旋操作,已成功获取锁。

https://gitee.com/hzm_pwj/FigureBed/raw/master/giteeImg/202108132017356.jpg

对比

synchronized 关键字和 volatile 关键字的区别

总体来讲 volatile 作用于变量,synchronized 作用于代码块。

可见性:先清空工作内存→在主内存中拷贝最新变量的副本到工作内存→执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

双重校验锁使用 volatile 是为了避免指令重排序

synchronize 和 Reentrantlock 的共同点与区别

https://www.secn.net/article/690148.html

  1. 两者都是可重入锁。
  2. synchronized 是非公平锁,Reentrantlock 可以是公平锁,也可以是非公平锁。
  3. synchronized 是关键字 JVM 层面的锁,不可控制中断;Reentrantlock 是代码层面的锁,可以控制中断。
  4. 底层
    1. ReentrantLock 通过 AQS 实现可重入性,也就是使用 stat 记录加锁的次数。
    2. Synchronized 实现可重入的原理:每一个可重入锁都会关联一个线程 ID 和一个锁状态 status,可重入就是将 status 加 1
  5. 高耗时,少争用适合synchronized,低耗时,高争用适合lock,因为 AQS 比偏向锁慢,但是比重量级锁快。

死锁

产生死锁必备的四个条件

  1. 互斥:该资源任意⼀个时刻只由⼀个线程占⽤。
  2. 请求与保持:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
  3. 不剥夺:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后 才释放资源。
  4. 循环等待:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。

解决死锁

只要破坏其中一个,就可以成功避免死锁的发生

其中,互斥这个条件没有办法破坏,因为用锁为的就是互斥。

  1. 对于“占用且等待”这个条件,可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
这篇关于Java 中的各种锁及其原理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!