乐观锁与悲观锁

1.锁的定义及其分类

1.1 锁的定义与基本分类

在代码中多个线程需要同时操作共享变量,这时需要给变量上把锁,保证变量值是线程安全的。 锁的种类非常多,比如:互斥锁、自旋锁、重入锁、读写锁、行锁、表锁等这些概念,总结下来就两种类型,乐观锁和悲观锁。

举个形象的例子:

有时候我们上公共厕所的时候要排队。如果你蹲马桶的时候开着门,外面有人排着队看着你。你会这么做吗?当然,如果在自己家里,有可能会这么干,这就是乐观锁。虽然,能进到房间,但是有人占着坑位,该排队还是得排队。比如数据库提供的类似于write_condition机制,Java API 并发工具包下面的原子变量类就是使用了乐观锁的CAS来实现的。

悲观锁就不同了,就相当于是进房间之后,第一件事就是把门锁上,那在门外排队等候的人不知道里面发生了什么,又着急但是又只能干等着,这就是悲观锁。比如行锁、表锁、读锁、写锁,都是在操作之前先上锁,Java API中的synchronized和ReentrantLock等独占锁都是悲观锁思想的实现。

1.2 乐观锁

乐观锁就是持比较乐观态度的锁。在操作数据时非常乐观,认为别的线程不会同时修改数据,只有到数据提交的时候才通过一种机制来验证数据是否存在冲突。一般使用CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中可以通过版本号(@Version)等机制来实现乐观锁。


看到这相信小伙伴们跟我一脸懵,CAS算法又是谁啊???

是的没错,我会出手的!!!


1.3 补充:CAS算法
1.3.1 什么是CAS

CAS(Compare and Swap)是无锁化编程的常用方法。CAS 实现了区别于 sychronized 同步锁(悲观锁的一种实现)的一种乐观锁;CAS一个线程失败或挂起并不会导致其他线程也失败或挂起--一种非阻塞算法实现。它能在不使用锁的情况下实现多线程安全,所以CAS是一种无锁算法

1.3.2 作用

(1)当线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试

(2)CAS 有 3 个操作数,内存值 V(主线程中共享变量的值),预期值 A(预期内存位置V应当持有的值),新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做,并返回false。CAS 有效地说明了“我认为位置 V 应该包含值 A,如果真的包含A值,则将 B 放到这个位置,否则,不要更改该位置,只告诉我这个位置现在的值(A)即可。”整个比较并交换的操作是原子操作

1.3.3 实现原理

一个线程间共享的变量,首先在主存中会保留一份,然后每个线程的工作内存也会保留一份副本。这里说的预期值,就是线程保留的副本。当该线程从主存中获取该变量的值后,主存中该变量可能已经被其他线程刷新了,但是该线程工作内存中该变量却还是原来的值,这就是所谓的预期值了。当你要用 CAS刷新该值的时候,如果发现线程工作内存和主存中不一致了,就会失败,如果一致,就可以更新成功。

1.3.4 JUC(java.util.concurrent)

java.util.concurrent.Atomic 包提供了一系列原子类。这些类可以保证多线程环境下,当某个线程在执行 atomic 中的方法时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法(atomic )执行完成后,才由 JVM 从等待队列中选择一个线程执行。Atomic 类在软件层面上是非阻塞的,它的原子性其实是在硬件层面上借助相关的指令来保证的。

以下是一些java.util.concurrent.atomic包中常见的原子变量类和它们的方法示例:
​
1.AtomicInteger:用于原子性地更新int值。
    get():获取当前值。
    set(int newValue):设置新值。
    incrementAndGet():以原子方式将当前值加1,并返回更新后的值。
    decrementAndGet():以原子方式将当前值减1,并返回更新后的值。
    addAndGet(int delta):以原子方式将给定值与当前值相加,并返回更新后的值。
    compareAndSet(int expect, int update):如果当前值等于预期值,则以原子方式设置该值为给定的更新值。
2.AtomicLong:与AtomicInteger类似,但用于long值。
3.AtomicBoolean:用于原子性地更新boolean值。
    get():获取当前值。
    set(boolean newValue):设置新值。
    compareAndSet(boolean expect, boolean update):如果当前值等于预期值,则以原子方式设置该值为给定的更新值。
4.AtomicReference:用于引用对象,并提供了对其引用的原子性操作。
    get():获取当前值。
    set(V newValue):设置新值。
    compareAndSet(V expect, V update):如果当前值等于预期值,则以原子方式设置该值为给定的更新值。
5.其他原子类:还包括如AtomicIntegerArray、AtomicLongArray、AtomicStampedReference等,它们提供了对数组或更复杂的原子操作的支持。
1.3.5 CAS算法解决的问题

使用CAS就可以不用加锁来实现线程安全。

