Skip to content

Java并发编程_进阶内容

大纲

  • 线程模型
  • 悲观锁
  • 乐观锁
  • ReentrantLock
    • AQS
    • CAS

悲观锁和乐观锁

image.png

1、什么是线程模型?

先回顾一下线程和进程的区分

  • 进程:
    • 一个在内存中运行的应用程序。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程,比如在Windows系统中,一个运行的xx.exe就是一个进程。
  • 线程
    • 进程中的一个执行任务(控制单元),负责当前进程中程序的执行。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享数据。
    • 与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

什么是线程模型

并发编程在计算机中如何实现的,在 Java 开发领域中,JVM线程对不同操作系统上的原生线程进行了高级抽象,使开发者大多数情况下可以不用关注下层细节,而只要专注上层开发。

JVM线程与操作系统线程之间存在着某种映射关系,这两种不同维度的线程之间的规范和协议,就是线程模型。

用户线程和内核线程

  • 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
  • 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。

线程模型类型

JVM线程模型有三种模型:一对一、多对一、多对多。

image.png

一对一

在Jva线程(用户线程)与操作系统线程(KLT)之间建立一对一的关系,简单粗暴,但好用。

  • 优点:
    • 每个线程都是独立的调度单元,直接利用操作系统内核提供的调度功能。
  • 缺点:
    • 用户线程的阻塞唤醒,会直接映射到内核线程上,容易引起频繁切换,降低性能。但是一些语言引入了CAS来避免一部分的内核调用,比如Java 引入了AQS这种函数级别的锁,减少使用内核级别的锁,就能提升性能。

目前大部分主流 JVM 上都是采用的这种线程模型。

多对一模型

  • 优点
    • 用户线程的很多操作对内核来说都是透明的,不需要用户态和内核态的频繁切换,使线程的创建、调度、同步等非常快:
  • 缺点
    • 如果其中一个用户线程阻塞,其他用户线程也无法执行
    • 这种模型下,内核并不知道用户态有哪些线程,调度和优先级等不完整。

多对多模型

  • 优点:兼具前两者的优点
  • 缺点:实现复杂

目前主流语言中,Java 使用的是 一对一线程模型;Go 语言使用的是 多对多线程模型;Python 的 gevent 使用的多对一线程模型。

2、悲观锁机制

在了解悲观锁机制前,我们先来了解一下什么是锁。

什么是锁

在并发环境下,会出现多个线程对同一个资源进行争抢的情况,假设A线程对资源正在进行修改,此时 B线程此时又对资源进行了修改,这就可能会导致数据不一致的问题。

为了解决这个问题,引入了锁机制,通过一种抽象的“锁”来对资源进行锁定,当一个线程持有“锁”的时候,其他线程必须等待“锁”,本质上是在临界资源上对线程进行一种串行化。

Java语言的锁机制

Java 虚拟机的内存结构

在了解 Java语言锁机制之前,我们先对简单了解一下 Java 虚拟机的内存结构。

image.png

JVM 运行时内存结构主要包含了五个部分:

  • 程序计数器(PC寄存器)、
  • JVM栈、
  • Native方法栈、
  • 堆、
  • 方法区。

上图中,红色区域是各个线程私有的。这个区域中的数据,不会出现线程竞争的关系。

而蓝色区域中的数据被所有线程共享,其中Jva堆中存放的是大量对象,方法区中存放类信息、常量、静态变 量等数据。

当多个线程在竞争其中的一些数据时,可能会发生难以预料的异常情况。在程序开发中,锁的主要应用范围就是在数据共享区域。

在代码层面,Jvva 主要采用了两种实现方式:

  • 1.基于Object的悲观锁。
  • 2.基于CAS的乐观锁。

本章主要讲解基于Object的悲观锁。

悲观锁机制

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。

也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

基于Object的悲观锁

在Java中,每个Object,也就是每个对象都拥有一把锁,这把锁存放在对象头中,记录了当前对象被哪个线程占用。

对象和对象头的结构

Java 对象

Java对象分为三个部分:

  • 对象头
  • 实例数据
  • 对齐填充字节

