【JavaEE】锁策略和CAS

    🔥个人主页: 中草药

🔥专栏:【Java】登神长阶 史诗般的Java成神之路


💰一.常见的的锁策略

        锁策略(Locking Strategy)是指在多线程环境中,为了控制对共享资源的访问,确保数据一致性和线程安全,而采用的一系列机制和规则。在并发编程中,锁是管理共享资源访问的核心工具,它防止了多个线程同时修改同一份数据,从而避免了数据竞争和不一致性的问题。不同的锁策略有着不同的特性和适用场景,它们在并发控制、性能、复杂性和可扩展性方面存在差异。

1.乐观锁vs悲观锁

乐观锁

        乐观锁基于“乐观主义”假设,认为数据不太可能被并发修改,因此在读取数据时不会锁定数据,只有在更新数据时才检查数据是否被其他事务修改过。乐观锁通常使用版本号或时间戳来实现,每次更新数据时都会检查版本号是否与读取时相同,如果不同,则表明数据已经被其他事务修改,本次更新将被拒绝。

        乐观锁的优点在于它减少了锁的使用,提高了系统的并发性能,尤其适合读多写少的场景。缺点是如果多个事务同时尝试更新同一份数据,可能会导致更新失败,需要重新读取数据并再次尝试更新,这被称为“重试”。

悲观锁

        悲观锁基于“悲观主义”假设,认为数据很可能被并发修改,因此在读取或写入数据之前,会先锁定数据,阻止其他线程或进程的并发访问。悲观锁通过在事务开始时获取锁并在事务结束时释放锁来实现。常见的悲观锁机制包括:

  • 排他锁(Exclusive Locks):写操作通常需要排他锁,不允许任何其他读写操作同时进行。
  • 共享锁(Shared Locks):读操作可以获取共享锁,允许多个读操作同时进行,但不允许写操作。

        悲观锁的优点在于它能保证数据的一致性,避免了脏读和不可重复读等问题。然而,它的缺点也很明显,主要是锁的等待时间可能较长,容易造成死锁,且降低了系统的并发度。

乐观锁vs悲观锁

特征乐观锁(Optimistic Locking)悲观锁(Pessimistic Locking)
基本假设假设数据不太可能被并发修改假设数据很可能被并发修改
实现机制使用版本号或时间戳进行并发控制通过锁定数据防止并发修改
锁的使用不在读取数据时使用锁在读取或写入数据时使用锁
更新策略更新时检查版本号,如果冲突则重试更新前锁定,更新后释放锁
并发性能高,因为它减少了锁的使用低,因为锁的等待时间可能较长
数据一致性较低,可能发生重试高,锁定期间确保数据一致性
死锁风险无,因为没有锁的等待高,尤其是在复杂的事务中
资源消耗低,较少的系统调用和上下文切换高,较多的系统调用和上下文切换
适用场景读多写少,对实时性要求较高的系统写操作频繁,对数据一致性要求极高的系统
复杂性实现上相对复杂,需要处理版本控制和冲突实现上相对简单,依赖于锁机制
Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换为悲观锁策略

2.轻量级锁vs重量级锁

轻量级锁

  • 定义:轻量级锁是JVM为了提高锁的性能而引入的一种机制,它试图在没有线程竞争的情况下避免使用重量级锁的开销。轻量级锁在Java 6之后的版本中默认启用。

  • 实现:轻量级锁使用了基于CAS(Compare and Swap)的原子操作。当一个线程尝试获取锁时,它会通过CAS操作将当前线程ID写入锁对象的Mark Word中。如果CAS操作成功,那么线程获得了锁;如果失败,则进入下一步骤。

  • 特点

    • 性能较高,因为它避免了操作系统层面的线程挂起和唤醒,减少了上下文切换的开销。
    • 当锁竞争较少时,轻量级锁的效果最佳。
    • 如果在一定次数的自旋后仍未能获取锁,轻量级锁会膨胀为重量级锁。
    • 少量内核态用户态的切换
    • 不太容易引发线程的调度

重量级锁

  • 定义:重量级锁是传统的锁实现,当一个线程获取重量级锁时,其他试图获取该锁的线程将被阻塞,直到锁被释放。

  • 实现:重量级锁的获取和释放涉及操作系统层面的线程挂起和唤醒,这通常需要从用户态切换到内核态,开销较大。

  • 特点

    • 性能较低,因为涉及到线程挂起和唤醒的开销。
    • 在锁竞争激烈时,重量级锁可以确保数据的一致性和线程安全。
    • 当线程竞争锁时,重量级锁可以更好地保证数据的完整性,但牺牲了性能。
    • 大量内核态用户态的切换
    • 很容易引发线程的调度

