中国领先的IT技术网站
|
|

资深架构师解读Java多线程与并发模型之锁

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

作者:魏靓来源:51CTO.com|2017-11-17 15:57

开发者大赛路演 | 12月16日,技术创新,北京不见不散


【51CTO.com原创稿件】互联网上充斥着对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的内置锁在子类对父类同步方法进行方法覆盖时,其同步标志是可以被子类继承使用的,我们看下面的例子:

  1. public class Parent { 
  2. public synchronized void doSomething() { 
  3.      System.out.println("parent do something"); 
  4.  
  5. public class Child extends Parent { 
  6. public synchronized void doSomething() { 
  7. .doSomething(); 
  8.  
  9. public static void main(String[] args) { 
  10.      new Child().doSomething(); 
  11.  

代码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接口都是为了保障并发的安全性,并发环境一般需要考虑的问题是如何保障共享对象的安全访问。在第二章将详细介绍内置对象引发的线程安全问题以及解决之道。  

作者简介

魏靓:现就职于五阿哥(www.wuage.com)任职专职架构师工作,负责平台的基础设施搭建工作。

【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】

【编辑推荐】

  1. 程序员Java编程进阶的5个注意点,别编程两三年还是增删改查!
  2. Java EE成为过去,Eclipse为其“改名”望成为顶级开源项目!
  3. 做前端好还是Java好?看这三方面
  4. 11个简单的Java性能调优技巧
  5. Java中的注解是如何工作的
【责任编辑:庞桂玉 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

Visual C++编程从基础到实践

Visual C++ 6.0是Microsoft公司的Visual Studio开发组件中最强大的编程工具,利用它可以开发出高性能的应用程序。本书由浅入深,从基础到实...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