其中对齐填充字节是为了满足“Java对象大小是8字节的倍数”这一条件而设计的,为对象对齐填充了一些无用字节。

实例数据是在初始化对象时设定的属性和状态等内容。

对象头

对象头存放了一些对象本身的运行时信息。对象头包含了两部分:

  • Mark Word
  • Class Pointer

相较于实例数据,对象头属于一些额外的存储开销,它被设计得极小(一般为232bt或264bt) 来提升效率。

Class Pointer是一个指针,指向当前对象类型所在方法区中的Class信息;

Mark Word 存储了很多当前对象的运行时状态信息,比如 HashCode.、锁状态标志、指向锁记录的指针、偏向线程 ID、锁标志位等等。

可以通过下面这张表对 Mark Word有一个更直观的认识:

image.png

“锁”的信息存储在对象头的Mark Word中。Mark Word 的最后两位,代表锁标志位,分别对应“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”四种状态。

在Java中,启用对象锁的方式是使用synchronized关键字。

对象锁的四种状态

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。

锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率

3、乐观锁机制

乐观锁:乐观锁总是假设最好的情况,即不会去考虑别的线程修改数据,所以不会上锁,但是在更新的时候会判断在此期间别的线程有没有去更新这个数据。

Java 中的乐观锁一般是基于 CAS 原语 来进行的(如AtomicInteger);而CAS 是一种原语操作,他也用于了悲观锁中 ReentrantLock 的锁状态管理。


在多个线程对于同一个资源进行访问的访问,互斥锁的方式是悲观的,在线程访问的时候,互斥锁会锁定资源,只供一个线程调用,而阻塞其他线程,让其他线程等待。

在一些情况下,同步代码块执行的耗时远远小于线程切换的耗时,这种情况下使用互斥锁对性能是不太划算的。在这种场景下,直接在用户态对线程的切换进行管理,效率更高。实际操作的时候是通过每次使用同步原语对共享资源进行锁定,让线程反复“乐观”地去尝试获取共享资源,如果发现空闲,那么使用,如果被占用,那么继续“乐观”地重试。

乐观锁和悲观锁 image.png

CAS

在 Java 中,实现这种同步原语的算法是 CAS (Compare And Swap)。

简单翻译是:比较然后交换

CAS操作包含三个操作数:内存位置(要更新的变量)、预期原值和新值。

工作原理

  1. 检查和更新
    • CAS首先检查目标内存位置的当前值是否与预期原值相同。如果相同,它会将该内存位置的值更新为新值。
    • 如果目标值已被其他线程改变(不等于预期原值),CAS操作失败。
  2. 无锁操作
    • CAS提供了一种无需锁定的方式来实现并发控制,减少了锁的开销和复杂性。
CAS操作是原子性的。

4、悲观锁和乐观锁

悲观锁示例

在Java中,悲观锁通常是通过synchronized关键字或ReentrantLock类实现的。

synchronized 使用示例:

public class PessimisticLockExample {
    private int count = 0;

    public synchronized void increment() {
        count++;  // 仅当持有对象锁时,才能执行此操作
    }

    public synchronized int getCount() {
        return count;  // 同样,需要持有对象锁才能执行
    }
}

在这个例子中,我们用synchronized关键字锁定了整个方法。

当一个线程进入incrementgetCount方法时,它必须先获得这个对象的锁。在这段时间内,其他任何试图进入这些同步方法的线程都会被阻塞。

乐观锁示例

乐观锁通常是通过CAS(比较并交换)操作实现的。

在Java中,AtomicInteger类提供了一种使用乐观锁的方式。以下是一个使用AtomicInteger实现乐观锁的示例:

import java.util.concurrent.atomic.AtomicInteger;

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

    public void increment() {
        int currentValue;
        int newValue;
        do {
            currentValue = count.get(); // 获取当前值
            newValue = currentValue + 1; // 计算新值
        } while (!count.compareAndSet(currentValue, newValue)); // CAS操作
    }

    public int getCount() {
        return count.get();
    }
}

在这个例子中,AtomicIntegercompareAndSet方法实现了CAS操作。

