乐观锁和悲观锁
使用场景
当程序中可能出现并发的情况时,就需要通过一定的手段来保证在并发情况下数据的准确性,通过这种手段保证了当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的。这种手段就叫做并发控制。并发控制的目的是保证一个用户的工作不会对另一个用户的工作产生不合理的影响。所以在遇到并发场景的时候,我们需要采用加锁的操作,实现手段可以分为悲观锁和乐观锁。
1. 悲观锁 和 乐观锁
乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。
2. 悲观锁
悲观锁的观点就是每次对数据进行操作的时候,都会认为其他的线程会对数据进行相应的操作,所会对数据进行加锁操作,直到数据处理完毕以后在把数据释放。同一时间 ,其他线程想获取该数据,就必须等待线程资源,进入阻塞状态。
其中Java 采用
synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
// 采用synchronized 关键字锁住类类方法或者代码块
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (Exception e) {
e.printStackTrace();
}
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
synchronized (ThreadCas.class){
count++;
}
}
}
}).start();
}
try{
Thread.sleep(2000);
}catch (Exception e){
e.printStackTrace();
}
System.out.println(count);
}
我们期望的结果是200,打印出结果也是200;但是当我们去掉关键字synchronized
的时候,我们打印的值不会得到200,可能是100-200中的一个值。但是使用synchronized
关键字的时候会让没有获取资源的线程进入阻塞状态,而获取资源后又进入运行状态,这样的上下文切换是比较消耗时间的,所以在等待资源时间较短的情况,可以不用synchronized
关键字,从而使用乐观锁中的CAS操作。
// ReentrantLock 加lock
private volatile static int count = 0;
public static void main(String[] args) {
ReentrantLock lock=new ReentrantLock();
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
//lock.lock()放在try块之前
lock.lock();
try {
test.count++;
} catch (Exception e) {
e.printStackTrace();
}
finally {
//unlock()必须放在finally中
lock.unlock();
}
}
}
}).start();
}
try{
Thread.sleep(100);
}catch (Exception e){
e.printStackTrace();
}
//输出200
System.out.println(count);
}
3.乐观锁
乐观锁默认为一个线程对该数据进行操作时,其他线程不会对该数据进行修改,所以线程对数据进行操作时候默认不会加锁。(乐观锁适用于大量读操作,少量写操作)在Java中
java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的,还有采用 版本号机制 进行实现
3.1 CAS(Compare and Swap 比较并交换)
// AtomicInteger 采用了CAS 的方法
private static AtomicInteger count1 = new AtomicInteger(0);
@Test
public void add1(){
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (Exception e) {
e.printStackTrace();
}
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
count1.incrementAndGet();
}
}
}).start();
}
try{
Thread.sleep(2000);
}catch (Exception e){
e.printStackTrace();
}
System.out.println(count1);
}
3.2 CAS采用的版本号机制
举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
- 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 50(
100-$50 )。
在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 20(
- 100-$20 )。
- 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
- 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。