JAVA 锁

本文详细介绍了Java中的锁机制,包括内置锁、线程安全问题、临界区资源、同步方法和同步代码块。讲解了无锁、偏向锁、轻量级锁和重量级锁的状态转换及各自优缺点,强调了锁在不同竞争情况下的性能表现。同时,分析了锁的状态在对象头中的表示,以及锁膨胀和撤销的过程。最后,探讨了各种锁在实际应用中的选择和权衡。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

JAVA 锁

​ java 锁是在多线程的场景中,对于公共变量提供安全的访问

​ java 内置的锁是一个互斥锁:如果 A 线程持有锁那么其他线程如果尝试获取 A 线程的锁,那么获取锁的线程就会等待或者阻塞,如果 A 线程不释放锁那么其他线程就会一直等待或者阻塞下去

​ java 中的任何对象都可也作为锁对象。线程进入该**同步方法或同步代码块(synchronized 修饰的代码块或者方法)**的时候就会获取到该锁信息。在退出方法的时候就会释放锁。获取内置锁的唯一途经就是进入这个线程的同步代码块或者方法

线程安全问题

​ 线程安全问题是指:在线程执行某一个代码块或者方法的时候不会被其他线程打断方法的执行这就说明线程是安全的。

​ java 中最典型的操作就是自增操作(++ 操作),在自增操作中转换为字节码的指令会分开:内存取值,进行+1操作,写入到内存。每一个单独的操作都是安全的(具有原子性)但是多个操作叠加到一起导致了整个操作不具有安全性(原子性)。

​ 如果 A 线程和 B 线程同时从内存中获取到 a 值并进行了 +1 操作,但是 A 线程先写入到了内存,A 线程再次执行前一次操作,但在 A 线程第二次写入内存后,B 线程才把第一次进行的操作后的值写入到内存中,如果 A 在一次取值就不会是期望的值 3 而是 B 线程写入的值 2

临界区资源和临界区代码段

​ 在开发的时候大部分线程是串行执行的不会考虑多线程的情况下的代码并行执行。这样就会导致执行出的结果不是期望的结果

​ 一般情况下只有在多线程操作公共相同资源(变量,数组,对象)的时候才会出现这种问题

  • **临界区资源:**一种可以被多个线程使用的公共资源或者共享数据,但是每一次只能有一个线程使用,一旦有线程使用其他线程就只能等待或者阻塞
  • **临界区代码段:**在并发的情况下临界区是受保护的对象。临界区代码段(Critical Section)是没一个线程中访问临界资源的代码。多个线程必须互斥的对其中的资源进行访问
  • **竞争条件:**是线程进入临界区代码段没有互斥访问进入临界区的必须条件

synchronized 关键字

​ synchronized 是 java 中的关键字。每一个 java 对象都隐藏含有一把锁,java 的内置锁(对象锁,隐式锁),使用 synchronized 关键字相当于调用其内置锁

// 修饰方法是获取的调用当前方法的对象的内置锁
public synchronized void selfPlus(){}
// 设置同步代码块 获取的指定对象的内资锁
synchronized (Object){}
// 设置静态代码块是静态类的 Class 对象
public static synchronized void selfPlus(){}

​ 使用 synchronized 后如果在进入代码块的时候会先获取对应的内置锁对象,如果该对象的锁为被释放就会导致其他线程进入阻塞状态

java 对象结构和内置锁

​ java 的对象由对象头,对象体,对齐字节组成

  • 对象头

    • Mark Word 标记字:用于存储运行时数据(GC 标志位,哈希码,锁状态):虚拟机操作对象需要的数据
    • Class Pointer 类对象指针:用于存储 Class 对象的地址,是虚拟机确定对象是哪一个类的实例
    • Array Length 数组长度:当 Java 对象是数组必须有
  • 对象体

    • 包含对象的实例变量,用于成员的属性,包括父类的属性。
  • 对齐字节

    • 保证 Java 对象的占内存的字节数为 8 的倍数。如果对象不为 8 的倍数就会使用对齐字节

java 内置锁的表示

  • lock(2 字节)锁状态标记。
  • biased_lock (1 字节)对象是否启用偏向锁标记。这两个标记位表示对象实例处于什么锁状态
