Java 并发编程解析 | 如何正确理解Java领域中的并发锁,我们应该具体掌握到什么程度?

2022/9/7 14:22:58

本文主要是介绍Java 并发编程解析 | 如何正确理解Java领域中的并发锁,我们应该具体掌握到什么程度?,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》

Picture-Navigation

写在开头

Picture-Header

对于Java领域中的锁,其实从接触Java至今,我相信每一位Java Developer都会有这样的一个感觉?不论是Java对锁的实现还是应用,真的是一种“群英荟萃”,而且每一种锁都有点各有各的驴,各有各的本,各不相同。

在很多情况下,以及在各种锁的应用场景里,各式各样的定义,难免会让我们觉得无所适从,很难清楚该如何对这些锁做到得心应手?

在并发编程色世界中,一般情况下,我们只需了解其是如何使用锁之后就已经满足我们大部分的需求,但是作为一名对技术研究有执念和热情的人来说,深入探究和分析才是对技术的探秘之乐趣。

作为一名Java Developer来说,深入探究和分析和正确了解和掌握这些锁的机制和原理,需要我们带着一些实际问题,通过对其探究分析和加上实际应用分析,才能真正意义上理解和掌握。

一般来说,针对于不同场景提供的锁,都用于解决什么问题?不论是从实现方式上,还是从使用场景上,都可以应对这些锁的特点,我们又该如何认识和理解?

接下来,今天我们就一起来盘一盘,Java领域中那些并发锁,盘点一下相关的锁,从设计基本思想和设计实现,以及应用分析等方面来总体分析探讨一下。

关健术语

Picture-Keyword

本文用到的一些关键词语以及常用术语,主要如下:

  • 进程(Process): 计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。
  • 线程(thread): 操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。在Unix System V及SunOS中也被称为轻量进程(Light-Weight Processes),但轻量进程更多指内核线程(Kernel Thread),而把用户线程(User Thread)称为线程。

基本概述

Picture-Content

在Java领域中,单纯从Java对其实现的方式上来看,我们大体上可以将其分为基于Java语法层面(关键词)实现的锁和基于JDK层面实现的锁。

基于这两个基本点,可以作为我们对于Java领域中的锁的一个基础认识,这对于我们认识和了解Java领域中的锁指导一个参考方向。

一般来说,锁是并发编程中最基础和最常用的一项技术,而且在Java的内部JDK中其使用也是非常地广泛。

接下来,我们便一起探究和认识一下Java领域中的各种各样的锁。

一.锁的基本理论

锁的基本理论主要是指从锁的基本定义和基本特点以及基本意义去分析的一般模型理论,是一套帮助我们认识和了解锁的简单的思维方法论。

Picture-Content

一般在了解一个事物之前,我们都会按照基本定义,基本特点以及基本意义去看待这个事物。在计算机的世界里,锁本身也和我们实际生活一样,也是一个比较普遍且应用场景繁多的一种事物。

比如,在操作系统中,也定义了各种各样的锁;在数据库系统中也出现了锁。甚至,在CPU处理器架构中都会看见锁的身影。

但是,这里就会有一个问题:既然都在使用锁,可是对于锁该去如何定义,似乎都很难给出一个准确的定义? 换而言之,这也许就是我们对于锁只是知道有这个东西,但是一直有云里雾里的基本原因。

从本质上讲,计算机软件开发领域中的锁是一种协调多个进程 或者多个线程对某一个资源的访问的控制机制,其核心是作用于资源,也作用于着这个定义中提到的进程和线程等。其中:

vTz8X9.png

  • 进程(Process): 操作系统进行资源分配和调度的基本单位,是计算机程序中的实体,其中,程序是指令、数据及其组织形式的描述。
  • 线程(Thread) : 操作系统能够进行运算调度的最小单位,一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

一般来说,线程主要分为位于系统内核空间的线程称为内核线程(Kernel Thread)和位于应用程序的用户空间的线程被称为用户线程(User Thread)两种,其中:

v8MXh4.png

也就是我们一般说的Java线程等均属于用户线程,而内核线程主要是操作系统封装的函数库以及API等。

而且最关健的就是,我们平日里所提到Java线程和JVM都是位于用户空间之中,从Java层到操作系统系统的线程调度顺序来看,一般流程是:java.lang.Thread(Target Thread)->Java Thread->OSThread->pthread->Kernel Thread。

v7plL9.png

简单来说,在Java领域中,锁是用于控制多个线程访问共享资源的工具。一般,锁提供对共享资源的独立访问:一次只有一个线程可以获取锁,所有对共享资源的访问都需要先获取锁。但是,某些锁可以并发访问共享资源。

对于并发访问共享资源来说,主要是依据现在大多数操作系统的线程的调度方式是抢占式调度,因此加锁是为了维护数据的一致性和完整性,其实就是数据的安全性。

综上所述,我们便可以得到一个关于锁的基本概念模型,接下来我们便来一一盘点以下主要有哪些锁。

二.锁的基本分类

在Java领域中,我们可以将锁大致分为基于Java语法层面(关键词)实现的锁和基于JDK层面实现的锁。

v7iA6U.png

单纯从Java对其实现的方式上来看,我们大体上可以将其分为基于Java语法层面(关键词)实现的锁和基于JDK层面实现的锁。其中:

  • Java内置锁:基于Java语法层面(关键词)实现的锁,主要是根据Java语义来实现,最典型的应用就是synchronized。
  • Java显式锁:基于JDK层面实现的锁,主要是根据基于Lock接口和ReadWriteLock接口,以及统一的AQS基础同步器等来实现,最典型的有ReentrantLock。

需要特别注意的是,在Java领域中,基于JDK层面的锁通过CAS操作解决了并发编程中的原子性问题,而基于Java语法层面实现的锁解决了并发编程中的原子性问题和可见性问题。

