Java 多线程系列Ⅴ(常见锁策略+CAS+synchronized原理)


一、乐观锁 & 悲观锁

锁的实现者,预测接下来锁冲突的概率,来决定接下来该怎么做。于是分为两大“门派”:

乐观锁:乐观锁是一种乐观的思想,预测接下来冲突概率不大或认为多个线程之间不会发生冲突,因此在访问数据时不会加锁,而是通过在读取数据时记录一个版本号,更新数据时如果版本号不一致,则认为数据已经被其他线程修改过,需要重新尝试更新(借助版本号或时间戳识别出当前的数据访问是否冲突)。例如 Java 中的 AtomicInteger 类,其内部实现使用了乐观锁机制。

悲观锁:悲观锁则是一种悲观的思想,预测接下来冲突概率比较大或认为多个线程之间会发生冲突,因此在访问数据时会对其加锁,以防止其他线程同时访问。

Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。

通常来说悲观锁一般要做的工作要更多一些,效率会更低一些。乐观锁做的工作会更少一点,效率更高一点。

二、重量级锁 & 轻量级锁

知识补充:锁的核心特性 “原子性”,这样的机制追根溯源是 CPU 这样的硬件设备提供的。CPU 提供了 “原子操作指令”,操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁。JVM 基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。

提供原子操作指令
提供mutex互斥锁
提供synchronized等关键字
CPU
操作系统
JVM
Java代码

重量级锁:加锁解锁,过程更低效。加锁机制重度依赖了 OS 提供了 mutex。其中涉及到大量的内核态用户态切换,很容易引发线程的调度。而这个操作,相对来说成本都比较高。

轻量级锁:加锁解锁,过程更高效。加锁机制尽可能不使用 mutex,而是尽量使用用户态代码完成。实在搞不定了,再使用 mutex。涉及到少量的内核态用户态切换,不太容易引发线程调度。

一般情况下:一个乐观锁很可能是一个轻量级锁(不绝对),一个悲观锁很可能是一个重量级锁(不绝对)

需要注意的是,用户态的时间成本是比较可控的,而内核态的时间成本不太可控

用户态下的程序只能访问用户空间的数据和代码,无法直接访问内核空间中的数据和资源,而内核态下的程序可以访问并操作所有的系统资源。

用户态和内核态之间的切换需要通过系统调用来实现,也就是从用户态陷入内核态,通过执行内核代码完成一些特权操作,并返回结果到用户态。这种切换过程需要耗费大量的时间和资源,因此,减少用户态和内核态之间的切换次数,是优化系统性能的一条重要途径。

三、自旋锁 & 挂起等待锁

自旋和阻塞:
实现自旋就是为了忙等,就是为了能够最快速度拿到锁。而阻塞等待,意味着放弃了当前cpu使用权,即使后续被唤醒,也不保证该线程第一时间重新拿到CPU。

自旋锁:是轻量级锁的一种典型实现(通常是存用户态的,不需要经过内核态)。
自旋锁是指当前线程反复地检查锁标志位,如果发现该标志位已经被其他线程设置,则该线程就会不停地循环检查,直到获取到锁为止。自旋锁适用于共享数据区访问短、竞争强度不高的情况下,因为自旋等待并没有真正释放 CPU 给其他线程使用,而是一直占用 CPU 进行循环检查,所以如果自旋等待时间过长,会浪费 CPU 资源,影响系统性能。

挂起等待锁(也称为阻塞锁):是重量级锁的一种典型实现。(通常是通过内核机制来实现挂起等待)
挂起等待锁是指当一个线程请求锁时,若发现该锁已经被其他线程持有,则该线程会被挂起等待,直到锁被释放为止。在挂起等待锁的过程中,该线程会进入睡眠状态,释放 CPU 资源给其他线程使用。当持有锁的线程释放锁之后,等待的线程便会被唤醒,重新请求该锁(可能不会立即获取到锁,需要重新进行锁竞争)。

总体而言,自旋锁适用于共享数据区访问短、竞争强度不高的情况下,可以避免线程上下文切换所产生的开销;而挂起等待锁适用于共享数据区访问长、竞争强度较高的情况下,可以有效地利用 CPU 资源,减少 CPU 的空转时间。

四、互斥锁 & 读写锁

