京东二面:Sychronized的锁升级过程是怎样的

引言

Java作为主流的面向对象编程语言,提供了丰富的并发工具来帮助开发者解决多线程环境下的数据一致性问题。其中,内置的关键字"Synchronized"扮演了至关重要的角色,它能够确保在同一时刻只有一个线程访问特定代码块或方法,从而有效地防止数据竞争和保持内存可见性。

在传统的Synchronized实现中,由于其采用的是重量级锁机制,每次获取和释放锁都涉及操作系统层面的线程调度,这无疑增加了线程上下文切换的开销,尤其在高并发且锁竞争较小的场景下,可能会导致不必要的性能损失。为此,从Java 6开始,JVM引入了锁升级机制,这是一种动态调整锁状态的技术,旨在根据不同场景灵活运用不同级别的锁,从而在保证并发安全性的同时,最大程度地提升程序的运行效率。

关于Synchronized的实现原理,请参考:美团一面:说说synchronized的实现原理?问麻了。。。。

本文将深入探讨"Synchronized"的锁升级过程,详细介绍从无锁状态到偏向锁、轻量级锁,直至重量级锁的不同阶段及其背后的原理。

Synchronized锁的基础概念

在Java中,synchronized关键字是实现线程同步的关键机制之一,它用于确保多个线程在访问共享资源时的正确性和一致性。synchronized锁的基本思想是,当一个线程进入某个synchronized代码块或方法时,它必须首先获取到该对象或类的锁,然后才能执行相应的操作。如果其他线程试图进入相同的synchronized区域,它们将被阻塞,直到锁被释放。

对象头与Mark Word简介

Java对象在内存中不仅包含类实例的字段,还包含一些元数据,这些元数据存储在对象头中。对象头是Java对象的重要组成部分,它包含了关于对象的重要信息,如哈希码、GC年龄以及锁状态等。其中,Mark Word是对象头中的一个关键字段,它记录了关于对象锁状态的信息。通过修改Mark Word的内容,JVM能够实现对对象锁的获取和释放。

Synchronized锁定的基本原理与运作机制概述

synchronized锁定的基本原理是通过对对象或类的监视器(Monitor)进行加锁和解锁操作来实现线程同步。当一个线程尝试进入synchronized代码块或方法时,它会首先尝试获取对象或类的锁。如果锁已经被其他线程持有,则该线程将被阻塞,直到锁被释放。synchronized锁的运作机制包括偏向锁、轻量级锁和重量级锁三种状态。偏向锁适用于单线程访问的情况,轻量级锁适用于多线程竞争不激烈的情况,而重量级锁则用于处理高竞争场景。通过这三种状态的转换,synchronized锁能够根据不同的并发场景动态调整锁策略,以实现高效的线程同步。

关于synchronized的实现方式,原理介绍,请参考:美团一面:说说synchronized的实现原理?问麻了。。。。

锁升级的概念

锁升级是指Java虚拟机(JVM)在并发环境下对synchronized关键字所使用的锁机制进行动态调整的过程,从最初的无锁状态逐渐过渡到偏向锁、轻量级锁,直至最终的重量级锁。这一过程旨在根据实际的并发状况选择最适合的锁类型,以实现对共享资源的最佳保护和最有效的并发控制。

锁升级的主要目的是为了提升并发性能,减少不必要的线程上下文切换和内存消耗。线程上下文切换是一个相对昂贵的操作,因为它涉及到保存当前线程的状态、恢复另一个线程的状态等一系列操作。通过优化锁策略,JVM可以减少这种切换的频率,从而提高系统的整体性能。

另外,锁升级也有助于减少内存消耗。相较于重量级锁需要创建额外的Monitor对象并在操作系统层面进行线程调度,偏向锁和轻量级锁在一定程度上降低了内存消耗,特别是对于大量短生命周期的锁请求场景。

Synchronized锁的四种状态详解

当我们使用synchronized时,Java虚拟机(JVM)会为每个被同步的对象维护一个锁(或称为监视器锁)。这个锁有四种状态:从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,用于控制多线程对共享资源的访问。

image.png

无锁

无锁状态是对象初始化后的默认锁状态,表示对象当前未被任何线程锁定。在这种状态下,对象头的锁标志位通常为空或特定的无锁标识,表明对象不受任何同步控制,任何线程都能够无障碍地访问该对象。

无锁的标志位为01,即如果是否偏向锁标识为0时是无锁状态,为1时是偏向锁。在这个状态下,没有线程拥有锁,并且存储了对象的hashcode、对象的分代年龄以及是否为偏向锁的标志(0表示不是偏向锁)。

