我准备在这里胡说八道的时候,其实初衷只是有以下几个疑问:
- 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种锁的状态:偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。
-
偏向锁
偏向锁是针对没有竞争的情况优化的锁。
如果一个线程获取了锁,对象会记录该线程的 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 中,每个对象在内存中分为以下几部分:
- 对象头
-
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 字节的整数倍。减少内存碎片,提高访问效率。
- 实现机制
- 偏向锁
- 如果一个线程首次访问对象,JVM 将线程 ID 写入对象头的 Mark Word。
- 后续该线程访问时,只需检查 Mark Word 是否匹配,无需进行 CAS 操作。
- 如果另一个线程尝试获取偏向锁,则撤销锁,升级为轻量级锁。
- 轻量级锁
- 线程尝试通过 CAS 操作将对象头的 Mark Word 替换为指向线程栈中锁记录的指针。
- 如果 CAS 失败,表示存在锁竞争,线程会自旋尝试获取锁。
- 线程在短时间内自旋尝试获取锁,避免线程阻塞。自旋的次数由 JVM 参数 -XX:PreBlockSpin 决定。
- 重量级锁
- 当自旋失败次数超过限制,锁升级为重量级锁,所有竞争锁的线程都会进入阻塞状态。
- JVM 为每个对象关联一个 Monitor,阻塞线程会进入 Monitor 的等待队列。
- 当锁释放时,Monitor 负责唤醒等待队列中的线程。
- 从字节码角度解析
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
}
- 同步代码块,字节码中包含
monitorenter
和monitorexit
指令。 - 同步方法,不需要显式的 monitorenter 和 monitorexit 指令。JVM 会在方法的访问标志中添加
ACC_SYNCHRONIZED
标志。
ps.上面看不到 ACC_SYNCHRONIZED
标志,在反编译时更换为语句:javap -v -c tetst 即可。使用 -v
(verbose)选项获取更详细的字节码信息。
- Monitor 详解
Monitor 是 JVM 内部的一种同步结构,用来实现 Java 中的同步机制。存储在 JVM 的堆或方法区中的专用数据结构里。Monitor 组成:
- Owner:当前持有锁的线程。
- Entry List:等待进入 Monitor 的线程列表。
- Wait Set :调用
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
方法,可以立即返回锁定结果或超时等待。 - 支持显式加锁和解锁,避免隐式释放锁的限制。
ReentrantLock 是基于 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 代码实现
非公平模式:
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 语义编译时插入对应的屏障指令 ,尤其在使用 volatile
、synchronized
等关键字时。
- 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线程请求某一个资源失败的时候就会进入阻塞状态 ,处于阻塞态的线程会不断请求资源,一旦请求成功,就会进入就绪队列,等待执行。 当线程调用wait
、join
、pack
函数时候会进入等待状态,需要其它线程显性的唤醒否则会无限期的处于等待状态。
ps.
- 开始动笔是在 2025-1-8 号,我看我到底能把这篇文章拖到啥时候写完...
- 哈哈哈 2025-4-11 号,读了一遍,继续完善 哈哈哈,重度拖延症患者...
- 后续我会补齐一些带有歧义的应用场景