在现代并发编程的迷宫中,锁是保护数据完整性的守护者。从基础的互斥锁(Mutex)确保单一线程访问,到读写锁(Read-Write Locks)平衡读多写少的场景,再到乐观锁(Optimistic Locking)减少锁的竞争,以及悲观锁(Pessimistic Locking)应对高冲突环境,每种锁都有其独特的用武之地。而细粒度锁(Fine-Grained Lock)则以其在更小的数据粒度上操作,进一步优化了并发控制。本文将带您一探这些锁的神秘面纱,了解它们如何协同工作以提升系统性能,同时确保数据安全。无论您是资深开发者还是并发领域的新手,这些锁的策略和实践都将是您构建高效、稳定系统的重要工具。
肖哥弹架构 跟大家“弹弹” 高并发锁, 关注公号回复 'mvcc' 获得手写数据库事务代码
欢迎 点赞,关注,评论。
关注公号Solomon肖哥弹架构获取更多精彩内容
历史热点文章
- 28个验证注解,通过业务案例让你精通Java数据校验(收藏篇)
- Java 8函数式编程全攻略:43种函数式业务代码实战案例解析(收藏版)
- 69 个Spring mvc 全部注解:真实业务使用案例说明(必须收藏)
- 24 个Spring bean 全部注解:真实业务使用案例说明(必须收藏)
- MySQL索引完全手册:真实业务图文讲解17种索引运用技巧(必须收藏)
- 一个项目代码讲清楚DO/PO/BO/AO/E/DTO/DAO/ POJO/VO
1、细粒度锁实现流程策略
细粒度锁内部实现思路
- 初始化锁:在应用程序启动或资源创建时,初始化锁对象。
- 请求锁:当线程需要访问受保护的资源时,请求锁。
- 锁是否可用:检查锁是否已经被其他线程占用。
- 获取锁:如果锁可用,当前线程获取锁。
- 等待锁释放:如果锁不可用,线程进入等待状态,直到锁被释放。
- 执行受保护的操作:线程在获取锁后执行需要保护的操作。
- 操作是否完成:检查受保护的操作是否已经完成。
- 释放锁:操作完成后,线程释放锁,允许其他线程获取锁。
- 通知等待线程:释放锁后,如果有线程在等待,通知它们锁已可用。
细粒度锁的本质思路是将锁的粒度细化到最小必要的范围,以减少锁竞争和提高并发性能。以下是细粒度锁的几个核心原则和思路:
- 最小化锁范围:
- 细粒度锁将锁的作用范围限制在最小的数据单元或操作上,而不是对整个数据结构或资源加锁。
- 减少锁持有时间:
- 通过快速完成操作并尽早释放锁,减少每个线程持有锁的时间,从而减少其他线程的等待时间。
- 锁分离:
- 将不同的操作或数据访问分离到不同的锁上,使得多个操作可以并行执行,而不是所有操作都依赖于同一个锁。
- 锁分段:
- 将数据结构分割成多个段,每个段有自己的锁,这样可以同时对不同段进行操作而不会相互阻塞。
- 无锁编程:
- 利用原子操作和数据结构来避免使用锁,通过CAS(Compare-And-Swap)等机制来保证数据的一致性。
- 读写锁:
- 允许多个读操作同时进行,但写操作需要独占锁,以此来提高读操作的并发性。
- 锁粗化:
- 在某些情况下,如果一个线程需要连续访问多个资源,可以考虑将锁的范围扩大,以减少频繁的锁获取和释放。
- 锁升级和降级:
- 根据实际情况动态调整锁的粒度,例如从读锁升级到写锁,或者在不同级别的锁之间进行切换。
- 避免死锁:
- 设计锁策略时,确保锁的获取和释放顺序一致,避免出现死锁。
- 性能监控:
- 监控锁的性能,包括锁的竞争率、等待时间和吞吐量,以便根据实际情况调整锁策略。
2、细颗粒锁使用通用流程
细粒度锁使用流程说明
- 资源访问请求:线程请求访问受保护的资源。
- 检查锁状态:线程检查细粒度锁的状态,确定是否可以获取锁。
- 锁定:如果锁可用,线程获取锁并执行资源操作。
- 执行资源操作:线程对资源进行操作,如读取、修改等。
- 等待锁可用:如果锁不可用,线程进入等待状态,直到锁被释放。
- 操作完成:线程完成资源操作。
- 释放锁:线程释放锁,允许其他等待的线程获取锁。
- 资源访问者等待队列:等待锁的线程排队等待。
3、HikariCP在连接池中细颗粒锁落地思路
细粒度锁在连接池落地设计说明
- 连接池管理器:负责整个连接池的管理和调度。
- 连接对象池:存储所有可用的数据库连接对象。
- 细粒度锁:为每个连接或连接组提供独立的锁,以减少锁竞争。
- 连接状态:记录每个连接的当前状态(如空闲、使用中、失效等)。
- 连接属性:存储每个连接的配置和属性。
- 连接操作:管理连接的创建、使用和销毁等操作。
- 应用程序线程:请求和使用数据库连接的应用程序线程。
- 连接请求队列:管理连接请求,确保按顺序分配连接。
- 应用程序操作:应用程序对数据库连接进行的操作。
- 连接释放队列:管理连接释放请求,确保连接被正确回收。
- 监控线程:定期检查连接状态和性能指标,确保连接池的健康。
4、细颗粒锁使用案例
4.1. 读写锁(ReadWriteLock)
ReadWriteLock
的本质思路是允许多个读操作同时进行,但写操作是独占的,以此来提高并发性能。这种设计模式通常用于读多写少的场景。
场景描述:在高并发的Web应用中,缓存系统需要频繁地读取数据,而写入操作相对较少。使用读写锁可以提高读取操作的并发性,减少锁的竞争。
应用代码
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class CacheService {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public Object getValue(String key) {
lock.readLock().lock();
try {
return cache.get(key);
} finally {
lock.readLock().unlock();
}
}
public void setValue(String key, Object value) {
lock.writeLock().lock();
try {
cache.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}
ReadWriteLock 类设计
UML类图中:
ReadWriteLock
是一个接口,定义了获取读锁和写锁的方法。ReentrantReadWriteLock
是ReadWriteLock
的具体实现,它内部维护了读锁和写锁的状态。ReentrantReadLock
和ReentrantWriteLock
是内部类,分别实现了读锁和写锁的行为。Lock
是一个接口,定义了锁的基本操作,如lock()
、unlock()
、tryLock()
等。ReentrantReadWriteLock
包含了ReentrantReadLock
和ReentrantWriteLock
,用于管理读锁和写锁。
ReadWriteLock设计思路
核心思路
- 读锁(Shared Lock) :
- 当一个线程获得读锁时,它可以读取数据。
- 多个线程可以同时持有读锁,这意味着多个线程可以同时读取数据,而不会互相阻塞。
- 写锁(Exclusive Lock) :
- 当一个线程获得写锁时,它可以修改数据。
- 写锁是独占的,这意味着在任何时候只能有一个线程持有写锁。
- 当写锁被持有时,所有其他读和写请求都必须等待直到写锁被释放。
- 锁升级和降级:
- 锁升级:从读锁升级到写锁。这通常需要先释放读锁,然后获取写锁。升级过程中可能会引入竞态条件,需要谨慎处理。
- 锁降级:从写锁降级到读锁。这通常比较安全,因为写锁持有者在释放写锁之前已经确保了数据的一致性。
实现机制
- 锁状态管理:
- 锁状态通常由一个计数器来管理,用于跟踪当前持有锁的读线程数量和是否有写线程持有锁。
- 读锁的获取:
- 如果没有线程持有写锁,读线程可以增加读锁计数器并立即获取读锁。
- 如果有线程持有写锁,读线程必须等待写锁释放。
- 写锁的获取:
- 写线程必须等待所有读锁和写锁都被释放后才能获取写锁。
- 这通常涉及到阻塞等待,直到读锁计数器为零且没有其他写线程竞争。
- 锁的释放:
- 读锁的释放涉及到减少读锁计数器。
- 写锁的释放允许其他等待的读或写线程尝试获取锁。
- 锁的公平性:
- 锁可以是公平的或非公平的。
- 公平锁确保等待时间最长的线程首先获得锁,而非公平锁则允许线程抢占锁。
- 锁的重入:
- 读锁和写锁都支持重入,这意味着同一个线程可以多次获取同一把锁。
- 锁的中断:
- 锁的获取操作可以响应中断,这意味着线程在等待锁的过程中可以被中断。
4.2. 分段锁(Segmented Locks)
分段锁(Segmented Locks)是一种用于提高并发性能的锁策略,它通过将数据结构分割成多个段(或分区),并为每个段提供独立的锁,从而允许多个线程同时对不同段进行操作。这种策略特别适用于那些需要频繁访问的大型数据结构,如哈希表、数组或其他集合类型。
场景描述:在分布式数据库系统中,数据被分割成多个片段(shards),每个片段可以独立地进行操作。使用分段锁可以确保对不同片段的操作不会相互干扰。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ShardedDatabase {
private final Map<Integer, Lock> locks = new ConcurrentHashMap<>();
private final Map<Integer, Map<String, Object>> shards = new ConcurrentHashMap<>();
public ShardedDatabase(int numShards) {
for (int i = 0; i < numShards; i++) {
locks.put(i, new ReentrantLock());
shards.put(i, new ConcurrentHashMap<>());
}
}
public void updateShard(int shardId, String key, Object value) {
locks.get(shardId).lock();
try {
shards.get(shardId).put(key, value);
} finally {
locks.get(shardId).unlock();
}
}
public Object getShardValue(int shardId, String key) {
locks.get(shardId).lock();
try {
return shards.get(shardId).get(key);
} finally {
locks.get(shardId).unlock();
}
}
}
Segmented Locks 类设计
图形解释:
-
**
SegmentedHashTable
**:- 包含一个整型变量
segmentCount
,表示分段的数量。 - 包含一个
Segment
数组_segments
,每个元素代表一个数据段。 - 提供
getSegment
方法,根据键的哈希值确定应该访问哪个段。 - 提供
get
和put
方法,用于客户端访问和修改哈希表。
- 包含一个整型变量
-
**
Segment
**:- 包含一个
Lock
对象lock
,用于控制对该段的并发访问。 - 包含一个
HashMap
对象map
,用于存储该段的数据。 - 提供
get
和put
方法,用于在该段内进行数据的读取和写入操作。
- 包含一个
-
关系:
SegmentedHashTable
包含多个Segment
对象,表示它是由多个段组成的。
Segmented Locks设计思路
核心思路
- 数据分段:
- 将数据结构分割成多个逻辑段或分区,每个段包含数据结构的一部分。
- 每个段可以独立于其他段进行操作,减少了锁的竞争。
- 独立锁:
- 为每个数据段分配一个独立的锁。
- 当一个线程需要操作某个数据段时,它只需获取该段的锁,而不影响其他段的操作。
- 并发访问:
- 允许多个线程同时访问不同的数据段,因为每个段的锁是独立的。
- 这种设计显著提高了并发性能,尤其是在读多写少的场景中。
- 锁粒度:
- 分段锁通过减小锁的粒度来减少锁的竞争。
- 相比于全局锁,分段锁允许更细粒度的并发控制。
- 平衡性能与复杂性:
- 分段锁增加了实现的复杂性,因为需要管理多个锁和数据段。
- 但是,它通常能提供更好的性能,特别是在高并发环境下。
- 避免热点:
- 在全局锁的情况下,数据结构的某些部分可能会成为热点,导致大量的线程竞争同一资源。
- 分段锁通过分散访问压力,减少了热点问题。
- 动态调整:
- 根据实际的访问模式和性能需求,可以动态地调整段的大小和数量。
- 这允许系统在运行时优化性能。
实现注意事项
- 锁管理:
- 需要有效管理锁的获取和释放,以避免死锁和性能瓶颈。
- 数据一致性:
- 在跨段操作时,需要确保数据的一致性和完整性。
- 可能需要额外的同步机制来处理涉及多个段的操作。
- 负载均衡:
- 需要合理分配数据到各个段,以避免某些段过载而其他段空闲,这可能导致性能瓶颈。
- 扩展性:
- 设计时需要考虑系统的扩展性,确保在增加更多的段或锁时,系统的性能不会受到负面影响。
4.3. 无锁数据结构(Lock-Free Data Structures)
无锁数据结构(Lock-Free Data Structures)是一种并发编程技术,旨在避免使用传统的锁机制来同步对共享数据的访问。无锁算法利用原子操作和内存模型来保证数据的一致性和线程之间的协调,从而减少锁带来的开销和潜在的死锁问题。 场景描述:在高并发系统中,如在线广告点击计数,使用无锁数据结构可以避免锁的开销,提高计数的效率。
import java.util.concurrent.atomic.AtomicInteger;
public class ClickCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
AtomicInteger 类设计
类设计图解释
-
AtomicInteger:
-
属性:
int value
: 存储整数值的变量,通常被标记为volatile
以确保内存可见性和防止指令重排序。
-
方法:
- 构造函数 (
AtomicInteger(int initialValue)
): 初始化AtomicInteger
的值。 get()
: 返回当前值。set(int newValue)
: 设置当前值。getAndSet(int newValue)
: 获取当前值,并设置为新值。getAndIncrement()
: 获取当前值,并自增。getAndDecrement()
: 获取当前值,并自减。incrementAndGet()
: 自增,并返回新值。decrementAndGet()
: 自减,并返回新值。compareAndSet(int expect, int update)
: 如果当前值等于预期值,则原子地更新为新值。weakCompareAndSet(int expect, int update)
: 与compareAndSet
类似,但可能不具有相同的内存语义。getAndAdd(int delta)
: 获取当前值,并添加指定的增量。addAndGet(int delta)
: 添加指定的增量,并返回新值。
- 构造函数 (
-
-
AtomicReference:
-
这是一个假设的内部类或辅助类,用于展示
AtomicInteger
可能使用的底层原子引用实现。 -
属性:
int value
: 存储整数值。
-
方法:
- 构造函数 (
AtomicReference(int initialValue)
): 初始化引用的值。 get()
: 返回当前引用的值。compareAndSet(int expect, int update)
: 如果当前引用的值等于预期值,则原子地更新为新值。set(int newValue)
: 设置引用的值。
- 构造函数 (
-
Lock-Free设计思路
核心思路
- 避免阻塞:
- 无锁数据结构通过非阻塞算法来避免线程因等待锁而进入阻塞状态。
- 原子操作:
- 利用原子操作(如CAS - Compare-And-Swap)来确保数据项的更新是不可分割的。
- 避免死锁:
- 由于不使用锁,无锁数据结构自然避免了死锁的可能性。
- 顺序保证:
- 通过内存模型和原子操作来保证操作的顺序性,确保数据的一致性。
- 避免饥饿:
- 设计算法以确保所有线程最终都能完成其操作,避免某些线程被无限期地延迟。
- 利用多核优势:
- 无锁数据结构可以更好地利用多核处理器的并行处理能力。
实现机制
- 原子变量:
- 使用原子变量(如
AtomicInteger
、AtomicReference
等)来存储数据项,这些变量提供了原子操作。
- 使用原子变量(如
- 比较并交换(CAS) :
- CAS操作是无锁数据结构的核心,它包括三个参数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相等,则将该位置值更新为新值。
- 失败重试:
- 当CAS操作失败时(通常是因为其他线程已经更改了数据),线程会重试操作,直到成功为止。
- 内存屏障:
- 使用内存屏障来确保在多处理器系统中内存操作的顺序性和可见性。
- 避免ABA问题:
- 使用版本号或标记来解决CAS操作可能遇到的ABA问题,即在检查和更新之间值被改变然后又变回原值的情况。
- 伪共享:
- 避免在多核处理器上因缓存行共享导致的性能问题,通过调整数据结构的内存布局来减少伪共享。
- 无锁算法设计:
- 设计算法时,需要考虑所有可能的执行路径和线程间的交互,确保算法的正确性和性能。
- 测试和验证:
- 无锁数据结构需要通过严格的测试和验证来确保其在各种并发条件下的正确性和性能。
4.4. 乐观锁(Optimistic Locking)
场景描述:在分布式系统中,配置信息需要被多个服务共享和更新。使用乐观锁可以减少锁的竞争,提高配置更新的效率。
public class DistributedConfig {
private String config;
private long version;
public boolean updateConfig(String newConfig) {
long currentVersion = version;
if (version == currentVersion) {
config = newConfig;
version++;
return true;
}
return false;
}
public String getConfig() {
return config;
}
public long getVersion() {
return version;
}
}
4.5. 条件变量(Condition Variables)
场景描述:在任务调度系统中,任务可能需要等待某些条件满足后才能执行。使用条件变量可以有效地管理这些条件的等待和通知。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.List;
import java.util.ArrayList;
public class TaskScheduler {
private final List<Runnable> tasks = new ArrayList<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
public void addTask(Runnable task) {
lock.lock();
try {
tasks.add(task);
condition.signalAll();
} finally {
lock.unlock();
}
}
public void executeTasks() {
lock.lock();
try {
while (tasks.isEmpty()) {
condition.await();
}
Runnable task = tasks.remove(0);
task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
ReentrantLock 类设计
核心思路
- 可重入性:
- 允许同一线程多次获得同一锁,每次获得锁都需要对应释放一次。
- 公平性选择:
- 可以选择公平锁或非公平锁。公平锁按照线程请求锁的顺序来分配,而非公平锁则可能允许“插队”。
- 锁的灵活性:
- 提供了尝试获取锁(tryLock)、定时获取锁(tryLock with timeout)、可中断获取锁(lockInterruptibly)等操作。
- 条件变量支持:
- 可以与
Condition
接口配合使用,提供更复杂的线程间协调功能。
- 可以与
实现机制
- 同步器 Sync:
ReentrantLock
使用一个内部的同步器(如AQS
- AbstractQueuedSynchronizer)来管理锁的获取和释放。
- 锁状态:
- 维护一个状态变量来表示锁的持有者和锁的重入次数。
- 线程调度:
- 当锁被占用时,请求锁的线程会被放入同步队列中等待。
- 锁的获取与释放:
- 提供了获取和释放锁的一系列方法,包括重入的逻辑处理。
- 条件对象:
- 可以为
ReentrantLock
创建一个或多个Condition
对象,用于线程间的等待/通知操作。
- 可以为
Condition Lock设计思路
核心思路
- 线程同步:
- 条件变量用于同步线程间的操作,使得线程可以在某个条件成立之前挂起。
- 等待/通知机制:
- 线程可以在条件不满足时挂起(等待),并在条件满足时被唤醒(通知)。
- 锁的结合使用:
- 条件变量通常与互斥锁(mutex)结合使用,以确保在等待条件变量时数据的一致性。
- 减少忙等待:
- 通过挂起线程而不是忙等待,条件变量减少了CPU资源的浪费。
- 响应性:
- 条件变量提供了一种机制,允许线程在条件改变时迅速响应。
实现机制
- 等待队列:
- 条件变量内部通常有一个等待队列,所有等待该条件的线程都会被放入这个队列。
- 锁的获取与释放:
- 在调用条件变量的
wait()
方法前,线程必须持有相关的锁。 - 在线程等待条件变量时,它会释放锁,以便其他线程可以访问共享资源。
- 当条件变量的
signal()
或broadcast()
被调用时,等待的线程会被唤醒,并在重新尝试获取锁后继续执行。
- 在调用条件变量的
- 条件检查:
- 线程在被唤醒后,需要重新检查条件是否满足,因为唤醒不一定意味着条件已经满足。
- 避免虚假唤醒:
- 线程在被唤醒后必须检查条件,以避免虚假唤醒,这是由于操作系统调度或其他原因导致线程被唤醒,但条件并未满足。
- 支持多个条件:
- 条件变量可以与多个条件结合使用,允许线程等待多个条件中的任何一个。