原子更新的基本类型包括:

  • AtomicBoolean:原子更新布尔变量;

  • AtomicInteger:原子更新整型变量;

  • AtomicLong:原子更新长整型变量;

下面以AtomicInteger为例:

public class AtomicInteger extends Number implements java.io.Serializable {
     //返回当前的值
     public final int get() {
         return value;
     }
     //原子更新为新值并返回旧值
     public final int getAndSet(int newValue) {
         return unsafe.getAndSetInt(this, valueOffset, newValue);
     }
     //最终会设置成新值
     public final void lazySet(int newValue) {
         unsafe.putOrderedInt(this, valueOffset, newValue);
     }
     //如果输入的值等于预期值,则以原子方式更新为新值
     public final boolean compareAndSet(int expect, int update) {
         return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
     }
     //原子自增,返回的是增加之前的值  
     public final int getAndIncrement() {
         return unsafe.getAndAddInt(this, valueOffset, 1);
     }
     //原子方式将当前值与输入值相加并返回结果
     public final int getAndAdd(int delta) {
         return unsafe.getAndAddInt(this, valueOffset, delta);
     }
 }

为了说明AtomicInteger的原子性,这里代码演示多线程对一个int值进行自增操作,最后输出结果,代码如下:

import java.util.concurrent.atomic.AtomicInteger;  
public class AtomicInteger {  
    // 将AtomicInteger实例初始值设为0  
    private static AtomicInteger atomicInteger = new AtomicInteger(0);  
  
    public static void main(String[] args) {  
        for (int i = 0; i < 5; i++) {  
            new Thread(new Runnable() {  
                public void run() {  
                    // 调用AtomicInteger的getAndIncrement方法,返回的是增加之前的值  
                    int previousValue = atomicInteger.getAndIncrement();  
                    System.out.println(Thread.currentThread().getName() + " 之前的值: " + previousValue);  
                }  
            }).start();  
        }  
  
        // 主线程可能不会等待所有其他线程完成,所以这里打印的值可能不是5个线程都增加后的最终值  
        // 要确保所有线程都完成,你需要某种形式的同步或等待机制  
        // 但为了简单起见,这里只是打印出主线程看到的值  
        System.out.println("主线程看到的最终值: " + atomicInteger.get());  
    }  
}

输出:

Thread-0 之前的值: 0  
Thread-2 之前的值: 1  
Thread-4 之前的值: 2  
Thread-1 之前的值: 3  
Thread-3 之前的值: 4  
主线程看到的最终值: 5

可见,在多线程的情况下,得到的结果是正确的,但是如果仅仅使用int类型的成员变量则可能得到不同的结果。这里的关键在于getAndIncrement是原子操作,那么是如何保证的呢?看源代码:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}
​
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = this.getIntVolatile(o, offset);
    } while(!this.compareAndSwapInt(o, offset, v, v + delta));
    return v;
}
​
public final native boolean compareAndSwapInt(Object o, long offset, int delta, int v);

通过方法调用,我们可以发现,getAndIncrement方法调用getAndAddInt方法,最后调用的是compareAndSwapInt方法,即本文的主角CAS。

getAndAddInt方法解析:拿到内存位置的最新值v,使用CAS尝试将内存位置的值v修改为目标值v+delta,如果修改失败,则获取该内存位置的新值v,然后继续尝试,直至修改成功,如果这是CAS一直失败,多次尝试后多没有成功,就会给CPU带来很大的开销。

1.3.6 CAS的缺点:

CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。

【1】循环时间长、开销很大。

当某一方法比如:getAndAddInt执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。

【2】只能保证一个共享变量的原子操作。

当操作1个共享变量时,我们可以使用循环CAS的方式来保证原子操作,但是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。

【3】存在ABA问题 ABA问题是CAS中的一个漏洞。CAS的定义,当且仅当内存值V等于就得预期值A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。

那么如果先将预期值A给成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,这时其他线程的CAS操作仍然能够成功,但是很明显是个漏洞,因为预期值A的值变化过了。

如何解决这个异常现象?java并发包为了解决这个漏洞,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,即在变量前面添加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

1.3.7 CAS获取锁

当CAS操作被用于尝试获取锁时,通常是在自旋锁(spinlock)的实现中。自旋锁是一种非阻塞锁,当一个线程试图获取锁但锁已被其他线程持有时,该线程会不断循环(自旋)检查锁是否可用,而不是被阻塞等待。

下面是一个基于CAS尝试获取锁的简单示例:

import java.util.concurrent.atomic.AtomicInteger;  
  
public class CASLock {  
    private AtomicInteger state = new AtomicInteger(0); // 锁的状态,0表示未锁定,1表示已锁定  
  
    // 尝试获取锁  
    public boolean tryLock() {  
        // 使用CAS操作尝试将state从0更新为1  
        return state.compareAndSet(0, 1);  
    }  
  
