编程语言
首页 > 编程语言> > Java 并发编程解析 | 如何正确理解Java领域中的锁机制,我们一般需要掌握哪些理论知识?

Java 并发编程解析 | 如何正确理解Java领域中的锁机制,我们一般需要掌握哪些理论知识?

作者:互联网

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

Picture-Navigation

写在开头

Picture-Header

提起Java领域中的锁,是否有种“道不尽红尘奢恋,诉不完人间恩怨“的”感同身受“之感?细数那些个“玩意儿”,你对Java的热情是否还如初恋般“人生若只如初见”?

Java中对于锁的实现真可谓是“百花齐放”,按照编程友好程度来说,美其名曰是Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。

但是,从理解的难度上来讲,其类型错中复杂,主要原因是Java是按照是否含有某一特性来定义锁的实现,如果不能正确理解其含义,了解其特性的话,往往都会深陷其中,难可自拔。

查询过很多技术资料与相关书籍,对其介绍真可谓是“模棱两可”,生怕我们搞懂了似的,但是这也是我们无法绕过去的一个“坎坎”,除非有其他的选择。

作为一名Java Developer来说,正确了解和掌握这些锁的机制和原理,需要我们带着一些实际问题,通过特性将锁进行分组归类,才能真正意义上理解和掌握。

比如,在Java领域中,针对于不同场景提供的锁,都用于解决什么问题?其实现方式是什么?各自又有什么特点,对应的应用有哪些?

带着这些问题,今天我们就一起来盘一盘,Java领域中的锁机制,盘点一下相关知识点,以及不同的锁的适用场景,帮助我们更快捷的理解和掌握这项必备技术奥义。

关健术语

Picture-Keyword

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

基本概述

Picture-Content

纵观Java领域中“五花八门”的锁,我们可以依据Java内存模型的工作机制,来具体分析一下对应问题的提出和表现,这也不失为打开Java领域中锁机制的“敲门砖”。

从本质上讲,锁是一种协调多个进程 或者多个线程对某一个资源的访问的控制机制。

一.计算机运行模型

计算机运行模型主要是描述计算机系统体系结构的基本模型,一般主要是指CPU处理器结构。

v42Fw8.png

在计算机体系结构中,中央处理器(CPU,Central Processing Unit)是一块超大规模的集成电路,是一台计算机的运算核心(Core)和控制核心( Control Unit)。它的功能主要是解释计算机指令以及处理计算机软件中的数据。

一个计算能够运行起来,主要是依靠CPU来负责执行我们的输入指令的,通常情况下,我们都把这些指令统称为程序。

一般CPU决定着程序的运行速度,可以看出CPU对程序的执行有很重要的作用,但是一个计算机程序的运行快慢并不是完全由CPU决定,除了CPU还有内存、闪存等。

由此可见,一个CPU主要由控制单元,算术逻辑单元和寄存器单元等3个部分组成。其中:

v5OZGR.png

一般来说,寄存器单元是为了减少CPU对内存的访问次数,提升数据读取性能而提出的,CPU中的寄存器单元主要分为通用寄存器和专用寄存器两个种,其中:

简单来说,CPU与主存储器主要是通过总线来进行通信,CPU通过控制单元来操作主存中的数据。而CPU与其他设备的通信都是由控制来实现。

综上所述,我们便可以得到一个计算机内存模型的大致雏形,接下来,我们便来一起盘点解析是计算机内存模型的基本奥义。

二.计算机内存模型

计算机内存模型一般是指计算系统底层与编程语言之间的约束规范,主要是描述计算机程序与共享存储器访问的行为特征表现。

v5qgts.png

根据介绍计算机运行模型来看,计算机内存模型可以帮助以及指导我们理解Java内存模型,主要在如下的两个方面:

由此可见,内存模型用于定义处理器间的各层缓存与共享内存的同步机制,以及线程与内存之间交互的规则。

在操作系统层面,内存主要可以分为物理内存与虚拟内存的概念,其中:

一般情况下,当物理内存不足时,可以用虚拟内存代替, 在虚拟内存出现之前,程序寻址用的都是物理地址。

从常见的存储介质来看,主要有:寄存器(Register),高速缓存(Cache),随机存取存储器(RAM),只读存储器(ROM)等4种,按照读取快慢的顺序是:Register>Cache>RAM>ROM。其中:

由于CPU的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的Cache(高速缓存),越靠近CPU的高速缓存越快,容
量也越小。

按照数据读取顺序和与CPU内核结合的紧密程度来看,大多数采用多层缓存策略,最经典的就三层高速缓存架构。