当一个线程首次尝试获取锁时,JVM会检查这个锁是否处于无锁状态。如果是,JVM会尝试将锁偏向给这个线程,也就是将锁标记为偏向这个线程,并且将这个线程的ID记录在锁的标记中。这样,当这个线程再次尝试获取锁时,就可以避免一些昂贵的操作,因为JVM可以直接检查锁是否仍然偏向这个线程。

偏向锁

当一个线程首次成功获取一个锁时,锁就进入了偏向锁状态。在偏向锁状态下,只有持有偏向锁的线程才能再次获取这个锁,而不会引起竞争。如果其他线程尝试获取这个锁,偏向锁就会升级为轻量级锁。

偏向锁的标志位为01,即是否偏向锁表标识位为1。与无锁状态的标志位相同,但存储的内容有所不同。偏向锁状态下,会存储偏向的线程ID、偏向时间戳、对象分代年龄以及是否偏向锁的标志(1)。

偏向锁是一种针对线程独占锁优化的机制,它适用于单一线程长时间、连续地访问同一段同步代码的情况。当某个线程首次获得同步代码块的锁后,Java虚拟机会在对象头的Mark Word中记录该线程的ID,形成偏向锁。在此之后,该线程再次进入同步代码块时,无需执行CAS操作等复杂的同步动作,仅需确认Mark Word中的偏向线程ID是否为自己,便可迅速获得锁,从而极大地减少了获取锁的开销,提升了并发性能。

在偏向锁生效期间,除非有其他线程尝试获取该锁,否则持有偏向锁的线程不会主动释放锁。当出现锁竞争时,原有的偏向锁持有者会经历撤销过程。此过程发生在全局安全点,即在所有线程均停止执行字节码的时刻,JVM会暂停当前持有偏向锁的线程,检查锁对象的状态。如果发现持有偏向锁的线程不再活动或者锁确实处于被争夺状态,则会撤销偏向锁,即将对象头恢复为无锁状态(标志位为01)或直接升级为轻量级锁(标志位调整为对应轻量级锁的状态)。

偏向锁主要是为了解决在一个线程连续多次获取同一锁的情况,降低不必要的同步操作开销。当首次获取锁的线程再次进入同步代码块时,会检查对象头中存储的线程ID是否与当前线程一致。如果一致,则直接获得锁;如果不一致,则需要撤销偏向锁,重新进行锁竞争,可能升级为轻量级锁。

优点
对于没有或很少发生锁竞争的场景,偏向锁可以显著减少锁的获取和释放所带来的性能损耗。

缺点

  • 额外存储空间:偏向锁会在对象头中存储一个偏向线程ID等相关信息,这部分额外的空间开销虽然较小,但在大规模并发场景下,累积起来也可能成为可观的成本。

  • 锁升级开销:当一个偏向锁的对象被其他线程访问时,需要进行撤销(revoke)操作,将偏向锁升级为轻量级锁,甚至在更高竞争情况下升级为重量级锁。这个升级过程涉及到CAS操作以及可能的线程挂起和唤醒,会带来一定的性能开销。

  • 适用场景有限:偏向锁最适合于绝大部分时间只有一个线程访问对象的场景,这样的情况下,偏向锁的开销可以降到最低,有利于提高程序性能。但如果并发程度较高,或者线程切换频繁,偏向锁就可能不如轻量级锁或重量级锁高效。

轻量级锁

当一个线程尝试获取一个已经被其他线程持有的偏向锁时,偏向锁会升级为轻量级锁。轻量级锁是一种用于处理线程之间轻量级竞争的机制。当一个线程尝试获取轻量级锁时,它会先自旋一段时间,尝试等待锁被释放。如果在这段时间内锁被释放了,那么这个线程就可以成功获取锁。如果自旋结束后锁仍然被持有,那么这个线程就会尝试将锁升级为重量级锁。

轻量级锁的标识位为:00。当锁从偏向锁升级为轻量级锁时,标志位会变为00。在轻量级锁状态下,多个线程可能会尝试获取锁,通过自旋来等待锁被释放。

轻量级锁利用CAS操作尝试将对象头的Mark Word替换为指向线程栈中锁记录的指针,如果CAS操作成功,则表示线程成功获取锁。获取锁失败的线程会进入自旋状态,不断循环尝试获取锁,直到获取成功或升级为重量级锁。在自旋期间,线程不会立即进入阻塞状态,而是不断循环检查锁是否可用。这种机制可以减少线程上下文切换的开销,但如果自旋次数过多或者竞争加剧,自旋就会失去意义,JVM会选择升级为重量级锁。

优点

  • 低开销:轻量级锁通过CAS操作尝试获取锁,避免了重量级锁中涉及的线程挂起和恢复等高昂开销。
  • 快速响应:在无锁竞争或者锁竞争不激烈的情况下,轻量级锁使得线程可以迅速获取锁并执行同步代码块。

缺点

  • 自旋消耗:当锁竞争激烈时,线程可能会长时间自旋等待锁,这会消耗CPU资源,导致性能下降。
  • 升级开销:如果自旋等待超过一定阈值或者锁竞争加剧,轻量级锁会升级为重量级锁,这个升级过程本身也有一定的开销。

重量级锁

当轻量级锁的自旋尝试达到一定阈值,或者检测到多个线程竞争激烈时,JVM会将轻量级锁升级为重量级锁。升级过程中,会取消当前线程的自旋操作,并在对象头中设置重量级锁标志。

重量级锁的标识位为:10。当锁从轻量级锁升级为重量级锁时,标志位会变为10。在重量级锁状态下,线程在获取锁时会阻塞,直到持有锁的线程释放锁。

在重量级锁状态下,线程在获取锁失败时会被操作系统挂起,放入到该对象关联的监视器(Monitor)的等待队列中,由操作系统进行线程调度,当锁被释放时,操作系统会选择合适的线程将其唤醒并授予锁。

尽管重量级锁的开销较大,涉及到线程上下文切换和内核态用户态的切换等,但它在高竞争场景下能提供稳定的互斥性和公平性,确保数据的一致性和线程的安全执行。因此,即使性能损耗较高,也是在特定情况下必要的权衡措施。

优点

  • 强一致性:重量级锁提供了最强的线程安全性,确保在多线程环境下数据的完整性和一致性。
  • 简单易用synchronized关键字的使用简洁明了,不易出错。

缺点

  • 性能开销大:获取和释放重量级锁时需要操作系统介入,可能涉及线程的挂起和唤醒,造成上下文切换,这对于频繁锁竞争的场景来说性能代价较高。
  • 延迟较高:线程获取不到锁时会被阻塞,导致等待时间增加,进而影响系统响应速度。

以上四种锁状态优缺点对比总结如下:

类型 优点 缺点 使用场景
偏向锁 快速:无须线程上下文切换,适合单一线程多次重复获取同一线程锁的场景
低开销:只需要检查对象头标记
不适合多线程竞争的场景
竞争时需要撤销偏向锁,有一定开销
大多数时候只有一线程访问同步代码块,很少出现锁竞争的情况
轻量级锁 较快:通过CAS操作和自旋避免了线程的阻塞与唤醒,减少了线程上下文切换
适用于锁竞争不激烈的场景
自旋可能导致CPU空耗,在高竞争下,大量的线程自旋会增加系统负担。
无法保证绝对的公平性
短时间的同步代码块,且锁竞争不激烈,期望快速重入和释放
重量级锁 稳定可靠:严格保证互斥性和公平性
能够有效应对高度竞争的锁场景
开销大:涉及到线程上下文切换,性能较低
阻塞线程可能导致响应时间变长
高并发、高竞争的场景,需要保证数据一致性,且线程等待锁的时间较长或不可预知

关于Java中锁的分类,以及各种所得介绍,请参考:阿里二面:Java中_锁的分类_有哪些?你能说全吗?

关于Java中如何定位以及避免死锁,请参考:阿里二面:如何定位&避免_死锁_?连着两个面试问到了!

锁升级的具体步骤与流程

1.无锁到偏向锁的升级流程:

  • 当线程首次尝试获取对象锁时,JVM首先检查对象是否处于无锁状态。
  • 若处于无锁状态,JVM则立即将其标记为偏向锁,并记录下当前线程的ID。
  • 这一过程通过CAS操作实现,确保线程安全地更新对象头的Mark Word为偏向锁状态,并保存偏向线程的ID。
  • 一旦设置成功,线程便可无阻碍地进入同步代码块,后续再次获取该锁时仅需验证是否仍偏向当前线程,无需额外同步操作

而对于偏向锁的释放机制:

  • 当持有偏向锁的线程正常退出同步代码块时,JVM仅简单地更新对象头的访问计数等相关信息。
  • 由于偏向锁的设计初衷是优化同一线程对锁的反复获取,因此它并不会立即释放偏向关系,而是假设下一次仍由同一线程获取锁。