轻量级锁vs重量级锁

特征轻量级锁(Lightweight Lock)重量级锁(Heavyweight Lock)
实现机制基于CAS的原子操作操作系统层面的线程挂起和唤醒
性能在锁竞争较少时性能高锁竞争激烈时,性能低
上下文切换减少了上下文切换需要进行上下文切换
资源消耗相对较低相对较高
适用场景读多写少,锁竞争较小的场景写操作频繁,对数据一致性要求高的场景
锁升级当锁竞争加剧时,轻量级锁可能升级为重量级锁无升级过程

synchronized 开始是⼀个轻量级锁. 如果锁冲突⽐较严重, 就会变成重量级锁

3.自旋锁vs挂起等待锁

自旋锁(Spin Lock)

  • 定义:当一个线程试图获取一个已经被其他线程持有的锁时,自旋锁会让当前线程在一个循环中不断检查锁的状态,直到锁变为可用状态。

  • 特点

    • 避免了线程的挂起和唤醒,减少了线程上下文切换的开销。
    • 适用于锁持有时间非常短的场景,因为在这种情况下,自旋等待的CPU消耗可能比线程挂起和唤醒的开销要小。
    • 如果锁的持有时间较长,或者竞争锁的线程数量很多,自旋锁可能会导致大量的CPU空转,浪费计算资源。

挂起等待锁(Sleeping Lock)

  • 定义:当一个线程试图获取一个已经被其他线程持有的锁时,挂起等待锁会让当前线程进入等待状态,直到锁变为可用。这通常涉及到线程的挂起和唤醒。

  • 特点

    • 减少了CPU的空转,节省了计算资源。
    • 适用于锁持有时间较长,或者线程竞争较为激烈的场景。
    • 线程的挂起和唤醒涉及到操作系统层面的操作,会有一定的开销,包括上下文切换。

自旋锁vs挂起等待锁

特征自旋锁(Spin Lock)挂起等待锁(Sleeping Lock)
CPU使用可能导致CPU空转,消耗CPU资源节省CPU资源,避免空转
上下文切换减少了线程的上下文切换增加了线程的上下文切换
适用场景锁持有时间短,竞争不激烈锁持有时间长,或竞争激烈
开销锁竞争大时CPU开销大锁竞争大时上下文切换开销大

4.公平锁vs非公平锁

公平锁

  • 定义:公平锁遵循先进先出(FIFO)的原则,确保请求锁的线程按照它们请求锁的顺序来获取锁。这意味着如果一个线程在另一个线程之前请求了锁,那么它将在那个线程之前获得锁,除非那个线程释放了锁。

  • 特点

    • 提供了更高的公平性,避免了后请求锁的线程“插队”。
    • 由于必须检查等待队列中的所有线程,因此在锁的竞争中可能会有更高的性能开销。
    • 在线程交替请求锁的场景下,公平锁可以避免线程饥饿,即某个线程长期得不到锁的情况。

非公平锁

  • 定义:非公平锁不保证锁的获取顺序,它允许后请求锁的线程有可能比先请求锁的线程更快地获得锁。在默认情况下,ReentrantLock就是非公平锁。(后文详细介绍)

  • 特点

    • 性能通常优于公平锁,因为它在获取锁时不需要遍历等待队列,而是直接尝试获取。
    • 可能会出现线程饥饿现象,即某些线程长时间无法获取到锁。
    • 在锁的竞争较少的情况下,非公平锁的性能优势更加明显。

公平锁vs非公平锁

特征公平锁(Fair Lock)非公平锁(Unfair Lock)
获取顺序按照请求锁的顺序获取锁不保证获取顺序,可能存在“插队”现象
性能锁竞争激烈时性能可能较低锁竞争较少时性能较高
公平性高,避免线程饥饿低,可能存在线程饥饿
默认行为ReentrantLock不默认使用ReentrantLock的默认行为
应用场景锁竞争激烈,需避免线程饥饿锁竞争较少,追求高吞吐量

注意

  • 操作系统内部的线程调度就可以视为随机的,如果不做任何额外的限制,锁就是非公平锁,如果想要实现公平锁,就需要依赖额外的数据结构,来记录线程的先后顺序
  • 公平锁和非公平锁没有好坏之分,关键还看适用场景 

