当前位置: 首页 > 科技观察

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

时间:2023-03-21 11:45:08 科技观察

[.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对象的同步方法(或同步代码块)的执行权限后,其他线程仍然可以访问该对象的非同步方法。3.当一个线程已经获取了Object对象的synchronized(this)同步方法(或代码块)的锁时,该对象的类锁修改的同步方法(或代码块)仍然可以被其他线程使用在同一个CPUcycleAcquirement中,两个锁不存在资源竞争。在我们对内置锁的种类有了基本的了解之后,我们可能会好奇JVM是如何实现和保存内置锁的状态的。实际上,JVM将锁信息保存在Java对象的对象头中。首先,让我们看看Java的对象头是怎么回事。为了解决早期synchronized关键字带来的锁性能开销问题,Java对象头从Java1.6开始就引入了锁状态升级的方法,以减少1.0时代锁带来的性能消耗。对象的锁从无锁态->偏向锁->轻量级锁->重量级锁类升级。图1.1:Hotspot虚拟机中的对象头分为两部分(多出一部分数组用于存放数组长度),一部分用于存放运行时数据,如HashCode、GC生成信息,以及锁定标志位,这部分也称为MarkWord。在虚拟机运行过程中,JVM为了节省存储开销,会重用MarkWord的存储区间,所以MarkWord的信息会随着锁状态的变化而变化。另一部分用于方法区的数据类型指针存储。Java内置锁的状态升级是通过替换对象头中的MarkWord标识来实现的。看看内置锁的状态是如何从无锁状态升级到重量级锁状态的。内置锁的状态升级为了提高锁的性能,JVM提供了四种级别的锁。级别从低到高分为:无状态锁、偏向锁、轻量级锁和重量级锁。对象锁主要用于Java应用程序中的锁定。随着线程竞争的加剧,对象锁最终可能会升级为重量级锁。锁可以升级但不能降级(这就是为什么我们需要对任何基准测试的数据进行预热以防止噪音干扰,当然噪音可能是其他原因)。在讲解内置锁状态升级之前,先介绍一个重要的锁概念自旋锁。自旋锁在互斥状态下的内置锁带来的性能下降是显而易见的。没有获得锁的线程需要等待持有锁的线程释放锁,才能竞争运行。挂起和恢复线程的操作需要从操作系统的用户态到内核态完成。但是CPU为了保证每个线程都能运行,分配了有限的时间片,每次上下文切换都是对CPU时间片的浪费。在这种情况下,自旋锁就发挥了优势。所谓自旋就是让没有获得锁的线程运行一段时间。线程自旋不会导致线程休眠(自旋会一直占用CPU资源),所以不是真的阻塞。当线程状态被其他线程改变时,就会进入临界区并被阻塞。Java1.6默认开启该设置(可通过JVM参数-XX:+UseSpinning开启,Java1.7取消自旋锁参数,不再支持用户配置但虚拟机会一直执行默认情况下。)。自旋锁虽然不会导致线程休眠,减少了等待时间,但是自旋锁也浪费了CPU资源。自旋锁在运行过程中需要空闲CPU资源。只有自旋等待时间高于同步阻塞才有意义。因此,JVM限制了自旋的时间限制。当超过这个限制时,线程将被挂起。Java1.6中提供了自适应自旋锁,优化了自旋锁的数量限制,改为由自旋线程时间和锁的状态决定。比如一个线程刚刚自旋成功获得了锁,那么下次获得锁的可能性就会很大,所以JVM允许自旋的时间比较长,否则自旋的时间会很短或者忽略锁。spin进程,这种情况在Java1.7中也得到了优化。自旋锁贯穿于内置锁的整个状态,作为偏向锁、轻量级锁、重量级锁的补充。偏向锁偏向锁是Java1.6提出的一种锁优化机制。其核心思想是如果当前线程没有竞争,则取消之前已经获取锁的线程同步操作,在JVM虚拟机模型中减少对锁的检测。也就是说,如果一个线程获取了该对象的偏向锁,那么该线程在此处请求偏向锁时,不需要额外的同步操作。具体实现是当一个线程访问同步块时,会在对象头的MarkWord中存储该锁的偏向线程ID。以后线程访问锁的时候,可以简单的检查MarkWord是否是偏向锁,以及它的偏向锁是否指向当前线程。如果测试成功,则线程获取偏向锁。如果测试失败,需要检查MarkWord中偏向锁的标志是否设置为偏向状态(标志位为1)。如果未设置,则使用CAS争用锁。如果设置了,尝试使用CAS将对象头的MarkWord偏向锁标记指向当前线程。您还可以使用JVM参数-XX:-UseBiastedLocking来禁用偏向锁。因为偏向锁采用了有竞争就释放锁的机制,当其他线程试图竞争偏向锁时,持有偏向锁的线程就会释放锁。轻量级锁如果偏向锁获取失败,JVM会尝试使用轻量级锁,从而导致锁升级。轻量级锁存在的出发点是在没有多线程竞争的Java1.0时代,优化锁的获取方式,减少锁互斥带来的性能开销。轻量级锁是在JVM内部使用BasicObjectLock对象实现的。它的具体实现是当前线程在进入同步代码块之前,会把BasicObjectLock对象放入Java栈帧中,这个对象内部由BasicLock对象和Java对象的指针组成。然后当前线程尝试使用CAS替换对象头中的MarkWord锁标记指向锁记录指针。如果成功,则获取锁,并将对象的锁标志更改为00|锁定。如果失败,说明有其他线程竞争,当前线程使用自旋尝试获取锁。当有两个(或多个)线程在竞争一个锁时,此时的轻量级锁将不再起作用,JVM会将其扩展为重量级锁,并将锁标记为10|监视器。轻量级锁解锁时,也是通过CAS的替换对象头来操作的。如果成功,说明已经成功获取锁。如果失败,说明有其他线程在争夺该对象,锁会扩展为重量级锁。重量级锁JVM在获取轻量级锁失败后,会使用重量级锁来处理同步操作。此时对象的MarkWord标记为10|监视器。在重量级锁处理线程的调度中,被阻塞的线程会被系统挂起。线程再次获得CPU资源后,需要切换系统上下文到CPU执行。这时候效率就会低很多。通过上面的介绍,我们了解了Java内置的锁升级策略。随着锁的每次升级,性能都会下降。因此,我们在设计程序时要尽量避免对锁的申请。可以使用集中式缓存来解决这个问题。.一个小插曲:内置锁的继承内置锁是可以继承的。当子类重写父类的同步方法时,Java内置的锁可以被子类继承和使用。让我们看下面的例子:publicclassParent{publicsynchronizedvoiddoSomething(){System.out.println("parentdosomething");}}publicclassChildextendsParent{publicsynchronizedvoiddoSomething(){.doSomething();}publicstaticvoidmain(String[]args){newChild().doSomething()代码;}}1.1:上述内置锁继承的代码能否正常运行?答案是肯定的。避免活性危害Java并发的安全性和活性相互影响。我们在使用锁来保证线程安全的同时,也需要规避线程活跃度的风险。Java线程不能像数据库一样自动排查死锁,也不能从死锁中恢复。而且,程序中的死锁检查有时并不明显,必须达到相应的并发状态才会发生。这个问题往往会给应用程序带来灾难性的后果。以下是liveness危害:死锁、线程饥饿、弱响应、活锁。死锁当一个线程永远持有一个锁,而其他线程试图获取锁时,该线程将被绝对阻塞。一个经典的例子就是AB锁问题。线程1获取共享数据A的锁,而线程2获取共享数据B的锁,此时线程1要获取共享数据B的锁,线程2获取共享数据A的锁。如果表示为图形关系,这将是一个循环。这是最简单的死锁形式。比如我们批量更新乱序数据时,如果乱序行为导致两个线程竞争资源,也会导致这个问题。解决办法是先排序再处理。Threadstarvation线程饥饿是指当一个线程访问它所需要的资源时,被绝对拒绝,以至于不能再继续后续的过程,从而发生线程饥饿;例如线程争夺CPU时间片,Java中对低优先级线程的不当引用等。虽然JavaAPI中定义了线程的优先级,但这只是对CPU的一种自我推荐行为(这里需要注意的是,不同操作系统的线程优先级并不统一,对应的Java线程优先级也是different.unified),但这并不能保证高优先级的线程会优先被CPU选择执行。WeakResponsiveness在GUI程序中,我们一般看到的客户端程序都是在后台运行,前端反馈的形式。当CPU密集型后台任务和前台任务一起竞争资源时,可能会造成前端GUI卡顿的效果。因此,我们可以降低后台程序的优先级,尽可能保证最佳的用户体验。livelock线程活动失败的另一种表现是线程没有阻塞,但是无法继续,因为同一个操作反复重试,却总是失败。线程活性危害是我们在开发中应该避免的行为。这种行为会给应用程序带来灾难性的后果。总结一下关于synchronized关键字的所有内容已经介绍到这里了。本章希望让大家明白,锁之所以“重”,是因为线程间的竞争程度不断升级。在实际开发中,我们可能还有其他的选择,比如Lock接口,在某些并发场景下比实现内置锁有更好的性能。无论是通过内置锁还是通过Lock接口,都是为了保证并发的安全。并发环境下普遍需要考虑的问题是如何保证共享对象的安全访问。在第二章中,我们将详细介绍内置对象带来的线程安全问题及解决方法。作者简介魏亮:目前就职于无鸽网(www.wuage.com)全职架构师,负责平台的基础设施建设。【原创稿件,合作网站转载请注明原作者和出处为.com】