也就是我们常说的,CPU高速缓存有L1和L2高速缓存(即一级高速缓存和二级缓存高速),部分高端CPU还具有L3高速缓存(即三级高速缓存):

v5OPqU.png

CPU内核读取数据时,先从L1高速缓存中读取,如果没有命中,再到L2、L3高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。

每一级高速缓存中所存储的数据都是下一级高速缓存的一部分,越靠近CPU的高速缓存读取越快,容量也越小。

当然,系统还拥有一块主存(即主内存),由系统中的所有CPU共享。拥有L3高速缓存的CPU,CPU存取数据的命中率可达95%,也就是说只有不到5%的数据需要从主存中去存取。

因此,高速缓存大大缩小了高速CPU内核与低速主存之间的速度差距,基本体现在如下:

总结来说,CPU通过高速缓存进行数据读取有以下优势:

综上所述,一般来说,对于单线程程序,编译器和处理器的优化可以对编程开发足够透明,对其优化的效果不会影响结果的准确性。

而在多线程程序来说,为了提升性能优化的同时又达到兼顾执行结果的准确性,需要一定程度上内存模型规范。

由于经常会采用多层缓存策略,这就导致了一个比较经典的并发编程三大问题之一的共享变量的可见性问题,除了可见性问题之外,当然还有原子性问题和有序性问题。

由此来看,在计算机内存模型中,主要可以提出主存和工作内存的概念,其中:

在Java领域中,为了解决这一系列问题,特此提出了Java内存模型,接下来,我们就来一看看Java内存模型的工作机制。

三.Java内存模型

Java内存模型主要是为了解决并发编程的可见性问题,原子性问题和有序性问题等三大问题,具有跨平台性。

vISuQA.png

JMM最初由JSR-133(Java Memory Model and ThreadSpecification)文档描述,JMM定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。

Java内存模型(Java Memory Model JMM)指的是Java HotSpot(TM) VM 虚拟机定义的一种统一的内存模型,将底层硬件以及操作系统的内存访问差异进行封装,使得Java程序在不同硬件以及操作系统上执行都能达到相同的并发效果。

Java内存模型对于内存的描述主要体现在三个方面:

一般来说,Java内存模型在对内存的描述上,我们可以依据是编译时分配还是运行时分配,是静态分配还是动态分配,是堆上分配还是栈上分配等角度来进行对比分析。

从Java HotSpot(TM) VM 虚拟机的整体结构上来看,内存区域可以分为线程私有区,线程共享区,直接内存等内容,其中:

v42nln.png

根据线程私有区中包含的数据(程序计数器、虚拟机栈、本地方法区)来具体分析看,其中:

根据线程共享区中包含的数据(JAVA 堆、方法区)来具体分析看,其中:

这里对线程共享区和程私有区其细节,就暂时不做展开,但是我们可以简单地看出,对于Java领域中的内存分配,这两者之间已经帮助我们做了具体区分。

在继续后续问题探索之前,我们一起来思考一个问题:按照线性思维来看,一个Java程序从程序编写到编译,编译到运行,运行到执行等过程来说,究竟是先入堆还是先入栈呢 ?

这个问题,其实我在看Java HotSpot(TM) VM 虚拟机相关知识的时候,一直有这样的个疑虑,但是其实这样的表述是不准确的,这需要结合编译原理相关的知识来具体分析。

按照编译原理的观点,从Java内存分配策略来看,程序运行时的内存分配有三种策略,其中:

vIpTH0.png

也就是说,在Java领域中,一个Java程序从程序编写到编译,编译到运行,运行到执行等过程来说,单纯考虑是先入堆还是入栈的问题,在这里得到了答案。

从整体上来看,Java内存模型主要考虑的事情基本与主存,线程本地内存,共享变量,变量副本,线程等概念息息相关,其中:

在Java内存模型中,一般来说主要提供volatile,synchronized,final以及锁等4种方式来保证变量的可见性问题,其中:

实际上,相比之下,Java内存模型还引入了一个工作内存的概念来帮助我们提升性能,而且JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。

其中,需要特别注意的是,其主存和工作内存的区别:

综上所述,我们对Java内存模型的探讨算是水到渠成了,但是Java内存模型也提出了一些规范,接下来,我们就来看看Happen-Before 关系原则。

四.Java一致性模型指导原则

Java一致性模型指导原则是指制定一些规范来将复杂的物理计算机的系统底层封装到JVM中,从而向上提供一种统一的内存模型语义规则,一般是指Happens-Before规则。

