Skip to content

Java锁机制

核心概念

锁分类

乐观锁与悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。

  • 悲观锁(Pessimistic Locking):假设会有其它线程竞争,所以会先加锁,再操作数据。

    • 实现:synchronized、ReentrantLock、select for update
  • 乐观锁(Optimistic Locking):假设没有其它线程竞争,所以不会加锁,只是获取数据的时候会判断有没有被其他线程变更过。

    • 实现:CAS 算法(AtomicInteger)、Version版本控制

自旋锁与适应性自旋锁

自旋锁(Spin Lock)是一种简单的锁机制,当一个线程尝试获取自旋锁时,如果锁已经被其他线程持有,线程将不断循环(自旋)检查锁的状态,直到锁变为可用。

  • 主要用在持锁时间非常短业务场景和CPU资源充足的的情况下。

  • 实现:CAS 算法(AtomicInteger)

  • 自旋次数默认是10次,可通过-XX:PreBlockSpinJVM参数配置。

  • 自旋锁在 JDK1.4.2 中引入,使用-XX:+UseSpinning来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

    • 自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
      • 如果之前成功通过自旋获取过锁,那么这次虚拟机会愿意等待更长时间,即增加自旋次数。
      • 如果之前通过自旋很少成功获得过锁,那么获取这个锁时可能会省略掉自旋过程,直接休眠当前线程,CPU切换其它线程执行。

可重入锁与非可重入锁

可重入锁是指线程在同一代码执行路径上,可以多次获取同一把锁而不会导致自己阻塞。也就是说,持有锁的线程可以再次获得该锁。这种机制避免了同一线程因反复进入同步代码块而被自己阻塞的情况。

  • 可重入锁本质上是用来防止一种特殊的死锁情形

    • 即同一线程再次尝试获取它已经持有的锁时,如果没有可重入机制,这种情况会导致自己与自己产生死锁。
    • 可重入锁可以避免单个线程死锁的场景,但是它不能防止多个线程之间发生死锁。
  • 实现:synchronized、ReentrantLock

公平锁与非公平锁

这里的“公平”,其实通俗意义来说就是“先来后到”,也就是 FIFO(先进先出-First In First Out)。

  • 公平锁按顺序获取锁,性能较低
  • 非公平锁性能较高,但可能导致线程饥饿(有一些线程长时间得不到锁)
  • ReentrantLock 支持非公平锁和公平锁两种

读写锁与排它锁

  • 排它锁(独享锁):同一时刻只允许一个线程进行访问。
    • 实现:synchronized、ReentrantLock
  • 读写锁(共享锁):同一时刻允许多个读线程访问,但是写线程还是只允许一个。适用于“读多写少”场景。
    • 实现:ReentrantReadWriteLock

CAS

CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。

工作原理:(值匹配)

  • 读取一个当前值,使用当前值与期望值比较
  • 如果当前值与期望值相等,则可更新为新值。

实现:java.util.concurrent.atomic.AtomicInteger#compareAndSet

ABA的问题

如果一个位置的值原来是 A,后来被改为 B,再后来又被改回 A,那么进行 CAS 操作的线程将无法知晓该位置的值在此期间已经被修改过。

  • 可以使用版本号/时间戳的方式来解决 ABA 问题。
  • 在CAS值匹配的基础上要求版本号匹配

循环性能开销的问题

自旋的 CAS,如果一直循环执行,一直不成功,会给 CPU 带来非常大的执行开销。

  • 限制自旋次数:自旋次数默认是10次,可通过-XX:PreBlockSpinJVM参数配置。

AQS

AQS,全称是 AbstractQueuedSynchronizer,中文意思是抽象队列同步器,由 Doug Lea 设计,是 Java 并发包java.util.concurrent的核心框架类,许多同步类的实现都依赖于它,如 ReentrantLock、Semaphore、CountDownLatch 等。

  • AQS 的思想是,如果被请求的共享资源空闲,则当前线程能够成功获取资源;否则,它将进入一个等待队列,当有其他线程释放资源时,系统会挑选等待队列中的一个线程,赋予其资源。

  • 整个过程通过维护一个 int 类型的状态和一个先进先出(FIFO)的队列,来实现对共享资源的管理。

  • AQS 支持两种同步方式:

    • 独占模式:这种方式下,每次只能有一个线程持有锁,例如 ReentrantLock。
    • 共享模式:这种方式下,多个线程可以同时获取锁,例如 Semaphore 和 CountDownLatch。