它会比较count的当前值和currentValue。如果相同,它会更新countnewValue。这个过程是无锁的,即使在并发环境下也不会阻塞其他线程。如果count的值在此期间被其他线程修改,compareAndSet会返回false,循环继续,直到更新成功。

对比

  • 悲观锁synchronizedReentrantLock)适用于写操作多的场景,因为它防止了多个线程同时写入,减少了冲突。
  • 乐观锁(如AtomicInteger的CAS操作)适用于读操作多的场景,它不会阻塞线程,但在写操作频繁的情况下可能会导致高重试成本。

5、ReentrantLock ☆

线程考察的一个重点,一个是 synchronized关键字,另外一个就是 ReentrantLock类。

这句话可以阐述 ReentrantLock 的大部分操作:

ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

建议可以直接看一下“源码解析”这一部分。

ReentrantLock 的基础使用

在并发编程中,悲观锁是一种策略,它假设最坏的情况:即在多个线程尝试同时访问共享资源时,会发生冲突。因此,悲观锁在访问任何共享资源之前会先锁定它,以防止其他线程的访问,直到它完成操作并释放锁。

ReentrantLock(可重入锁)

ReentrantLock 是一个可重入的互斥锁,它提供了比 synchronized 关键字更高级的功能,如可中断的锁获取操作、公平锁策略、锁绑定多个条件等。

基于 AQS 的实现

  • AQS 提供了一种管理锁状态的框架,并处理了线程的排队和阻塞。在 ReentrantLock 的实现中,AQS 负责维护一个表示锁状态的变量和一个由等待锁的线程组成的队列。
  • 当一个线程尝试获取 ReentrantLock 时,AQS 会检查锁的状态,然后要么授予锁(如果当前未被其他线程持有),要么将尝试获取锁的线程放入等待队列。

悲观锁的特性

  • ReentrantLock 作为一种悲观锁,是基于这样的假设:如果不采取措施,多个线程同时修改同一个资源会导致问题。因此,它在修改资源之前先加锁,防止其他线程同时进行写操作。
  • 这与乐观锁的策略不同,乐观锁允许多个线程进入临界区,但在实际修改数据时检查是否存在冲突。

示例

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();  // 获取锁
        try {
            count++;
        } finally {
            lock.unlock();  // 释放锁
        }
    }

    public int getCount() {
        return count;
    }
}

在这个示例中,我们在修改 count 变量之前获取了锁,并在操作完成后释放了锁。

这确保了即使多个线程尝试同时调用 increment 方法,count 的增加也是线程安全的。

ReentrantLock 的实现原理

ReentrantLock 是 Java 并发包中的一个重要组件,基于 AQS 实现。

基本原理

  1. 可重入性
    • ReentrantLock 是一个可重入锁。这意味着同一个线程可以多次获取同一个锁而不会发生死锁。这是通过为每个锁维护一个持有计数和一个指向当前持有锁的线程的引用来实现的。
  2. 基于 AQS
    • ReentrantLock 的实现依赖于 AQS。AQS 使用一个整型的 volatile 变量来表示同步状态,并使用一个 FIFO 队列来管理那些等待获取锁的线程。
  3. 锁的状态
    • ReentrantLock 中,锁的状态是由 AQS 的同步状态变量来表示的。状态为 0 表示锁是可用的,状态为 1 表示锁被一个线程持有,大于 1 表示同一个线程重入了这个锁。
  4. 获取锁
    • 当一个线程尝试获取锁时,如果同步状态为 0,AQS 会尝试通过 CAS(比较并交换)操作将状态设置为 1,从而获取锁。
    • 如果锁已经被其他线程持有(同步状态非 0),那么尝试获取锁的线程会被加入到 AQS 维护的等待队列中。
  5. 释放锁
    • 当线程完成任务后,它会调用 unlock 方法来释放锁。这将减少 AQS 同步状态的计数。当计数降到 0 时,锁被完全释放,等待队列中的下一个线程将有机会获取锁。

公平性和非公平性

  • 公平锁:在公平模式下,ReentrantLock 会按照线程在等待队列中的等待顺序来获取锁。这意味着首先进入等待队列的线程将先获得锁。
  • 非公平锁:在非公平模式下,当锁可用时,任何请求它的线程都有机会获取锁。这可能不会遵守等待队列中的顺序。

