谈谈 kotlin 和 java 中的锁!你是不是在协程中使用 synchronized?

我准备在这里胡说八道的时候,其实初衷只是有以下几个疑问:

  • synchronized 可以保证协程安全?
  • 为啥 kotlin 项目里他们就直接使用了 synchronized 呢?
  • synchronized vs mutex

我认为理清以上之后,打算通过下面几部分,把锁在这一篇文章中彻底说明白「或者说尽量说明白,又或者说让我自己明白就行」,争取由浅入深且出错不多,若看到错误希望大家不吝惜文字,评论区中多多讨论。

  • java 中的锁
  • kotlin 中的锁
  • 锁在底层的实现
  • 怎么选择该用什么锁
  • 一些不得不说明的概念,可以提前阅读的部分

Java 中的锁分类

按照锁的共享粒度

  • 独占锁 该锁一次只能被一个线程所持有。

    eg: synchronized/ReentrantLock。

  • 共享锁 该锁可以被多个线程所持有。

    eg: 读写锁 ReentrantReadWriteLock 中的读锁 ReadLock 是共享锁。

按公平性

  • 公平锁 多个线程相互竞争时要排队,多个线程按照申请锁的顺序来获取锁。

    eg: ReentrantLock 的公平模式(通过构造函数指定)。

  • 非公平锁 多个线程相互竞争时,先尝试插队,插队失败再排队。

    eg: synchronized、ReentrantLock。

按锁的实现机制

Java 对象锁 synchronized 在 JVM 内部的不同实现阶段,是通过锁升级来实现的。

DK 1.6 为了减少获得锁和释放锁所带来的性能消耗,在JDK 1.6里引入了3种锁的状态:偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。

  1. 偏向锁

    偏向锁是针对没有竞争的情况优化的锁。

    如果一个线程获取了锁,对象会记录该线程的 ID,后续该线程再次获取锁时,不需要任何同步操作(如 CAS 操作),直接获取锁,性能最高。

    当有其他线程试图竞争该锁时,偏向锁会升级为轻量级锁。
    特点:偏向于单线程执行,无需锁竞争。

  2. 轻量级锁

    当锁有竞争时,偏向锁会升级为轻量级锁。

    轻量级锁使用 CAS(Compare-And-Swap)操作来尝试获取锁。若线程获取成功,执行代码;若获取失败,则线程会自旋等待锁释放。
    特点:适合竞争不激烈的场景,减少线程挂起和唤醒的代价。

穿插一个概念:自旋锁

自旋锁是指线程在获取锁失败时,不立即挂起,而是执行一段空循环(自旋),尝试重新获取锁。

自旋锁适用于线程持有锁的时间很短 的场景,避免了线程挂起和唤醒的开销。

JVM 的轻量级锁会利用自旋锁,在一定次数自旋后如果还未获取锁,就会升级为重量级锁。

自旋锁是轻量级锁的一部分策略,用于短时间等待锁释放。CAS 是轻量级锁实现的核心,提供无锁的原子操作。在自旋锁中,CAS 是实现锁状态更新的核心技术。当多个线程竞争锁时,自旋锁通过 CAS 判断当前锁是否可用,并在锁可用时将其状态更新为已占用。

自旋锁 CAS
是一种锁机制,强调等待策略(自旋)。 是硬件支持的原子操作。
用于在竞争条件下协调线程的访问。 用于实现无锁状态的更新。
内部常使用 CAS 判断和更新锁状态。 可单独用于原子变量的更新(如计数)。
  1. 重量级锁
    如果自旋等待的线程越来越多,或者线程自旋的时间过长,轻量级锁会升级为重量级锁。 重量级锁通过操作系统的互斥量(Mutex)实现,会导致线程挂起和上下文切换,性能较低。 特点:适合高竞争场景,但代价较高。