除此之外之外,在Java并发容器中曾用到过一种Segment数组结构来实现的分段锁。

而从具体到对应的Java线程资源来说,我们按照是否含有某一特性来定义锁,主要可以从如下几个方面来看:

  • 从加锁对象角度方面上来看,线程要不要锁住同步资源 ? 如果是需要加锁,锁住同步资源的情况下,一般称其为悲观锁;否则,如果是不需要加锁,且不用锁住同步资源的情况就属于为乐观锁。
  • 从获取锁的处理方式上来看,假设锁住同步资源,其对该线程是否进入睡眠状态或者阻塞状态?如果会进入睡眠状态或者阻塞状态,一般称其为互斥锁,否则,不会进入睡眠状态或者阻塞状态属于一种非阻塞锁,即就是自旋锁。
  • 从锁的变化状态方面来看,多个线程在竞争资源的流程细节上是否有差别?
  • 首先,对于不会锁住资源,多个线程只有一个线程能修改资源成功,其他线程会依据实际情况进行重试,即就是不存在竞争的情况,一般属于无锁。
  • 其次,对于同一个线程执行同步资源会自动获取锁资源,一般属于偏向锁。
  • 然而,对于多线程竞争同步资源时,没有获取到锁资源的线程会自旋等待锁释放,一般属于轻量级锁。
  • 最后,对于多线程竞争同步资源时,没有获取到锁资源的线程会阻塞等待唤醒,一般属于重量级锁。
  • 从锁竞争时公平性上来看,多个线程在竞争资源时是否需要排队等待?如果是需要排队等待的情况,一般属于公平锁;否则,先插队,然后再尝试排队的情况属于非公平锁。
  • 从获取锁的操作频率次数来看,一个线程中的多个流程是否可以获取同一把锁?如果是可以多次进行加锁操作的情况,一般属于可重入锁,否则,可以多次进行加锁操作的情况属于非可重入锁。
  • 从获取锁的占有方式上来看,多个线程能不能共享一把锁?如果是可以共享锁资源的情况,一般属于共享锁;否则,独占锁资源的情况属于排他锁。

针对于上述描述的各种情况,接下来,我们便来一起详细看看,在Java领域中,这个锁的具体情况。

三.Java内置锁

在Java领域中,Java内置锁主要是指基于Java语法层面(关键词)实现的锁。

v7EYxP.png

在Java领域中,我们把基于Java语法层面(关键词)实现的锁称为内置锁,比如synchronized 关键字。

对于synchronized 关键字的解释,最直接的就是Java语言中为开发人员提供的同步工具,可以看作是Java中的一种“语法糖”。主要宗旨在于解决多线程并发执行过程中数据同步的问题。

不像其他的编程语言(C++),在处理同步问题时都需要自己进行锁处理,主要特点就是简单,直接声明即可。

在 Java 程序中,利用 synchronized 关键字来对程序进行加锁,其实现同步的语义是互斥锁。既可以用来声明一个 synchronized 代码块,也可以直接标记静态方法或者实例方法。

其中,对于互斥的概念来说,在数学范畴来讲,是一个数学名词,表示和描述的是事件A与事件B在任何一次试验中都不会同时发生,则称事件A与事件B互斥。

因此,对于互斥锁可以理解为: 对于某一个锁来说,任意时刻只能有一个线程获得该锁,对于其他线程想获取锁的时候就得等待或者被阻塞。

1.使用方式

在Java领域中,synchronized关键字互斥锁主要有作用于对象方法上面,作用于类静态方法上面,作用于对象方法里面,作用于类静态方法里面等4种方式。

v7Eha4.png

在Java领域中,synchronized关键字从使用方式来看,主要可以分为:

  • 作用于对象方法上面:
    • 描述对象的方法,表示该对象的方法具有同步性。由于描述的对象的方法,作用范围是在对象(Object),整个对象充当了锁。
    • 需要注意的是,类可以实例化多个对象,这时每一个对象都是一个锁,每个锁的范围相当于是当前对象来说的。
  • 作用于类静态方法上面:
    • 描述类的静态方法,表示该方法具有同步性。由于描述的类静态的方法,作用范围是在类(Class),整个类充当了锁。
    • 需要注意的是,某一个类的本身也是一个对象,JVM使用这个对象作为模板去生成该类的对象时,每个锁的范围相当于是当前类来说的。
  • 作用于对象方法里面:
    • 描述方法内部的某块逻辑,表示该代码块具有同步性。
    • 需要注意的是,一般需要我们指定对象,比如synchronized(this){xxx}是指当前对象的,也可以创建一个对象来作为锁。
  • 作用于类静态方法里面:
    • 描述静态方法内部的某块逻辑,表示该代码块具有同步性。
    • 需要注意的是,一般需要我们指定锁对象,比如synchronized(this){xxx}是指当前类class作为锁对象的,也可以创建一个对象来作为锁。

一般当我们在编写代码的过程中,如果按照上述方式声明时,被synchronized关键字声明的代码会比普通代码在编译之后,使用javap -c xxx.class 查看字节码,就会发现多两个monitorenter和monitorexit指令。

2.基本思想

在Java领域中,synchronized关键字互斥锁主要基于一个阻塞队列和等待对列,类似于一种“等待-通知”的工作机制来实现。

v7EoGR.png

一般情况下,“等待 - 通知”的工作机制的要求是线程首先获取互斥锁,其中:

  • 当线程要求的条件不满足时,释放互斥锁,进入等待状态。
  • 当要求的条件满足时,通知等待的线程,重新获取互斥锁。