5.可重入锁vs不可重入锁

可重入锁(Reentrant Lock)

  • 定义:可重入锁允许一个线程多次获取同一把锁,而不会导致死锁。每当一个线程获取锁时,锁的计数器会递增,当该线程释放锁时,计数器递减,直到计数器归零,锁才真正被释放。

  • 特点

    • 支持递归锁定,即一个线程可以在已经获取锁的情况下再次获取锁。
    • 避免了因递归锁定而导致的死锁问题。
    • 在多线程环境中,特别当线程需要多次进入同一临界区时,可重入锁提供了灵活性和安全性。

不可重入锁(Non-reentrant Lock)

  • 定义:不可重入锁不允许一个线程多次获取同一把锁。如果一个线程已经获取了一把锁,再次尝试获取这把锁会导致阻塞,直到锁被另一个线程释放。

  • 特点

    • 简化了锁的管理,因为不需要跟踪锁的嵌套级别。
    • 如果不恰当地使用递归锁定,不可重入锁可以防止死锁,但这也限制了它的使用场景。
    • 在单线程多次访问同一临界区的场景下,不可重入锁可能不如可重入锁灵活。

可重入锁vs不可重入锁

特征可重入锁(Reentrant Lock)不可重入锁(Non-reentrant Lock)
递归锁定支持,允许多次获取同一把锁不支持,再次获取同一把锁会导致阻塞
死锁预防内部机制可以避免递归锁定导致的死锁简化了锁管理,但限制了灵活性
灵活性高,适合复杂多线程场景低,适合简单或不需要递归锁定的场景
安全性高,避免了死锁较高,但在某些场景下可能过于限制
synchronized 是可重⼊锁

6.读写锁

读写锁(Read-Write Lock)是一种特殊的锁机制,它允许多个读操作同时进行,但写操作是独占的。读写锁的设计目的是为了提高并发性能,尤其是在读操作远远多于写操作的场景下。下面详细解释读写锁的工作原理及其在Java中的实现:

工作原理

读写锁维护了两把锁:一把读锁和一把写锁。

  • 读锁:允许多个线程同时获取,只要没有线程持有写锁。这意味着多个线程可以同时读取共享资源,只要没有线程正在进行写操作。

  • 写锁:是独占的,意味着在任何时刻,只能有一个线程持有写锁。当一个线程持有写锁时,其他所有线程(无论是读还是写)都无法获取锁,直到写锁被释放。

特点

  • 高并发性:读写锁提高了读操作的并发度,因为在没有写操作时,多个读线程可以同时访问共享资源。

  • 写操作独占:写操作总是独占的,确保了数据在写入时的一致性,防止了数据竞争条件。

  • 公平性:读写锁可以有不同的公平性实现。有些实现保证了读写操作的公平性,即按照请求顺序获取锁;有些则优先考虑写操作,以避免写饿死。

Java中的实现

在Java中,读写锁通过java.util.concurrent.locks.ReadWriteLock接口来实现,最常见的实现是ReentrantReadWriteLockReadWriteLock接口定义了readLock()writeLock()两个方法,分别用于获取读锁和写锁。

ReentrantReadWriteLock.ReadLock 类 表示一个读锁,这个对象提供了lock/unlock方法进行加锁解锁.

ReentrantReadWriteLock.WriteLock 类 表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

  • 读加锁和读加锁之间,不互斥
  • 写加锁和写加锁之间,互斥
  • 读加锁和写加锁之间,互斥
Synchronized 不是读写锁.

🪙二.CAS

        比较并交换(Compare-and-Swap,简称CAS)是一种无锁算法的基本构建块,广泛应用于并发编程中,用于实现原子操作。CAS操作在多处理器架构中特别有用,因为它能够确保即使在多个线程或处理器同时尝试修改同一内存位置时,操作也能正确、原子地执行。下面详细讲述CAS的概念、工作原理以及在Java中的实现。

1.基本概念

CAS是一种硬件级别的原子操作,通常由处理器直接支持。它涉及三个操作数:内存位置(V)、期望的旧值(A)和新值(B)。CAS操作会比较内存位置V的当前值与期望的旧值A是否相等,如果相等,则将V的值原子地更新为新值B;如果不相等,则操作失败,返回当前的V值。由于CAS操作是原子的,这意味着在CAS操作过程中,不会有其他线程或进程能够干扰这个操作。

