锁从不同的角度有不同的分类,从线程是否需要锁住同步资源角度来分,可以分为:悲观锁和乐观锁。
一、悲观锁、乐观锁的定义
悲观锁就是我们常说到的锁。对于悲观锁来说,他总是认为每次访问共享资源时会发生冲突(认为别的线程会修改),所以必须每次数据操作会上锁,以保证临界区的程序同一时间只能有一个线程在执行(共享资源同一时间只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
由于悲观锁的频繁加锁,因此导致了一些问题的出现:比如在多线程竞争下,频繁加锁、释放锁导致频繁的上下文切换和调度延时,一个线程持有锁会导致其他线程进入阻塞状态,从而引起性能问题。
乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问不会产生冲突(认为别的线程不会修改),线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用乐观锁的一种实现方式CAS实现的。
由于无锁操作中没有锁的存在,因此不可能出现死锁情况,也就是说乐观锁天生免疫死锁。
乐观锁多用于“读多写少”的环境,避免频繁加锁影响性能;而悲观锁锁用于“写多读少”的环境,避免频繁失败和重试影响性能。
二、实现方式
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁。synchronized关键字和Lock的实现类都是悲观锁。
乐观锁的实现方式主要有两种:CAS机制和版本号机制。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法。
以Java中的自增操作( i++ )为例,看一下悲观锁和CAS分别是如何保证线程安全的。在Java中自增操作不是原子操作,它实际上包含三个独立的操作:
- 从内存中取出 i 的当前值;
- 将 i 的值加 1;
- 将计算好的值放入到内存当中;
因此,如果并发执行自增操作,可能导致计算结果的不准确。在下面的代码示例中:value1没有进行任何线程安全方面的保护,value2使用了乐观锁(CAS),value3使用了悲观锁(synchronized)。运行程序,使用5000个线程同时对value1、value2和value3进行自增操作,可以发现:value2和value3的值总是等于5000,而value1的值常常小于5000。
package com.example.demo;import java.util.concurrent.atomic.AtomicInteger;public class Test {//value1: 线程不安全private static int value1 = 0;//value2: 使用乐观锁private static AtomicInteger value2 = new AtomicInteger(0);//value2: 使用悲观锁private static int value3 = 0;private static synchronized void increaseValue3(){value3++;}public static void main(String[] args) throws InterruptedException {for (int i=0; i< 5000; i++){new Thread(new Runnable() {@Overridepublic void run() {try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}value1++;value2.getAndIncrement();increaseValue3();}}).start();}// 打印结果Thread.sleep(10000);System.out.println("value1 线程不安全:"+ value1);System.out.println("value2 乐观锁:"+ value2);System.out.println("value3 悲观锁:"+ value3);}
}
输出结果:
value1 线程不安全:4760
value2 乐观锁:5000
value3 悲观锁:5000
三、乐观锁两种实现方式
(1)版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子: 假设数据库中帐户信息表中有一个 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 的操作结果的可能。
package com.example.demo;import java.math.BigDecimal;public class DebitCard {// 账户名称private String account;//账户余额private BigDecimal amount;public DebitCard(String account, BigDecimal amount) {this.account = account;this.amount = amount;}public String getAccount() {return account;}public void setAccount(String account) {this.account = account;}public BigDecimal getAmount() {return amount;}public void setAmount(BigDecimal amount) {this.amount = amount;}@Overridepublic String toString() {return "DebitCard{" +"account='" + account + '\'' +", amount='" + amount + '\'' +'}';}
}
package com.example.demo;import java.math.BigDecimal;
import java.util.concurrent.atomic.AtomicInteger;public class OptimisticLockDemo {private AtomicInteger version = new AtomicInteger(0);private DebitCard debitCard = new DebitCard("zhangsan", new BigDecimal(100));public AtomicInteger getVersion() {return version;}public DebitCard getDebitCard() {return debitCard;}public void updateDebitCard(BigDecimal amount){int currentVersion = version.get();// 模拟读取数据的过程DebitCard currentDebitCard = debitCard;// 模拟其他线程修改数据try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}// 检查版本号是发生变化if (currentVersion == version.get()){// 版本号为变化,可以进行更新操作currentDebitCard.setAmount(currentDebitCard.getAmount().add(amount));debitCard = currentDebitCard;version.incrementAndGet();System.out.println("数据更新成功,当前版本号:"+ version.get()+",数据内容 debitCard= "+ debitCard.toString());} else {// 版本号已经变化,更新操作失败System.out.println("数据更新失败,版本号已变化");}}public static void main(String[] args) {OptimisticLockDemo demo = new OptimisticLockDemo();// 创建两个线程同时更新数据Thread thread1 = new Thread(()->{demo.updateDebitCard(new BigDecimal(-50));});Thread thread2 = new Thread(()->{demo.updateDebitCard(new BigDecimal(-20));});thread1.start();thread2.start();try {thread1.join();thread2.join();} catch (InterruptedException e){e.printStackTrace();}System.out.println("最终版本号:"+ demo.getVersion());System.out.println("最终数据:debitCard= "+ demo.getDebitCard().toString());}
}
输出结果:
数据更新成功,当前版本号:2,数据内容 debitCard= DebitCard{account='zhangsan', amount='80'}
数据更新成功,当前版本号:2,数据内容 debitCard= DebitCard{account='zhangsan', amount='80'}
数据更新失败,版本号已变化
最终版本号:2
最终数据:debitCard= DebitCard{account='zhangsan', amount='80'}
在上面的示例代码中,我们使用了AtomicInteger
类来实现版本号的自增操作,并通过比较版本号来判断数据是否被其他线程修改过。如果版本号未变化,则可以进行更新操作;如果版本号已变化,则更新操作失败。
(2)CAS机制
CAS机制即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值 B 来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
package com.example.demo;import java.math.BigDecimal;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;import static java.util.concurrent.ThreadLocalRandom.current;public class AtomicReferenceExample {private static AtomicReference<DebitCard> debitCardAtomicReference = new AtomicReference<>(new DebitCard("zhangsan", new BigDecimal(0)));public static void main(String[] args){for (int i =0; i<10; i++){new Thread("T-"+i){@Overridepublic void run(){DebitCard dc;DebitCard newDebitCard;do {dc = debitCardAtomicReference.get();newDebitCard = new DebitCard(dc.getAccount(), dc.getAmount().add(new BigDecimal(10)));// 循环检测尝试获取锁} while (!debitCardAtomicReference.compareAndSet(dc, newDebitCard));System.out.println(Thread.currentThread().getName()+"=" + newDebitCard);try {TimeUnit.MILLISECONDS.sleep(current().nextInt(20));} catch (InterruptedException e) {e.printStackTrace();}}}.start();}}}
输出结果:
T-0=DebitCard{account='zhangsan', amount='10'}
T-6=DebitCard{account='zhangsan', amount='50'}
T-2=DebitCard{account='zhangsan', amount='40'}
T-3=DebitCard{account='zhangsan', amount='30'}
T-1=DebitCard{account='zhangsan', amount='20'}
T-4=DebitCard{account='zhangsan', amount='70'}
T-8=DebitCard{account='zhangsan', amount='60'}
T-9=DebitCard{account='zhangsan', amount='100'}
T-7=DebitCard{account='zhangsan', amount='90'}
T-5=DebitCard{account='zhangsan', amount='80'}
CAS虽然很高效,但是它也存在三大问题:
1)ABA问题
CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。
2)循环时间长开销大
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
3)只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
需要注意的是,乐观锁并不能保证绝对的并发安全,因为在更新数据的过程中,可能会有其他线程修改了数据。因此,在实际应用中,还需要结合其他的并发控制手段来确保数据的一致性和安全性。
四、优缺点和适用场景
1、功能限制
与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized 则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
2、竞争激烈程度
如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。