性能考量

  • 选择公平性:公平锁通常会有较低的性能,因为它严格按照等待队列来分配锁,但它可以减少线程饥饿的情况。
  • 选择非公平性:非公平锁可能会有更好的性能,但可能导致线程饥饿,因为某些线程可能会长时间等待而不得不频繁地重新调度。

ReentrantLock 提供了一种灵活的锁定机制,通过 AQS 实现了可重入性和可选的公平性。

它允许更细粒度的锁控制,从而在高度竞争的环境中提供比内置 synchronized 更高的性能和更强的功能。

源码解析

ReentrantLock 类结构

ReentrantLock 本身是一个相对简单的类,它依赖于内部类 Sync 的实现,而 SyncAbstractQueuedSynchronizer 的子类。

ReentrantLock 中,有两个主要的内部类:FairSyncNonfairSync,分别对应公平锁和非公平锁的实现。

public class ReentrantLock implements Lock, java.io.Serializable {
    private final Sync sync;

    abstract static class Sync extends AbstractQueuedSynchronizer {
        // ... 实现细节
    }

    static final class NonfairSync extends Sync {
        // ... 非公平锁实现
    }

    static final class FairSync extends Sync {
        // ... 公平锁实现
    }

    // 构造函数等其他方法
}
Sync 类

Sync 类是 AbstractQueuedSynchronizer 的一个扩展,提供了大部分与锁状态管理相关的功能。

它使用 AQS 的状态变量来表示锁的持有次数,以及持有锁的线程。

abstract static class Sync extends AbstractQueuedSynchronizer {
    protected final boolean isHeldExclusively() {
        return getExclusiveOwnerThread() == Thread.currentThread();
    }

    final void lock() {
        acquire(1);
    }

    // 其他方法,包括 tryAcquire, tryRelease 等
}
公平锁和非公平锁的实现

FairSyncNonfairSync 类重写了 tryAcquire 方法以提供不同的锁获取策略。

  • 非公平锁NonfairSync):在尝试获取锁时,会立即尝试改变状态,而不检查等待队列。
  • 公平锁FairSync):在尝试获取锁之前,会检查等待队列,以确保队列中的线程先获得服务。
static final class NonfairSync extends Sync {
    protected final boolean tryAcquire(int acquires) {
        // 实现非公平的锁获取逻辑
    }
}

static final class FairSync extends Sync {
    protected final boolean tryAcquire(int acquires) {
        // 实现公平的锁获取逻辑
    }
}
AbstractQueuedSynchronizer(AQS)

AQS 是实现锁和其他同步器的框架。它使用一个 int 类型的 volatile 变量来表示同步状态,并使用一个队列来管理那些未能成功获取同步状态的线程。

  • 状态管理:AQS 提供了一系列方法来操作其状态变量,如 getState(), setState(int)compareAndSetState(int, int)
  • 队列管理:AQS 维护了一个等待队列,当线程尝试获取资源失败时,它会被加入到这个队列中。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
    private transient volatile Node head;
    private transient volatile Node tail;
    private volatile int state;

    // 内部类 Node,队列节点的定义
    static final class Node {
        // Node 的结构和方法
    }

    // 状态管理和队列操作的方法
}
锁的获取与释放
  • 获取锁:当 ReentrantLocklock() 方法被调用时,实际上是调用了 Sync 类的 acquire 方法,该方法又会调用 tryAcquire 方法。tryAcquire 的具体实现取决于是 FairSync 还是 NonfairSync
  • 释放锁unlock() 方法调用 Sync 类的 release 方法,进而调用tryRelease 方法。这个方法会更新同步状态,并在状态变为 0 时(即锁被完全释放时),唤醒等待队列中的线程.
获取锁的过程
  1. 当一个线程调用 lock() 方法时,它实际上调用的是 Sync 类中的 acquire(int) 方法。
  2. acquire(int) 方法会调用重写的 tryAcquire(int) 方法(取决于是 FairSync 还是 NonfairSync)。
    • NonfairSync 中,它会立即尝试获取锁,不管其他线程是否在等待。
    • FairSync 中,它会先检查队列,以确保没有其他线程在等待时间更长。
