Java中的锁(二)

        锁从不同的角度有不同的分类,从线程是否需要锁住同步资源角度来分,可以分为:悲观锁和乐观锁。

一、悲观锁、乐观锁的定义

        悲观锁就是我们常说到的锁。对于悲观锁来说,他总是认为每次访问共享资源时会发生冲突(认为别的线程会修改),所以必须每次数据操作会上锁,以保证临界区的程序同一时间只能有一个线程在执行(共享资源同一时间只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

        由于悲观锁的频繁加锁,因此导致了一些问题的出现:比如在多线程竞争下,频繁加锁、释放锁导致频繁的上下文切换和调度延时,一个线程持有锁会导致其他线程进入阻塞状态,从而引起性能问题。

        乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问不会产生冲突(认为别的线程不会修改),线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用乐观锁的一种实现方式CAS实现的。

        由于无锁操作中没有锁的存在,因此不可能出现死锁情况,也就是说乐观锁天生免疫死锁。 

        乐观锁多用于“读多写少”的环境,避免频繁加锁影响性能;而悲观锁锁用于“写多读少”的环境,避免频繁失败和重试影响性能。

二、实现方式

        悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁。synchronized关键字和Lock的实现类都是悲观锁。

        乐观锁的实现方式主要有两种:CAS机制和版本号机制。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法。

        以Java中的自增操作( i++ )为例,看一下悲观锁和CAS分别是如何保证线程安全的。在Java中自增操作不是原子操作,它实际上包含三个独立的操作:

  1. 从内存中取出 i 的当前值;
  2. 将 i 的值加 1;
  3. 将计算好的值放入到内存当中;

        因此,如果并发执行自增操作,可能导致计算结果的不准确。在下面的代码示例中: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 

三、乐观锁两种实现方式

(1)版本号机制

        一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

        举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 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;
    }

    @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类来实现版本号的自增操作,并通过比较版本号来判断数据是否被其他线程修改过。如果版本号未变化,则可以进行更新操作;如果版本号已变化,则更新操作失败。

(2)CAS机制

        CAS机制即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  1. 需要读写的内存值 V
  2. 进行比较的值 A
  3. 拟写入的新值 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 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虽然很高效,但是它也存在三大问题:
        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资源。

        悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。

        乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

你可能感兴趣的:(JAVA,java,开发语言)