在Java领域中, Java 语言内置的 synchronized 配合java.lang.Object类定义的 wait()、notify()、notifyAll() 这三个方法就能轻松实现等待 - 通知机制,其中:

  • wait: 表示持有对象锁的线程A准备释放对象锁权限,释放cpu资源并进入等待。
  • notify:表示持有对象锁的线程A准备释放对象锁权限,通知jvm唤醒某个竞争该对象锁的线程X。线程A synchronized 代码作用域结束后,线程X直接获得对象锁权限,其他竞争线程继续等待(即使线程X同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的notify ,notifyAll被调用)。
  • 表示持有对象锁的线程A准备释放对象锁权限,通知jvm唤醒所有竞争该对象锁的线程,线程A synchronized 代码作用域结束后,jvm通过算法将对象锁权限指派给某个线程X,所有被唤醒的线程不再等待。线程X synchronized代码作用域结束后,之前所有被唤醒的线程都有可能获得该对象锁权限,这个由JVM算法决定。

一个线程一旦调用了任意对象的wait()方法,就会变为非运行状态,直到另一个线程调用了同一个对象的notify()方法。

为了调用wait()或者notify(),线程必须先获得那个对象的锁。也就是说,线程必须在同步块里调用wait()或者notify()。

对于等待队列的工作机制来说,同一时刻,只允许一个线程进入 synchronized 保护的临界区。当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。在并发程序中,其中:

v7ZYB8.png

  • 当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。
  • 当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。
  • 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。

对于通知队列的工作机制来说,那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify() 和 notifyAll() 方法。当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。为什么说是曾经满足过呢?其中:

v7ZaNQ.png

  • 因为 notify() 只能保证在通知时间点,条件是满足的。
  • 而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。
  • 除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。

上面我们一直强调 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,其中:

  • 如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();
  • 如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。

而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。

如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。

对于和notifyAll() 和notify()来实现通知机制,特别需要注意的是,两者之间的区别:

  • notify()方法 : 随机地通知等待队列中的一个线程。
  • notifyAll()方法: 通知等待队列中的所有线程。

从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但是实际上使用 notify() 也很有风险,主要在于可能导致某些线程永远不会被通知到。

在具体使用过程中,所以除非经过深思熟虑,一般推荐尽量使用 notifyAll()。

3.基本实现

在Java领域中,synchronized关键字互斥锁主要基于Java HotSpot(TM) VM 虚拟机通过Monitor(监视器)来实现monitorenter和monitorexit指令的。

v7E7xx.png

在Java HotSpot(TM) VM 虚拟机中,主要是通过Monitor(监视器)来实现monitorenter和monitorexit指令的,Monitor(监视器)一般包括一个阻塞队列和一个等待队列,其中:

  • 阻塞队列 : 用来保存锁竞争失败的线程,它们处于阻塞状态。
  • 等待队列:用来保持synchronized关键字块中的调用 wait()方法后放置的队列。

其中,需要注意的是,当调用 wait()方法后会释放锁并通知阻塞队列。

一般来说,当Java字节码(class)被托管到Java HotSpot(TM) VM 虚拟机后,Monitor(监视器)就被采用ObjectMonitor接管,其中:

  • 每⼀个对象都有⼀个属于⾃⼰的monitor,其次如果线程未获取到singal (许可),则线程阻塞。
  • monitor相当于⼀个对象的钥匙,只有拿到此对象的monitor,才能访问该对象的同步代码。 相反未获得monitor的只能阻塞来等待持有monitor的线程释放monitor。

对于monitorenter指令来说,其中:

  • ⼀个对象都会和⼀个监视器monitor关联。监视器被占⽤时会被锁住,其他线程⽆法来获取该monitor。当JVM执⾏某个线程的某个⽅法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。
  • synchronized的锁对象会关联⼀个monitor,这个monitor不是我们主动创建的,是JVM的线程执⾏到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当⼀个线程拥有monitor后其他线程只能等待。

主要工作流程如下:

  • 若monior的进⼊数为0,线程可以进⼊monitor,进入后将monitor的进数置为1。当前线程成为monitor的owner(所有者) 。
  • 若线程已拥有monitor的所有权,允许它重⼊monitor,则进⼊monitor的进⼊数再加1。
  • 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞。直到monitor的进⼊数变为0,才能重新尝试获取monitor的所有权。

对于monitorexit指令来说,其中:

  • 能执⾏monitorexit指令的线程,⼀定是拥有当前对象的monitor的所有权的线程。
  • 执⾏monitorexit时会将monitor的进⼊数减1。当monitor的进⼊数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

主要工作流程如下:

  • monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异常退出释放锁。
  • monitorexit释放锁monitorexit插⼊在⽅法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。

综上所述,monitorenter和monitorexit两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现。被阻塞的线程会被挂起、等待重新调度,会导致"用户态和内核态"两个态之间来回切换,对性能有较大影响。

4.具体实现

在Java领域中,JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。

v7MaNT.png

本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:

  • 同步:监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。
  • 协作:监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。

在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的,ObjectMonitor类定义在ObjectMonitor.hpp文件中,ObjectMonitor的Owner(_owner)、WaitSet(_WaitSet)、Cxq(_cxq)、EntryList(_EntryList)这几个属性比较关键。

v7MB34.png

ObjectMonitor的WaitSet、Cxq、EntryList这三个队列存放抢夺重量级锁的线程,而ObjectMonitor的Owner所指向的线程即为获得锁的线程。其中:

  • Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中
  • EntryList:Cxq中那些有资格成为候选资源的线程被移动到EntryList中。
  • WaitSet:某个拥有ObjectMonitor的线程在调用Object.wait()方法之后将被阻塞,然后该线程将被放置在WaitSet链表中。

Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素时,会从队尾获取。显然,Cxq结构是一个无锁结构。
在线程进入Cxq前,抢锁线程会先尝试通过CAS自旋获取锁,如果获取不到,就进入Cxq队列,这明显对于已经进入Cxq队列的线程是不公平的。所以,synchronized同步块所使用的重量级锁是不公平锁。

EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为OnDeck Thread(Ready Thread)。EntryList中的线程作为候选竞争线程而存在。

JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称为“竞争切换”。
OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)。
在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。

如果Owner线程被Object.wait()方法阻塞,就转移到WaitSet队列中,直到某个时刻通过Object.notify()或者Object.notifyAll()唤醒,该线程就会重新进入EntryList中。

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。

5.基本分类

在Java领域中,synchronized关键字互斥锁主要中内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。

v7EqsK.png

在Java领域中,一般Java对象(Object实例)结构包括三部分:对象头、对象体和对齐字节,其中:

v7h8kn.png

  • 对象头(Object Header) :对象头包括三个字段,主要是作Mark Word(标记字段)、Klass Pointer(类型指针)以及Array Length(数组长度)等。
  • 对象体(Object Data) :包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按4字节对齐。
  • 对齐字节(Padding): 也叫作填充对齐,其作用是用来保证Java对象所占内存字节数为8的倍数HotSpot VM的内存管理要求对象起始地址必须是8字节的整数倍。

一般地,对象头本身是8的倍数,当对象的实例变量数据不是8的倍数时,便需要填充数据来保证8字节的对齐。

上面在悲观锁和乐观锁分类时候,提到synchronized是悲观锁, 以Hotspot虚拟机为例,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里,其中:

  • Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Pointer(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

而对于synchronized来说,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步,主要是通过JVM中Monitor监视器来实现monitorenter和monitorexit指令的,而Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

在JDK 1.6版本之前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间频繁切换,所以代价高、效率低。

JDK 1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的实现。

在JDK 1.6版本中内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。其中:

  • 无锁状态:Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它),这时偏向锁标识位是0,锁状态是01。
  • 偏向锁状态: 指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。
  • 轻量级锁状态:当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。
  • 重量级锁状态:重量级锁会让其他申请的线程之间进入阻塞,性能降低。重量级锁也叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。

因此,根据上述的锁状态来看,我们可以把Java内置锁分为无锁,偏向锁,轻量级锁和重量级锁等4种锁,其中:

  • 无锁:表示Java对象实例刚创建,还没有锁参与竞争。即就是没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
  • 偏向锁:偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心,即锁会偏向于当前已经占有锁的线程。指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  • 轻量级锁:轻量级锁主要有两种:普通自旋锁和自适应自旋锁。当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,JVM轻量级锁开销较小。
  • 重量级锁: JVM重量级锁使用了Linux内核态下的互斥锁,升级为重量级锁时,等待锁的线程都会进入阻塞状态,其开销较大。

从锁升级的状态顺序来看,只能是: 无锁->偏向锁->轻量级锁->重量级锁 ,而且顺序不可逆,也就是不能降级。

v7WjxS.png

综上所述,在Java内置锁中,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

6.应用分析

在Java领域中,synchronized关键字互斥锁主要中内置锁使用简单,但是锁的粒度比较大,无法支持超时等。

v7eV8s.png

从synchronized的执行过程,大致如下:

  • 线程抢锁时,JVM首先检测内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
  • 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
  • 如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
  • 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁
  • JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
  • 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。

总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有了第二个线程争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。

四.Java显式锁

在Java领域中,Java显式锁主要是指基于JDK层面实现的锁。

v7MqVP.png

在Java领域中,基于JDK层面实现的锁都存在于java.util.concurrent.locks包下面,大致可以分为:

  • 基于Lock接口实现的锁
  • 基于ReadWriteLock接口实现的锁
  • 基于AQS基础同步器实现的锁
  • 基于自定义API操作实现的锁

一直以来,并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作等。

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。

1.JDK源码

在Java领域中,Java显式锁从JDK源码表现出来的锁大致可以分为基于Lock接口实现的锁,基于ReadWriteLock接口实现的锁,基于AQS基础同步器实现的锁,以及基于自定义API操作实现的锁等。

v7QFaV.png

在Java领域中,基于JDK源码层面体现出来的锁,主要分为如下几种:

  • 基于Lock接口实现的锁:基于Lock接口实现的锁主要有ReentrantLock。
  • 基于ReadWriteLock接口实现的锁:基于ReadWriteLock接口实现的锁主要有ReentrantReadWriteLock。
  • 基于AQS基础同步器实现的锁:基于AQS基础同步器实现的锁主要有CountDownLatch,Semaphore,ReentrantLock,ReentrantReadWriteLock等。
  • 基于自定义API操作实现的锁: 不依赖于上述三种方式来直接封装实现的锁,最典型是JDK1.8版本中提供的StampedLock。

从一定程度上说,Java显式锁都是基于AQS基础同步器实现的锁,其中JDK1.8版本中提供的StampedLock是是对ReentrantReadWriteLock读写锁的一种改进。

综上所述,认识和掌握Java内置锁,都需要AQS基础同步器设计与实现,它是ava内置锁的基础和核心实现。

2.基本思想

在Java领域中,Java显式锁的基本思想来源于JDK并发包JUC的作者Doug Lea,发表的论文为java.util.concurrent Synchronizer Framework 。

v7Q1IK.png

在Java领域中,同步器是指专门为多线程并发而设计的同步机制,在这种机制下,多线程并发执行时线程之间通过某种共享状态实现同步,只有满足某种条件时线程才能执行。

在不同的应用场景中,对同步器的需求也不同,JDK将各种同步器的相同部分抽象封装成一个统一的基础同步器,然后基于这个同步器为模板,通过继承的方式来实现不同的同步器,即就是我们说的统一的基础AQS同步器。

