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

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

一、乐观锁 & 悲观锁

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

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


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


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

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

二、重量级锁 & 轻量级锁

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

image.png

重量级锁:加锁解锁,过程更低效。加锁机制重度依赖了 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主要有以下锁升级策略:

image.png

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

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

如果整个代码执行过程中,都没有遇到别的线程竞争当前标记的对象的锁,此时就不用真加锁了。这时就节省了加锁和解锁带来的开销。

如果后续有其他线程来竞争该锁,由于已经在该锁对象中记录了当前锁属于哪个线程了,因此很容易识别当前申请锁的线程是不是之前记录的线程,如果不是,那就取消原来的偏向锁状态,进入一般的轻量级锁状态。

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

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

3、synchronized 锁优化操作

锁消除

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

锁粗化

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

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


相关文章
|
1天前
|
安全 算法 Java
Java 中的并发控制:锁与线程安全
在 Java 的并发编程领域,理解并正确使用锁机制是实现线程安全的关键。本文深入探讨了 Java 中各种锁的概念、用途以及它们如何帮助开发者管理并发状态。从内置的同步关键字到显式的 Lock 接口,再到原子变量和并发集合,本文旨在为读者提供一个全面的锁和线程安全的知识框架。通过具体示例和最佳实践,我们展示了如何在多线程环境中保持数据的一致性和完整性,同时避免常见的并发问题,如死锁和竞态条件。无论你是 Java 并发编程的新手还是有经验的开发者,这篇文章都将帮助你更好地理解和应用 Java 的并发控制机制。
|
6天前
|
安全 Java 开发者
Java并发编程中的线程安全性与性能优化
在Java编程中,处理并发问题是至关重要的。本文探讨了Java中线程安全性的概念及其在性能优化中的重要性。通过深入分析多线程环境下的共享资源访问问题,结合常见的并发控制手段和性能优化技巧,帮助开发者更好地理解和应对Java程序中的并发挑战。 【7月更文挑战第14天】
|
6天前
|
监控 Java API
Java并发编程之线程池深度解析
【7月更文挑战第14天】在Java并发编程领域,线程池是提升性能、管理资源的关键工具。本文将深入探讨线程池的核心概念、内部工作原理以及如何有效使用线程池来处理并发任务,旨在为读者提供一套完整的线程池使用和优化策略。
|
8天前
|
Java 调度
java中线程的6种状态
java中线程的6种状态
|
10天前
|
存储 安全 Java
Java面试题:请解释Java内存模型,并说明如何在多线程环境下使用synchronized关键字实现同步,阐述ConcurrentHashMap与HashMap的区别,以及它如何在并发环境中提高性能
Java面试题:请解释Java内存模型,并说明如何在多线程环境下使用synchronized关键字实现同步,阐述ConcurrentHashMap与HashMap的区别,以及它如何在并发环境中提高性能
12 0
|
10天前
|
安全 Java 开发者
Java多线程:synchronized关键字和ReentrantLock的区别,为什么我们可能需要使用ReentrantLock而不是synchronized?
Java多线程:synchronized关键字和ReentrantLock的区别,为什么我们可能需要使用ReentrantLock而不是synchronized?
12 0
|
2月前
|
安全 Java 编译器
Java多线程基础-6:线程安全问题及解决措施,synchronized关键字与volatile关键字(一)
线程安全问题是多线程编程中最典型的一类问题之一。如果多线程环境下代码运行的结果是符合我们预期的,即该结果正是在单线程环境中应该出现的结果,则说这个程序是线程安全的。 通俗来说,线程不安全指的就是某一代码在多线程环境下执行会出现bug,而在单线程环境下执行就不会。线程安全问题本质上是由于线程之间的调度顺序的不确定性,正是这样的不确定性,给我们的代码带来了很多“变数”。 本文将对Java多线程编程中,线程安全问题展开详细的讲解。
49 0
|
2月前
|
安全 Java 调度
Java多线程- synchronized关键字总结
Java多线程- synchronized关键字总结
30 0
|
安全 Java 数据安全/隐私保护
Java基础进阶多线程-线程安全和synchronized关键字
Java基础进阶多线程-线程安全和synchronized关键字
Java基础进阶多线程-线程安全和synchronized关键字
|
Java 数据安全/隐私保护
Java——多线程高并发系列之synchronized关键字
Java——多线程高并发系列之synchronized关键字
Java——多线程高并发系列之synchronized关键字