Java 并发编程解析 | 如何正确理解 Java 领域中的锁机制,我们一般需要掌握哪些理论知识?
写在开头
提起 Java 领域中的锁,是否有种“道不尽红尘奢恋,诉不完人间恩怨“的”感同身受“之感?细数那些个“玩意儿”,你对 Java 的热情是否还如初恋般“人生若只如初见”?
Java 中对于锁的实现真可谓是“百花齐放”,按照编程友好程度来说,美其名曰是 Java 提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。
但是,从理解的难度上来讲,其类型错中复杂,主要原因是 Java 是按照是否含有某一特性来定义锁的实现,如果不能正确理解其含义,了解其特性的话,往往都会深陷其中,难可自拔。
查询过很多技术资料与相关书籍,对其介绍真可谓是“模棱两可”,生怕我们搞懂了似的,但是这也是我们无法绕过去的一个“坎坎”,除非有其他的选择。
作为一名 Java Developer 来说,正确了解和掌握这些锁的机制和原理,需要我们带着一些实际问题,通过特性将锁进行分组归类,才能真正意义上理解和掌握。
比如,在 Java 领域中,针对于不同场景提供的锁,都用于解决什么问题?其实现方式是什么?各自又有什么特点,对应的应用有哪些?
带着这些问题,今天我们就一起来盘一盘,Java 领域中的锁机制,盘点一下相关知识点,以及不同的锁的适用场景,帮助我们更快捷的理解和掌握这项必备技术奥义。
关健术语
本文用到的一些关键词语以及常用术语,主要如下:
线程调度(Thread Scheduling ):系统分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
线程切换(Thread Switch ):主要是指在并发过程中,多线程之间会对上下文进行切换资源,并交叉执行的一种并发机制。
指令重排(Command Reorder ): 指编译器或处理器为了优化性能而采取的一种手段,在不存在数据依赖性情况下(如写后读,读后写,写后写),调整代码执行顺序。
内存屏障(Memory Barrier): 也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
基本概述
纵观 Java 领域中“五花八门”的锁,我们可以依据 Java 内存模型的工作机制,来具体分析一下对应问题的提出和表现,这也不失为打开 Java 领域中锁机制的“敲门砖”。
从本质上讲,锁是一种协调多个进程 或者多个线程对某一个资源的访问的控制机制。
一.计算机运行模型
计算机运行模型主要是描述计算机系统体系结构的基本模型,一般主要是指 CPU 处理器结构。
在计算机体系结构中,中央处理器(CPU,Central Processing Unit)是一块超大规模的集成电路,是一台计算机的运算核心(Core)和控制核心( Control Unit)。它的功能主要是解释计算机指令以及处理计算机软件中的数据。
一个计算能够运行起来,主要是依靠 CPU 来负责执行我们的输入指令的,通常情况下,我们都把这些指令统称为程序。
一般 CPU 决定着程序的运行速度,可以看出 CPU 对程序的执行有很重要的作用,但是一个计算机程序的运行快慢并不是完全由 CPU 决定,除了 CPU 还有内存、闪存等。
由此可见,一个 CPU 主要由控制单元,算术逻辑单元和寄存器单元等 3 个部分组成。其中:
控制单元( Control Unit): 属于 CPU 的控制指挥中心,主要负责指挥 CPU 工作,通过向算术逻辑单元和寄存器单元来发送控制指令达到控制效果。
算术逻辑单元(Arithmetic Logic Unit, ALU): 主要负责执行运算,一般是指算术运算和逻辑运算,主要是依据控制单元发送过来的指令进行处理。
寄存器单元(Register Unit): 主要用于存储临时数据,保存着等待处理和已经处理的数据。
一般来说,寄存器单元是为了减少 CPU 对内存的访问次数,提升数据读取性能而提出的,CPU 中的寄存器单元主要分为通用寄存器和专用寄存器两个种,其中:
通用寄存器:主要用于临时存放 CPU 正在使用的数据。
专用寄存器:主要用于临时存放类似指令寄存器和程序计数器等 CPU 中专有用途的数据。其中:指令寄存器:用于存储正在执行的指令程序计数器: 保存等待执行的指令地址
简单来说,CPU 与主存储器主要是通过总线来进行通信,CPU 通过控制单元来操作主存中的数据。而 CPU 与其他设备的通信都是由控制来实现。
综上所述,我们便可以得到一个计算机内存模型的大致雏形,接下来,我们便来一起盘点解析是计算机内存模型的基本奥义。
二.计算机内存模型
计算机内存模型一般是指计算系统底层与编程语言之间的约束规范,主要是描述计算机程序与共享存储器访问的行为特征表现。
根据介绍计算机运行模型来看,计算机内存模型可以帮助以及指导我们理解 Java 内存模型,主要在如下的两个方面:
首先,系统底层希望能够对程序进行更多的优化策略,一般主要是针对处理器和编译器,从而提高运行性能。
其次,为编程语言带来了更多的可编程性问题,主要是复杂的内存模型会有更多的约束,从而增加了程序设计的编程难度。
由此可见,内存模型用于定义处理器间的各层缓存与共享内存的同步机制,以及线程与内存之间交互的规则。
在操作系统层面,内存主要可以分为物理内存与虚拟内存的概念,其中:
物理内存(Physical Memory): 通常指通过安装内存条而获得的临时储存空间。主要作用是在计算机运行时为操作系统和各种程序提供临时储存。常见的物理内存规格有 256M、512M、1G、2G 等。
虚拟内存(Virtual Memory):计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
一般情况下,当物理内存不足时,可以用虚拟内存代替, 在虚拟内存出现之前,程序寻址用的都是物理地址。
从常见的存储介质来看,主要有:寄存器(Register),高速缓存(Cache),随机存取存储器(RAM),只读存储器(ROM)等 4 种,按照读取快慢的顺序是:Register>Cache>RAM>ROM。其中:
寄存器(Register): CPU 处理器的一部分,主要分为通用寄存器和专用寄存器。
高速缓存(Cache):用于减少 CPU 处理器访问内存所需平均时间的部件,一般是指 L1/L2/L3 层高级缓存。
随机存取存储器(Random Access Memory,RAM):与 CPU 直接交换数据的内部存储器,它可以随时读写,而且速度很快,通常作为操作系统或其他正在运行中的程序的临时数据存储媒介。
只读存储器(Read-Only Memory,ROM):所存储的数据通常都是装入主机之前就写好的,在工作的时候只能读取而不能像随机存储器那样随便写入。
由于 CPU 的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代 CPU 不直接和主存进行通信,而是在 CPU 和主存之间设计了多层的 Cache(高速缓存),越靠近 CPU 的高速缓存越快,容量也越小。
按照数据读取顺序和与 CPU 内核结合的紧密程度来看,大多数采用多层缓存策略,最经典的就三层高速缓存架构。
也就是我们常说的,CPU 高速缓存有 L1 和 L2 高速缓存(即一级高速缓存和二级缓存高速),部分高端 CPU 还具有 L3 高速缓存(即三级高速缓存):
CPU 内核读取数据时,先从 L1 高速缓存中读取,如果没有命中,再到 L2、L3 高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。
每一级高速缓存中所存储的数据都是下一级高速缓存的一部分,越靠近 CPU 的高速缓存读取越快,容量也越小。
当然,系统还拥有一块主存(即主内存),由系统中的所有 CPU 共享。拥有 L3 高速缓存的 CPU,CPU 存取数据的命中率可达 95%,也就是说只有不到 5%的数据需要从主存中去存取。
因此,高速缓存大大缩小了高速 CPU 内核与低速主存之间的速度差距,基本体现在如下:
L1 高速缓存:最接近 CPU,容量最小、存取速度最快,每个核上都有一个 L1 高速缓存。
L2 高速缓存:容量更大、速度低些,在一般情况下,每个内核上都有一个独立的 L2 高速缓存。
L3 高速缓存:最接近主存,容量最大、速度最低,由在同一个 CPU 芯片板上的不同 CPU 内核共享。
总结来说,CPU 通过高速缓存进行数据读取有以下优势:
写缓冲区可以保证指令流水线持续运行,可以避免由于 CPU 停顿下来等待向内存写入数据而产生的延迟。
通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。
综上所述,一般来说,对于单线程程序,编译器和处理器的优化可以对编程开发足够透明,对其优化的效果不会影响结果的准确性。
而在多线程程序来说,为了提升性能优化的同时又达到兼顾执行结果的准确性,需要一定程度上内存模型规范。
由于经常会采用多层缓存策略,这就导致了一个比较经典的并发编程三大问题之一的共享变量的可见性问题,除了可见性问题之外,当然还有原子性问题和有序性问题。
由此来看,在计算机内存模型中,主要可以提出主存和工作内存的概念,其中:
主存:一般指的物理内存,主要是指 RAM 随机存取存储器和 ROM 只读存储器等
工作内存:一般指寄存器,还有以及我们说的三层高速缓存策略中的 L1/L2/L3 层高级缓存 Cache 等
在 Java 领域中,为了解决这一系列问题,特此提出了 Java 内存模型,接下来,我们就来一看看 Java 内存模型的工作机制。
三.Java 内存模型
Java 内存模型主要是为了解决并发编程的可见性问题,原子性问题和有序性问题等三大问题,具有跨平台性。
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 虚拟机的整体结构上来看,内存区域可以分为线程私有区,线程共享区,直接内存等内容,其中:
线程私有区(Thread Local):主要包括程序计数器、虚拟机栈、本地方法区,其中线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁。
线程共享区(Thread Shared):主要包括 JAVA 堆、方法区,其中,线程共享区域随虚拟机的启动/关闭而创建/销毁。
直接内存(Driect Memory):不会受 Java HotSpot(TM) VM 虚拟机中的 GC 影响,并不是 JVM 运行时数据区的成员。
根据线程私有区中包含的数据(程序计数器、虚拟机栈、本地方法区)来具体分析看,其中:
程序计数器(Program Counter Register ):一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,而且是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈(VM Stack):是描述 Java 方法执行的内存模型,在方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
本地方法区(Native Method Stack):和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务。
根据线程共享区中包含的数据(JAVA 堆、方法区)来具体分析看,其中:
JAVA 堆(Heap):是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。
方法区(Method Area):是指 Java HotSpot(TM) VM 虚拟机把 GC 分代收集扩展至方法区,Java HotSpot(TM) VM 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器,其中这里需要注意的是:在 JDK1.8 之前,使用永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. , 即使用 Java 堆的永久代来实现方法区, 主要是因为永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 其收益一般很小。在 JDK1.8 之后,永久代已经被移除,被一个称为“元数据区(Metadata Area)”的区域所取代。元空间(Metadata Space)的本质和永久代类似,最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 Native Memory, 字符串池和类的静态变量放入 Java 堆中,这样可以加载多少类的元数据由系统的实际可用空间来控制。
这里对线程共享区和程私有区其细节,就暂时不做展开,但是我们可以简单地看出,对于 Java 领域中的内存分配,这两者之间已经帮助我们做了具体区分。
在继续后续问题探索之前,我们一起来思考一个问题:按照线性思维来看,一个 Java 程序从程序编写到编译,编译到运行,运行到执行等过程来说,究竟是先入堆还是先入栈呢 ?
这个问题,其实我在看 Java HotSpot(TM) VM 虚拟机相关知识的时候,一直有这样的个疑虑,但是其实这样的表述是不准确的,这需要结合编译原理相关的知识来具体分析。
按照编译原理的观点,从 Java 内存分配策略来看,程序运行时的内存分配有三种策略,其中:
静态存储分配:静态存储分配要求在编译时能知道所有变量的存储要求,指在编译时,就能确定每个数据在运行时的存储空间,因而在编译时就可以给他们分配固定的内存空间。这种分配策略要求程序代码中不允许有可变数据结构的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间需求。
栈式存储分配:栈式存储分配要求在过程的入口处必须知道所有的存储要求,也可称为动态存储分配,是由一个类似于堆栈的运行栈来实现的。和静态存储分配相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到运行的时候才能够知道,也就是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能够为其分配内存。栈式存储分配按照先进后出的原则进行分配。
堆式存储分配:堆式存储分配则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
也就是说,在 Java 领域中,一个 Java 程序从程序编写到编译,编译到运行,运行到执行等过程来说,单纯考虑是先入堆还是入栈的问题,在这里得到了答案。
从整体上来看,Java 内存模型主要考虑的事情基本与主存,线程本地内存,共享变量,变量副本,线程等概念息息相关,其中:
从主存与线程本地内存的关系来看 : 主存主要保存 Java 程序中的共享变量,其中主存不保存局部变量和方法参数列表;而线程本地内存主要保存 Java 程序中的共享变量的变量副本。
从线程与线程本地内存的关系来看:每个线程都会维护一个自己专属的本地内存,不同线程之间互相不可直接通信,其线程之间的通信就会涉及共享变量可见性的问题。
在 Java 内存模型中,一般来说主要提供 volatile,synchronized,final 以及锁等 4 种方式来保证变量的可见性问题,其中:
通过 volatile 关键词实现: 利用 volatile 修饰声明时,变量一旦有更改都会被立即同步到主存中,当线程需要使用这个变量时,需要从主存中刷新到工作内存中。
通过 synchronized 关键词实现:利用 synchronized 修饰声明时,当一个线程释放一个锁,强制刷新工作内存中的变量到主存中,当另外一个线程需要使用此锁时,会强制重新载入变量值。
通过 final 关键词实现:利用 final 修饰声明时,变量一旦初始化完成,Java 中的线程都可以看到这个变量。
通过 JDK 中锁实现:当一个线程释放一个锁,强制刷新工作内存中的变量到主存中,当另外一个线程需要使用此锁时,会强制重新载入变量值。
实际上,相比之下,Java 内存模型还引入了一个工作内存的概念来帮助我们提升性能,而且 JMM 提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。
其中,需要特别注意的是,其主存和工作内存的区别:
主存: 可以在计算机内存模型说是物理内存,对应到 Java 内存模型来讲,是 Java HotSpot(TM) VM 虚拟机中虚拟内存的一部分。
工作内存:在计算机内存模型内是指 CPU 缓存,一般是指寄存器,还有以及我们说的三层高速缓存策略中的 L1/L2/L3 层高级缓存;对应到 Java 内存模型来讲,主要是三层高速缓存 Cache 和寄存器。
综上所述,我们对 Java 内存模型的探讨算是水到渠成了,但是 Java 内存模型也提出了一些规范,接下来,我们就来看看 Happen-Before 关系原则。
四.Java 一致性模型指导原则
Java 一致性模型指导原则是指制定一些规范来将复杂的物理计算机的系统底层封装到 JVM 中,从而向上提供一种统一的内存模型语义规则,一般是指 Happens-Before 规则。
Happen-Before 关系原则,是 Java 内存模型中保证多线程操作可见性的机制,也是对早期语言规范中含糊的可见性概念的一个精确定义,其行为依赖于处理器本身的内存一致性模型。
Happen-Before 关系原则主要规定了 Java 内存在多线程操作下的顺序性,一般是指先发生操作的执行结果对后续发生的操作可见,因此称其为 Java 一致性模型指导原则。
由于 Happen-Before 关系原则是向上提供一种统一的内存模型语义规则,它规范了 Java HotSpot(TM) VM 虚拟机的实现,也能为上层 Java Developer 描述多线程并发的可见性问题。
在 Java 领域中,Happen-Before 关系原则主要有 8 种,具体如下:
单线程原则:线程内执行的每个操作,都保证 happen-before 后面的操作,这就保证了基本的程序顺序规则,这是开发者在书写程序时的基本约定。
锁原则:对于一个锁的解锁操作,保证 happen-before 加锁操作。
volatile 原则:对于 volatile 变量,对它的写操作,保证 happen-before 在随后对该变量的读取操作。
线程 Start 原则:类似线程内部操作的完成,保证 happen-before 其他 Thread.start() 的线程操作原则。
线程 Join 原则:类似线程内部操作的完成,保证 happen-before 其他 Thread.join() 的线程操作原则。
线程 Interrupt 原则:类似线程内部操作的完成,保证 happen-before 其他 Thread.interrupt() 的线程操作原则。
finalize 原则: 对象构建完成,保证 happen-before 于 finalizer 的开始动作。
传递原则: Happen-Before 关系是存在着传递性的,如果满足 A happen-before B 和 B happen-before C,那么 A happen-before C 也成立。
对于 Happen-Before 关系原则来说,而不是简单地线性思维的前后顺序问题,是因为它不仅仅是对执行时间的保证,也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后,并不能保证线程交互的可见性。
在 Java HotSpot(TM) VM 虚拟机内部的运行时数据区,但是真正程序执行,实际是要跑在具体的处理器内核上。简单来说,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。
总的来说,JMM 内部的实现通常是依赖于内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种 happen-before 规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。
五.Java 指令重排
Java 指令重排是指在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序的一种防护措施机制。
我们在实际开发工作中编写代码时候,是按照一定的代码的思维和习惯去编排和组织代码的,但是实际上,编译器和 CPU 执行的顺序可能会代码顺序产生不一致的情况。
毕竟,编译器和 CPU 会对我们编写的程序代码自身做一定程度上的优化再去执行,以此来提高执行效率,因此提出了指令重排的机制。
一般来说,我们在程序中编写的每一个行代码其实就是程序指令,按照线性思维方式来看,这些指令按道理是一行行代码存在的顺序去执行的,只有上一行代码执行完毕,下一行代码才会被执行,这就说明代码的执行有一定的顺序。
但是这样的顺序,对于程序的执行时间上来看是有一定的耗时的,为了加快代码的执行效率,一般会引入一种流水线技术的方式来解决这个问题,就像 Jenkins 流水线部署机制的编写那样。
但是流水线技术的本质上,是把每一个指令拆成若干个部分,在同一个 CPU 的时间内使其可以执行多个指令的不同部分,从而达到提升执行效率的目的,主要体现在:
获取指令阶段: 主要使用指令通道和指令寄存器,一般是在 CPU 处理器主导
编译指令阶段:主要使用指令编译器,一般是在编译器主导
执行指令阶段:主要使用执行单元和数据通道,相对来说像是从内存在主导
一般来说,指令从排会涉及到 CPU,编译器,以及内存等,因此指令重排序的类型大致可以分为 编译器指令重排,CPU 指令重排,内存指令重排,其中:
编译器指令重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
CPU 指令重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存指令重排:由于处理器使用缓存和读/写缓冲区,其加载和存储操作看上去类似乱序执行的情况。
在 Java 领域中,指令重排的原则是不能影响程序在单线程下的执行的准确性,但是在多线程的情况下,可能会导致程序执行出现错误的情况,主要是依据 Happen-Before 关系原则来组织部重排序,其核心就是使用内存屏障来实现,通过内存屏障可以堆内存进行顺序约束,而且作用于线程。
由于 Java 有不同的编译器和运行时环境,对应起来看,Java 指令重排主要发生在编译阶段和运行阶段,而编译阶段对应的是编译器,运行阶段对应着 CPU,其中:
编译阶段指令重排:1⃣️ 通用描述:源代码->机器码的指令重排: 源代码经过编译器变成机器码,而机器码可能被重排 2⃣️ Java 描述:Java 源文件->Java 字节码的指令重排: Java 源文件被 javac 编译后变成 Java 字节码,其字节码可能被重排
运行阶段指令重排:1⃣️ 通用描述:机器码->CPU 处理器的指令重排:机器码经过 CPU 处理时,可能会被 CPU 重排才执行 2⃣️ Java 描述:Java 字节码->Java 执行器的指令重排: Java 字节码被 Java 执行器执行时,可能会被 CPU 重排才执行
既然设置内存屏障,可以确保多 CPU 的高速缓存中的数据与内存保持一致性, 不能确保内存与 CPU 缓存数据一致性的指令也不能重排,内存屏障正是通过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法。
但是内存屏障的是需要考虑 CPU 的架构方式,不同硬件实现内存屏障的方式不同,一般以常见 Intel CPU 来看,主要有:
1⃣️ lfence 屏障: 是一种 Load Barrier 读屏障。
2⃣️ sfence 屏障: 是一种 Store Barrier 写屏障 。
3⃣️ mfence 屏障:是一种全能型的屏障,具备 ifence 和 sfence 的能力 。
4⃣️ Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。
在 Java 领域中,Java 内存模型屏蔽了这种底层硬件平台的差异,由 JVM 来为不同的平台生成相应的机器码。
从广义上的概念定义看,Java 中的内存屏障一般主要有 Load 和 Store 两类:
1⃣️ 对 Load Barrier 来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
2⃣️ 对 Store Barrier 来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存
从具体的使用方式来看,Java 中的内存屏障主要有以下几种方式:
1⃣️ 通过 synchronized 关键字包住的代码区域:当线程进入到该区域读取变量信息时,保证读到的是最新的值。- a. 在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中。- b. 对数据的读取也不能从缓存读取,只能从内存中读取,保证了数据的读有效性.这也是会插入 StoreStore 屏障的缘故。
2⃣️ 通过 volatile 关键字修饰变量:当对变量的写操作,会插入 StoreLoad 屏障。
3⃣️ 其他的设置方式,一般需要通过 Unsafe 这个类来执行,主要是:- a. Unsafe.putOrderedObject():类似这样的方法,会插入 StoreStore 内存屏障- b. Unsafe.putVolatiObject() 类似这样的方法,会插入 StoreLoad 屏障
综上所述,一般来说 volatile 关健字能保证可见性和防止指令重排序,也是我们最常见提到的方式。
六.Java 并发编程的三宗罪
Java 并发编程的三宗罪主要是指原子性问题、可见性问题和有序性问题等三大问题。
在介绍 Java 内存模型时,我们都说其核心的价值在于解决可见性和有序性,以及还有原子性等,那么对其总结来说,就是 Java 并发编程的三宗罪,其中:
原子性问题:就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。
可见性问题:一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。
有序性问题:指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。
但是,这里我们需要知道,Java 内存模型是如何解决这些问题的?主要体现如下几个方面:
解决原子性问题:Java 内存模型通过 read、load、assign、use、store、write 来保证原子性操作,此外还有 lock 和 unlock,直接对应着 synchronized 关键字的 monitorenter 和 monitorexit 字节码指令。
解决可见性问题:Java 保证可见性通过 volatile、final 以及 synchronized,锁来实现。
解决有序性问题:由于处理器和编译器的重排序导致的有序性问题,Java 主要可以通过 volatile、synchronized 来保证。
一定意义上来讲,一般在 Java 并发编程中,其实加锁可以解决一部分问题,除此之外,我们还需要考虑线程饥饿问题,数据竞争问题,竞争条件问题以及死锁问题,通过综合分析才能得到意想不到的结果。
综上所述,我们在理解 Java 领域中的锁时,可以以此作为一个考量标准之一,来帮助和方便我们更快理解和掌握并发编程技术。
七.Java 线程饥饿问题
Java 线程饥饿问题是指长期无法获取共享资源或抢占 CPU 资源而导致线程无法执行的现象。
在 Java 并发编程的过程中,特别是开启线程数过多,会遇到某些线程贪婪地把 CPU 资源占满,导致某些线程分配不到 CPU 而没有办法执行。
在 Java 领域中,对于线程饥饿问题,可以从以下几个方面来看:
互斥锁 synchronized 饥饿问题:在使用 synchronized 对资源进行加锁时,不断有大量的线程去竞争获取锁,那么就可能会引发线程饥饿问题,主要是 synchronized 只是加锁,没有要求公平性导致的。
线程优先级饥饿问题:Java 中每个线程都有自己的优先级,一般情况下使用默认优先级,但是由于线程优先级不同,也会引起线程饥饿问题。
线程自旋饥饿问题: 主要是在 Java 并发操作中,会使用自旋锁,由于锁的核心的自旋操作,会导致大量线程自旋,也会引起线程饥饿问题。
等待唤醒饥饿问题: 主要是因为 JVM 中 wait 和 notify 实现不同,比方说 Java HotSpot(TM) VM 虚拟机是一种先入先出结构,也会引起线程饥饿问题。
针对上述的饥饿问题,为了解决它,JDK 内部实现一些具备公平性质的锁,可以直接使用。所以,解决线程饥饿问题,一般是引入队列,也就是排队处理,最典型的有 ReentrantLock。
综上所述,这不就是为我们掌握和理解 Java 中的锁机制时,需要考虑 Java 线程饥饿问题。
八.Java 数据竞争问题
Java 数据竞争问题是指至少存在两个线程去读写某个共享内存,其中至少一个线程对其共享内存进行写操作。
对于数据竞争问题,最简单的理解就是,多个线程在同时对于共享内存的进行写操作时,在写的过程中,其他的线程读到数据是内存数据中非正确预期的。
产生数据竞争的原因,一个 CPU 在任意时刻只能执行一条指令,但是对其某个内存中的写操作可能会用到若干条件机器指令,从而导致在写的过程中还没完全修改完内存,其他线程去读取数据,从而导致结果不可预知。从而引发数据竞争问题,这个情况有点像 MySQL 数据中并发事务引起的脏读情况。
在 Java 领域中,解决数据竞争问题的方式一般是把共享内存的更新操作进行原子化,同时也保证内存的可见性。
针对上述的饥饿问题,为了解决它,JDK 内部实现一系列的原子类,比如 AtomicReference 类等,但是主要可以采用 CAS+自旋锁的方式来实现。
综上所述,这不就是为我们掌握和理解 Java 中的锁机制时,需要考虑 Java 数据竞争问题。
九.Java 竞争条件问题
Java 竞争条件问题是指代码在执行临界区产生竞争条件,主要是因为多个线程不同的执行顺序以及线程并发的交叉执行导致执行结果与预期不一致的情况。
对于竞争条件问题,其中临界区是一块代码区域,其实说白了就是我们自己写的逻辑代码,由于没有考虑位,从而引发的多个线程不同的执行顺序以及线程并发的交叉执行导致执行结果与预期不一致的情况。
产生竞争条件问题的主要原因,一般主要有线程执行顺序的不确定性和并发机制导致上下文切换等两个原因导致竞争条件问题,其中:
线程执行顺序的不确定性:这个线程调度的工作方式有关,现在大部分计算机的操作系统都是抢占方式的调度方式,所有的任务调度由操作系统来完全控制,线程的执行顺序不一定是按照编码顺序的,主要有操作系统调度算法决定。
并发机制导致上下文切换:在并发的多线程的程序中,多个线程会导致进行上下文的资源切换,并且交叉执行,从而并发机制自身也会引起竞争条件问题。
在 Java 领域中,解决竞争条件问题的方式一般是把临界区进行原子化,保证临界区的源自性,保证了临界区捏只有一个线程,从而避免竞争产生。
针对上述的饥饿问题,为了解决它,JDK 内部实现一系列的原子类或者说直接使用 synchronized 来声明,均可实现。
综上所述,这不就是为我们掌握和理解 Java 中的锁机制时,需要考虑 Java 竞争条件问题。
十.Java 死锁问题
Java 死锁问题主要是指一种有两个或者两个以上的线程或者进程构成一个无限互相等待的环形状态的情况,不是一种锁概念,而是一种线程状态的表征描述。
一般为了保证线程安全问题,我们都会想着给会使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。
或者有的场景我们使用线程池和信号量等来限制资源的使用,但这些被限制的行为可能会导致资源死锁(Resource DeadLock)。
Java 死锁问题的主要体现在以下几个方面:
1⃣️ Java 应用程序不具备 MySQL 数据库服务器的本地事务,无法检测一组事务中是否有死锁的发生。
2⃣️ 在 Java 程序中,如果过度地使用加锁,轻则导致程序响应时间变长,系统吞吐量变小,重则导致应用中的某一个功能直接失去响应能力无法提供服务。
当然,死锁问题的产生也必须具备以及同时满足以下几个条件:
互斥条件:资源具有排他性,当资源被一个线程占用时,别的线程不能使用,只能等待。
阻塞不释放条件: 某个线程或者线程请求某个资源而进入阻塞状态,不会释放已经获取的资源。
占有并等待条件: 某个线程或者线程应该至少占有一个资源,等待获取另外一个资源,该资源被其他线程或者线程霸占。
非抢占条件: 不可抢占,资源请求者不能强制从资源占有者手中抢夺资源,资源只能由占有者主动释放。
环形条件: 循环等待,多个线程存在环路的锁依赖关系而永远等待下去。
对于死锁问题,一般都是需要编程开发人员人为去干预和防止的,只是需要一些措施区规范处理,主要可以分为事前预防和事后处理等 2 种方式,其中:
事前预防: 一般是保证锁的顺序化,资源合并处理,以及避免嵌套锁等。
事后处理: 一般是对锁设置超时机制,在死锁发生时抢占锁资源,以及撤销线程机制等。
除了有死锁的问题,当然还有活锁问题,主要是因为某些逻辑导致一直在做无用功,使得线程无法正确执行的情况。
应用分析
在 Java 领域中,我们可以将锁大致分为基于 Java 语法层面(关键词)实现的锁和基于 JDK 层面实现的锁。
单纯从 Java 对其实现的方式上来看,我们大体上可以将其分为基于 Java 语法层面(关键词)实现的锁和基于 JDK 层面实现的锁。其中:
基于 Java 语法层面(关键词)实现的锁,主要是根据 Java 语义来实现,最典型的应用就是 synchronized。
基于 JDK 层面实现的锁,主要是根据统一的 AQS 基础同步器来实现,最典型的有 ReentrantLock。
需要特别注意的是,在 Java 领域中,基于 JDK 层面的锁通过 CAS 操作解决了并发编程中的原子性问题,而基于 Java 语法层面实现的锁解决了并发编程中的原子性问题和可见性问题。
单纯从 Java 对其实现的方式上来看,我们大体上可以将其分为基于 Java 语法层面(关键词)实现的锁和基于 JDK 层面实现的锁。其中:
基于 Java 语法层面(关键词)实现的锁,主要是根据 Java 语义来实现,最典型的应用就是 synchronized。
基于 JDK 层面实现的锁,主要是根据统一的 AQS 基础同步器来实现,最典型的有 ReentrantLock。
需要特别注意的是,在 Java 领域中,基于 JDK 层面的锁通过 CAS 操作解决了并发编程中的原子性问题,而基于 Java 语法层面实现的锁解决了并发编程中的原子性问题和可见性问题。
而从具体到对应的 Java 线程资源来说,我们按照是否含有某一特性来定义锁,主要可以从如下几个方面来看:
从加锁对象角度方面上来看,线程要不要锁住同步资源 ? 如果是需要加锁,锁住同步资源的情况下,一般称其为悲观锁;否则,如果是不需要加锁,且不用锁住同步资源的情况就属于为乐观锁。
从获取锁的处理方式上来看,假设锁住同步资源,其对该线程是否进入睡眠状态或者阻塞状态?如果会进入睡眠状态或者阻塞状态,一般称其为互斥锁,否则,不会进入睡眠状态或者阻塞状态属于一种非阻塞锁,即就是自旋锁。
从锁的变化状态方面来看,多个线程在竞争资源的流程细节上是否有差别?
1⃣️ 对于不会锁住资源,多个线程只有一个线程能修改资源成功,其他线程会依据实际情况进行重试,即就是不存在竞争的情况,一般属于无锁。
2⃣️ 对于同一个线程执行同步资源会自动获取锁资源,一般属于偏向锁。
3⃣️ 对于多线程竞争同步资源时,没有获取到锁资源的线程会自旋等待锁释放,一般属于轻量级锁。
4⃣️ 对于多线程竞争同步资源时,没有获取到锁资源的线程会阻塞等待唤醒,一般属于重量级锁。
从锁竞争时公平性上来看,多个线程在竞争资源时是否需要排队等待?如果是需要排队等待的情况,一般属于公平锁;否则,先插队,然后再尝试排队的情况属于非公平锁。
从获取锁的操作频率次数来看,一个线程中的多个流程是否可以获取同一把锁?如果是可以多次进行加锁操作的情况,一般属于可重入锁,否则,可以多次进行加锁操作的情况属于非可重入锁。
从获取锁的占有方式上来看,多个线程能不能共享一把锁?如果是可以共享锁资源的情况,一般属于共享锁;否则,独占锁资源的情况属于排他锁。
针对于上述描述的各种情况,这里就不做展开和赘述,看到这里只需要在脑中形成一个概念就行,后续会有专门的内容来对其进行分析和探讨。
写在最后
在上述的内容中,一般常规的概念中,我们很难会依据上述这些问题去认识和看待 Java 中的锁机制,主要是在学习和查阅资料的时,大多数的论调都是零散和细分的,很难在我们的脑海中形成知识体系。
从本质上讲,我们对锁应该有一个认识,其主要是一种协调多个进程 或者多个线程对某一个资源的访问的控制机制,是并发编程中最关键的一环。
接下来,对于上述内容做一个简单的总结:
1⃣️ 计算机运行模型主要是描述计算机系统体系结构的基本模型,一般主要是指 CPU 处理器结构。
2⃣️ 计算机内存模型一般是指计算系统底层与编程语言之间的约束规范,主要是描述计算机程序与共享存储器访问的行为特征表现。
3⃣️ Java 内存模型主要是为了解决并发编程的可见性问题,原子性问题和有序性问题等三大问题,具有跨平台性。
4⃣️ Java 一致性模型指导原则是指制定一些规范来将复杂的物理计算机的系统底层封装到 JVM 中,从而向上提供一种统一的内存模型语义规则,一般是指 Happens-Before 规则。
5⃣️ Java 指令重排是指在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序的一种防护措施机制。
6⃣️ Java 并发编程的三宗罪主要是指原子性问题、可见性问题和有序性问题等三大问题。
7⃣️ Java 线程饥饿问题是指长期无法获取共享资源或抢占 CPU 资源而导致线程无法执行的现象。
8⃣️ Java 数据竞争问题是指至少存在两个线程去读写某个共享内存,其中至少一个线程对其共享内存进行写操作。
9⃣️ Java 竞争条件问题是指代码在执行临界区产生竞争条件,主要是因为多个线程不同的执行顺序以及线程并发的交叉执行导致执行结果与预期不一致的情况。
🔟 Java 死锁问题主要是指一种有两个或者两个以上的线程或者进程构成一个无限互相等待的环形状态的情况,不是一种锁概念,而是一种线程状态的表征描述。
单纯从 Java 对其实现的方式上来看,我们大体上可以将其分为基于 Java 语法层面(关键词)实现的锁和基于 JDK 层面实现的锁,其中:
1⃣️ 基于 Java 语法层面(关键词)实现的锁,主要是根据 Java 语义来实现,最典型的应用就是 synchronized。
2⃣️ 基于 JDK 层面实现的锁,主要是根据统一的 AQS 基础同步器来实现,最典型的有 ReentrantLock。
综上所述,我相信看到这里的时候,对 Java 领域中的锁机制已经有一个基本的轮廓,后面会专门写一篇内容来详细介绍,敬请期待。
最后,技术研究之路任重而道远,愿我们熬的每一个通宵,都撑得起我们想在这条路上走下去的勇气,未来仍然可期,与君共勉!
评论