【Java 并发编程】锁和JUC

锁的分类

image

乐观锁和悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。

image

悲观锁

对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。

Java 中的 synchronized 和 ReentrantLock 等独占锁,就是悲观锁思想的实现。

示例1:

public void performSynchronisedTask() {
    synchronized (this) {
        // 需要同步的操作
    }
}

示例2:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
    private Lock lock = new ReentrantLock();

    public void write() {
        lock.lock();
        try {
            // 需要同步的操作
        } finally {
            lock.unlock();
        }
    }
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

乐观锁

乐观锁认为自己在使用数据时,不会有别的线程修改数据,所以不会加锁,只是在更新数据的时候,会去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在 Java 中是通过无锁编程来实现的,最常采用的是 CAS 算法,Java 原子类的递增操作就通过 CAS 自旋实现的。

乐观锁一般会使用版本号机制或 CAS 算法实现。

Java 中的 java.util.concurrent.atomic 包下面的原子变量类,比如:AtomicInteger、LongAdder,就是使用了乐观锁的一种实现方式 CAS 实现的。

示例:

// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
LongAdder sum = new LongAdder();
sum.increment();

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。

不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder 以空间换时间的方式就解决了这个问题。

乐观锁的实现

版本号机制

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

CAS 算法

CAS(Compare And Swap, 比较与交换)的思想用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)

  • E:预期值(Expected)

  • N:拟写入的新值(New)

当且仅当变量 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

Unsafe 类提供了 compareAndSwapObject、compareAndSwapInt、compareAndSwapLong 方法来实现的对 Object、int、long 类型的 CAS 操作。

乐观锁的问题

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。

ABA 问题的解决思路是:在变量前面追加上版本号或者时间戳

JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

// expectedReference 表示旧的值,expectedStamp 表示旧的版本号,newReference 表示新的值,newStamp 表示新的版本号
boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

  • 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。

  • 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。

但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作,所以,我们可以使用锁或者利用 AtomicReference 类,把多个共享变量合并成一个共享变量来操作。

对比

根据上面的概念描述我们可以发现:

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

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

自旋锁和适应性自旋锁

阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复线程花费的时间可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否会很快释放锁。

为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不用阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

image

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是 10 次,可以使用-XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是 CAS,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

// sun.misc.Unsafe
public final class Unsafe {
    ...
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
    ...
}

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功的,进而它将允许自旋等待更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

无锁、偏向锁、轻量级锁、重量级锁

参考:synchronized

可重入锁和非可重入锁

可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提:锁的是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。

Java 中的 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点就是可以一定程度避免死锁。

公平锁和非公平锁

这里的“公平”,其实通俗意义来说就是“先来后到”,也就是 FIFO。

如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。

一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。

ReentrantLock 支持非公平锁和公平锁两种。

读写锁和排它锁

  • 排它锁在同一时刻只允许一个线程进行访问。

    Java 中的 ReentrantLock 和 synchronized 都是排它锁。

  • 读写锁可以在同一时刻允许多个读线程访问。

JUC 包下的锁

JDK 中关于并发的类大多都在 JUC 包下。

image

其中,locks 包是提供一些并发锁的工具类的。

抽象类 AQS/AQLS/AOS

AQS(AbstractQueuedSynchronizer):它是在 JDK 1.5 发布的,提供了一个“队列同步器”的基本功能实现。

AQLS(AbstractQueuedLongSynchronizer):它的代码跟 AQS 几乎一样,只是把资源的类型变成了 long 类型。

AQS 和 AQLS 都继承了一个类叫 AOS(AbstractOwnableSynchronizer),这个类也是在 JDK 1.6 中出现的。

接口 Condition、Lock、ReadWriteLock

locks 包下共有三个接口:Condition、Lock、ReadWriteLock。

Lock 和 ReadWriteLock 从名字就可以看得出来,分别是锁和读写锁的意思。

Lock 接口里面有一些获取锁和释放锁的方法声明,而 ReadWriteLock 里面只有两个方法,分别返回“读锁”和“写锁”:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

Lock 接口中有一个方法可以获得一个Condition:

Condition newCondition();

可重入锁 ReentrantLock

ReentrantLock 是 Lock 接口的默认实现,实现了锁的基本功能,它是一个“可重入”锁,它内部有一个抽象类Sync,继承了 AQS,自己实现了一个同步器。

同时,ReentrantLock 内部有两个非抽象类 NonfairSync 和 FairSync,它们都继承了 Sync。从名字上可以看得出,分别是”非公平同步器“和”公平同步器“的意思。这意味着 ReentrantLock 可以支持”公平锁“和”非公平锁“。

这两个同步器都是”独占“的,都调用了 AOS 的 setExclusiveOwnerThread 方法,所以, ReentrantLock 锁是”独占“的,也就是说,它的锁都是”排他锁“,不能共享。

在 ReentrantLock 的构造方法里,可以传入一个 boolean 类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过 isFair() 方法来查看。

读写锁 ReentrantReadWriteLock

ReentrantReadWriteLock 是 ReadWriteLock 接口的默认实现。

它与 ReentrantLock 的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持”读写锁“。

StampedLock

StampedLock 实现了“读写锁”的功能,并且性能比 ReentrantReadWriteLock 更高。

StampedLock 还把读锁分为了“乐观读锁”和“悲观读锁”两种。

ReentrantReadWriteLock 会发生“写饥饿”的现象,但 StampedLock 不会。它是怎么做到的呢?

它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和 CAS 自旋的思想一样。这种操作方式决定了 StampedLock 在读线程非常多而写线程非常少的场景下非常适用,同时还避免了写饥饿情况的发生。


参考:

posted @ 2023-10-11 17:34  LARRY1024  阅读(24)  评论(0编辑  收藏  举报