2. 偏向锁到轻量级锁的升级流程:

  • 当第二个线程尝试获取已被偏向的锁时,它会首先校验对象头是否指向当前线程的ID。
  • 若校验失败,表明锁已偏向其他线程,此时需要撤销偏向锁。
  • 撤销后,对象会回到无锁状态或过渡至轻量级锁状态。
  • 接着,新线程会尝试在其栈帧中创建锁记录,并使用CAS操作将对象头的Mark Word替换为指向该锁记录的指针。
  • 若CAS操作成功,线程即获得轻量级锁;若失败,则进入自旋状态,循环尝试获取锁。

对于轻量级锁的释放机制:

  • 持有轻量级锁的线程在退出同步代码块时,会尝试通过CAS操作将对象头恢复为原始状态,即撤销锁记录指针的替换。
  • 若CAS操作成功,则轻量级锁被顺利释放;否则,可能需要进一步的锁升级或处理。

3. 轻量级锁到重量级锁的升级流程:

  • 当轻量级锁的持有线程退出同步代码块并释放锁时,它会尝试将对象头恢复到无锁或偏向锁状态。
  • 若存在多个线程竞争锁资源,轻量级锁的释放可能导致自旋线程长时间无法获取锁。
  • JVM会综合考量自旋次数、竞争激烈程度以及系统负载等因素,决策是否将轻量级锁升级为重量级锁。
  • 一旦升级为重量级锁,原持有线程必须完成锁的释放。新来的线程将被阻塞,并被加入对象的监视器(Monitor)等待队列,由操作系统负责线程的调度管理。

对于释放重量级锁:

  • 持有重量级锁的线程在退出同步代码块时,会通过调用Monitor的释放操作来唤醒等待队列中的下一个线程。
  • 被唤醒的线程将获得锁并继续执行同步代码,确保资源的顺序访问和线程安全

image.png

锁降级与锁消除

锁降级

锁降级通常出现在使用读写锁(如Java中的ReentrantReadWriteLock)的场景中。在多线程环境下,一个线程首先获取到了写锁,那么在它持有写锁期间,任何其他线程都无法获取读锁或写锁,确保了对该资源的独占访问权以进行修改。这个在持有写锁的同时,线程会尝试获取读锁。由于该线程已经持有写锁,所以它可以成功获取读锁,而不会造成死锁或其他同步问题。然后线程释会放写锁,但仍持有读锁。此时,其他线程可以获取读锁进行读取操作,但无法获取写锁进行写入操作。

锁降级的意义在于,线程在完成写操作后,如果接下来的任务主要是读取而不是继续写入,那么通过降级能够允许其他读线程同时访问资源,提高了系统的并发性能,同时保证了数据一致性,因为所有读线程看到的都是最近一次写操作完成后的一致性视图。锁降级是针对读写锁的一种高级使用方式,用于提升多读少写的并发场景性能。

锁消除

锁消除(Lock Elimination)是一种由编译器或虚拟机在运行时进行的优化技术,其目的是去除那些不必要的锁操作。当编译器或JVM的即时编译器(JIT Compiler)在分析代码时发现某个锁保护的变量并没有发生实际的共享数据竞争,也就是说,该变量的生命周期仅限于方法内部,不会逃逸出该方法,那么这个锁就可以安全地被消除掉。

例如,如果一段同步代码块中的变量只在栈上分配并且没有其他线程可以直接访问,那么即使对该变量进行了同步也不会带来任何好处,反而增加了上下文切换和锁获取释放的开销。在这种情况下,JVM可以通过逃逸分析等手段确定该变量不存在共享状态,进而消除对它的同步操作。

锁消除则是编译器和JVM层面的一种优化技术,用于消除不必要的同步,减少锁带来的性能损耗。

总结

Synchronized锁升级机制是Java虚拟机为优化多线程环境下同步操作性能而设计的一种动态调整策略。通过偏向锁、轻量级锁和重量级锁之间的智能转换,JVM可以根据实际的并发状况在低竞争和高竞争场景下分别采取不同的锁策略,从而有效减少线程上下文切换、内存占用以及CPU空转等问题,提升系统的整体并发性能。

偏向锁适用于单一线程反复访问同一锁的情况,轻量级锁则在轻度竞争场景下通过CAS和自旋优化锁的获取和释放,而重量级锁虽然开销较大,但在高强度竞争下提供了严格的互斥性和线程调度的公平性。

本文已收录于我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

posted @ 2024-05-21 16:38  码农Academy  阅读(654)  评论(0编辑  收藏  举报