互斥锁(Mutex):是一种用于多线程编程中,防止两个或多个线程同时访问共享资源的机制。通过用锁包围多个线程所要访问的代码区域,只有一个线程能够占有锁,其他线程必须等待该线程释放锁后才能继续执行。互斥锁通常用于保护对共享资源的单线程访问,并且在保证数据正确性的同时保证程序的效率。

读写锁(ReadWrite Lock):则是一种更加高级的同步机制,它允许多个线程同时读取共享资源,但在写入共享资源时需要互斥锁的保护。

读写锁的实现方式是,在读取共享资源时,多个线程可以同时占有读锁;而在写入共享资源时,只允许一个线程占有写锁,其他线程必须等待其释放写锁后才能获取锁。读写锁的使用场景一般是读操作频繁,但写操作比较少的场景,如数据库、文件系统等。

  1. 读加锁和读加锁之间,不互斥。
  2. 写加锁和写加锁之间,互斥。
  3. 读加锁和写加锁之间,互斥。

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁:

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

五、可重入锁 & 不可重入锁

可重入锁:可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁,而不会被阻塞。这种锁可以在同一个线程获取同一把锁时避免死锁状态的发生,同时能够保证代码的高效性和正确性。

比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁

ReentrantLock 是一个常见的可重入锁的实现,它使用一个计数器来追踪锁的持有次数,每当一个线程获取一次锁时,计数器加 1,释放锁时计数器减 1。

不可重入锁:是指一个线程获取锁后,在未释放锁之前,再次请求获取该锁时将被阻塞,直到锁被释放。这种锁通常会导致死锁的情况,因为如果一个线程已经获取了锁并期望继续获取该锁,则会一直等待自己的锁被释放。

:JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

六、公平锁 & 非公平锁

约定:遵循先来后到就是公平锁,不遵循先来后到,就是非公平锁。

:系统对于线程的调度是随机的,sychronized 这个锁是非公平的。

七、CAS

1、CAS特点

CAS:全称Compare and swap,字面意思:”比较并交换“。
CAS(V,A,B); CAS操作包括三个参数:内存位置V、期望值A和新值B。它的执行过程如下:

  1. 首先,比较内存位置V中的值是否等于期望值A。
  2. 如果相等,则将内存位置V中的值替换为新值B,操作成功。否则,操作失败。
  3. 无论操作是否成功,都返回内存位置V当前的值。

特别注意:

  1. CAS 是一个原子的硬件指令完成的。CAS 的读内存,比较,写内存操作是一条硬件指令,是原子的。
  2. CAS 是直接读写内存的, 而不是操作寄存器。
  3. 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。CAS 可以视为是一种乐观锁,或者可以理解成 CAS 是乐观锁的一种实现方式。

2、CAS的应用

实现原子类
标准库 java.util.concurrent.atomic 中的类,它们都是使用 CAS(Compare-And-Swap)技术实现的:

例如 AtomicInteger 类,这些类本身就是原子的,因此相关操作即使在多线程下也是安全的:

  1. num.getAndIncrement();// 此操作相当于num++

  2. num.incrementAndGet();// 此操作相当于++num

  3. num.getAndDecrement();// 此操作相当于num–

  4. num.decrementAndGet();// 此操作相当于–num

测试原子类:

public class CAS {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        // 创建原子类,初始化值为0
        AtomicInteger num = new AtomicInteger(0);

        Thread t1 = new Thread(()->{
    
    
            for (int i = 0; i < 50000; i++) {
    
    
                // num++ 操作
                num.getAndIncrement();
            }
        });

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

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(num);

    }
}

num.getAndIncrement();操作伪代码:

class AtomicInteger {
    
    
    private int value;
    public int getAndIncrement() {
    
    
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
    
    
            oldValue = value;
       }
        return oldValue;
   }
}

说明:此处CAS操作中的参数 oldValue,可以将看做是工作内存(寄存器)中的值,value 看做是主内存中的值。如果value 和 oldValue 值相同,也就是在这次更新期间 value 值没变过,这时再将 oldValue+1 的值赋给 value 实现自增。如果比较时 value 不等于 oldValue 说明这次更新操作期间 value 被改变,所以此次更新失败,并刷新 oldValue 值进行下次更新操作。

3、CAS 实现自旋锁

使用CAS实现自旋锁伪代码:

