锁从不同的角度有不同的分类,从线程是否需要锁住同步资源角度来分,可以分为:悲观锁和乐观锁。
悲观锁就是我们常说到的锁。对于悲观锁来说,他总是认为每次访问共享资源时会发生冲突(认为别的线程会修改),所以必须每次数据操作会上锁,以保证临界区的程序同一时间只能有一个线程在执行(共享资源同一时间只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
由于悲观锁的频繁加锁,因此导致了一些问题的出现:比如在多线程竞争下,频繁加锁、释放锁导致频繁的上下文切换和调度延时,一个线程持有锁会导致其他线程进入阻塞状态,从而引起性能问题。
乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问不会产生冲突(认为别的线程不会修改),线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用乐观锁的一种实现方式CAS实现的。
由于无锁操作中没有锁的存在,因此不可能出现死锁情况,也就是说乐观锁天生免疫死锁。
乐观锁多用于“读多写少”的环境,避免频繁加锁影响性能;而悲观锁锁用于“写多读少”的环境,避免频繁失败和重试影响性能。
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁。synchronized关键字和Lock的实现类都是悲观锁。
乐观锁的实现方式主要有两种:CAS机制和版本号机制。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法。
以Java中的自增操作( i++ )为例,看一下悲观锁和CAS分别是如何保证线程安全的。在Java中自增操作不是原子操作,它实际上包含三个独立的操作:
因此,如果并发执行自增操作,可能导致计算结果的不准确。在下面的代码示例中: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() {
@Override
public 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
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
这样,就避免了操作员 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;
}
@Override
public 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
类来实现版本号的自增操作,并通过比较版本号来判断数据是否被其他线程修改过。如果版本号未变化,则可以进行更新操作;如果版本号已变化,则更新操作失败。
CAS机制即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
当且仅当 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 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){
@Override
public 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需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
需要注意的是,乐观锁并不能保证绝对的并发安全,因为在更新数据的过程中,可能会有其他线程修改了数据。因此,在实际应用中,还需要结合其他的并发控制手段来确保数据的一致性和安全性。
与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized 则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。