按锁的性能优化方式

  • 自旋锁 线程在尝试获取锁时,不直接阻塞,而是自旋一段时间再尝试获取。典型代表:JVM 的轻量级锁使用自旋锁。

  • 无锁 基于 CAS 实现,没有真正的锁,适用于简单的原子操作。典型代表:AtomicInteger、AtomicReference。

  • 读写锁 区分读操作和写操作,读操作可以并发,写操作需要独占。典型代表:ReentrantReadWriteLock。

按锁的可中断性

  • 可中断锁 可以在获取锁时响应中断,避免线程一直等待。典型代表:ReentrantLock 提供的 lockInterruptibly() 方法。

  • 不可中断锁 线程在等待锁时无法响应中断。典型代表:synchronized。

按锁的可重入性

  • 可重入锁 一个线程获取锁后,可以再次获取该锁(递归调用时无需阻塞)。典型代表:ReentrantLock、synchronized。特点:避免死锁问题,适用于递归调用或多方法协作场景。

  • 非可重入锁 一个线程获取锁后,如果再次尝试获取,会发生死锁。典型代表:java.util.concurrent.locks.Lock 接口的某些实现。

按是否为显式锁

  • 显式锁 开发者需要手动控制锁的获取和释放。典型代表:ReentrantLock、ReadWriteLock。灵活性更高,支持尝试加锁、超时加锁等高级功能。需要显式调用 lock() 和 unlock() 方法来加锁和解锁,容易出现忘记释放锁的问题。

  • 隐式锁 由 JVM 自动管理,无需开发者手动处理。典型代表:synchronized。使用简单,只需在方法或代码块前加 synchronized 关键字。自动释放锁(如方法或代码块执行结束时)。

还有其他的分类标准,在此不赘述。

JVM 平台的锁实现

  • synchronized
  • ReentrantLock / ReentrantReadWriteLock
  • 基于 CAS 的无锁机制:java 提供的 java.util.concurrent.atomic 包
  • StampedLock:Java 8 引入的一种优化锁,支持三种模式:
    • 写锁:独占锁。
    • 读锁:允许多个线程访问。
    • 乐观读:非阻塞读取。

synchronized 详解

在 Java 中,synchronized 是一种重量级锁,属于 JVM 提供的内置同步机制,用于保证多线程环境下的共享资源访问安全。其实现依赖 JVM 的内置机制,如对象头(Object Header)和监视器(Monitor)。

  1. JVM 对象内存布局

在 JVM 中,每个对象在内存中分为以下几部分:

  • 对象头
    • Mark Word

      • 存储对象的运行时数据,例如锁状态、GC 标记、哈希值等。
      • Mark Word 是一个 32 位或 64 位字段(取决于 JVM 位数)。
      • 根据对象的状态(如锁状态、垃圾回收阶段)内容会有所不同。
    • Klass Pointer

      • 指向对象所属类的元数据,表示对象的类型。通常是一个指针的大小(4 字节或 8 字节)。