vIC2lQ.png

Happen-Before 关系原则,是 Java 内存模型中保证多线程操作可见性的机制,也是对早期语言规范中含糊的可见性概念的一个精确定义,其行为依赖于处理器本身的内存一致性模型。

Happen-Before 关系原则主要规定了Java内存在多线程操作下的顺序性,一般是指先发生操作的执行结果对后续发生的操作可见,因此称其为Java一致性模型指导原则。

由于Happen-Before 关系原则是向上提供一种统一的内存模型语义规则,它规范了Java HotSpot(TM) VM 虚拟机的实现,也能为上层Java Developer描述多线程并发的可见性问题。

在Java领域中,Happen-Before 关系原则主要有8种,具体如下:

对于Happen-Before 关系原则来说,而不是简单地线性思维的前后顺序问题,是因为它不仅仅是对执行时间的保证,也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后,并不能保证线程交互的可见性。

在Java HotSpot(TM) VM 虚拟机内部的运行时数据区,但是真正程序执行,实际是要跑在具体的处理器内核上。简单来说,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。

总的来说,JMM 内部的实现通常是依赖于内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种 happen-before 规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。

五.Java指令重排

Java指令重排是指在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序的一种防护措施机制。

v4226I.png

我们在实际开发工作中编写代码时候,是按照一定的代码的思维和习惯去编排和组织代码的,但是实际上,编译器和CPU执行的顺序可能会代码顺序产生不一致的情况。

毕竟,编译器和CPU会对我们编写的程序代码自身做一定程度上的优化再去执行,以此来提高执行效率,因此提出了指令重排的机制。

一般来说,我们在程序中编写的每一个行代码其实就是程序指令,按照线性思维方式来看,这些指令按道理是一行行代码存在的顺序去执行的,只有上一行代码执行完毕,下一行代码才会被执行,这就说明代码的执行有一定的顺序。

但是这样的顺序,对于程序的执行时间上来看是有一定的耗时的,为了加快代码的执行效率,一般会引入一种流水线技术的方式来解决这个问题,就像Jenkins 流水线部署机制的编写那样。

但是流水线技术的本质上,是把每一个指令拆成若干个部分,在同一个CPU的时间内使其可以执行多个指令的不同部分,从而达到提升执行效率的目的,主要体现在:

一般来说,指令从排会涉及到CPU,编译器,以及内存等,因此指令重排序的类型大致可以分为 编译器指令重排,CPU指令重排,内存指令重排,其中:

在Java领域中,指令重排的原则是不能影响程序在单线程下的执行的准确性,但是在多线程的情况下,可能会导致程序执行出现错误的情况,主要是依据Happen-Before 关系原则来组织部重排序,其核心就是使用内存屏障来实现,通过内存屏障可以堆内存进行顺序约束,而且作用于线程。

由于Java有不同的编译器和运行时环境,对应起来看,Java指令重排主要发生在编译阶段和运行阶段,而编译阶段对应的是编译器,运行阶段对应着CPU,其中:

vI0LnS.png

既然设置内存屏障,可以确保多CPU的高速缓存中的数据与内存保持一致性, 不能确保内存与CPU缓存数据一致性的指令也不能重排,内存屏障正是通过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法。

但是内存屏障的是需要考虑CPU的架构方式,不同硬件实现内存屏障的方式不同,一般以常见Intel CPU来看,主要有:

在Java领域中,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。

v5r9u8.png

从广义上的概念定义看,Java中的内存屏障一般主要有Load和Store两类:

从具体的使用方式来看,Java中的内存屏障主要有以下几种方式:

综上所述,一般来说volatile关健字能保证可见性和防止指令重排序,也是我们最常见提到的方式。

六.Java并发编程的三宗罪

Java并发编程的三宗罪主要是指原子性问题、可见性问题和有序性问题等三大问题。

v5DojK.png

在介绍Java内存模型时,我们都说其核心的价值在于解决可见性和有序性,以及还有原子性等,那么对其总结来说,就是Java并发编程的三宗罪,其中:

但是,这里我们需要知道,Java内存模型是如何解决这些问题的?主要体现如下几个方面:

一定意义上来讲,一般在Java并发编程中,其实加锁可以解决一部分问题,除此之外,我们还需要考虑线程饥饿问题,数据竞争问题,竞争条件问题以及死锁问题,通过综合分析才能得到意想不到的结果。

综上所述,我们在理解Java领域中的锁时,可以以此作为一个考量标准之一,来帮助和方便我们更快理解和掌握并发编程技术。