2.工作原理

  1. 加载值:CAS操作开始时,线程会加载内存位置V的当前值。

  2. 比较值:将加载的值与期望的旧值A进行比较。如果两者相等,则进行下一步;如果不等,CAS操作失败。

  3. 交换值:如果比较成功,CAS操作会原子地将V的值更新为新值B。

  4. 返回结果:CAS操作返回一个结果,指示操作是否成功。如果成功,通常返回新值;如果失败,返回旧值。

工作原理伪代码

下⾯写的代码不是原⼦的, 真实的 CAS 是⼀个原⼦的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的⼯作流程.

boolean CAS(address, expectValue, swapValue) {
    if (&address == expectedValue) {
    &address = swapValue;
    return true;
    }
    return false;
}

3.CAS在Java中的实现

在Java中,CAS操作主要通过Unsafe类的compareAndSwapIntcompareAndSwapLongcompareAndSwapObject方法实现,这些方法提供了对底层硬件CAS操作的访问。然而,直接使用Unsafe类通常被认为是不推荐的,因为它破坏了Java的封装性和安全性。

更安全且推荐的方式是使用Java并发库中的Atomic类,如AtomicIntegerAtomicLongAtomicReference。这些类内部使用了CAS操作,但对外提供了更高级、更安全的API。

举例 

import java.util.concurrent.atomic.AtomicInteger;

public class demo5 {
    private static AtomicInteger count=new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement(); //count++
//                count.incrementAndGet(); ++count
//                count.getAndDecrement(); count--
//                count.decrementAndGet(); --count
//                count.getAndAdd(10)      count+=10
            }
        });

        Thread t2=new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

4.CAS的局限性

尽管CAS操作提供了原子性,但它也有其局限性:

  1. ABA问题:如果一个值被多次设置为相同的值A,CAS操作可能误以为值没有被改变。解决这个问题通常需要使用版本号或标记值。

  2. 循环时间:在多线程环境中,如果多个线程尝试同时更新同一变量,可能会导致CAS操作反复失败,从而导致线程在自旋中消耗大量CPU时间。

  3. 性能问题:在高并发场景下,CAS操作的性能可能会下降,因为失败的CAS操作需要重试,这可能导致CPU空转。

尽管如此,CAS仍然是实现无锁数据结构和算法的关键技术,能够显著提高并发程序的性能和可伸缩性。

💳三.相关面试题

不做具体详细的拓展回答,仅做一个简答