锁状态 Mark Word 内容 标志位 线程 ID(可能包含)
无锁 对象哈希码、GC 信息 01
偏向锁 持有锁的线程 ID 和时间戳 01
轻量级锁 指向线程栈中锁记录的指针 00
重量级锁 指向 Monitor 的指针 10
GC 标记 GC 信息 11
  • 实例数据(Instance Data)

    • 实例数据是对象的主要部分,存储对象的实际字段(成员变量)值。
    • 按字段在类中声明的顺序存储。优先分配基本数据类型,按字节对齐规则存储(以提高访问效率)。
    • 对象的字段值,包括基本类型和引用类型的指针。
  • 对齐填充(Padding)

    • 为了满足内存对齐要求,填充无意义的字节。JVM 通常要求对象的起始地址是 8 字节或 16 字节的整数倍。减少内存碎片,提高访问效率。
  1. 实现机制
  • 偏向锁
    • 如果一个线程首次访问对象,JVM 将线程 ID 写入对象头的 Mark Word。
    • 后续该线程访问时,只需检查 Mark Word 是否匹配,无需进行 CAS 操作。
    • 如果另一个线程尝试获取偏向锁,则撤销锁,升级为轻量级锁。
  • 轻量级锁
    • 线程尝试通过 CAS 操作将对象头的 Mark Word 替换为指向线程栈中锁记录的指针。
    • 如果 CAS 失败,表示存在锁竞争,线程会自旋尝试获取锁。
    • 线程在短时间内自旋尝试获取锁,避免线程阻塞。自旋的次数由 JVM 参数 -XX:PreBlockSpin 决定。
  • 重量级锁
    • 当自旋失败次数超过限制,锁升级为重量级锁,所有竞争锁的线程都会进入阻塞状态。
    • JVM 为每个对象关联一个 Monitor,阻塞线程会进入 Monitor 的等待队列。
    • 当锁释放时,Monitor 负责唤醒等待队列中的线程。
  1. 从字节码角度解析

synchronized 的实现依赖 JVM 指令集中的 Monitor 操作。

java 复制代码
package com;
public class tetst {
    int a = 0;
    public synchronized void aaa(){
        System.out.println("Inside synchronized block");
        a= 1;
    }


    public void bbb(){
        synchronized (this){
            a= 2;
        }
    }

}

上述代码,先执行 javac tetst.java,再执行 javap -c tetst,可输出如下:

kclass 复制代码
Compiled from "tetst.java"
public class com.tetst {
  int a;

  public com.tetst();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2                  // Field a:I
       9: return

  public synchronized void aaa();
    Code:
       0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #4                  // String Inside synchronized block
       5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: aload_0
       9: iconst_1
      10: putfield      #2                  // Field a:I
      13: return

  public void bbb();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: aload_0
       5: iconst_2
       6: putfield      #2                  // Field a:I
       9: aload_1
      10: monitorexit
      11: goto          19
      14: astore_2
      15: aload_1
      16: monitorexit
      17: aload_2
      18: athrow
      19: return
    Exception table:
       from    to  target type
           4    11    14   any
          14    17    14   any
}
  • 同步代码块,字节码中包含 monitorentermonitorexit 指令。
  • 同步方法,不需要显式的 monitorenter 和 monitorexit 指令。JVM 会在方法的访问标志中添加 ACC_SYNCHRONIZED 标志。

ps.上面看不到 ACC_SYNCHRONIZED 标志,在反编译时更换为语句:javap -v -c tetst 即可。使用 -v(verbose)选项获取更详细的字节码信息。

  1. Monitor 详解

Monitor 是 JVM 内部的一种同步结构,用来实现 Java 中的同步机制。存储在 JVM 的堆或方法区中的专用数据结构里。Monitor 组成:

  • Owner:当前持有锁的线程。
  • Entry List:等待进入 Monitor 的线程列表。
  • Wait Set :调用 wait() 的线程列表。
  • 计数器:记录锁的重入次数。每次同一线程获取锁时,计数器递增。每次释放锁时,计数器递减。当计数器降为 0 时,锁才真正释放,其他线程才能获得锁。

在 JVM 层面,synchronized 是通过 对象头中的 Mark WordMonitor 实现的。

  • 重入计数器Monitor 中维护了一个计数器来记录锁的重入次数。

    • 每次同一线程获取锁时,计数器递增。
    • 每次释放锁时,计数器递减。
    • 当计数器降为 0 时,锁才真正释放,其他线程才能获得锁。

小结:

  • synchronized 基于对象头的 Mark Word 和 Monitor 实现。支持锁状态的动态升级机制。
  • synchronized 适用于线程数少、锁竞争低的场景。
  • 高并发场景下,可以考虑使用更高效的锁实现(如 ReentrantLock 或无锁数据结构)。
  • 当然了,如果是 kotlin,还是使用其他方式来避免使用锁。