状态biased_locklock
无锁001
偏向锁101
轻量级锁000
重量级锁010
GC 标志位011
  • age(4 位最大值为 15):对象分代年龄,在 JVN 堆中的 yang 区,转换一次就加 1
  • identity_hsahcode:31 位的对象表示 HashCode 码。只有调用 Object.hashCode 方法获取 System.identityHashCode 方法计算对象的对象头中。当对象被锁定的时候 会移动带 Nonitor(监视器)中
  • thread:54 位的线程 ID 为持有偏向锁的线程 ID
  • epoch:偏向时间戳
  • ptr_to_lock_record:62 位,在轻量级锁的状态下指向栈帧中锁记录的指针(枪锁的次数)
  • ptr_to_heavyweight_monitor:62 位,在重量级锁状态下指向对象的监视器指针

大小端问题

大端模式:数据字节数越大就会存在低地址中。主要用于网络中传输数据

小端模式:数据字节数越大就会存在高地址中。主要用于 CPU 计算过程中

JAVA 的锁的状态

​ 在 JDK1.6 版本前,JAVA 中只有重量级锁。但是重量级锁会导致 CPU 频繁在内核态和用户态之间切换,代价高,效率低。为了解决重量级锁的问题 JAVA 在 JDK 1.6 中引入了偏向锁和轻量级锁的实现。JDK 1.6 中 java 锁的状态分为 无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁,4 总状态。但是锁只能升级不能降级。目的是为了提高获得锁和释放锁的效率

JAVA 锁状态下的对象的标记字(Mark Work)

  • 无锁

    25位(未使用) | 31 位(Hash Code)| 1 位(未使用)| 4 位(分代年龄)| 0 | 01

  • 偏向锁

    54位(线程 ID) | 2 位(epoch)| 1 位(未使用)| 4 位(分代年龄)| 1 | 01

  • 轻量级锁

    62位(锁记录指针)| 00

  • 重量级锁

    62 位(Monitor 对象指针(锁监视器对象))| 10

偏向锁

​ 偏向锁就是不存在线程竞争的一个线程获得了锁,那么锁就进入到了偏向的状态,此时的 mark work 结构就会变为偏向锁的结构,锁对象的标志位(lock)就会变成 01,偏向标志(biased_lock)就会被改为 1,然后将线程 ID 记录到 Mark Work 中(使用 CAS 修改)。此后该线程如果进入到这个同步代码块就只需要对比线程 ID 就可以了,不需要在一次切换为内核态

​ 主要是消除无竞争下的同步代码的效率问题,如果有线程竞争那么偏向状态就会立即进入到轻量级锁状态

​ 运行原理:在无竞争的环境下,获取锁的线程进入到了同步代码块中就会获取锁对象,判断锁对象记录的线程 ID 是否和当前进入的线程 ID 相同。如果相同就不用再次获取锁而是直接进入到同步代码块中,如果不是当前线程就会采用 CAS 操作将 Mark Work 中的线程ID 设置为当前线程 ID,如果成功就会获取锁成功进入到同步代码块,如果 CAS 失败,表示有竞争就会挂起当前线程,膨胀为轻量级锁

​ 缺点:如果锁对象经常有多线程竞争那么锁膨胀会带来性能开销

​ 备注:JVM 会在启动的时候延迟启动偏向锁机制(JVM 在启动的时候会加载大量的资源,如果启动时使用偏向锁会带来不必要的资源消耗)可以通过 -XX:+UseBiasedLocking -XX:+BiasedLockingStartupDelay=0 设置偏向锁延迟启动时间

java -XX:+UseBiasedLocking -XX:+BiasedLockingStartupDelay=0 mainclass

偏向锁的膨胀和撤销

​ 假如多个线程竞争偏向锁,此时对象锁已经发生了偏向,其他线程发现偏向锁并不是偏向自己说明发生了竞争。就会尝试撤销偏向锁(引入到安全点进行撤销)然后膨胀为轻量级锁

偏向锁的撤销
  1. 在一个安全点(不会出现线程竞争的地方)停止拥有该锁的线程
  2. 遍历线程栈帧检查是否存在锁记录:如果存在记录就会清空锁记录,使其变为无锁状态,并修复锁记录指向的 Mark Work 清除其他线程 ID
  3. 将当前锁升级为轻量级锁
  4. 唤醒当前线程

