【Java 并发编程】synchronized 加锁的四种状态与升级过程

Java 对象内存结构

Java 对象在内存中的布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

image

Java 对象头

以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段)Klass Pointer(类型指针)

  • Mark Word:默认存储对象的 HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

在上面中我们知道了,synchronized 用的锁是存在Java对象头里的,那么具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?

在64位的虚拟机中:

image

在32位的虚拟机中:

image

下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的

  • 无锁:对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放对象分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为01。

  • 偏向锁: 在偏向锁中划分更细,还是开辟 25 bit 的空间,其中,23 bit 用来存放线程 ID,2 bit 用来存放 Epoch,4bit 存放对象分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。

  • 轻量级锁:在轻量级锁中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2 bit 存放锁的标志位,其标志位为 00。

  • 重量级锁: 在重量级锁中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11。

  • GC 标记: 开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。

其中,无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。

Monitor

Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时,还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

Synchronized 是通过对象内部的一个叫做 监视器锁(monitor) 来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。

JDK 1.6 中默认是开启偏向锁和轻量级锁的,我们也可以通过 -XX:-UseBiasedLocking=false 来禁用偏向锁。

Java 中的锁状态

Java中的锁有几种状态:无锁 → 偏向锁 → 轻量级锁 → 重量级锁

image

无锁状态

程序不会有锁的竞争。那么这种情况我们不需要加锁,所以这种情况下对象锁状态为无锁。

偏向锁

偏向锁,顾名思义,它会偏向于第一个访问锁的线程

共享资源首次被访问时,JVM 会对该共享资源对象做一些设置,比如,将对象头中是否偏向锁标志位置为 1,对象头中的线程 ID 设置为当前线程 ID(注意:这里指的是操作系统的线程ID),后续当前线程再次访问这个共享资源时,会根据偏向锁标识和线程 ID 进行比对是否相同,比对成功则直接获取到锁,进入临界区域,这也是 synchronized 锁的可重入功能。

  • 如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。

    如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

  • 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。偏向锁通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。

    升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 STW 操作。

锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

轻量级锁(自旋锁)

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么,那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过 CAS 修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,然后,将当前锁的持有者信息修改为当前线程 ID。

image

重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态

当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起,等待将来被唤醒。在 JDK1.6 之前,synchronized 直接加重量级锁,很明显现在得到了很好的优化。

重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。

锁升级场景

场景1: 经常只有某一个线程来加锁。

加锁过程:也许获取锁的经常为同一个线程,这种情况下为了避免加锁造成的性能开销,加偏向锁。

偏向锁的执行流程如下:

  1. 线程首先检查该对象头的线程 ID 是否为当前线程;

    • A:如果对象头的线程 ID 和当前线程 ID 一致,则直接执行代码;

    • B:如果不是当前线程ID则使用CAS方式替换对象头中的线程 ID,如果使用 CAS 替换不成功则说明有线程正在执行,存在锁的竞争,这时需要撤销偏向锁,升级为轻量级锁。

  2. 如果 CAS 替换成功,则把对象头的线程 ID 改为自己的线程 ID,然后执行代码。

  3. 执行代码完成之后释放锁,把对象头的线程 ID 修改为空。

场景2: 有线程来参与锁的竞争,但是获取锁的冲突时间很短

当开始有锁的竞争了,那么,偏向锁就会升级到轻量级锁;

线程获取锁出现冲突时,线程必须做出决定是继续在这里等,还是先去做其他事情,等会再来看看,而轻量级锁的采用了继续在这里等的方式。

当发现有锁竞争,线程首先会使用自旋的方式循环在这里获取锁,因为使用自旋的方式非常消耗CPU。当一定时间内通过自旋的方式无法获取到锁的话,那么锁就开始升级为重量级锁了。

场景3: 有大量的线程参与锁的竞争,冲突性很高

当获取锁冲突多,时间越长的时候,线程肯定无法继续在这里死等了,所以只好先挂起,然后等前面获取锁的线程释放了锁之后,再开启下一轮的锁竞争,而这种形式就是我们的重量级锁。


参考:

posted @ 2024-01-07 23:14  LARRY1024  阅读(93)  评论(0编辑  收藏  举报