在JDK的并发包java.util.concurrent.下面,提供了各种同步工具,其中大部分同步工具都基于AbstractQueuedSynchronizer类实现,即就是AQS同步器,为不同场景提供了实现锁以及同步机制的基础框架,为同步状态的原子性管理,线程阻塞与解除以及排队管理提供一种通用的机制。

其中,AQS的理论基础是JDK并发包JUC的作者Doug Lea,发表的论文为java.util.concurrent Synchronizer Framework [AQS Framework论文],其中包括框架的基础原理,需求,设计,实现思路,设计以及用户和性能分析等。

3.基本实现

在Java领域中,Java显式锁从一定程度上说,Java显式锁都是基于AQS基础同步器实现的锁。

v7QYxH.png

从JDK1.8版本的源码来看,AbstractQueuedSynchronizer的主要继承了抽象类AbstractOwnableSynchronizer,其主要封装了setExclusiveOwnerThread()和getExclusiveOwnerThread()两个方法。其中:

  • setExclusiveOwnerThread()方法: 设置线程独享模式,其参数为java.lang.Thread对象。
  • getExclusiveOwnerThread()方法: 获取独享模式的线程,其返回参数类型为java.lang.Thread对象。

对于一个AbstractQueuedSynchronizer(AQS同步器)从内部结构上来说,主要有5个核心要素: 同步状态,等待队列,独占模式,共享模式,条件队列。其中:

v7QaqI.png

  • 同步状态(Synchronizer Status):用于实现锁机制
  • 等待队列(Wait Queue):用于存储等待锁的线程
  • 独占模式(Exclusive Model): 实现独占锁
  • 共享模式(Shared Model): 实现共享锁
  • 条件队列(Condition Queue):提供可替代wait/notify机制的条件队列模式

从采用的数据结构来看,AQS同步器主要是将线程封装到一个Node里面,并维护一个CLH Node FIFO队列(非阻塞FIFO队列),以为着在并发条件下,对此队列中进行插入和移除操作时不会阻塞,主要是采用CAS+自旋锁来保证节点的插入和移除的原子性操作,从而实现快速插入的。

从JDK1.8版本的源码来看,AbstractQueuedSynchronizer的源码结构主要如下:

  • 等待队列:主要定义了两个Node类变量,主要是等待队列的结构变量head和tail等
  • 同步状态为state,其必须是32位整数类型,更新时必须保证是原子性的
  • CAS操作的变量:定义了stateOffset,headOffset,tailOffset,waitStatusOffset,nextOffset的句柄,主要用于执行CAS操作,其中JDK的CAS操作主要使用Unsafe类来实现,处于sun.misc.下面提供的类。
  • 阀值:spinForTimeoutThreshold,决定使用自旋方式消耗时间还是使用系统阻塞方式消耗时间的分割线,默认值为1000L(ns),是长整数类型,表示锁竞争小于1000(ns)使用自旋,如果超过1000(ns)使用系统阻塞。
  • 条件队列对象:基于Condition接口封装了一个ConditionObject对象。

但是,特别需要注意的是,在JDK1.8版本之后,AbstractQueuedSynchronizer的源码结构有所不同:

  • 等待队列:主要定义了两个Node类变量,主要是等待队列的结构变量head和tail
  • 同步状态为state,其必须是32位整数类型,更新时必须保证是原子性的
  • CAS操作的变量:使用VarHandle定义了state,head,tail的句柄,主要用于执行CAS操作,其中JDK1.9的CAS操作主要使用VarHandle来替代Unsafe类,位于java.lang.invoke.下面。
  • 阀值:spinForTimeoutThreshold,决定使用自旋方式消耗时间还是使用系统阻塞方式消耗时间的分割线,默认值为1000L(ns),是长整数类型,表示锁竞争小于1000(ns)使用自旋,如果超过1000(ns)使用系统阻塞。
  • 条件队列对象:基于Condition接口封装了一个ConditionObject对象。

由此可见,最大的不同就是使用VarHandle来替代Unsafe类,Varhandle是对变量或参数定义的变量系列的动态强类型引用,包括静态字段,非静态字段,数组元素或堆外数据结构的组件。 在各种访问模式下都支持访问这些变量,包括简单的读/写访问,volatile 的读/写访问以及 CAS (compare-and-set)访问。简单来说 Variable 就是对这些变量进行绑定,通过 Varhandle 直接对这些变量进行操。

4.具体实现

在Java领域中,Java显式锁中基于AQS基础同步器实现的锁主要都是采用自旋锁(CLH锁)+CAS操作来实现。

v7QNMd.png

在介绍内置锁的时候,提到轻量级锁的主要分类为普通自旋锁和自适应自旋锁,但其实对于自旋锁的实现方式来看,主要可以分为普通自旋锁和自适应自旋锁,CLH锁和MCS锁等4种,其中:

  • 普通自旋锁:多个线程不断自旋,不断尝试获取锁,其不具备公平性和由于要保证CPU和缓存以及主存之间的数据一致性,其开销较大。
  • 自适应自旋锁:主要是为解决普通自旋锁的公平性问题,引入了一个排队机制,一般称为排他自旋锁,其具备公平性,但是没有解决保证CPU和缓存以及主存之间的数据一致性问题,其开销较大。
  • CLH锁:通过一定手段将线程对于某一个共享变量的轮询竞争转化为一个线程队列,且队列中的线程各自轮询自己本地变量。
  • MCS锁:主旨在于解决 CLH锁的问题,也是基于FIFO队列,与CLH锁不同是,只对本地变量自旋,前驱节点负责通知MCS锁中线程自适结束。