如果存在线程竞争就需要将偏向锁的功能关闭

撤销偏向锁的条件
  1. 多个线程对偏向锁竞争
  2. 调用偏向锁对象的 hashcode 方法或 System.identityHashCode 方法重新计算对象的 hashcode 值,将哈希码放入到对象的 Mark Work 中。内置锁就会变为无锁状态,偏向锁就会撤销
偏向锁的膨胀

​ 如果偏向锁被占据,一旦有第二个线程争抢这个对象,应为偏向锁不会主动释放,所以第二个线程可以看见内置锁的偏向状态,标明这个锁对象已经存在竞争。

​ JVM 会检查原来持有锁对象的线程是否存活,如果不存活就会把锁对象设置为无锁状态,然后重新偏向。如果不存活。就会进一步检查占有线程的调用栈是否通过锁记录持有偏向锁。如果存在锁记录就表明原线程还在使用偏向锁,发生锁竞争就会撤销原来的偏向锁膨胀为轻量级锁

偏向锁的好处

​ 解决了在低线程竞争下的操作系统在内核态和用户态之间的切换,在 JAVA 中大部分的同步代码块进入的线程都是同一个线程,这就是引入偏向锁的原因

轻量级锁

​ 轻量级锁出现的目的就是尽量避免使用操作系统的互斥锁,因为互斥锁的性能较差,线程的阻塞和唤醒都需要 CPU 从用户态转为内核态。频繁的阻塞和唤醒对 CPU 是一件负担很重的工作。轻量级锁的引入就是解决短时间内唤醒和阻塞线程的状态。轻量级锁是通过自旋解决线程的同步问题的

​ 轻量级锁的执行过程:在枪锁进程进入进入临界区之前,如果内置锁(临界区的同步对象)没有被锁定,JVM 就会在枪锁的栈帧中建立一个锁记录(Lock Record)用于存储对象目前的 Mark Work 的拷贝。

​ 枪锁线程如果获取锁就会尝试使用 CAS 自旋操作将锁对象的 Mark Word 的 ptr_to_lock_record(锁记录指针)更新为枪锁线程栈帧中锁记录的地址,如果更新执行成功了,就说明线程拥有了这个对象锁,然后 JVM 将 Mark Word 中的 lock 标记位改为 00(轻量级锁标志位)即表示该对象进入到了轻量级锁状态,枪锁成功后将 Mark Word 中原来的锁对象信息(哈希码…)保存到枪锁线程的锁记录的 Displaced Mark Work 字段中,在将锁记录的 owner 指向锁对象

​ 锁记录是线程私有的,每一次枪锁就会记录一份锁的记录,会将锁对象的 Mark Work 复制到锁记录的 Displaced Mark Word 字段中,是因为内置锁对象的 Mark Word 会有所变化,Mark Work 将会出现一个指向锁记录的指针,而不再存着无锁状态下的对象的哈希码等信息,所以将锁的 Mark Word 存入到线程的锁记录中,方便释放锁的时候使用

轻量级锁的分类

  • 普通自旋锁

    指当有线程竞争锁的时候,枪锁的线程就会原地循环等待,而不是被阻塞,知道占有线程释放锁后枪锁线程才可以获取锁,默认情况下自旋 10 次可通过 -XX:PreBlockSpin 选项更改

  • 自适应自旋锁

    等待的空循环的次数不是固定的而是动态根据实际情况更改循环次数。是通过同一个锁的自旋实际以及锁拥有者的状态决定

    如果成功获取过锁就可能在一次获取到锁,就会延长循环时间

    如果枪锁线程很少成功获取到锁,就会减少循环时间甚至省略自旋过程

自旋锁锁整个过程会将线程空循环等待而不是将线程阻塞挂起所以被称为非阻塞同步、乐观锁。在 JDK 1.6 前需要手动打开自旋锁 -XX:+UseSpinning 但是在 JDK1.7 后轻量级锁就使用自适应了,JVM 启动是开启

轻量级锁膨胀

​ 轻量级锁是解决临界代码执行时间短的场景下。如果指向同步代码块的时间过长就会导致 CPU 消耗过大(很多的空循环)。