释放锁的过程
  1. 当线程调用 unlock() 方法时,实际上是调用 Sync 类的 release(int) 方法。
  2. release(int) 方法内部会调用 tryRelease(int) 方法,该方法检查当前线程是否是锁的持有者,并尝试将同步状态设置回 0。
  3. 如果同步状态成功设置回 0,表示锁已经被释放,等待队列中的其他线程可能会被唤醒并尝试获取锁。
AQS 中的等待队列

AQS 使用一个内部的 FIFO 队列来管理那些无法获取到锁的线程。

每个节点代表一个线程。当一个线程无法获取到锁时,它会被包装成一个节点加入到队列的末尾。当锁被释放时,队列头部的节点会被唤醒并尝试再次获取锁。

CAS 在 AQS 中的使用

AQS 使用 CAS 操作来安全地修改同步状态。这是一种无锁的原子操作,可以保证即使在多个线程同时尝试修改状态时,同步状态的更新也是一致的。

总结

ReentrantLock 的实现依赖于 AQS 的强大功能,提供了一种灵活且高效的方式来处理并发编程中的同步问题。

通过将锁的具体实现细节委托给 AQS,ReentrantLock 不仅提供了基本的锁功能,还支持如公平性/非公平性选择、条件变量、以及锁的可重入性等高级特性。

5、synchronized

这部分内容的锁升级状态相关内容有必要阐述一下(无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态)

锁升级过程

说一下偏向锁、轻量级锁、重量级锁(锁升级过程)。

在Java中,为了优化同步操作的性能,引入了偏向锁、轻量级锁和重量级锁的概念。

这些锁是对synchronized关键字的优化,旨在减少锁操作的开销。在不同竞争条件下,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。

偏向锁(Biased Locking)

  • 目的:在只有一个线程访问同步块的情况下,减少不必要的锁竞争开销。
  • 原理:当锁被第一次获取时,它会将锁对象的头部信息标记为偏向模式,并记录获取它的线程ID。之后,该线程进入同步块时不需要再进行同步。
  • 升级条件:当有另一个线程尝试获取这个锁时,偏向锁会升级为轻量级锁。

轻量级锁(Lightweight Locking)

  • 目的:在无明显线程竞争的情况下,减少传统的锁机制(重量级锁)的性能消耗。
  • 原理:线程在获取锁时,会在栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的头部信息。然后,尝试使用CAS(Compare And Swap)操作替换对象头部信息,将其指向锁记录。如果成功,线程获得锁;如果失败,表示其他线程竞争锁。
  • 升级条件:当多个线程竞争同一个锁时,轻量级锁会升级为重量级锁。

重量级锁(Heavyweight Locking)

  • 目的:在强竞争条件下,保证线程安全。
  • 原理:当锁处于重量级状态时,线程尝试获取锁失败,会进入阻塞状态。这需要操作系统的帮助,代价比轻量级锁和偏向锁要高。
  • 特点:重量级锁是最传统的同步方式,当有多个线程频繁竞争同一个锁时,使用重量级锁可以确保线程安全。

锁升级过程

  1. 初始状态:新创建的锁对象默认是无锁状态。
  2. 偏向锁:当第一个线程访问同步块时,锁会变为偏向锁。该线程后续进入同步块不需要真正的锁操作。
  3. 轻量级锁:当有另一个线程尝试获取这个偏向锁时,如果持有偏向锁的线程不活跃,偏向锁可以撤销并升级为轻量级锁。
  4. 重量级锁:如果轻量级锁的自旋失败(即多个线程竞争同一个锁),则升级为重量级锁。

类比

可以将这些锁的升级过程类比为不同安全级别的门禁系统:

  • 偏向锁:类似于一扇只允许一个特定员工进入的门。只要是这个员工,门会自动打开,无需任何验证。
  • 轻量级锁:类似于有一个简单的锁系统,员工需要刷卡(CAS操作)进入,如果门没有被其他人使用,这个过程很快。
  • 重量级锁:如果门经常被多人同时尝试使用,系统会升级到一个有保安的更复杂的门禁系统,来确保每次只有一个人进入。

参考: