java多线程与并发模型之锁

     2022-03-26     731

关键词:

这是一篇总结Java多线程开发的长文。文章是从Java创建之初就存在的synchronized关键字引入,对Java多线程和并发模型进行了探讨。希望通过此篇内容的解读能帮助Java开发者更好的理清Java并发编程的脉络。

互联网上充斥着对Java多线程编程的介绍,每篇文章都从不同的角度介绍并总结了该领域的内容。但大部分文章都没有说明多线程的实现本质,没能让开发者真正“过瘾”。

本篇内容从Java的线程安全鼻祖内置锁介绍开始,让你了解内置锁的实现逻辑和原理以及引发的性能问题,接着说明了Java多线程编程中锁的存在是为了保障共享变量的线程安全使用。下面让我们进入正题。

以下内容如无特殊说明均指代Java环境。

第一部分:锁

提到并发编程,大多数Java工程师的第一反应都是synchronized关键字。这是Java在1.0时代的产物,至今仍然应用于很多的项目中,伴随着Java的版本更新已经存在了20多年。在如此之长的生命周期中,synchronized内部也在进行着“自我”进化。

早期的synchronized关键字是Java并发问题的唯一解决方案, 伴随引入这种“重量型”锁,带来的性能开销也是很大的,早期的工程师为了解决性能开销问题,想出了很多解决方案(例如DCL)来提升性能。好在Java1.6提供了锁的状态升级来解决这种性能消耗。一般通俗的说Java的锁按照类别可以分为类锁和对象锁两种,两种锁之间是互不影响的,下面我们一起看下这两种锁的具体含义。

类锁和对象锁

由于JVM内存对象中需要对两种资源进行协同以保证线程安全,JVM堆中的实例对象和保存在方法区中的类变量。因此Java的内置锁分为类锁和对象锁两种实现方式实现。前面已经提到类锁和对象锁是相互隔离的两种锁,它们之间不存在相互的直接影响,以不同方式实现对共享对象的线程安全访问。下面根据两种锁的隔离方式做如下说明:

1、当有两个(或以上)线程共同去访问一个Object共享对象时,同一时刻只有一个线程可以访问该对象的synchronized(this)同步方法(或同步代码块),也就是说,同一时刻,只能有一个线程能够得到CPU的执行,另一个线程必须等待当前获得CPU执行的线程完成之后才有机会获取该共享对象的锁。

2、当一个线程已经获得该Object对象的同步方法(或同步代码块)的执行权限时,其他的线程仍然可以访问该对象的非synchronized方法。

3、当一个线程已经获取该Object对象的synchronized(this)同步方法(或代码块)的锁时,该对象被类锁修饰的同步方法(或代码块)仍然可以被其他线程在同一CPU周期内获取,两种锁不存在资源竞争情况。

在我们对内置锁的类别有了基本了解后,我们可能会想JVM是如何实现和保存内置锁的状态的,其实JVM是将锁的信息保存在Java对象的对象头中。首先我们看下Java的对象头是怎么回事。

Java对象头

为了解决早期synchronized关键字带来的锁性能开销问题,从Java1.6开始引入了锁状态的升级方式用以减轻1.0时代锁带来的性能消耗,对象的锁由无锁状态 -> 偏向锁 -> 轻量级锁 -> 重量级锁状的升级。

技术分享图片

图1.1:对象头

在Hotspot虚拟机中对象头分为两个部分(数组还要多一部分用于存储数组长度),其中一部分用来存储运行时数据,如HashCode、GC分代信息、锁标志位,这部分内容又被称为Mark Word。在虚拟机运行期间,JVM为了节省存储成本会对Mark Word的存储区间进行重用,因此Mark Word的信息会随着锁状态变化而改变。另外一部分用于方法区的数据类型指针存储。

Java的内置锁的状态升级实现是通过替换对象头中的Mark Word的标识来实现的,下面具体看下内置锁的状态是如何从无锁状态升级为重量级锁状态。

内置锁的状态升级

JVM为了提升锁的性能,共提供了四种量级的锁。级别从低到高分为:无状态的锁、偏向锁、轻量级的锁和重量级的锁。在Java应用中加锁大多使用的是对象锁,对象锁随着线程竞争的加剧,最终可能会升级为重量级的锁。锁可以升级但不能降级(也就是为什么我们进行任何基准测试都需要对数据进行预热,以防止噪声的干扰,当然噪声还可能是其他原因)。在说明内置锁状态升级之前,先介绍一个重要的锁概念,自旋锁。

自旋锁

在互斥(mutex)状态下的内置锁带来的性能下降是很明显的。没有得到锁的线程需要等待持有锁的线程释放锁才可以争抢运行,挂起和恢复一个线程的操作都需要从操作系统的用户态转到内核态来完成。然而CPU为保障每个线程都能得到运行,分配的时间片是有限的,每次上下文切换都是非常浪费CPU的时间片的,在这种条件下自旋锁发挥了优势。

所谓自旋,就是让没有得到锁的线程自己运行一段时间,线程自旋是不会引起线程休眠的(自旋会一直占用CPU资源),所以并不是真正的阻塞。当线程状态被其他线程改变才会进入临界区,进而被阻塞。在Java1.6版本已经默认开启了该设置(可以通过JVM参数-XX:+UseSpinning开启,在Java1.7中自旋锁的参数已经被取消,不再支持用户配置而是虚拟机总会默认执行)。

虽然自旋锁不会引起线程的休眠,减少了等待时间,但自旋锁也存在着对CPU资源浪费的情况,自旋锁需要在运行期间空转CPU的资源。只有当自旋等待的时间高于同步阻塞时才有意义。因此JVM限制了自旋的时间限度,当超过这个限度时,线程就会被挂起。

在Java1.6 中提供了自适应自旋锁,优化了原自旋锁限度的次数问题,改为由自旋线程时间和锁的状态来确定。例如,如果一个线程刚刚自旋成功获取到锁,那么下次获取锁的可能性就会很大,所以JVM准许自旋的时间相对较长,反之,自旋的时间就会很短或者忽略自旋过程,这种情况在Java1.7也得到了优化。

自旋锁是贯穿内置锁状态始终的,作为偏向锁,轻量级锁以及重量级锁的补充。

偏向锁

偏向锁是Java1.6 提出的一种锁优化机制,其核心思想是,如果当前线程没有竞争则取消之前已经取得锁的线程同步操作,在JVM的虚拟机模型中减少对锁的检测。也就是说如果某个线程取得对象的偏向锁,那么当这个线程在此请求该偏向锁时,就不需要额外的同步操作了。

具体的实现为当一个线程访问同步块时会在对象头的Mark Word中存储锁的偏向线程ID,后续该线程访问该锁时,就可以简单的检查下Mark Word是否为偏向锁并且其偏向锁是否指向当前线程。

如果测试成功则线程获取到偏向锁,如果测试失败,则需要检测下Mark Word中偏向锁的标记是否设置成了偏向状态(标记位为1)。如果没有设置,则使用CAS竞争锁。如果设置了,尝试使用CAS将对象头的Mark Word偏向锁标记指向当前线程。也可以使用JVM参数-XX:-UseBiastedLocking参数来禁用偏向锁。

因为偏向锁使用的是存在竞争才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

轻量级的锁

如果偏向锁获取失败,那么JVM会尝试使用轻量级锁,带来一次锁的升级。轻量级锁存在的出发点是为了优化锁的获取方式,在不存在多线程竞争的前提下,以减少Java 1.0时代锁互斥带来的性能开销。轻量级锁在JVM内部是使用BasicObjectLock对象实现的。

其具体的实现为当前线程在进入同步代码块之前,会将BasicObjectLock对象放到Java的栈桢中,这个对象的内部是由BasicLock对象和该Java对象的指针组成的。然后当前线程尝试使用CAS替换对象头中的Mark Word锁标记指向该锁记录指针。如果成功则获取到锁,将对象的锁标记改为00 | locked,如果失败则表示存在其他线程竞争,当前线程使用自旋尝试获取锁。

当存在两条(或以上)的线程共同竞争一个锁时,此时的轻量级的锁将不再发挥作用,JVM会将其膨胀为重量级的锁,锁的标位为也会修改为10 | monitor 。

轻量级锁在解锁时,同样是通过CAS的置换对象头操作。如果成功,则表示成功获取到锁。如果失败,则说明该对象存在其他线程竞争,该锁会随着膨胀为重量级的锁。

重量级的锁

JVM在轻量级锁获取失败后,会使用重量级的锁来处理同步操作,此时对象的Mark Word标记为 10 | monitor,在重量级锁处理线程的调度中,被阻塞的线程会被系统挂起,在线程再次获得CPU资源后,需要进行系统上下文的切换才能得到CPU执行,此时效率会低很多。

通过上面的介绍我们了解了Java的内置锁升级策略,随着锁的每次升级带来的性能的下降,因此我们在程序设计时应该尽量避免锁的征用,可以使用集中式缓存来解决该问题。

一个小插曲:内置锁的继承

内置锁是可以被继承的,Java的内置锁在子类对父类同步方法进行方法覆盖时,其同步标志是可以被子类继承使用的,我们看下面的例子:

public class Parent { 
public synchronized void doSomething() { 
     System.out.println("parent do something"); 
} 
} 
 java学习群669823128
public class Child extends Parent { 
public synchronized void doSomething() { 
.doSomething(); 
} 
 
public static void main(String[] args) { 
     new Child().doSomething(); 
} 
} 

代码1.1:内置锁继承

以上的代码可以正常的运行么?

答案是肯定的。

避免活跃度危险

Java并发的安全性和活跃度是相互影响的,我们使用锁来保障线程安全的同时,需要避免线程活跃度的风险。Java线程不能像数据库那样自动排查解除死锁,也无法从死锁中恢复。而且程序中死锁的检查有时候并不是显而易见的,必须到达相应的并发状态才会发生,这个问题往往给应用程序带来灾难性的结果,这里介绍以下几种活跃度危险:死锁、线程饥饿、弱响应性、活锁。

死锁

当一个线程永远的占有一个锁,而其他的线程尝试去获取这个锁时,这个线程将被永久的阻塞。

一个经典的例子就是AB锁问题,线程1获取到了共享数据A的锁,同时线程2获取到了共享数据B的锁,此时线程1想要去获取共享数据B的锁,线程2获取共享数据A的锁。如果用图的关系表示,那么这将是一个环路。这是死锁是最简单的形式。还有比如我们再对批量无序的数据做更新操作时,如果无序的行为引发了2个线程的资源争抢也会引发该问题,解决的途径就是排序后再进行处理。

线程饥饿

线程饥饿是指当线程访问它所需要的资源时却永久被拒绝,以至于不能再继续进行后面的流程,这样就发生了线程饥饿;例如线程对CPU时间片的竞争,Java中低优先级的线程引用不当等。虽然Java的API中对线程的优先级进行了定义,这仅仅是一种向CPU自我推荐的行为(此处需要注意不同操作系统的线程优先级并不统一,而且对应的Java线程优先级也不统一),但是这并不能保障高优先级的线程一定能够先被CPU选择执行。

弱响应性

在GUI的程序中,我们一般可见的客户端程序都是使用后台运行,前端反馈的形式,当CPU密集型后台任务与前台任务共同竞争资源时,有可能造成前端GUI冻结的效果,因此我们可以降低后台程序的优先级,尽可能的保障最佳的用户体验性。

活锁

线程活跃度失败的另一种体现是线程没有被阻塞,但是却不能继续,因为不断重试相同的操作,却总是失败。

线程的活跃度危险是我们在开发中应该避免的一种行为。这种行为会造成应用程序的灾难性后果。 

总结

关于synchronized关键字的所有内容到这里全部介绍完毕了,在这一章节希望可以让大家明白锁之所以“重”是因为随着线程间竞争的程度升级导致的。在真正的开发中我们可能还有别的选择,例如Lock接口,在某些并发场景下性能优于内置锁的实现。

不论是通过内置锁还是通过Lock接口都是为了保障并发的安全性,并发环境一般需要考虑的问题是如何保障共享对象的安全访问。在第二章将详细介绍内置对象引发的线程安全问题以及解决之道。

java学习群669823128

java多线程与并发:内存模型

前言在并发变成中,我们需要关注两个问题:线程之间如何通信。线程之间如何同步。线程之间通信指的是线程之间如何交换信息。线程之间的通信机制有两种:共享内存和消息传递。在共享内存的并发模型里,线程之间共享程... 查看详情

多线程之锁机制(代码片段)

前言  在Java并发编程实战,会经常遇到多个线程访问同一个资源的情况,这个时候就需要维护数据的一致性,否则会出现各种数据错误,其中一种同步方式就是利用Synchronized关键字执行锁机制,锁机制是先给共享资源上锁,... 查看详情

java并发之锁的使用浅析

    锁像synchronized同步块一样,是一种线程同步机制。让自Java5开始,java.util.concurrent.locks包提供了另一种方式实现线程同步机制——Lock。那么问题来了既然都可以通过synchronized来实现同步访问了,那么为什... 查看详情

java并发优化之锁lock

...们了解到如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待 查看详情

java并发优化之锁lock