ReentrantLock 详解

ReentrantLock 是 Java 中一种灵活且高效的锁机制,属于 java.util.concurrent.locks 包的一部分。相比 synchronized,它提供了更细粒度的控制能力,如可重入性、可中断性、非阻塞尝试、超时获取等。

  • 可重入性:允许同一线程多次获取同一把锁。
  • 支持公平与非公平模式
  • 支持中断:支持线程在等待锁的过程中响应中断信号,通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。
  • 提供 tryLock 方法,可以立即返回锁定结果或超时等待。
  • 支持显式加锁和解锁,避免隐式释放锁的限制。

ReentrantLock 是基于 AbstractQueuedSynchronizer(AQS)实现的,可将其视为一个增强版的互斥锁。

  • private final Sync sync;
  • Sync 是 ReentrantLock 的核心,负责具体的加锁、解锁逻辑,继承自 AQS。分为 FairSync 公平锁 和 NonFairSync 非公平锁 两种实现。
  1. AQS 详解

AbstractQueuedSynchronizer 是 java.util.concurrent 包的核心组件,用于构建锁和同步器。

  • 状态字段:int state,表示同步状态,对于 ReentrantLock,state 的值记录了锁的重入次数。
  • 等待队列:AQS 维护了一个 FIFO 的双向链表,存储等待获取锁的线程。
  • 条件队列:是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾
  • 通过 acquire 和 tryAcquire 尝试获取锁。
  • 通过 release 和 tryRelease 释放锁。
  • 当获取锁失败时,线程会被加入到 AQS 的等待队列中,并进入阻塞状态。

获取资源失败入队、线程唤醒、线程的状态等,AQS 已经实现好,实现 AQS 的子类的任务是:

  • 通过CAS操作维护共享变量state
  • 重写资源的获取方式
  • 重写资源释放的方式

AQS 的这种设计模式也是模版方法模式。

  1. ReentrantLock 代码实现

非公平模式:

java 复制代码
static final class NonfairSync extends Sync {