自旋锁是一种实现同步的方案,属于一种非阻塞锁,与常规锁主要的区别就在于获取锁失败之后的处理方式不同,主要体现在:

  • 一般情况下,常规锁在获取锁失败之后,会将线程阻塞并适当时重新唤醒
  • 而自旋锁则是使用自旋来替换阻塞操作,主要是线程会不断循环检查该锁是否被释放,一旦释放线程便会获取锁资源。

其实,自旋是一钟忙等待状态,会一直消耗CPU的执行时间。一般情况下,常规互斥锁适用于持有锁长时间的情况,自旋锁适合持有时间短的情况。

其中,对于CLH锁来说,其核心是为解决同步带来的花销问题,Craig,Landim,Hagersten三人发明了CLH锁,其中主要是:

  • 构建一个FIFO(先进先出)队列,构建时主要通过移动尾部节点tail来实现队列的排队,每个想获得锁的线程都会创建一个新节点(next)并通过CAS操作原子操作将新节点赋予给tail,当前线程轮询前一个节点的状态。
  • 执行完线程后,只需将当前线程对应节点状态设置为解锁即可,主要是判断当前节点是否为尾部节点,如果是直接设置尾部节点设置为空。由于下一个节点一直在轮询,所以可以获得锁。

CLH锁将众多线程长时间对资源的竞争,通过有序化这些线程将其转化为只需要对本地变量检测。唯一存在竞争的地方就是入队之前对尾部节点tail 的竞争,相对来说,当前线程对资源的竞争次数减少,这节省了CPU缓存同步的消耗,从而提升了系统性能。

但是同时也有一个问题,CLH锁虽然解决了大量线程同时操作同一个变量时带来的开销问题,如果前驱节点和当前节点在本地主存中不存在,则访问时间过长,也会引起性能问题。MCS锁就时为解决这个问题提出的,作者主要是John Mellor Curmmey和Michhael Scott两人发明的。

而对于CAS操作来说,CAS(Compare And Swap,比较并交换)操作时一种乐观锁策略,主要涉及三个操作数据:内存值,预期值,新值,主要是指当且仅当预期值和内存值相等时才去修改内存值为新值。

CAS操作的具体逻辑,主要可以分为三个步骤:

  • 首先,检查某个内存值是否与该线程之前取到值一样。
  • 其次,如果不一样,表示此内存值已经被别的线程修改,需要舍弃本次操作。
  • 最后,如果时一样,表示期间没有线程更改过,则需要用新值执行更新内存值。

除此之外,需要注意的是CAS操作具有原子性,主要是由CPU硬件指令来保证,并且通过Java本地接口(Java Native Interface,JNI)调用本地硬件指令实现。

当然,CAS操作避免了悲观策略独占对象的 问题,同时提高了并发性能,但是也有以下三个问题:

  • 乐观策略只能保证一个共享变量的原子操作,如果是多个变量,CAS便不如互斥锁,主要是CAS操作的局限所致。
  • 长时间循环操作可能导致开销过大。
  • 经典的ABA问题: 主要是检查某个内存值是否与该线程之前取到值一样,这个判断逻辑不严谨。解决ABA问题的核心在于,引入版本号,每次更新变量值更新版本号。

其中,在Java领域中,对于CAS操作在

  • JDK1.8版本之前,CAS操作主要使用Unsafe类,具体可以参考源码自行分析。
  • JDK1.8版本之后,JDK1.9的CAS操作主要使用VarHandle类,具体可以参考源码自行分析。

综上所述,主要说明Java显式锁为啥使用基于AQS基础同步器实现的锁主要都是采用自旋锁(CLH锁)+CAS操作来的具体实现。

5.基本分类

在Java领域中,Java显式锁的基本分类大致可以分为可重入锁和不可重入锁、悲观锁和乐观锁、公平锁和非公平锁、共享锁和独占锁、可中断锁和不可中断锁。

v7lAeI.png

显式锁有很多种,从不同的角度来看,显式锁大概有以下几种分类:可重入锁和不可重入锁、悲观锁和乐观锁、公平锁和非公平锁、共享锁和独占锁、可中断锁和不可中断锁。

从同一个线程是否可以重复占有同一个锁对象的角度来分,显式锁可以分为可重入锁与不可重入锁。其中:

  • 可重入锁也叫作递归锁,指的是一个线程可以多次抢占同一个锁,JUC的ReentrantLock类是可重入锁的一个标准实现类。
  • 不可重入锁与可重入锁相反,指的是一个线程只能抢占一次同一个锁。

从线程进入临界区前是否锁住同步资源的角度来分,显式锁可以分为悲观锁和乐观锁。其中:

  • 悲观锁:就是悲观思想,每次进入临界区操作数据的时候都认为别的线程会修改,所以线程每次在读写数据时都会上锁,锁住同步资源,这样其他线程需要读写这个数据时就会阻塞,一直等到拿到锁。总体来说,悲观锁适用于写多读少的场景,遇到高并发写时性能高。Java的synchronized重量级锁是一种悲观锁。
  • 乐观锁是一种乐观思想,每次去拿数据的时候都认为别的线程不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样就更新),如果失败就要重复读-比较-写的操作。总体来说,乐观锁适用于读多写少的场景,遇到高并发写时性能低。Java中的乐观锁基本都是通过CAS自旋操作实现的。CAS是一种更新原子操作,比较当前值跟传入值是否一样,是则更新,不是则失败。在争用激烈的场景下,CAS自旋会出现大量的空自旋,会导致乐观锁性能大大降低。Java的synchronized轻量级锁是一种乐观锁。另外,JUC中基于抽
    象队列同步器(AQS)实现的显式锁(如ReentrantLock)都是乐观锁。