...们了解到如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待 查看详情

java的线程模型

 并发不一定要依赖多线程(如PHP中很常见的多进程并发),但是在Java里面谈论并发,大多数都与线程脱不开关系。线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线... 查看详情

java多线程并发09——如何实现线程间与线程内数据共享

...。关注我的公众号「Java面典」了解更多Java相关知识点。线程间数据共享Java里面进行多线程通信的主要方式就是共享内存的方式,共享内存主要的关注点有两个:可见性和有序性原子性。Java内存模型(JMM)解决了可见性和有序... 查看详情

深入理解java的内存模型与线程并发问题

文章大部分内容参考《深入理解Java虚拟机》!!!一、引言为什么要了解java的内存模型?java的内存模型是程序运行的基础知识,对于我们理解java的并发编程有一定的帮助,甚至一些并发知识的底层实现... 查看详情

java面试:多线程与并发

关键词多线程,并发,线程池多线程Q:如何新建一个线程?继承Thread,或者实现Runnable接口,或者通过Callable接口实现Q:线程池有没有了解过?为什么要用线程池?新建线程的开销太大了,使用线程池可以节省系统资源。Q:线程池... 查看详情

java并发总结(代码片段)

...被称为JMM,从上图可以看出,java内存模型主要是针对多线程。为什么要先说java的内存模型,事实上,涉及到线程之间通信的两种模型;第一种是消息传递,这种通信方式对程序员是不透明的,即程序员必须显示的用一个线程发... 查看详情

juc多线程:jmm内存模型与volatile内存语义(代码片段)

...:        Java内存模型是Java虚拟机定义的一种多线程访问Java内存各个变量的访问规范,主要围绕如何解决并发过程中的原子性、可见性、有序性这三个问题来解决线程的安全问题。        Java内存模型将内存分... 查看详情

java多线程和并发,jmm(java内存模型)

目录1.什么是JMM2.JMM的主内存和工作内存3.JMM如何解决可见性问题-指令重排序4.Volatile十、JMM(Java内存模型)(暂时没有理解)1.什么是JMM 2.JMM的主内存和工作内存(1)主内存 (2)工作内存 (3)主内存和工作内存数... 查看详情

java并发编程:管程内存模型无锁并发线程池aqs原理与锁线程安全集合类并发设计模式

文章目录基础1.进程与线程2.并发与并行3.同步与异步4.主线程与守护线程5.Thread与Runnable6.线程方法7.线程状态管程1.共享问题、临界区、竞态条件2.Monitor3.synchronized4.wait&notify5.Park&Unpark6.活跃性7.ReentrantLock8.lockvssynchronized内存... 查看详情

java多线程与并发:前置知识

目的这一系列的博文的目的是帮助自己对多线程的知识做一个总结,并且将Java中的多线程知识做一个梳理。尽量做到全面和和简单易懂。概念进程与线程进程是操作系统级别的,进程是操作系统分配资源的基本单位,一个进程... 查看详情

java内存模型与jvm运行时数据区的区别

...一谈。1.什么是Java内存模型?Java内存模型是Java语言在多线程并发情况下对于共享变量读写(实际是共享变量对应的内存操作)的规范,主要是为了解决多线程可见性、原子性的问题,解决共享变量的多线程操作冲突问题。多线程... 查看详情

Java多线程并发与并行

】Java多线程并发与并行【英文标题】:Javamulti-threadingConcurrencyvsParallelism【发布时间】:2021-12-1215:24:24【问题描述】:嗨,全世界的程序员为了了解并发与并行之间的区别,我得到了这个问题来解决,但是我在这个我无法解决的... 查看详情

java并发编程:管程内存模型无锁并发线程池aqs原理与锁线程安全集合类并发设计模式(代码片段)

文章目录基础1.进程与线程2.并发与并行3.同步与异步4.主线程与守护线程5.Thread与Runnable6.线程方法7.线程状态管程1.共享问题、临界区、竞态条件2.Monitor3.synchronized4.wait&notify5.Park&Unpark6.活跃性7.ReentrantLock8.lockvssynchronized内存... 查看详情

java并发-多线程面试(全面)

1.什么是线程?2. 什么是线程安全和线程不安全?3. 什么是自旋锁?4. 什么是Java内存模型?5. 什么是CAS?6. 什么是乐观锁和悲观锁?7. 什么是AQS?8. 什么是原子操作?在JavaConcurrencyAPI中有哪些原子类(atomicclasses)?9. 什么是Executors... 查看详情