    final boolean initialTryLock() {
        Thread current = Thread.currentThread();
        if (compareAndSetState(0, 1)) { 
        //优先尝试抢占锁而不是按队列顺序等待
            setExclusiveOwnerThread(current);
            return true;
        } else if (getExclusiveOwnerThread() == current) {
        //如果当前线程已经持有锁,只需增加 state 的值即可。体现可重入性
            int c = getState() + 1;
            if (c < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(c);
            return true;
        } else
            return false;
    }

    protected final boolean tryAcquire(int acquires) {
        if (getState() == 0 && compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
        return false;
    }
}

公平模式

java 复制代码
static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;

    
    final boolean initialTryLock() {
        Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
        //检查当前线程是否有前驱节点。如果有,则进入等待队列。
            if (!hasQueuedThreads() && compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        } else if (getExclusiveOwnerThread() == current) {
        //如果当前线程已经持有锁,只需增加 state 的值即可。体现可重入性
            if (++c < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(c);
            return true;
        }
        return false;
    }

    protected final boolean tryAcquire(int acquires) {
        if (getState() == 0 && !hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
        return false;
    }
}

AQS 中大量使用了 CAS(Compare-And-Swap)操作,确保状态修改的原子性。 CAS 是通过 JVM 提供的 Unsafe 类实现的。是基于硬件实现的无锁操作,直接操作内存地址,性能更高。

synchronized vs. ReentrantLock

特性 synchronized ReentrantLock
是否支持重入
实现方式 通过JVM实现,其中synchronized又有多个类型的锁,除了重量级锁是通过monitor对象(操作系统mutex互斥原语)实现外,其它类型的通过对象头实现。 基于 AQS(AbstractQueuedSynchronizer)
锁释放 隐式释放(方法或代码块结束) 必须显式调用 unlock()
公平性选择 不支持,默认非公平 支持公平和非公平模式
中断响应 不支持 支持
超时获取锁 不支持 支持(tryLock(timeout)

底层实现 上来说,synchronized 是JVM 层面的锁,是Java关键字 ,通过monitor对象来完成(monitorenter与monitorexit),对象只有在同步块或同步方法中才能调用wait/notify方法,ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁,通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

synchronized不能绑定条件Condition; ReentrantLock通过绑定Condition结合await()/singal()方法实现线程的精确唤醒,而不是像synchronized通过Object类的wait()/notify()/notifyAll()方法要么随机唤醒一个线程要么唤醒全部线程。

Kotlin 协程的锁实现

Kotlin 运行在 JVM 上,继承了 Java 的大部分锁实现,同时结合协程提供了一些新的并发工具。

  • Mutex:Kotlin 协程提供的轻量级锁,不会阻塞线程,而是挂起协程。
  • Channel:用于线程安全的数据传递,避免传统锁的竞争。实现基于无阻塞队列,提供多种缓冲模式,通过挂起与恢复机制实现高效的协程通信。
  • Actor:通过单线程状态访问避免锁竞争,适用于复杂的状态管理。是对 Channel 的进一步封装,专注于通过单线程消息驱动的方式,实现线程安全的状态管理和逻辑封装。

Channel 和 Actor 并不是真正意义的锁,而是可以实现与锁类似的功能。另外目前来看,应优先使用 Flow/SharedFlow/StateFlow,只有某些特定场景才适合使用 Channel 和 Actor,在此不赘述。

Mutex 详解

在 Kotlin 中,Mutex 是一种轻量级的协程锁,它提供了互斥机制,用于协程之间对共享资源的安全访问。Mutex 是非阻塞的,与传统线程锁不同,它不会阻塞线程,而是挂起协程,等待锁被释放。

  • 挂起协程而非线程 :当协程尝试获取已被占用的 Mutex 时,该协程会挂起,直到锁释放。
  • 公平性:非公平
  • 原子操作:底层使用 CAS(Compare-And-Swap)实现状态的原子更新,确保线程安全。
  • 队列管理:维护一个等待队列,存储当前正在等待锁的协程

方法:

  • lock():尝试获取锁,如果锁被占用,则挂起当前协程。
  • unlock():释放锁,并唤醒等待队列中的下一个协程。
  • tryLock():尝试获取锁,不挂起协程,如果获取失败立即返回 false
  • withLock():自动处理锁的获取和释放,项目中用的最多。

Mutex 支持协程取消,当协程被取消时,会从等待队列中移除,避免死锁。线程因为 synchronized 被阻塞时,无法响应线程中断或取消信号,会一直等待,直到锁被释放。

具体的实现细节比较简单,这里不想说了,可以自行查阅。

那能不能用 synchronized 和 ReentrantLock 来保证协程 Coroutine 环境下的数据安全呢

synchronized 和 ReentrantLock,都能保证同一时刻,只有一个线程可以访问同步的代码块或临界区,在进入同步块时,会从主内存读取变量,离开同步块时,会将变量刷新到主内存,因此可以解决线程竞争问题。

结论:

  • synchronized 和 ReentrantLock 在协程中可以保证数据安全。
  • synchronized 和 ReentrantLock,不适合协程,它会阻塞线程。如果协程在 synchronized 块中挂起或者说在持有锁的时候挂起,会导致整个线程被阻塞,其他协程无法利用该线程执行,降低了并发性能。

概念

一、临界区

在多线程编程中,为了保证共享资源的正确访问,在某一时间段内,只允许一个线程进行临界区代码的执行,保证代码的正确性和稳定性。

kotlin 复制代码
private val mutex = Mutex()  
private var count = 0  
  
suspend fun addCount() {  
    mutex.withLock {  //这里开始,获取到锁之后就进入临界区
        count++  //这里就是执行临界区代码
    }//执行完毕 退出临界区  
}

二、内存屏障

脱离Java,单独看内存屏障,有以下几大分类:

屏障名称 含义
LoadLoad 前面的读必须完成,后面的读才能开始
LoadStore 前面的读必须完成,后面的写才能开始
StoreStore 前面的写必须完成,后面的写才能开始
StoreLoad 前面的写必须完成,后面的读才能开始「最强」

这些组合就像是在两个内存访问操作之间加了一堵墙,确保不会被 CPU 或编译器优化重排。

JMM(Java 内存模型)本身不直接暴露内存屏障指令,但 JVM 会根据 Java 语义编译时插入对应的屏障指令 ,尤其在使用 volatilesynchronized 等关键字时。

  • volatile
    • 可见性保证(确保变量写入被其他线程看到)
    • 重排序限制(禁止指令调换位置导致逻辑错误)
行为 内存屏障
volatile 读 LoadLoad + LoadStore
volatile 写 StoreStore + StoreLoad

所以,JMM 在实现 volatile 时,直接使用上述屏障类型。但实际真正作用到 cpu 会经过一系列编译转换,可以去自行了解。但是初步理解内存屏障是啥,volatile 作用,目前是够了。

  • synchronized 上面说了实现中使用到了 monitor,其内存屏障行为可以用下面的表格去理解:
行为 内存屏障
enter monitor 加锁 LoadLoad + LoadStore
exit monitor 释放锁 StoreStore + StoreLoad

三、CAS

Java是在Unsafe(sun.misc.Unsafe)类实现CAS的操作,而我们知道Java是无法直接访问操作系统底层的API的(原因是Java的跨平台性限制了Java不能和操作系统耦合),所以Java并没有在Unsafe类直接实现CAS的操作,而是通过JDI(Java Native Interface) 本地调用C/C++语言来实现CAS操作的。

CAS 导致的 ABA 问题,但可以加版本号解决,细节可以自行查询。

CAS操作并不会锁住共享变量,也就是一种非阻塞 的同步机制,CAS就是乐观锁的实现。Java利用CAS的乐观锁、原子性的特性高效解决了多线程的安全性问题,例如JDK1.8中的集合类ConcurrentHashMap、关键字volatile、ReentrantLock等。

四、线程的阻塞状态 vs 等待状态

Java线程请求某一个资源失败的时候就会进入阻塞状态 ,处于阻塞态的线程会不断请求资源,一旦请求成功,就会进入就绪队列,等待执行。 当线程调用waitjoinpack函数时候会进入等待状态,需要其它线程显性的唤醒否则会无限期的处于等待状态。

ps.

  1. 开始动笔是在 2025-1-8 号,我看我到底能把这篇文章拖到啥时候写完...
  2. 哈哈哈 2025-4-11 号,读了一遍,继续完善 哈哈哈,重度拖延症患者...
  3. 后续我会补齐一些带有歧义的应用场景
相关推荐
QING6181 小时前
详解:Kotlin 类的继承与方法重载
android·kotlin·app
QING6181 小时前
Kotlin 伴生对象(Companion Object)详解 —— 使用指南
android·kotlin·app
一一Null1 小时前
Android studio 动态布局
android·java·android studio
AD钙奶-lalala8 小时前
某车企面试备忘
android
我爱拉臭臭9 小时前
kotlin音乐app之自定义点击缩放组件Shrink Layout
android·java·kotlin
匹马夕阳10 小时前
(二十五)安卓开发一个完整的登录页面-支持密码登录和手机验证码登录
android·智能手机
吃饭了呀呀呀10 小时前
🐳 深度解析:Android 下拉选择控件优化方案——NiceSpinner 实践指南
android·java
吃饭了呀呀呀11 小时前
🐳 《Android》 安卓开发教程 - 三级地区联动
android·java·后端
_祝你今天愉快12 小时前
深入剖析Java中ThreadLocal原理
android