    // 释放锁  
    public void unlock() {  
        // 将state重置为0,表示释放锁  
        state.set(0);  
    }  
}

在上面的示例中,state是一个AtomicInteger对象,它用于表示锁的状态。当state的值为0时,表示锁未被占用;当state的值为1时,表示锁已被占用。tryLock方法使用CAS操作尝试将state的值从0更新为1。如果更新成功,则表示线程成功获取了锁;如果更新失败(即state的值已经为1),则表示锁已被其他线程占用,当前线程需要继续等待或执行其他操作。


好了,各位看观,CAS算法讲解到这里咯。。。

1.4 悲观锁

比较悲观的锁,总是想着最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

在Java中,synchronized从偏向锁、轻量级锁到重量级锁,全是悲观锁。JDK提供的Lock实现类全是悲观锁。一般用于多写的场景

悲观锁(Pessimistic Locking)是一种在数据被处理时对数据采取加锁的策略,即在数据处理过程中,数据是处于被锁定的状态,以此来达到排他性的目的。悲观锁认为数据在处理过程中极有可能被其他事务修改,因此,在整个数据处理过程中,会将数据处于锁定状态。

悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

悲观锁的使用流程大致如下:

  1. 请求锁:

    • 当一个事务需要访问某个数据项时,它会尝试请求对该数据项的锁。

  2. 获取锁:

    • 如果该数据项当前没有被其他事务锁定,则请求锁的事务会成功获得锁,并继续执行后续操作。

    • 如果该数据项已经被其他事务锁定,则请求锁的事务会被阻塞,直到锁被释放。

  3. 访问数据:

    • 获得锁的事务可以安全地访问(读取或修改)数据项,因为它知道在事务完成之前,其他事务无法修改该数据项。

  4. 释放锁:

    • 当事务完成对数据的访问后,它会释放对数据项的锁,以便其他事务可以访问该数据项。

悲观锁适用于写操作频繁的场景,因为它可以确保数据的一致性。但是,悲观锁也有其缺点:

  • 降低并发性能:因为悲观锁会阻塞其他事务对数据项的访问,所以会降低系统的并发性能。

  • 死锁:如果多个事务相互等待对方释放资源,就可能导致死锁。

为了避免这些缺点,需要根据具体的应用场景和需求来选择合适的锁策略。例如,在读多写少的场景中,可以使用乐观锁来提高并发性能。

2.乐观锁使用方法

在涉及到乐观锁的使用方法时,只学习在项目中如何使用。


2.1数据版本记录
  • 在数据表中增加一个额外的字段(“version”),用于记录数据的版本信息-----即表示当前谁在操纵这条数据。这个字段通常是一个整数或时间戳,每次数据被更新时,这个字段的值都会增加----会自增1

  • 默认值设为1

2.2 相应的实体类中加入@Version和相应字段值
@Version
private Interger version
2.3 在配置拦截器中添加乐观锁
//添加乐观锁
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

2.4 在业务层处理时,必须把version字段值收集到

@Test
void testUpdate(){
User user = new User();
user.setId(3L);
user.setVersion(1);
userMapper.updateById(user);
}
如果不提供ve
如果不提供version,就没有这套锁机制了。 

3. 乐观锁与多线程

乐观锁(Optimistic Locking)与多线程编程之间存在紧密的联系,特别是在并发控制和数据同步方面。

乐观锁是一种在数据处理过程中保持乐观态度的策略,它假设在数据处理过程中,其他事务不会修改数据。因此,在读取数据时,乐观锁不会立即对数据进行加锁,而是在数据更新时检查数据是否在此期间被其他事务修改过。如果数据没有被修改,则更新操作会成功执行;如果数据已经被其他事务修改,则更新操作会失败,并需要重新读取数据再尝试更新。

在多线程编程中,乐观锁常用于实现并发控制和数据同步。当多个线程需要同时访问和修改共享数据时,使用乐观锁可以避免线程之间的冲突和竞态条件。具体来说,每个线程在读取数据时都会获取一个数据的当前版本(如版本号、时间戳等),并在更新数据时检查该版本是否仍然与读取时的版本一致。如果版本一致,则说明数据在此期间没有被其他线程修改过,更新操作可以安全执行;如果版本不一致,则说明数据已经被其他线程修改过,更新操作会失败,需要重新读取数据并再次尝试更新。

与悲观锁相比,乐观锁具有更高的并发性能,因为它不会阻塞其他线程对数据的访问。但是,乐观锁也存在一些潜在的问题。例如,当多个线程同时尝试更新同一块数据时,可能会出现大量的更新失败和重试操作,这会导致额外的CPU和网络资源消耗。此外,乐观锁的实现通常依赖于数据的版本号或时间戳等机制,这些机制可能会增加数据的复杂性和存储空间的需求。

  • 28
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值