原创 张力尹 2025-06-04 08:31 重庆
Java 线程请求某一个资源失败的时候就会进入阻塞状态,处于阻塞态的线程会不断请求资源,一旦请求成功,就会进入就绪队列等待执行。 当线程调用wait、join、pack函数时候会进入等待状态,需要其它线程显性的唤醒否则会处于等待状态。
(💰金石瓜分计划强势上线,速戳上图了解详情🔍)
我准备在这里胡说八道的时候,其实初衷只是有以下几个疑问:
synchronized 可以保证协程安全?
为啥 kotlin 项目里他们就直接使用了 synchronized 呢?
synchronized vs mutex
我认为理清以上之后,打算通过下面几部分,把锁在这一篇文章中彻底说明白「或者说尽量说明白,又或者说让我自己明白就行」,争取由浅入深且出错不多,若看到错误希望大家不吝惜文字,评论区中多多讨论。
java 中的锁
kotlin 中的锁
如果要保证某块代码线程安全,kotlin 中 必需用锁么?
锁在底层的实现
怎么选择该用什么锁
一些不得不说明的概念,可以提前阅读的部分
Java 中的锁分类
按照锁的共享粒度
独占锁 该锁一次只能被一个线程所持有。eg: synchronized/ReentrantLock。
共享锁 该锁可以被多个线程所持有。eg: 读写锁 ReentrantReadWriteLock 中的读锁 ReadLock 是共享锁。
按公平性
公平锁 多个线程相互竞争时要排队,多个线程按照申请锁的顺序来获取锁。eg: ReentrantLock 的公平模式(通过构造函数指定)。
非公平锁 多个线程相互竞争时,先尝试插队,插队失败再排队。eg: synchronized、ReentrantLock。
按锁的实现机制
Java 对象锁 synchronized 在 JVM 内部的不同实现阶段,是通过锁升级来实现的。
DK 1.6 为了减少获得锁和释放锁所带来的性能消耗,在 JDK 1.6 里引入了 3 种锁的状态:偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。
偏向锁偏向锁是针对没有竞争的情况优化的锁。如果一个线程获取了锁,对象会记录该线程的 ID,后续该线程再次获取锁时,不需要任何同步操作(如 CAS 操作),直接获取锁,性能最高。当有其他线程试图竞争该锁时,偏向锁会升级为轻量级锁。「特点」:偏向于单线程执行,无需锁竞争。
轻量级锁当锁有竞争时,偏向锁会升级为轻量级锁。轻量级锁使用 CAS(Compare-And-Swap)操作来尝试获取锁。若线程获取成功,执行代码;若获取失败,则线程会自旋等待锁释放。「特点」:适合竞争不激烈的场景,减少线程挂起和唤醒的代价。
自旋锁
自旋锁是指线程在获取锁失败时,不立即挂起,而是执行一段空循环(自旋),尝试重新获取锁。自旋锁适用于「线程持有锁的时间很短」的场景,避免了线程挂起和唤醒的开销。JVM 的轻量级锁会利用自旋锁,在一定次数自旋后如果还未获取锁,就会升级为重量级锁。
自旋锁是轻量级锁的一部分策略
,用于短时间等待锁释放。「CAS」 是轻量级锁实现的核心,提供无锁的原子操作。在自旋锁中,CAS 是实现锁状态更新的核心技术。当多个线程竞争锁时,自旋锁通过 CAS 判断当前锁是否可用,并在锁可用时将其状态更新为已占用。「自旋锁」 | 「CAS」 |
---|---|
是一种锁机制,强调等待策略(自旋)。 | 是硬件支持的原子操作。 |
用于在竞争条件下协调线程的访问。 | 用于实现无锁状态的更新。 |
内部常使用 CAS 判断和更新锁状态。 | 可单独用于原子变量的更新(如计数)。 |
重量级锁如果自旋等待的线程越来越多,或者线程自旋的时间过长,轻量级锁会升级为重量级锁。 重量级锁通过操作系统的互斥量(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)。
JVM 对象内存布局
在 JVM 中,每个对象在内存中分为以下几部分:
对象头
指向对象所属类的元数据,表示对象的类型。通常是一个指针的大小(4 字节或 8 字节)。
存储对象的运行时数据,例如锁状态、GC 标记、哈希值等。
Mark Word 是一个 32 位或 64 位字段(取决于 JVM 位数)。
根据对象的状态(如锁状态、垃圾回收阶段)内容会有所不同。
Mark Word
Klass Pointer
「锁状态」 | 「Mark Word 内容」 | 「标志位」 | 「线程 ID」 (可能包含) |
---|---|---|---|
「无锁」 | 对象哈希码、GC 信息 | 01 | 无 |
「偏向锁」 | 持有锁的线程 ID 和时间戳 | 01 | 是 |
「轻量级锁」 | 指向线程栈中锁记录的指针 | 00 | 是 |
「重量级锁」 | 「指向 Monitor 的指针」 | 10 | 无 |
「GC 标记」 | GC 信息 | 11 | 无 |
实例数据(Instance Data)
实例数据是对象的主要部分,存储对象的实际字段(成员变量)值。
按字段在类中声明的顺序存储。优先分配基本数据类型,按字节对齐规则存储(以提高访问效率)。
对象的字段值,包括基本类型和引用类型的指针。
对齐填充(Padding)
为了满足内存对齐要求,填充无意义的字节。JVM 通常要求对象的起始地址是 8 字节或 16 字节的整数倍。减少内存碎片,提高访问效率。
实现机制
偏向锁
如果一个线程首次访问对象,JVM 将线程 ID 写入对象头的 Mark Word。
后续该线程访问时,只需检查 Mark Word 是否匹配,无需进行 CAS 操作。
如果另一个线程尝试获取偏向锁,则撤销锁,升级为轻量级锁。
轻量级锁
线程尝试通过 CAS 操作将对象头的 Mark Word 替换为指向线程栈中锁记录的指针。
如果 CAS 失败,表示存在锁竞争,线程会自旋尝试获取锁。
线程在短时间内自旋尝试获取锁,避免线程阻塞。自旋的次数由 JVM 参数 -XX:PreBlockSpin 决定。
重量级锁
当自旋失败次数超过限制,锁升级为重量级锁,所有竞争锁的线程都会进入阻塞状态。
JVM 为每个对象关联一个 Monitor,阻塞线程会进入 Monitor 的等待队列。
当锁释放时,Monitor 负责唤醒等待队列中的线程。
从字节码角度解析
synchronized 的实现依赖 JVM 指令集中的 「Monitor」 操作。
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,可输出如下:
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}
monitorenter
和monitorexit
指令。ACC_SYNCHRONIZED
标志。ACC_SYNCHRONIZED
标志,在反编译时更换为语句:javap -v -c tetst 即可。使用-v
(verbose)选项获取更详细的字节码信息。Monitor 详解
Monitor 是 JVM 内部的一种同步结构,用来实现 Java 中的同步机制。存储在 JVM 的堆或方法区中的专用数据结构里。Monitor 组成:
「Owner」:当前持有锁的线程。
「Entry List」:等待进入 Monitor 的线程列表。
wait()
的线程列表。「计数器」:记录锁的重入次数。每次同一线程获取锁时,计数器递增。每次释放锁时,计数器递减。当计数器降为 0 时,锁才真正释放,其他线程才能获得锁。
在 JVM 层面,synchronized 是通过 「对象头中的 Mark Word」 和 「Monitor」 实现的。
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
方法,可以立即返回锁定结果或超时等待。支持显式加锁和解锁,避免隐式释放锁的限制。
AbstractQueuedSynchronizer
(AQS)实现的,可将其视为一个增强版的互斥锁。private final Sync sync;
Sync
是 ReentrantLock 的核心,负责具体的加锁、解锁逻辑,继承自 AQS。分为 FairSync 公平锁 和 NonFairSync 非公平锁 两种实现。AQS 详解
AbstractQueuedSynchronizer 是 java.util.concurrent 包的核心组件,用于构建锁和同步器。
「状态字段」:int state,表示同步状态,对于 ReentrantLock,state 的值记录了锁的重入次数。
「等待队列」:AQS 维护了一个 FIFO 的双向链表,存储等待获取锁的线程。
「条件队列」:是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾
通过 acquire 和 tryAcquire 尝试获取锁。
通过 release 和 tryRelease 释放锁。
当获取锁失败时,线程会被加入到 AQS 的等待队列中,并进入阻塞状态。
获取资源失败入队、线程唤醒、线程的状态等,AQS 已经实现好,实现 AQS 的子类的任务是:
CAS
操作维护共享变量state
重写资源的获取方式
重写资源释放的方式
AQS 的这种设计模式也是模版方法模式。
ReentrantLock 代码实现
非公平模式:
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; }}
公平模式
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; }}
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 详解
Mutex 是非阻塞的
,与传统线程锁不同,它不会阻塞线程,而是挂起协程
,等待锁被释放。Mutex
时,该协程会挂起,直到锁释放。「公平性」:非公平
「原子操作」:底层使用 CAS(Compare-And-Swap)实现状态的原子更新,确保线程安全。
「队列管理」:维护一个等待队列,存储当前正在等待锁的协程
方法:
lock():尝试获取锁,如果锁被占用,则挂起当前协程。
unlock():释放锁,并唤醒等待队列中的下一个协程。
尝试获取锁,不挂起协程,如果获取失败立即返回 false
。withLock():自动处理锁的获取和释放,项目中用的最多。
Mutex 支持协程取消,当协程被取消时,会从等待队列中移除,避免死锁。线程因为 synchronized 被阻塞时,无法响应线程中断或取消信号,会一直等待,直到锁被释放。
具体的实现细节比较简单,这里不想说了,可以自行查阅。
那能不能用 synchronized 和 ReentrantLock 来保证协程 Coroutine 环境下的数据安全呢
synchronized 和 ReentrantLock,都能保证同一时刻,只有一个线程可以访问同步的代码块或临界区,在进入同步块时,会从主内存读取变量,离开同步块时,会将变量刷新到主内存,因此可以解决线程竞争问题。
结论:
synchronized 和 ReentrantLock 在协程中可以保证数据安全。
阻塞线程
。如果协程在 synchronized 块中挂起或者说在持有锁的时候挂起,会导致整个线程被阻塞,其他协程无法利用该线程执行,降低了并发性能。概念
一、临界区
在多线程编程中,为了保证共享资源的正确访问,在某一时间段内,只允许一个线程进行临界区代码的执行,保证代码的正确性和稳定性。
private val mutex = Mutex() private var count = 0 suspend fun addCount() { mutex.withLock { //这里开始,获取到锁之后就进入临界区 count++ //这里就是执行临界区代码 }//执行完毕 退出临界区 }
二、内存屏障
脱离 Java,单独看内存屏障,有以下几大分类:
屏障名称 | 含义 |
---|---|
LoadLoad | 前面的读必须完成,后面的读才能开始 |
LoadStore | 前面的读必须完成,后面的写才能开始 |
StoreStore | 前面的写必须完成,后面的写才能开始 |
StoreLoad | 前面的写必须完成,后面的读才能开始「最强」 |
这些组合就像是在「两个内存访问操作之间加了一堵墙」,确保不会被 CPU 或编译器优化重排。
volatile
、synchronized
等关键字时。volatile
「可见性保证」(确保变量写入被其他线程看到)
「重排序限制」(禁止指令调换位置导致逻辑错误)
行为 | 内存屏障 |
---|---|
volatile 读 | LoadLoad + LoadStore |
volatile 写 | StoreStore + StoreLoad |
所以,JMM 在实现 volatile 时,直接使用上述屏障类型。但实际真正作用到 cpu 会经过一系列编译转换,可以去自行了解。但是初步理解内存屏障是啥,volatile 作用,目前是够了。
synchronized 上面说了实现中使用到了 monitor,其内存屏障行为可以用下面的表格去理解:
行为 | 内存屏障 |
---|---|
enter monitor 加锁 | LoadLoad + LoadStore |
exit monitor 释放锁 | StoreStore + StoreLoad |
三、CAS
Unsafe(sun.misc.Unsafe)
类实现CAS
的操作,而我们知道 Java 是无法直接访问操作系统底层的 API 的 (原因是 Java 的跨平台性限制了 Java 不能和操作系统耦合),所以 Java 并没有在Unsafe
类直接实现CAS
的操作,而是通过 「JDI(Java Native Interface)」 本地调用C/C++
语言来实现CAS
操作的。CAS 导致的 ABA 问题,但可以加版本号解决,细节可以自行查询。
乐观锁
的实现。Java 利用 CAS 的乐观锁、原子性的特性高效解决了多线程的安全性问题,例如 JDK1.8 中的集合类 ConcurrentHashMap、关键字 volatile、ReentrantLock 等。四、线程的阻塞状态 vs 等待状态
wait
、join
、pack
函数时候会进入「等待状态」,需要其它线程显性的唤醒否则会无限期的处于等待状态。ps.
开始动笔是在 2025-1-8 号,我看我到底能把这篇文章拖到啥时候写完...
哈哈哈 2025-4-11 号,读了一遍,继续完善 哈哈哈,重度拖延症患者...
后续我会补齐一些带有歧义的应用场景
AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding
点击"阅读原文"了解详情~