七.Java线程饥饿问题

Java线程饥饿问题是指长期无法获取共享资源或抢占CPU资源而导致线程无法执行的现象。

v5rkNj.png

在Java并发编程的过程中,特别是开启线程数过多,会遇到某些线程贪婪地把CPU资源占满,导致某些线程分配不到CPU而没有办法执行。

在Java领域中,对于线程饥饿问题,可以从以下几个方面来看:

针对上述的饥饿问题,为了解决它,JDK内部实现一些具备公平性质的锁,可以直接使用。所以,解决线程饥饿问题,一般是引入队列,也就是排队处理,最典型的有ReentrantLock。

综上所述,这不就是为我们掌握和理解Java中的锁机制时,需要考虑Java线程饥饿问题。

八.Java数据竞争问题

Java数据竞争问题是指至少存在两个线程去读写某个共享内存,其中至少一个线程对其共享内存进行写操作。

v5sreU.png

对于数据竞争问题,最简单的理解就是,多个线程在同时对于共享内存的进行写操作时,在写的过程中,其他的线程读到数据是内存数据中非正确预期的。

产生数据竞争的原因,一个CPU在任意时刻只能执行一条指令,但是对其某个内存中的写操作可能会用到若干条件机器指令,从而导致在写的过程中还没完全修改完内存,其他线程去读取数据,从而导致结果不可预知。从而引发数据竞争问题,这个情况有点像MySQL数据中并发事务引起的脏读情况。

在Java领域中,解决数据竞争问题的方式一般是把共享内存的更新操作进行原子化,同时也保证内存的可见性。

针对上述的饥饿问题,为了解决它,JDK内部实现一系列的原子类,比如AtomicReference类等,但是主要可以采用CAS+自旋锁的方式来实现。

综上所述,这不就是为我们掌握和理解Java中的锁机制时,需要考虑Java数据竞争问题。

九.Java竞争条件问题

Java竞争条件问题是指代码在执行临界区产生竞争条件,主要是因为多个线程不同的执行顺序以及线程并发的交叉执行导致执行结果与预期不一致的情况。

v5yPYj.png

对于竞争条件问题,其中临界区是一块代码区域,其实说白了就是我们自己写的逻辑代码,由于没有考虑位,从而引发的多个线程不同的执行顺序以及线程并发的交叉执行导致执行结果与预期不一致的情况。

产生竞争条件问题的主要原因,一般主要有线程执行顺序的不确定性和并发机制导致上下文切换等两个原因导致竞争条件问题,其中:

在Java领域中,解决竞争条件问题的方式一般是把临界区进行原子化,保证临界区的源自性,保证了临界区捏只有一个线程,从而避免竞争产生。

针对上述的饥饿问题,为了解决它,JDK内部实现一系列的原子类或者说直接使用synchronized来声明,均可实现。

综上所述,这不就是为我们掌握和理解Java中的锁机制时,需要考虑Java竞争条件问题。

十.Java死锁问题

Java死锁问题主要是指一种有两个或者两个以上的线程或者进程构成一个无限互相等待的环形状态的情况,不是一种锁概念,而是一种线程状态的表征描述。

v5yt0O.png

一般为了保证线程安全问题,我们都会想着给会使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。

或者有的场景我们使用线程池和信号量等来限制资源的使用,但这些被限制的行为可能会导致资源死锁(Resource DeadLock)。

Java死锁问题的主要体现在以下几个方面:

当然,死锁问题的产生也必须具备以及同时满足以下几个条件:

对于死锁问题,一般都是需要编程开发人员人为去干预和防止的,只是需要一些措施区规范处理,主要可以分为事前预防和事后处理等2种方式,其中:

除了有死锁的问题,当然还有活锁问题,主要是因为某些逻辑导致一直在做无用功,使得线程无法正确执行的情况。

应用分析

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

Picture-Content

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

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

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

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

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

vIfOBR.png

针对于上述描述的各种情况,这里就不做展开和赘述,看到这里只需要在脑中形成一个概念就行,后续会有专门的内容来对其进行分析和探讨。

写在最后

Picture-Footer

在上述的内容中,一般常规的概念中,我们很难会依据上述这些问题去认识和看待Java中的锁机制,主要是在学习和查阅资料的时,大多数的论调都是零散和细分的,很难在我们的脑海中形成知识体系。

从本质上讲,我们对锁应该有一个认识,其主要是一种协调多个进程 或者多个线程对某一个资源的访问的控制机制,是并发编程中最关键的一环。

接下来,对于上述内容做一个简单的总结: