区别
悲观锁和乐观锁是在并发编程中常见的两种锁机制,它们分别基于不同的假设和实现方式来处理共享资源的访问和修改。
- 悲观锁
悲观锁总是假设在数据访问时会发生冲突(它总是持有悲观态度的),因此每次获取数据都会进行加锁操作,阻止其他线程的访问,直到当前线程完成操作。在关系型数据库中,行锁、表锁等都是悲观锁的典型实现;在Java 中,synchronized 关键字、ReentrantLock类也都是悲观锁,它们在进入临界区之前会先获取锁,以确保线程安全。
- 乐观锁
与悲观锁相反,乐观锁假设数据访问时不会发生冲突(总是持有乐观态度),因此不进行加锁操作,而是在更新数据时进行检查,以确保数据的一致性。乐观锁适用于读多写少的场景,可以提高系统的吞吐量,常见的乐观锁实现方式包括使用版本标识或 CAS(Compare and Swap)操作。例如,Java 中的 java.util.concurrent.atomic 包下的原子变量类就是使用 CAS 实现的乐观锁。
实现方式
乐观锁
使用版本标识
通过在数据中添加版本信息,更新时比较版本信息是否一致来判断数据是否被修改,如果版本不一致,则进行相应的处理,例如丢弃数据或者重试操作。
import java.util.concurrent.atomic.AtomicInteger;
public class OptimisticLockWithVersioning {
// 共享资源
private static int sharedResource = 0;
// 版本号
private static AtomicInteger version = new AtomicInteger(0);
// 乐观锁示例
public static void optimisticLockExample() {
// 获取当前版本号
int currentVersion = version.get();
try {
// 假设这里是业务操作
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试更新共享资源
if (version.compareAndSet(currentVersion, currentVersion + 1)) {
// 更新共享资源
sharedResource++;
System.out.println("共享资源已更新为:" + sharedResource);
} else {
System.out.println("版本号不一致,更新共享资源失败~");
}
}
public static void main(String[] args) {
// 计数器数组
final int[] count = {0};
// 顺便自定义一下线程工厂和拒绝策略,熟悉一下,嘿嘿~
ThreadFactory myThreadFactory = (Runnable r) -> {
Thread thread = new Thread(r);
thread.setName("MyThread-" + count[0]++);
return thread;
};
RejectedExecutionHandler myRejectionHandler = (r, executor) -> {
throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + executor.toString());
};
// 创建 ThreadPoolExecutor 线程池并设置线程工厂和拒绝策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5,
5,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
myThreadFactory,
myRejectionHandler
);
// 提交5个任务给线程池,每个任务都是运行 optimisticLockExample() 方法
for (int i = 0; i < 5; i++) {
executor.submit(OptimisticLockWithVersioning::optimisticLockExample);
}
// 关闭线程池
executor.shutdown();
}
}
CAS(Compare and Swap)
CAS 操作是一种乐观锁的实现方式,当多个线程尝试更新同一个变量时,只有一个线程能成功更新,其他线程会被告知操作失败,可以进行重试。
CAS 操作包含三个操作数,即为需要读写的内存位置、预期原值和拟写入的新值,如果内存位置的值与预期原值相符,则更新为新值,否则不执行任何操作。
import java.util.concurrent.atomic.AtomicInteger;
public class OptimisticLockWithCAS {
// 共享资源
private static AtomicInteger sharedResource = new AtomicInteger(0);
// 乐观锁示例
public static void optimisticLockExample() {
// 预期原值
int expectedValue = sharedResource.get();
try {
// 假设这里有一些业务操作
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试更新共享资源
if (sharedResource.compareAndSet(expectedValue, expectedValue + 1)) {
System.out.println(Thread.currentThread().getName() + " 共享资源已更新为:" + sharedResource.get());
} else {
System.out.println(Thread.currentThread().getName() + " CAS失败,更新共享资源失败~");
}
}
public static void main(String[] args) {
// 计数器数组
final int[] count = {0};
// 顺便自定义一下线程工厂和拒绝策略,熟悉一下,嘿嘿~
ThreadFactory myThreadFactory = (Runnable r) -> {
Thread thread = new Thread(r);
thread.setName("MyThread-" + count[0]++);
return thread;
};
RejectedExecutionHandler myRejectionHandler = (r, executor) -> {
throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + executor.toString());
};
// 创建 ThreadPoolExecutor 线程池并设置线程工厂和拒绝策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5,
5,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
myThreadFactory,
myRejectionHandler
);
// 提交5个任务给线程池,每个任务都是运行 optimisticLockExample() 方法
for (int i = 0; i < 5; i++) {
executor.submit(OptimisticLockWithVersioning::optimisticLockExample);
}
// 关闭线程池
executor.shutdown();
}
}
悲观锁
在每次数据访问之前都进行加锁操作,确保在当前线程操作完成前,其他线程无法访问该数据,这种方式会造成一定的性能开销,但能保证数据的一致性和安全性。
使用synchronized 关键字
在使用synchronized 关键字修饰的代码块或方法中,同一时刻只允许一个线程访问被锁定的资源,其他线程需要等待锁的释放才能继续执行。这种方式能够确保在任何时刻只有一个线程能够访问共享资源,从而保证了数据的一致性和安全性。
当一个线程进入synchronized 代码块或方法时,它会尝试获取锁,如果锁已经被其他线程持有,则当前线程会被阻塞,直到获取到锁为止。只有当持有锁的线程执行完毕并释放锁后,其他线程才能竞争获取锁。
public class SynchronizedMutexExample {
// 共享资源
private static int sharedResource = 0;
public static synchronized void synchronizedMutexExample() {
// 修改共享资源
sharedResource++;
System.out.println(Thread.currentThread().getName() + " 共享资源已更新为:" + sharedResource);
}
public static void main(String[] args) {
// 计数器数组
final int[] count = {0};
// 顺便自定义一下线程工厂和拒绝策略,熟悉一下,嘿嘿~
ThreadFactory myThreadFactory = (Runnable r) -> {
Thread thread = new Thread(r);
thread.setName("MyThread-" + count[0]++);
return thread;
};
RejectedExecutionHandler myRejectionHandler = (r, executor) -> {
throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + executor.toString());
};
// 创建 ThreadPoolExecutor 线程池并设置线程工厂和拒绝策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5,
5,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
myThreadFactory,
myRejectionHandler
);
// 提交5个任务给线程池,每个任务都是运行 optimisticLockExample() 方法
for (int i = 0; i < 5; i++) {
executor.submit(OptimisticLockWithVersioning::synchronizedMutexExample);
}
// 关闭线程池
executor.shutdown();
}
}
使用ReentrantLock 类
线程在访问共享资源之前需要调用 lock() 方法获取锁,如果没有其他线程持有锁,那么该线程会立即获取到锁,并继续执行;如果有其他线程持有锁,则该线程会被阻塞,直到获取到锁。当线程在访问共享资源结束后,需要调用 unlock() 方法手动释放锁,这样才可以让后续其他线程继续尝试获取锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockMutexExample {
// 共享资源
private static int sharedResource = 0;
private static Lock lock = new ReentrantLock();
public static void reentrantLockMutexExample() {
// 获取锁
lock.lock();
try {
// 修改共享资源
sharedResource++;
System.out.println(Thread.currentThread().getName() + " 共享资源已更新为:" + sharedResource);
} finally {
// 释放锁
lock.unlock();
}
}
public static void main(String[] args) {
// 计数器数组
final int[] count = {0};
// 顺便自定义一下线程工厂和拒绝策略,熟悉一下,嘿嘿~
ThreadFactory myThreadFactory = (Runnable r) -> {
Thread thread = new Thread(r);
thread.setName("MyThread-" + count[0]++);
return thread;
};
RejectedExecutionHandler myRejectionHandler = (r, executor) -> {
throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + executor.toString());
};
// 创建 ThreadPoolExecutor 线程池并设置线程工厂和拒绝策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5,
5,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
myThreadFactory,
myRejectionHandler
);
// 提交5个任务给线程池,每个任务都是运行 optimisticLockExample() 方法
for (int i = 0; i < 5; i++) {
executor.submit(OptimisticLockWithVersioning::reentrantLockMutexExample);
}
// 关闭线程池
executor.shutdown();
}
}
总结
乐观锁存在的问题
- ABA问题
ABA问题是当一个线程读取到数据后,其他线程可能修改了数据,并将其重新修改回原始值,而这时候该线程可能无法察觉到数据的变化,从而导致潜在的问题。当然,我们可以使用带有版本信息的原子引用类,例如 AtomicStampedReference,它可以在更新时同时检查版本信息,从而避免出现ABA问题。
- 自旋开销大
在资源竞争激烈的情况下,乐观锁通常会进行自旋操作,即不断地重试 CAS 操作,直到成功为止,这样可能会导致CPU资源的浪费,因为线程在自旋期间是持续占用CPU的。
- 仅保证单个共享变量的原子操作
CAS 操作只能保证单个共享变量的原子性,而对于多个共享变量的操作,需要使用其他同步机制来保证一致性,这意味着如果需要保证多个共享变量的原子性操作,可能需要额外的同步机制,增加了代码开发复杂性。
悲观锁存在的问题
- 性能开销高
悲观锁在每次访问共享资源之前都会加锁,这会带来较高的性能开销,因为每次加锁都需要线程阻塞和唤醒的操作,以及额外的上下文切换。
- 可能引发死锁
如果在多线程环境下不正确地使用悲观锁,可能会导致死锁的发生。比如循环引用,当线程 A 获取了资源 A 的锁,然后尝试获取资源 B 的锁,但此时资源 B 已被线程 B 占用,并且线程 B 同时也在等待资源 A 的锁,这样就会发生死锁。
- 容易导致线程饥饿
悲观锁可能导致线程饥饿的问题,即某些线程无法获得锁而长时间无法执行,如果某些线程持续占用锁或锁竞争激烈,其他线程可能会长时间等待锁的释放,从而导致饥饿现象。
其实乐观锁和悲观锁都有其各自存在的问题,在开发中我们应该根据业务实际情况进行选择,我们可以理解为乐观锁是高可用思想,而悲观锁是强一致思想,它们都有相应合适的应用场景。