1.你是怎么理解乐观锁和悲观锁的

        悲观锁认为多个线程访问同⼀个共享变量冲突的概率较⼤, 会在每次访问共享变量之前都去真正加锁.

        乐观锁认为多个线程访问同⼀个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.

        悲观锁的实现就是先加锁(⽐如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待。
        乐观锁的实现可以引入⼀个版本号. 借助版本号识别出当前的数据访问是否冲突. 
2.介绍一下读写锁
读写锁就是把读操作和写操作分别进⾏加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要⽤在 "频繁读, 不频繁写" 的场景中.
3.什么是自旋锁,为什么要使用自旋锁策略,缺点是什么?
如果获取锁失败, ⽴即再尝试获取锁, ⽆限循环, 直到获取到锁为⽌. 第⼀次获取锁失败, 第⼆次的尝试 会在极短的时间内到来. ⼀旦锁被其他线程释放, 就能第⼀时间获取到锁.
相⽐于挂起等待锁,
优点: 没有放弃 CPU 资源, ⼀旦锁被释放就能第⼀时间获取到锁, 更⾼效. 在锁持有时间⽐较短的场景
下⾮常有⽤.
缺点: 如果锁的持有时间较⻓, 就会浪费 CPU 资源.
4.讲解一下你自己了解的CAS机制
全称 Compare and swap, 即 "⽐较并交换". 相当于通过⼀个原⼦的操作, 同时完成 "读取内存, ⽐较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的⽀撑.
5.ABA问题怎么解决
给要修改的数据引⼊版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 如 果发现当前版本号和之前读到的版本号⼀致, 就真正执⾏修改操作, 并让版本号自增; 如果发现当前版 本号比之前读到的版本号大, 就认为操作失败

💸四.总结与反思

神龟虽寿,犹有竞时。——曹操

在深入学习基本锁策略和CAS(Compare-and-Swap)操作的过程中,我对并发编程有了更深刻的理解。这些知识点不仅增强了我的编程技能,还帮助我更好地应对多线程环境下的挑战。以下是我在学习过程中的总结与反思。

基本锁策略

基本锁策略包括悲观锁、乐观锁、自旋锁、挂起等待锁、可重入锁、不可重入锁、公平锁、非公平锁、轻量级锁和重量级锁等。每种锁策略都有其独特的应用场景和优缺点。

  1. 悲观锁与乐观锁

    • 悲观锁假设数据很可能被并发修改,因此在读取或写入数据之前会锁定数据,确保数据的一致性。
    • 乐观锁假设数据不太可能被并发修改,因此在读取数据时不锁定数据,只有在更新数据时才检查数据是否被其他事务修改过。
    • 选择哪种锁策略取决于具体的应用场景和需求,如数据访问模式、并发程度、对数据一致性的要求等。
  2. 自旋锁与挂起等待锁

    • 自旋锁在尝试获取锁失败时不会放弃CPU,而是持续循环尝试获取锁,适用于锁持有时间短的情况。
    • 挂起等待锁在尝试获取锁失败时会释放CPU,等待锁可用后再获取,适用于锁持有时间长的情况。
    • 选择自旋锁还是挂起等待锁需要考虑锁的持有时间和线程竞争的程度。
  3. 可重入锁与不可重入锁

    • 可重入锁允许一个线程多次获取同一把锁,而不会导致死锁。
    • 不可重入锁不允许一个线程多次获取同一把锁,适用于不需要递归锁定的简单场景。
    • 选择哪种锁取决于程序设计的需求,如是否存在线程需要多次进入同一临界区的情况。
  4. 公平锁与非公平锁

    • 公平锁按照线程请求锁的顺序来分配锁,保证了锁的公平性。
    • 非公平锁不保证获取锁的顺序,可能存在线程饥饿的情况。
    • 在锁竞争激烈的情况下,公平锁可以避免线程饥饿,但在锁竞争较少的情况下,非公平锁的性能更高。
  5. 轻量级锁与重量级锁

    • 轻量级锁使用基于CASS的原子操作,在没有线程竞争的情况下避免使用重量级锁的开销。
    • 重量级锁涉及操作系统层面的线程挂起和唤醒,适用于锁竞争激烈的情况。
    • 选择哪种锁需要考虑锁的持有时间、线程竞争程度等因素。

CAS操作

CAS(Compare-and-Swap)操作是一种无锁算法的基本构建块,广泛应用于并发编程中。CAS操作涉及三个操作数:内存位置(V)、期望的旧值(A)和新值(B)。CAS操作会比较内存位置V的当前值与期望的旧值A是否相等,如果相等,则将V的值原子地更新为新值B;如果不相等,则操作失败,返回当前的V值。

  1. CAS操作的特点

    • CAS操作是原子的,能够在多线程环境下确保数据的一致性。
    • CAS操作可以用来实现无锁数据结构,如无锁队列、无锁栈等。
    • CAS操作也可能遇到ABA问题,即一个值被多次设置为相同的值A,可以通过使用版本号或标记值来解决这个问题。
  2. CAS操作的局限性

    • 在高并发场景下,CAS操作的性能可能会下降,因为失败的CAS操作需要重试,可能导致CPU空转。
    • CAS操作在多处理器架构中特别有用,但在单处理器系统中可能不是最佳选择。

        通过对基本锁策略和CAS操作的学习,我深刻认识到在多线程环境下正确使用这些机制的重要性。每种锁策略都有其适用的场景,选择合适的锁策略对于实现高效、稳定的并发程序至关重要。此外,CAS操作作为一种无锁算法的基础,对于提高并发程序的性能和可伸缩性同样重要。

        在实际应用中,我们需要根据具体的应用需求和系统特性来选择最合适的锁策略和CAS操作。此外,还需要注意锁策略之间的权衡,如性能与公平性、简单性与灵活性之间的平衡。通过不断实践和探索,我们可以更好地理解和应用这些知识,以解决实际问题。

        总之,学习基本锁策略和CAS操作不仅提高了我的并发编程技能,还让我意识到了在设计并发程序时需要考虑的诸多因素。在未来的学习和工作中,我会继续深入研究这些知识点,并努力将它们应用到实践中去。

🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀

以上,就是本期的全部内容啦,若有错误疏忽希望各位大佬及时指出💐

  制作不易,希望能对各位提供微小的帮助,可否留下你免费的赞呢🌸 

  • 19
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值