从抢占资源的公平性来说,显示锁可以分为公平锁和非公平锁,其中:

  • 公平锁是指不同的线程抢占锁的机会是公平的、平等的,从抢占时间上来说,先对锁进行抢占的线程一定被先满足,抢锁成功的次序体现为FIFO(先进先出)顺序。简单来说,公平锁就是保障各个线程获取锁都是按照顺序来的,先到的线程先获取锁。
  • 非公平锁是指不同的线程抢占锁的机会是非公平的、不平等的,从抢占时间上来说,先对锁进行抢占的线程不一定被先满足,抢锁成功的次序不会体现为FIFO(先进先出)顺序。

默认情况下,ReentrantLock实例是非公平锁,但是,如果在实例构造时传入了参数true,所得到的锁就是公平锁。另外,ReentrantLock的tryLock()方法是一个特例,一旦有线程释放了锁,正在tryLock的线程就能优先取到锁,即使已经有其他线程在等待队列中。

从在抢锁过程中能通过某些方法终止抢占过程角度来看,显式锁可以分为可中断锁和不可中断锁,其中:

  • 可中断锁:什么是可中断锁?如果某一线程A正占有锁在执行临界区代码,另一线程B正在阻塞式抢占锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己的阻塞等待,
  • 不可中断锁: 什么是不可中断锁?一旦这个锁被其他线程占有,如果自己还想抢占,只能选择等待或者阻塞,直到别的线程释放这个锁,如果别的线程永远不释放锁,那么自己只能永远等下去,并且没有办法终止等
    待或阻塞。

简单来说,在抢锁过程中能通过某些方法终止抢占过程,这就是可中断锁,否则就是不可中断锁。

Java的synchronized内置锁就是一个不可中断锁,而JUC的显式锁(如ReentrantLock)是一个可中断锁。

  • 独占锁指的是每次只有一个线程能持有的锁。独占锁是一种悲观保守的加锁策略,它不必要地限制了读/读竞争,如果某个只读线程获取锁,那么其他的读线程都只能等待,这种情况下就限制了读操作的
    并发性,因为读操作并不会影响数据的一致性。JUC的ReentrantLock类是一个标准的独占锁实现类。

  • 共享锁允许多个线程同时获取锁,容许线程并发进入临界区。与独占锁不同,共享锁是一种乐观锁,它放宽了加锁策略,并不限制读/读竞争,允许多个执行读操作的线程同时访问共享资源。JUC的ReentrantReadWriteLock(读写锁)类是一个共享锁实现类。使用该读写锁时,读操作可以有很多线程一起读,但是写操作只能有一个线程去写,而且在写入的时候,别的线程也不能进行读的操作。用ReentrantLock锁替代ReentrantReadWriteLock锁虽然可以保证线程安全,但是也会浪费一部分资源,因为多个读操作并没有线程安全问题,所以在读的地方使用读锁,在写的地方使用写锁,可以提高程序执行效率。

综上所述,对于Java显式锁的基本分类,一般情况下我们都可按照这样的方式去分析。

6.应用分析

在Java领域中,Java显式锁的Java显式锁比Java内置锁的锁粒度更细腻,可以设置超时机制,更加可控,使用起来更加灵活。

v7llOs.png

对比基于Java内置锁实现一种简单的“等待-通知”方式的线程间通信:通过Object对象的wait、notify两类方法作为开关信号,用来完成通知方线程和等待方线程之间的通信。

“等待-通知”方式的线程间通信机制,具体来说是指一个线程A调用了同步对象的wait()方法进入等待状态,而另一线程B调用了同步对象的notify()或者notifyAll()方法去唤醒等待线程,当线程A收到线程B的唤醒通知后,就可以重新开始执行了。

需要特别注意的是,在通信过程中,线程需要拥有同步对象的监视器,在执行Object对象的wait、notify方法之前,线程必须先通过抢占到内置锁而成为其监视器的Owner。

与Object对象的wait、notify两类方法相类似,JUC也为大家提供了一个用于线程间进行“等待-通知”方式通信的接口——java.util.concurrent.locks.Condition。其中:

v7l3mn.png

  • await()方法:唤醒一个等待队列
  • awaitUninterruptibly() 方法:唤醒一个不可中断的等待队列
  • awaitNanos(long nanosTimeout) 方法:唤醒一个带超时的等待队列
  • await(long time, TimeUnit unit)方法:唤醒一个带超时的等待队列
  • awaitUntil(Date deadline) 方法:唤醒一个带超时的等待队列
  • signal()方法:随机地通知等待队列中的一个线程
  • signalAll()方法:通知等待队列中的所有线程

同时,JUC提供的一个线程阻塞与唤醒的工具类(java.util.concurrent.locks.LockSupport),该工具类可以让线程在任意位置阻塞和唤醒,其所有的方法都是静态方法。

v7lapF.png

  • void park()方法: 对当前线程执行阻塞操作,直到获取许可后才解除阻塞
  • void parkNanos(long nanos)方法:对当前线程执行阻塞操作,直到获取许可后才解除阻塞,最大等待时间有参数传入指定,一旦超过最大时间也会解除阻塞
  • void parkNanos(Object blocker, long nanos)方法:对当前线程执行阻塞操作,直到获取许可后才解除阻塞,最大等待时间有参数传入指定,一旦超过最大时间也会解除阻塞,需要指定阻塞对象
  • void parkUntil(long deadline)方法:对当前线程执行阻塞操作,直到获取许可后才解除阻塞最大等待时间为指定最后期限
  • void parkUntil(Object blocker, long deadline)方法: 对当前线程执行阻塞操作,直到获取许可后才解除阻塞最大等待时间为指定最后期限,需要指定阻塞对象
  • void unpark(Thread thread)方法: 将指定线程设置为可用

相比之下,Java显式锁比Java内置锁的锁粒度更细腻,可以设置超时机制,更加可控,使用起来更加灵活。