public class SpinLock {
    
    
    private Thread owner = null;
    public void lock(){
    
    
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
    
    
       }
   }
    public void unlock (){
    
    
        this.owner = null;
   }
}

原理说明:上面CAS伪代码表示,如果当前锁持有者为空,就比较成功,就可以把锁获取权给当前线程,加锁完成循环结束。如果owner非空,说明当前锁被其他线程持有,此时CAS操作失败进入循环空转,持续询问当前锁持有者是否为空,此时一旦其它线程释放了锁,当前线程就能立即获取到锁。

4、CAS的ABA问题

CAS只能对比值是否相同,不能确定这个值是否中间发生过改变。可能导致线程对该值进行操作时出现误判或错误结果的问题。

举例来说,线程 T1 读取一个内存位置 V 的值为 A,然后执行一些操作,最后将值更新为 B。在此期间,线程 T2 将 V 的值从 A修改为 C,再修改回 A。此时,T1 再次执行 CAS 操作时,会发现 V 的值仍然是 A,于是认为它没有被其他线程修改过,就会将 V的值更新为 B。但实际上,在这个过程中 V 的值已经被其他线程修改过了,这样就造成了 ABA 问题。

例如再进行取钱操作时:假设此时我有100元存款,需要取出50块钱,此时建立了两个线程进行取钱操作。正常情况下,线程1、线程2读取当前存款100元,然后线程1将其修改为50元,扣款成功。线程2阻塞结束,比较当前存款50元和100元不同,线程2失败。但是如果在线程1扣款成功后,突然有人给我又转账了50,此时存款又变成了100元,然后线程2开始扣款操作时会发现,存款和读取值相同,就会再次扣款。这就是一个ABA问题。

解决方案
给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期,如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增。如果发现当前版本号比之前读到的版本号大,就认为操作失败。

八、synchronized 原理

1、synchronized 基本特征

结合以上的锁策略,我们就可以总结出,Synchronized 具有如下特性:

  1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
  2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
  3. 轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。
  4. 是可重入锁。
  5. 不是读写锁。
  6. 是非公平锁。

2、synchronized 锁升级策略

从上述synchronized锁具有的策略可知,synchronized锁可根据实际场景进行锁升级,在JVM中对synchronized主要有以下锁升级策略:

遇到锁竞争
锁竞争更激烈
无锁
偏向锁
轻量级锁
重量级锁

上述锁策略中设计到偏向锁概念:

偏向锁:就是非必要不加锁。偏向锁不是真正的“加锁”,只是给对象头中做一个 “偏向锁的标记”,记录这个锁属于哪个线程:

  1. 如果整个代码执行过程中,都没有遇到别的线程竞争当前标记的对象的锁,此时就不用真加锁了。这时就节省了加锁和解锁带来的开销。
  2. 如果后续有其他线程来竞争该锁,由于已经在该锁对象中记录了当前锁属于哪个线程了,因此很容易识别当前申请锁的线程是不是之前记录的线程,如果不是,那就取消原来的偏向锁状态,进入一般的轻量级锁状态。

简单来说,偏向锁就相当于是“搞暧昧”,一旦发现潜在危险,就立即官宣!

总之synchronized的锁升级策略主要指:当一个线程访问共享资源时,秉承非必要不加锁, 优先进入偏向锁状态。随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态。如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁。

3、synchronized 锁优化操作

锁消除
在程序中,可能存在有些程序的代码,用到了 synchronized,但其实没有在多线程环境下。此时这些加锁操作是非常没有必要的,而且会白白浪费加锁和解锁的资源开销。(如单线程下使用StringBuffer)这时我们的编译器+JVM 就会判断锁是否可消除,如果可以,就直接消除。

锁粗化
在一代码段逻辑中,如果出现多次加锁解锁,编译器 + JVM 会自动进行锁的粗化。

这里的锁粗化(细化)是相对于锁的粒度的,锁粒度即synchronized代码块包含代码的多少(代码越多,粒度越粗。越少粒度越细)。一般写代码的时候,多数情况下,希望锁的粒度更小一点(串行执行的代码少一些,并发执行的代码多一些,充分利用CPU内核资源)。但是实际上可能并没有其他线程来抢占这个锁进行并发,这种情况 JVM 就会自动把锁粗化,避免频繁申请释放锁带来额外开销。

猜你喜欢

转载自blog.csdn.net/LEE180501/article/details/130546165