​ 轻量级锁是为了减少多线程进入操作系统底层的互斥锁(Mutex Lock)的概率,不能替代互斥锁,所以在竞争激烈的情况下轻量级锁为基于操作系统内核互斥锁实现的重量级锁

重量级锁

​ JVM 中每一个对象都关联一个对象监视器,包含 Object 实例和 Class 实例。监视器相当于一个同步工具或者许可证,只有拥有许可证的线程才可以进入临界区进行操作,没有就会阻塞等待,保证了任何时间只允许一个线程通过受到监视器的临界区代码(执行临界区代码并释放锁)

​ 重量级锁就是通过对象监视器实现的一种同步机制主要特点

同步:临界区的代码是互斥执行的。一个监视器是一个运行许可,在线程进入临界区代码的时候都需要获得这个许可,退出的时候归还

协作:监视器提供看 Signal 机制,运行持有线程可以短暂的放弃许可,使其进入阻塞状态,等待其他线程发送 Signal 去唤醒;其他拥有许可的线程可以发送 Signal 唤醒正在等待的线程,让其他现新获取许可并执行

重量级锁的属性

WaitSet:拥有 Object Monitor 的线程调用 Object.wait() 方法后被阻塞,然后将线程放置在 Wait Set 链表中

Cxq

​ 竞争队列(Contention Queue)所有请求锁的线程首先被放入竞争队列中

​ Cxq 是虚拟的队列,是在于 Cxq 是由 Node 及其 next 指针逻辑构成的,并不是队列结构,但是每一次加入新的 Node 会在 Cxq 的对头进行,通过 CAS 改变第一个节点的指针指向新增的节点,同时设置 Node 的 next 节点,从 Cxq 取得元素是会在对位获取,Cxq 是一个无锁结构。

​ 因为只有 owner 线程才能从队尾获取元素,线程的出列操作无竞争。

​ 在线程进入 Cxq 时会通过 CAS 自旋获取锁,如果获取不到锁就会进入 Cxq 队列,对于已经在 Cxq 中的线程就是不公平的,所有 synchroniezd 使用的是非公平锁

EntryList

​ Cxq 中那些资源有资格成为候选资源线程被移动到 EntryList 中

​ Cxq 会被线程并发访问,为了降低对 Cxq 队尾的争用而建立 EntryLis 在 Owner 线程释放锁时,JVM 会从 Cxq 迁移线程到 EntryList。并指定获取到锁的线程。所有 EntryList 是为候选线程而存在的

OnDeck Thread 与 Owner Thread

​ JVM 不直接把锁传递到 Owner Thread,而是把锁竞争的权利交个 OnDeck Thread,OnDeck 需要重新竞争锁,提高了吞吐量但是牺牲了一些公平性、在 JVM 中这种行为称为“竞争切换”

​ OnDeck Thread 线程获取到锁资源后会成为 Owner Thread,其他在 EntryList 中线程任然在其中位置不会发生改变

​ OnDeck Thread 在成为 Owner 过程中可能会被后来的线程通过 CAS 操作直接成为 Owner 而枪到锁

Wait Set

​ Owner 会因为 Object.Wait() 进入到 Wait Set 中,在使用 Obect.notify() 获取 Obect.notifyAll() 后加入到 Entry Set 中

重量级锁的开销

​ 处于 ContentionList、EntryList、waitSet 中的线程都处于阻塞状态,线程的阻塞和唤醒都需要使用到操作系统完成,进程需要从用户态切换为内核态。

​ 用户态对 CPU资源协调,内存分配、调用系统资源有限制,但是内核态可以任意操作系统资源

​ 进程可以通过硬件中断。系统调用,异常从用户态切换到内核态

​ 为了应用程序访问到内核管理的资源就需要应用程序调用内核提供的访问接口实现,这些接口就是系统调用。pthread_mutex_lock 就是系统调用提供的 Linux 内核下互斥锁的访问机制。所以切换需要消耗时间

偏向锁、轻量级锁、重量级锁的对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳米的差距线程存在竞争会带来额外的锁的消耗适用于只有一个线程访问的临界区场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度抢不到锁竞争的线程使用 CAS 自旋等待,会消耗 CPU锁占用时间短,吞吐量低
重量级锁线程不使用自旋,不消耗 CPU线程阻塞,响应时间缓慢锁占用时间长,吞吐量高
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值