Java中各种锁的详细介绍(二):悲观锁和乐观锁

Java中锁的类型多种多样,有简单有复杂,适合各种不同的应用场景,接下来会分几章给大家详细介绍java中各种类型的锁。

一、悲观锁和乐观锁的说明

1、悲观锁(Pessimistic Lock):对于同一个数据的并发操作,想的很坏,很悲观,都认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。别的线程想拿数据就被挡住,直到悲观锁被释放,悲观锁中的共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

此外阻塞、唤醒以及引起的CPU状态切换等处理悲观锁的机制会产生额外的开销,还有增加产生死锁的机会,另外还会降低程序的并行性。

Java中synchronized关键字和Lock的实现类,以及数据库中的行锁、表锁、读锁(共享锁)和写锁(排他锁)都是悲观锁。

2、乐观锁(Optimistic Lock):很乐观,每次去拿数据的时候都认为别的线程不会修改。所以不会上锁,只有在想要更新数据时候,去检查在读取至更新这段时间别的线程有没有修改过这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入;如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在Java中是通过使用无锁编程来实现,所以不会产生任何锁和死锁,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

数据库实现乐观锁并不会使用数据库提供的锁机制。一般实现乐观锁的方式就是数据表字段增加版本号(version)或者是时间戳来实现,使用版本号是最常用的。

二、悲观锁和乐观锁的调用方式

1、悲观锁的调用方式

//悲观锁的调用方式
// synchronized
public synchronized void testMethod() {
	// 操作同步资源
}
// Reentrantlock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用同一个锁
public void modifyPublicResources() {
	lock.lock();
	//操作同步资源
	lock.unlock();
}

悲观锁通过显式的锁定再操作同步资源,但是如果存在嵌套锁的情况下,会出现死锁,例如:

public class DeadlockExample {
    private Object lock1 = new Object();
    private Object lock2 = new Object();
    
    public void method1() {
        synchronized (lock1) {
            System.out.println("method1 acquired lock1");
            synchronized (lock2) {
                System.out.println("method1 acquired lock2");
            }
        }
    }
    
    public void method2() {
        synchronized (lock2) {
            System.out.println("method2 acquired lock2");
            synchronized (lock1) {
                System.out.println("method2 acquired lock1");
            }
        }
    }
}

在上面的代码中,method1()获取lock1锁后,又尝试获取lock2锁;method2()获取lock2锁后,又尝试获取lock1锁。如果两个线程分别调用这两个方法,且在相应的时刻互相等待对方释放锁,就会出现死锁。实际应用中解决这种问题的方法是尽量避免嵌套锁的使用,并且对锁的获取顺序进行规定。比如,在上面的代码中,可以规定获取锁的顺序为先获取lock1锁,再获取lock2锁,这样就可以避免死锁的出现。

2、乐观锁的调用方式

public class OptimisticLockDemo {
    private String value;
    private AtomicInteger version = new AtomicInteger(0);

    public void update(String newValue) {
        while (true) {
            int currentVersion = version.get();
            //业务处理
            if (currentVersion == version.get()) {
                value = newValue;
                version.incrementAndGet();
                break;
            }
        }
    }
}

 乐观锁采用无锁方式,所以不存在死锁的情况,因此在并发性能上会更加优越,适用于读多写少的场景

三、乐观锁的实现方式CAS

1、CAS简介

通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们通过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为大家解惑。

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。

  • 进行比较的值 A。

  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

2、CAS存在的三大问题

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、悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。

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

关于数据库实现悲观锁和乐观锁的示例可以参考文章:

悲观锁与乐观锁的实现(详情图解)_乐观锁和悲欢锁的实现_零点零六了的博客-CSDN博客

猜你喜欢

转载自blog.csdn.net/m0_37258559/article/details/130523555
今日推荐