五.Java锁综合对比分析

Java锁综合对比分析主要是对Java内置锁和Java显式锁等作一个对比分析,看看两者之间各自的特点。

v7G2VA.png

在Java领域中,对于Java内置锁和Java显式锁,一般可以从以下几个方面去看:

  • 从基本定义上来看,Java内置锁是基于Java语法层面实现的锁,而Java显式锁是基于JDK层面实现的锁
  • 从基本思想上来看,Java内置锁是关键字+Object类中wait()、notify()、notifyAll() 方法来实现“等待-通知“工作机制,而Java显式锁是基于统一的AQS基础同步器+条件队列Condition对象+LockSupport线程阻塞与唤醒的工具类来实现“等待-通知“工作机制的,两者之间都可以用于实现线程之间的 通信
  • 从实现方式上来看,Java内置锁是通过JVM中通过Monitor来实现monitorenter和monitorexit指令实现,底层是调用操作系统的互斥锁原语实现,而Java显式锁是基于统一的AQS基础同步器来实现的
  • 从底层结构上来看,Java内置锁是基于JVM中Monitor与ObjectMonitor映射对应+CAS操作来实现的,而Java显式锁是基于CLH锁 Node FIFO 队列(先进先出)队列+CAS操作来实现
  • 从锁粒度细分上来看,Java内置锁是锁粒度比较大,相对比较粗,而Java显式锁的锁粒度比较小,相对比较细腻
  • 从锁是否支持超时中断来看,Java内置锁是不支持超时,不可中断,发生异常自动释放锁或阻塞,而Java显式锁是支持超时,可中断,发生异常自动释放锁或自旋
  • 从使用方式上看,Java内置锁是使用简单,可编程性较低,而Java显式锁是使用方式比较灵活,可编程性较高
  • 从锁资源和目标上看,Java内置锁是面向是类和对象中方法以及变量,而Java显式锁是面向的是线程本身和线程状态的控制
  • 从锁的公平性保证上来看,Java内置锁是无法保证锁的公平性,而Java显式锁是可以实现和保障锁的公平性的
  • 从并发三宗罪来看,Java内置锁是可以解决并发问题的原子性和可见性,而对于有序性问题是交给编译器来实现,而Java显式锁可以解决并发问题的原子性和可见性以及有序性问题
  • 从线程饥饿问题来看,Java内置锁是可能产生线程饥饿问题,而Java显式锁是可以防止和解决线程饥饿问题的
  • 从线程竞争问题来看,Java内置锁是可能产生线程竞争问题,而Java显式锁是可以防止和解决线程竞争问题的
  • 从线程竞争条件问题来看,Java内置锁是可能产生线程竞争条件问题,而Java显式锁是可以防止和解决线程竞争条件问题的

综上所述,通过对Java锁综合对比分析,我相信大家对于Java领域中的锁已经可以很好地认识以及深入了解。

写在最后

Picture-Footer

对于Java 领域中锁,我们一般可以从如下两个方面去认识,其中:

  • Java内置锁:基于Java语法层面(关键词)实现的锁,主要是根据Java语义来实现,最典型的应用就是synchronized。
  • Java显式锁:基于JDK层面实现的锁,主要是根据基于Lock接口和ReadWriteLock接口,以及统一的AQS基础同步器等来实现,最典型的有ReentrantLock。

对于Java内置锁来说:

  • 使用方式:synchronized关键字互斥锁主要有作用于对象方法上面,作用于类静态方法上面,作用于对象方法里面,作用于类静态方法里面等4种方式。
  • 基本思想:synchronized关键字互斥锁主要基于一个阻塞队列和等待对列,类似于一种“等待-通知”的工作机制来实现。
  • 基本实现:synchronized关键字互斥锁主要基于Java HotSpot(TM) VM 虚拟机通过Monitor(监视器)来实现monitorenter和monitorexit指令的。
  • 具体实现:JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。
  • 基本分类: synchronized关键字互斥锁主要中内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级,其中升级顺序为:无锁->偏向锁->轻量级锁状态->重量级,其顺序不可逆转。
  • 应用分析: synchronized关键字互斥锁主要中内置锁使用简单,但是锁的粒度比较大,无法支持超时等。

对于Java显式锁来说:

  • 使用方式:Java显式锁从JDK源码表现出来的锁大致可以分为基于Lock接口实现的锁,基于ReadWriteLock接口实现的锁,基于AQS基础同步器实现的锁,以及基于自定义API操作实现的锁等。
  • 基本思想:Java显式锁的基本思想来源于JDK并发包JUC的作者Doug Lea,发表的论文为java.util.concurrent Synchronizer Framework 。
  • 基本实现:Java显式锁从一定程度上说,Java显式锁都是基于AQS基础同步器实现的锁。
  • 具体实现:Java显式锁中基于AQS基础同步器实现的锁主要都是采用自旋锁(CLH锁)+CAS操作来实现。
  • 基本分类:Java显式锁的基本分类大致可以分为可重入锁和不可重入锁、悲观锁和乐观锁、公平锁和非公平锁、共享锁和独占锁、可中断锁和不可中断锁。
  • 应用分析: Java显式锁的Java显式锁比Java内置锁的锁粒度更细腻,可以设置超时机制,更加可控,使用起来更加灵活。

最后,技术研究之路任重而道远,愿我们熬的每一个通宵,都撑得起我们想在这条路上走下去的勇气,未来仍然可期,与君共勉!

版权声明:本文为博主原创文章,遵循相关版权协议,如若转载或者分享请附上原文出处链接和链接来源。



这篇关于Java 并发编程解析 | 如何正确理解Java领域中的并发锁,我们应该具体掌握到什么程度?的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程