【谈一谈】并发编程_锁的分类
Hello!~
大家好!~每天进步一点点,日复一日,我们终将问剑顶峰这里主要是介绍下我们常用的锁可以分为几类,目的是整体框架作用~方便后续的并发文章
说白了,这篇就是开头哈~
本文总纲:
一.可重入锁和不可重入锁
我们开发中一般用到的都是可重入锁比如
Synchronized
,ReentrantReadWriteLock
,ReentrantLock
都是可重入的不可重入的锁很少见,也不怎么用到(如果要用,一般都自己通过
Lock
定义实现)
1.可重入锁:
它也称为递归锁,啥意思呢?
- 是指同一线程在获取锁(如A锁)之后,可以再次对该锁(A)进行获取,而不会造成死锁。
- 这种锁支持同一个线程对资源的重复加锁,并且在释放锁时,必须是获取锁的次数与释放锁的次数相等时,才会真正释放锁
还是有点迷糊吗?我们举个Java
中ReentrantLock
实现可重入锁的简单例子来加深理解:
ReentrantLock
的锁机制:(我先说下,不然看下面代码,估计不怎么好理解哈)
- 内部维护了一个计数器,
- 每当线程获取锁时,计数器加1;每当线程释放锁时,计数器减1;
- 只有当计数器为0时,其他线程才有机会获取该锁
下面代码:
doSomething()
方法调用了doSomethingElse()
方法,由于ReentrantLock
的可重入特性,第二次调用lock()
不会导致线程阻塞,而是使锁计数器加1。- 当对应的
unlock()
方法被调用两次后,锁才会真正释放,允许其他线程获取锁
import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockExample {private final ReentrantLock lock = new ReentrantLock();public void doSomething() {lock.lock(); // 获取锁try {// 在这里执行临界区代码doSomethingElse();} finally {lock.unlock(); // 无论是否发生异常,最终都要释放锁}}private void doSomethingElse() {lock.lock(); // 同一线程内再次获取锁,不会阻塞try {// 在这里执行另一段临界区代码} finally {lock.unlock(); // 释放锁}}
}
2.不可重入锁
是指一个线程获取锁后,在未释放该锁的情况下**,无法再次获取**该锁的同步机制,必须等此锁释放后,才能再获取锁
细心的同学可能会发现,在
Java
的标准库中,并没有直接提供不可重入锁的实现,为什么??
- 因为在多层级调用或递归场景下(大多数的并发场景中),它们很容易造成意外死锁问题,
- 而可重入锁(如
ReentrantLock
)则可以安全地支持这些复杂情况。
举个例子:(不考虑复杂场景哈~你别杠,我们只是演示下,目的是懂)
通过简单的计数器模拟,当锁被获取时,增加计数器,有且仅有计数器为0是才允许获取锁
下面的代码:
- 这样的锁如果在线程内部递归调用
lock
方法,将会导致后续尝试获取锁的操作阻塞,从而表现出不可重入的特性。
public class NonReentrantLock {private boolean isLocked = false;private Thread holdingThread;public synchronized void lock() throws InterruptedException {while (isLocked && holdingThread != Thread.currentThread()) {wait();}isLocked = true;holdingThread = Thread.currentThread();}public synchronized void unlock() {if (holdingThread == Thread.currentThread()) {isLocked = false;holdingThread = null;notifyAll();} else {throw new IllegalMonitorStateException("当前线程并未持有此锁");}}
}
二.乐观锁和悲观锁
1.乐观锁 (Optimistic Locking
)
- 是一种在读取数据时不会立即加锁,而是在更新数据时才会检查在此期间是否有其他事务对数据进行了修改的并发控制策略。
- 它假设大多数情况下不会有冲突发生(很乐观吧~哈哈哈,),因此在进行数据操作时保持乐观态度。
在补充下:
在数据库系统中,乐观锁通常通过版本号或时间戳等机制实现。
- 当一个事务准备更新数据时,它会首先检查该数据的版本号或时间戳是否与最初读取时一致,
- 如果一致: 则执行更新操作并更新版本号或时间戳;
- 如果不一致,则表示在此期间有其他事务对该数据进行了修改,此时当前事务通常会选择回滚以避免覆盖其他事务的更改。
例子:
如
Hibernate
中使用@Version
注解:
- 每次更新
MyEntity
实例时,Hibernate都会自动检查并更新version字段,从而实现了乐观锁的效果又如
Java
中提供的CAS
操作,典型的乐观锁实现
再举个实际点: 一个整数版本号来模拟乐观锁机制
transfer
方法尝试进行转账操作时,
- 首先记录下当前账户的版本号。
- 然后模拟可能存在其他事务的情况,这里简单地直接增加版本号以示意图。
- 最后,在真正执行更新操作前,再次检查版本号是否与最初读取时一致。
- 如果一致,则执行转账逻辑并递增版本号;
- 如果不一致,则表示存在并发冲突,转账操作失败。
这样就实现了一个基于版本号的乐观锁机制,它可以防止在并发环境下的数据不一致性问题。
public class Account {private int balance; // 账户余额private int version; // 数据版本号public Account(int initialBalance) {this.balance = initialBalance;this.version = 0;}// 使用乐观锁进行转账操作public boolean transfer(Account to, int amount) {// 保存当前账户和目标账户的原始版本号int originalVersion = this.version;// 模拟并发环境下可能出现的其他事务操作simulateOtherTransactions();// 尝试更新账户余额和版本号if (this.version == originalVersion) {// 更新前检查版本号未变if (this.balance >= amount) {this.balance -= amount;to.balance += amount;// 成功更新数据后,将版本号递增this.version++;return true;} else {System.out.println("余额不足,转账失败");}} else {System.out.println("并发冲突,有其他事务修改了账户数据,转账失败");}return false;}// 模拟在转账操作过程中可能存在的其他事务对账户数据的修改private void simulateOtherTransactions() {// 这里仅用于演示,在实际应用中可能是由其他线程或事务引起的// 假设另一个事务在此时修改了账户数据并增加了版本号this.version++;}
}
2.悲观锁(Pessimistic Locking
)
获取不到锁资源时,会将当前线程挂起(进入
Blocked
或者waitting
)(有着一种生于忧患意识)官方术语:
- 是一种在访问数据时假设会发生并发冲突,并立即对数据进行加锁以防止其他事务或线程对其进行修改的并发控制策略。
- 倾向于认为每次对数据的操作都可能引发并发问题,所以在获取数据前就先锁定资源
这种操作例子在数据库层面经常能看见:
如: 当第一个事务执行
SELECT ... FOR UPDATE
时,
- 会对当前的查询记录进行锁定,此时其他任何事务试图读取或修改这条记录都会被阻塞,直到第一个事务提交或回滚并释放锁
如: 在
Java
应用层面,
JDBC
中的java.sql.Connection
提供的setAutoCommit(false)
方法:- 可以开启手动事务管理,配合数据库的悲观锁机制实现更细粒度的并发控制。
再举个: 以synchronized
关键字为例,提供一个简单的线程安全的银行账户转账操作:
transfer()
方法通过synchronized
关键字修饰,这意味着在同一时间只能有一个线程访问这个方法。当一个线程调用
transfer
进行转账操作时,其他线程必须等待当前线程完成操作并释放锁后才能继续执行。这就是悲观锁的应用,它假设并发环境下会存在数据冲突,并直接对资源进行锁定,以防止多个线程同时修改共享资源导致的数据不一致问题。
public class BankAccount {private double balance;public BankAccount(double initialBalance) {this.balance = initialBalance;}// 使用synchronized实现悲观锁public synchronized void transfer(BankAccount to, double amount) throws InterruptedException {if (this.balance >= amount) {// 锁定当前对象(即锁定该方法),确保同一时间只有一个线程能执行此方法Thread.sleep(100); // 模拟耗时操作,如数据库查询或更新this.balance -= amount;to.balance += amount;System.out.println("From: " + Thread.currentThread().getName() + ", Transfer " + amount + " completed.");} else {throw new IllegalArgumentException("Insufficient balance.");}}public static void main(String[] args) {BankAccount accountA = new BankAccount(100);BankAccount accountB = new BankAccount(0);Thread thread1 = new Thread(() -> accountA.transfer(accountB, 50));Thread thread2 = new Thread(() -> accountA.transfer(accountB, 60));thread1.start();thread2.start();try {thread1.join();thread2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Final balances: A=" + accountA.balance + ", B=" + accountB.balance);}
}
三.公平锁和非公平锁
这当中的关键点就是: 是否正常排队
1.公平锁
- 是一种线程调度策略,它保证了等待锁的线程按照它们请求锁的顺序获得锁。
- 在公平锁机制下,当锁释放时,会优先分配给已经在队列中等待时间最长的线程,而不是随机选择一个等待的线程。
注意:
- 在公平锁环境下,如果有多个线程在等待获取锁,那么锁会被分配给等待时间最久的那个线程,这种策略能够减少
"线程饥饿"
(即某些线程长时间无法获取到锁)的问题,提高系统的整体公平性- 公平锁虽然在理论上提供了更好的公平性,但可能会降低系统的整体吞吐量,
- 因为每次释放锁时都需要维护和检查等待队列,并且需要考虑线程上下文切换的成本。
- 而在非公平锁(默认情况下)中,获取锁的线程可能是最近刚刚尝试获取锁的线程,这可能导致更高的并发性和系统性能,但可能也会导致某些线程长期得不到执行机会。
举个例子:
假设我们有一个共享资源(一个计数器)需要多个线程安全地进行递增操作:
ReentrantLock
类:参数设为true
,可以创建一个公平锁在这个例子中:
我们创建了一个公平锁,并在一个共享的计数器上进行了递增操作。
当多个线程同时调用
increment()
方法时,公平锁会确保等待时间最长的线程优先获得锁并执行操作。由于每个线程在操作后都休眠了100毫秒,这有助于模拟实际的并发环境,使得不同线程之间的执行顺序更易于观察。
在公平锁策略下,理论上线程获取锁的顺序将尽可能按照它们请求锁的时间顺序进行,因此输出的结果应能体现出相对有序的执行过程。
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;public class FairLockExample {private final ReentrantLock lock = new ReentrantLock(true); // 创建一个公平锁private int counter = 0;public void increment() {lock.lock();try {counter++;System.out.println(Thread.currentThread().getName() + " incremented the counter to: " + counter);Thread.sleep(100); // 模拟耗时操作} catch (InterruptedException e) {Thread.currentThread().interrupt();} finally {lock.unlock();}}public static void main(String[] args) throws InterruptedException {FairLockExample example = new FairLockExample();IntStream.range(0, 10).forEach(i -> new Thread(() -> example.increment(), "Thread-" + i).start());Thread.sleep(5000); // 等待所有线程执行完成System.out.println("Final counter value: " + example.counter);}
}
2.非公平锁
是一种线程调度策略,与公平锁相反,
- 在释放锁后并不保证等待时间最长的线程一定能获得锁。
- 当锁可用时,非公平锁可能会允许任何一个正在等待获取锁的线程获取锁,即使有其他线程已经等待了更长的时间。(适者生存,能者居之)
模拟场景说明:
线程
A
获取到锁资源,线程B
没有拿到,线程B
去排队,这时线程C
跑来了,线程C
咋么做呢?
- 首先去尝试竞争一波
- 竞争成功: 拿到锁,美滋滋进行执行
- 竞争失败: 没有拿到锁资源,老老实实的排到
B
的后面,直到B
拿到锁资源或者B
取消后,才去竞争锁资源
举个例子:
使用非公平锁实现多线程安全递增操作的例子
在这个例子中,
- 我们创建了一个非公平锁,并在一个共享的计数器上进行了递增操作。
- 当多个线程同时调用
increment()
方法时,非公平锁可能让任何等待锁的线程获取到锁,而不考虑它们等待的先后顺序。- 因此,输出的结果可能显示出线程获取锁和执行的相对无序性。
- 虽然非公平锁可能导致某些线程
“饥饿”
(长时间无法获取锁),但在某些情况下,它能提供更高的吞吐量,因为减少了线程上下文切换的成本和队列维护的开销。
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;public class NonFairLockExample {private final ReentrantLock lock = new ReentrantLock(); // 创建一个非公平锁(默认false)private int counter = 0;public void increment() {lock.lock();try {counter++;System.out.println(Thread.currentThread().getName() + " incremented the counter to: " + counter);Thread.sleep(100); // 模拟耗时操作} catch (InterruptedException e) {Thread.currentThread().interrupt();} finally {lock.unlock();}}public static void main(String[] args) throws InterruptedException {NonFairLockExample example = new NonFairLockExample();IntStream.range(0, 10).forEach(i -> new Thread(() -> example.increment(), "Thread-" + i).start());Thread.sleep(5000); // 等待所有线程执行完成System.out.println("Final counter value: " + example.counter);}
}