JMM(Java Memory Model)

JMM(Java内存模型)主要定义了多线程环境下的内存可见性和指令执行顺序,确保线程间共享变量的正确访问,防止数据不一致和指令重排的问题。

JMM 定义了线程内存和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了共享变量的副本,用来进行线程内部的读写操作。

volatile

volatile 关键字主要有两个作用,一个是保证变量的内存可见性,一个是禁止指令重排序。

保证变量的内存可见性:(通过读写屏障实现)

  • 当线程对 volatile 变量进行写操作时,JMM 会在写入这个变量之后插入一个 Store-Barrier(写屏障)指令,这个指令会强制将本地内存中的变量值刷新到主内存中。
  • 当线程对 volatile 变量进行读操作时,JMM 会插入一个 Load-Barrier(读屏障)指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。

禁止指令重排序:(确保写操作在读操作之前,即happens-before规则)

  • 在程序执行期间,为了提高性能,编译器和处理器会对指令进行重排序。

  • 但涉及到 volatile 变量时,它们必须遵循一定的规则:

    • 写 volatile 变量的操作之前的操作不会被编译器重排序到写操作之后。

    • 读 volatile 变量的操作之后的操作不会被编译器重排序到读操作之前。

synchronized的锁升级

锁升级是 Java 虚拟机中的一个优化机制,用于提高多线程环境下 synchronized 的并发性能。锁升级涉及从较轻的锁状态(如无锁或偏向锁)逐步升级到较重的锁状态(如轻量级锁和重量级锁),以适应不同程度的竞争情况。

synchronized 锁的状态

  • 无锁状态:
    • 当一个对象没有被任何线程锁住时,它处于无锁状态。
  • 偏向锁(Biased Locking):
    • JVM 通过偏向锁来优化对资源的访问。当一个线程首次获取锁时,JVM 会将锁标记为偏向锁,并在对象头中记录持有该锁的线程 ID。此后,该线程再次获取该锁时无需进行同步操作,性能更高。
    • 偏向锁的存在旨在优化场景中只有一个线程频繁获取同一把锁的情况。
  • 轻量级锁(Lightweight Locking):
    • 当一个偏向锁被其他线程请求时,偏向锁会被撤销,并且转变为轻量级锁。在轻量级锁下,JVM 会使用 CAS(Compare And Swap)操作来尝试获取锁。
    • 如果获取成功,则锁被标记为轻量级锁。如果有其他线程同时争夺这把锁,则轻量级锁会被膨胀为重量级锁。
  • 重量级锁(Heavyweight Locking):
    • 当锁被升级为重量级锁时,线程会进入阻塞状态,使用操作系统的互斥量(mutex)机制来控制对共享资源的访问。此时,其他尝试获取该锁的线程会被阻塞,直到持有锁的线程释放锁。

锁的升级流程

  1. 初始状态:一个对象处于无锁状态。
  2. 获取偏向锁:当一个线程首次获取该锁时,JVM 将该锁标记为偏向锁。如果该线程在后续操作中继续使用该锁,则它可以快速地重复获取。
  3. 请求偏向锁:如果其他线程试图获取这个偏向锁,偏向锁将被撤销,转而使用轻量级锁机制。
  4. 尝试轻量级锁
    • 使用 CAS 操作尝试获取锁。如果成功,则锁变为轻量级锁,线程可以继续执行。
    • 如果失败,说明其他线程正在持有锁,此时轻量级锁会膨胀为重量级锁。
  5. 请求重量级锁:一旦轻量级锁变为重量级锁,所有等待的线程将被阻塞,直到持锁的线程释放锁。

锁降级

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World(Java 垃圾回收中的一个重要概念,JVM 篇会细讲)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

synchronized

ReentrantLock

参考

https://javabetter.cn/sidebar/sanfene/nixi.html