Java-并发

线程

线程模型

进程是操作系统进行资源分配和调度的基本单位。每个进程都拥有独立的内存空间、独立的系统资源。对于 Java 来说,一个 main 函数启动的其实就是开启了一个 JVM 进程,而 main 函数所在的线程称之为主线程。

线程是进程的一个执行单元,是 CPU 调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。线程自己也有私有的内存资源。对于 Java 来说,堆内存就是多个线程共享的资源,本地方法栈就是线程自己私有的资源。

进程和线程最大的区别就是,多个进程之间是完全独立的,相互没有影响。而同一个进程中的多个线程极有可能会出现线程安全问题,因为他们可能同时操作同一个数据。

实际上操作系统的线程和 Java 的线程还有有所区别的。

操作系统的线程称之为内核线程(Kernel-Level Thread,KLT),由内核来完成线程切换,内核通过调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。程序一般不会直接去使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),也就是通常意义上的 java 线程。内核线程和 java 线程是一一对应关系。

一一对应的关系简单好用,但也有缺点,当操作系统需要从一个线程切换到另一个线程时,就需要进行线程上下文切换。这个过程通常包括:

  1. 保存当前线程的 CPU 寄存器状态、程序计数器等信息。
  2. 加载下一个线程的 CPU 寄存器状态、程序计数器等信息。

如果线程的切换涉及到用户态和内核态的切换,那么开销会更大,因为还需要切换地址空间、刷新等。

这里有个很重要的知识点,线程切换,不一定发生用户态和内核态的切换。比如后面说的 CAS 操作也能实现线程切换,但是不会触发户态和内核态的切换。

一句话概括 Java 线程和操作系统线程的关系:Java 线程的本质其实就是操作系统的线程的映射,映射关系是一一对应的

线程状态

在操作系统层面来说线程有以下状态:

线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片后就处于 RUNNING(运行) 状态。其他没有在执行的线程就是 WAITING(等待)状态。

在 JVM 层面来说:

  • 线程初始状态:NEW
  • 线程运行状态:RUNNABLE
  • 线程阻塞状态:BLOCKED
  • 线程等待状态:WAITING
  • 超时等待状态:TIMED_WAITING
  • 线程终止状态:TERMINATED

在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态。之所以 JVM 没有区分这两种状态,是因为这两种状态的切换时间间隔太快了,只有 一个CPU 时间片。线程切换的如此之快,区分这两种状态就没什么意义了。
JVM 将 操作系统的 WAITING 状态又细分为了 BLOCKED、WAITING、TIMED_WAITING。

那么 JVM 的线程状态之间又是如何切换的呢?参考下图。

多线程

在我是个小白的期间,一直有一个疑惑,单核CPU的服务器上运行多线程程序是不是没有意义?

即使在单核 CPU 的服务器上运行 Java 程序,使用多线程在某些情况下仍然可能比单线程执行得更快。这听起来似乎有些违反直觉,因为单核 CPU 在任何给定时刻只能执行一个线程。然而,以下几个因素解释了这种现象:

  1. I/O 密集型任务的并发性:
  • 阻塞等待: 程序经常需要进行 I/O 操作,例如读写文件、网络请求或数据库访问。这些操作通常是阻塞的,意味着当前线程必须等待 I/O 操作完成才能继续执行。在单线程程序中,CPU 会在等待 I/O 完成的过程中空闲下来,造成浪费。
  • 线程切换: 在多线程程序中,当一个线程阻塞在 I/O 操作上时,操作系统可以切换到另一个线程执行,从而充分利用 CPU 时间。当 I/O 操作完成后,操作系统再切换回原来的线程继续执行。这种线程切换的开销相对较小,通常比等待 I/O 完成的时间要短得多。
  • 提高效率: 通过这种方式,多线程程序可以在 I/O 等待期间执行其他任务,提高了程序的整体效率。
  1. 避免界面卡顿: 在具有用户界面的应用程序中,如果所有任务都在一个线程中执行,那么当执行耗时操作时,界面会卡顿,用户体验很差。

假设一个程序需要从网络下载 10 个文件。

  • 单线程: 程序会依次下载每个文件,必须等待前一个文件下载完成后才能开始下载下一个文件。如果每个文件下载需要 1 秒,那么总共需要 10 秒。

  • 多线程: 程序可以创建 10 个线程,每个线程负责下载一个文件。虽然 CPU 在任何给定时刻只能执行一个线程,但当一个线程在等待网络响应时,CPU 可以切换到另一个线程执行。这样,10 个文件可以几乎同时下载完成,总时间接近 1 秒(加上一些线程切换的开销)。

    并非所有任务都适合多线程: 对于 CPU 密集型任务(即需要大量计算的任务),在单核 CPU 上使用多线程并不能提高执行速度,反而会因为线程切换的开销而降低效率。因为CPU一直在满负荷运转,没有空闲时间可以利用。

  • 线程切换的开销: 线程切换需要一定的开销,包括保存和恢复线程的上下文信息。如果线程切换过于频繁,反而会降低程序的性能。

  • 资源竞争和同步问题: 多线程编程需要考虑资源竞争和同步问题,例如共享变量的访问需要进行同步控制,否则可能会导致数据不一致或其他错误。这会增加编程的复杂性。

在单核 CPU 上,多线程主要通过提高 I/O 密集型任务的并发性、提高用户交互的响应性以及利用 CPU 的空闲时间来提高程序的效率。对于 CPU 密集型任务,多线程并不能带来性能提升。在实际应用中,需要根据任务的类型和特点来选择合适的并发模型。

另外使在单核 CPU 上运行多线程程序,仍然会出现并发线程安全问题。虽然单核 CPU 在任何给定时刻只能执行一个线程,但操作系统通过时间片轮转的方式快速切换线程,给人一种“同时”执行的错觉。这种快速切换就可能导致并发问题。

volatile

volatile 是 Java 中的一个关键字,用于修饰变量。它主要有两个作用:

1.可见性

在多线程环境下,每个线程都有自己的工作内存(Working Memory),其中存储了共享变量的副本。线程对共享变量的操作实际上是在自己的工作内存中进行的。如果没有 volatile 关键字,一个线程对共享变量的修改可能不会立即被其他线程看到,导致数据不一致。volatile 关键字通过强制线程在每次访问变量时都从主内存中读取,并在修改变量后立即将修改写回主内存,从而保证了可见性。

2. 禁止指令重排序

为了提高程序的执行效率,编译器和处理器会对指令进行重排序。重排序在单线程环境下不会有问题,但在多线程环境下可能会导致意想不到的结果。volatile 关键字通过插入内存屏障(Memory Barrier)来禁止指令重排序。内存屏障是一种 CPU 指令,用于控制特定类型的内存访问操作的顺序,强制处理器在执行内存访问操作时遵循一定的约束。

虽然 volatile 可以保证可见性和禁止指令重排序,但它不能保证原子性。原子性指的是一个操作是不可中断的,要么全部执行完成,要么完全不执行。

例如,对于 count++ 操作,它实际上包含三个步骤:

  1. 读取 count 的值。
  2. count 的值加 1。
  3. 将新的值写回 count

如果 count 是一个 volatile 变量,那么每个线程在读取 count 的值时都会从主内存中读取,并且在写回新的值时会立即写回主内存。但是,这三个步骤仍然不是原子的。如果多个线程同时执行 count++,仍然可能出现竞态条件,导致数据丢失。

public class VolatileExample {
    private volatile int count = 0;

    public void increment() {
        count++; // 不是原子操作
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    example.increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Count: " + example.getCount()); // 结果通常小于 10000
    }
}

在这个例子中,即使 count 被声明为 volatile,最终的 count 值通常也小于 10000,因为 count++ 不是原子操作。

volatile 适用于一个线程写,多个线程读 的场景,当只有一个线程修改变量,而多个线程读取变量时,可以使用 volatile 来保证可见性。比如在单例模式中就有用到。

public class Singleton {
    // 保证可见性
    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码,避免竞争锁
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                // 双重检查,避免并发时可能重复创建实例
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

乐观锁和悲观锁

乐观锁和悲观锁是并发控制中两种重要的思想,用于解决并发环境下数据竞争的问题。它们并不是某种具体的锁,而是一种设计思想,可以应用于各种编程语言、数据库和分布式系统中。

核心思想

  • 悲观锁(Pessimistic Locking): 悲观地认为在数据处理过程中一定会发生并发冲突,因此在整个数据处理过程中都需要进行加锁,确保只有一个线程可以访问和修改数据。就像悲观的人总是担心事情会往坏的方向发展,所以总是提前做好最坏的打算。

  • 乐观锁(Optimistic Locking): 乐观地认为在数据处理过程中一般不会发生并发冲突,只有在提交更新时才会进行冲突检测。如果检测到冲突,则返回错误信息,由用户决定如何处理(例如重试)。就像乐观的人总是相信事情会顺利进行,只有在真正遇到问题时才会采取措施。

区别和比较

特性 悲观锁 乐观锁
加锁时机 在数据处理的整个过程中加锁 只在提交更新时进行冲突检测
并发性
冲突检测 总是加锁,强制串行执行 提交时检测,通过版本号或时间戳等机制实现
适用场景 写操作频繁,并发冲突概率高;对数据一致性要求高 读操作频繁,并发冲突概率低;对性能要求较高
实现方式 数据库的行锁、表锁,Java 的 synchronizedReentrantLock 版本号机制、时间戳机制等
优点 简单易用,保证数据一致性 并发性好,性能高
缺点 开销大,容易造成死锁,影响并发性能 需要应用层处理冲突,实现较为复杂

实现方式

1. 悲观锁的实现

  • 数据库中的锁: 数据库提供了行锁、表锁等机制来实现悲观锁。例如,MySQL 的 SELECT ... FOR UPDATE 语句可以对查询到的行加排他锁,防止其他事务修改这些行。

  • Java 中的锁: Java 提供了 synchronized 关键字和 java.util.concurrent.locks 包中的锁(如 ReentrantLock)来实现悲观锁。

2. 乐观锁的实现

  • 版本号机制: 为每条数据增加一个版本号字段,每次更新数据时,版本号加 1。在提交更新时,比较数据库中数据的版本号与更新前读取的版本号是否一致。如果一致,则更新成功;否则,更新失败,需要重试。
-- 查询记录,获取版本号
SELECT id, quantity, version FROM products WHERE id = 1;

-- 更新记录,同时更新版本号
UPDATE products SET quantity = quantity - 1, version = version + 1 WHERE id = 1 AND version = #{oldVersion};
  • 时间戳机制: 为每条数据增加一个时间戳字段,每次更新数据时,更新时间戳。在提交更新时,比较数据库中数据的时间戳与更新前读取的时间戳是否一致。如果一致,则更新成功;否则,更新失败,需要重试。

  • Java中使用CAS机制: 后面会介绍。

CAS

CAS,全称为 Compare and Swap(比较并交换),是一种无锁原子操作。它是实现乐观锁的一种重要方式,广泛应用于并发编程中,尤其是在 Java 的 java.util.concurrent.atomic 包中。

CAS 操作的核心在于一条 CPU 指令,这条指令通常被称为 CMPXCHG(在 x86 架构中)或其他类似的指令(在其他架构中)。这条指令的执行过程是原子的,即在执行过程中不会被其他线程或中断打断。

为了保证 CMPXCHG 指令的原子性,CPU 内部使用了多种机制,例如:

  • 总线锁定(Bus Locking): 在早期的 CPU 中,使用总线锁定来保证原子性。当 CPU 执行 CMPXCHG 指令时,会锁定系统总线,阻止其他 CPU 或设备通过总线访问内存。这种方式的缺点是会影响其他 CPU 的性能。
  • 缓存一致性协议(Cache Coherence Protocols): 现代 CPU 使用缓存一致性协议(例如 MESI 协议)来保证多个 CPU 缓存之间的数据一致性。当一个 CPU 执行 CMPXCHG 指令时,缓存一致性协议会确保其他 CPU 缓存中相应的数据失效,从而保证只有一个 CPU 可以修改该内存地址的值。

总之 CAS 在物理层面保证了原子性,那么在 java 代码中又是如何用到它的呢?


在 Java 中,实现 CAS 操作的一个关键类是UnsafeUnsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。Unsafe类提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作。比如 java 的 compareAndSwapInt(V,A,B) 方法实现 CAS 操作包含三个操作数:

  • V(Value): 内存地址的实际值。
  • A(Expected): 期望的旧值。
  • B(New): 要修改的新值。

方法的原理是:

  1. 比较内存地址 V 的实际值与期望值 A 是否相等。
  2. 如果相等,则将内存地址 V 的值修改为新值 B。返回 TRUE。
  3. 如果不相等,则表示在此期间有其他线程修改了该值,返回FALSE,本次操作失败,通常会进行重试。

CAS 可以实现乐观锁,那么它有什么缺点呢?

  1. ABA 问题: 如果一个值从 A 变为 B,然后又变回 A,CAS 操作会认为该值没有发生变化。虽然值最终变回了原来的值,但中间可能发生了其他操作,这可能会导致一些潜在的问题。

解决 ABA 问题的方法:

  • 版本号: 为每个变量增加一个版本号,每次修改变量时,版本号加 1。在进行 CAS 操作时,不仅要比较值,还要比较版本号。这样即使值变回了原来的值,版本号也已经发生了变化,CAS 操作会失败。Java 的 AtomicStampedReference 类就是使用版本号来解决 ABA 问题的。
  1. 长时间自旋的开销: 如果 CAS 操作一直不成功,就会导致线程长时间自旋,消耗大量的 CPU 资源。

  2. 只能保证一个共享变量的原子操作: CAS 只能对一个共享变量执行原子操作。如果需要对多个共享变量执行原子操作,CAS 就无法胜任,这时需要使用锁或其他同步机制。

synchronized

使用

synchronized 是 Java 中的一个关键字,用于提供线程之间的同步机制,保证共享资源在多线程环境下的安全访问。它可以修饰方法、代码块,以及静态方法和静态代码块。

  • 修饰实例方法:

    public synchronized void method() {
        // 同步代码块
    }

    当一个线程调用一个对象的 synchronized 实例方法时,会自动获取该对象实例的锁。其他线程如果也想调用该对象的 synchronized 实例方法,则需要等待当前线程释放锁。

  • 修饰静态方法:

    public static synchronized void staticMethod() {
        // 同步代码块
    }

    当一个线程调用一个类的 synchronized 静态方法时,会自动获取该类的 Class 对象的锁。其他线程如果也想调用该类的 synchronized 静态方法,则需要等待当前线程释放锁。

  • 修饰代码块:

    public void method() {
        synchronized (this) { // 同步代码块,锁对象为 this
            // 同步代码块
        }
    
        synchronized (object) { // 同步代码块,锁对象为 object
            // 同步代码块
        }
    
        synchronized (ClassName.class) { // 同步代码块,锁对象为 Class 对象
            // 同步代码块
        }
    }

    当一个线程进入一个 synchronized 代码块时,会自动获取指定的锁对象。其他线程如果也想进入该 synchronized 代码块,则需要等待当前线程释放锁。

另外,构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。

原理

上面我们介绍了 synchronized 的三种使用方式,无论哪种方式本质都是对某个对象加锁,我们称它为锁对象。锁对象中有一个叫对象头(Object Header)的元数据,其中记录了锁的基础信息。实际上每个 Java 对象都有对象头,它是理解 Java 对象内存布局、垃圾回收(GC)以及锁机制等高级特性的基础。对象头有一个重要的部分Mark Word。

Mark Word(标记字): 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。它是对象头中最重要的部分,也是实现锁机制的关键。

上图中 Mark Word 内有很多记录,最重要的是 锁状态指针

锁升级

锁升级是 Java 虚拟机 (JVM) 为了优化 synchronized 关键字的性能而引入的一种机制。它根据线程竞争的激烈程度,动态地调整锁的状态,从开销最小的状态逐渐升级到开销最大的状态,从而尽可能地减少锁带来的性能损耗。锁升级是单向的,只能升级不能降级。

锁升级就是通过检查、修改对象头中的 Mark Word 来进行的。

最开始,锁对象被创建出来的时候,就是无锁状态。表示锁对象没有被任何线程获得。

从最初的无锁升级到偏向锁,再到轻量级锁,最后到重量级锁。

假设有三个线程 A、B、C 竞争同一个对象的锁:

  1. 线程 A 第一次访问同步代码块,JVM 将对象头设置为偏向锁状态,并记录线程 A 的 ID。
  2. 线程 A 再次访问同步代码块,由于是偏向锁状态且偏向线程 ID 是线程 A 的 ID,所以直接获得锁。
  3. 线程 B 尝试访问同步代码块,发现是偏向锁状态但偏向线程 ID 不是线程 B 的 ID,则发生偏向锁撤销,升级为轻量级锁。线程 B 使用 CAS 操作尝试获取锁。
  4. 如果线程 B CAS 操作成功,则线程 B 获得锁。如果线程 B CAS 操作失败,则线程 B 进行自旋。
  5. 如果线程 B 自旋一定次数后仍然没有获得锁,或者线程 C 也来竞争锁,则轻量级锁升级为重量级锁。线程 B 和线程 C 进入阻塞状态,等待线程 A 释放锁后竞争锁。

偏向锁

当一个线程第一次访问一个同步代码块并获取锁时,JVM 会将对象头的 Mark Word 中的锁标志位设置为偏向模式,并将线程 ID 记录在 Mark Word 中。此时,对象就“偏向”了这个线程。

当该线程再次访问同一个同步代码块时,只需要检查对象头的 Mark Word 中记录的线程 ID 是否与当前线程 ID 一致。如果一致,则无需再进行任何同步操作,直接进入同步代码块。这避免了 CAS 操作的开销。

如果有另一个线程尝试获取该锁(无论是否获取到),偏向模式就会被撤销。撤销的过程需要等到持有偏向锁的线程到达安全点(Safe Point),安全点状态下所有线程都是暂停的。然后锁就升级为轻量级锁。

综上所述,偏向锁仅仅在单线程访问同步代码块的场景中可以获得性能收益。撤销过程非常耗性能,实际上在高版本的 jdk 中,偏向锁已经被废弃。

轻量级锁

偏向锁升级为轻量级锁,意味着不再是单线程。而是当多线程之间不存在竞争关系,即轮流交替获得锁。或者存在竞争的时间间隔很短,通过 CAS 来竞争获取锁。一旦进程很激烈导致 CAS 循环次数超过阈值就会触发锁升级为重量级锁,这称之为锁膨胀。

轻量级锁本质是通过 CAS 来获取锁对象,避免了内核态和用户态的转换对性能的消耗。

轻量级锁的逻辑:

  1. 检查 Mark Word: 检查对象的 Mark Word 中锁状态是轻量级锁。
  2. CAS 操作: 线程使用 CAS 操作尝试将对象的 Mark Word 更新为指向锁记录的指针。
  3. 加锁成功: 如果 CAS 操作成功,则线程获得锁,进入同步代码块执行。Mark Word 指针指向获得锁的线程对象。
  4. 加锁失败: 如果 CAS 操作失败,说明有其他线程也在竞争锁。此时,当前线程会进行自旋(在一个循环中不断尝试 CAS 操作)。如果自旋一定次数后仍然失败,轻量级锁会膨胀为重量级锁,当前线程会被阻塞。

重量级锁

重量级锁的实现依赖于 JVM 的 Monitor(监视器) 机制。每个 Java 对象都关联一个 Monitor,当一个线程执行到 synchronized 代码块或方法时,会尝试获取该对象关联的 Monitor。Monitor实际上是C++写的一个对象。Monitor 的底层实现是依赖于操作系统的。Java 虚拟机通过调用操作系统提供的同步原语(如互斥量)来实现 Monitor 的功能。

当锁升级为重量级锁时,Mark Word 中的指针指向 Monitor 对象。我们首先来看看 Monitor 对象的结构吧,它有以下重要属性:

  • Owner: 当前持有 Monitor 的线程。
  • Cxq: 主要用于暂时存放刚开始竞争锁失败的线程。是先进后出的栈结构。当 Owner 线程释放锁后,cxq 中的线程会被转移到 Entry Set 中,等待重新竞争锁。
  • Entry Set: 一个等待队列,主要用于存放真正要竞争锁的线程。
  • Wait Set: 一个等待队列,存放调用了 wait() 方法的线程,这些线程在等待被 notify() 或 notifyAll() 唤醒。

最后总结下锁升级的重要内容:

无锁 偏向锁 轻量级锁 重量级锁
Mark Word 中的锁状态 001 101 100 110
Mark Word 中的指针 持有锁的线程对象 持有锁的线程对象 Monitor 对象
适用场景 非并发场景 单线程 多线程顺序交替执行,或者竞争时间很短 多线程并发竞争锁
说明 由于偏向锁撤销的性能消耗高版本jdk已经丢弃 使用 CAS 避免了内核态和用户态的转换 线程会阻塞,线程切换会导致内核态和用户态的转换

Lock

Lock 是 java 中的一个接口,其有很多实现类,可以满足不同场景下的同步锁的功能。相较于synchronized 更加灵活。下面我介绍下它的几种常用实现类。

ReentrantLock

ReentrantLock 类是 Lock 接口重要的一个实现类,它是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,它具备以下几大功能特点:

  • 可重入:一个线程获得锁后,可以再次获取同一个锁,以免出现死锁。比如两个方法上都对同一个对象加锁,这两个方法可以相互调用。
  • 公平:先申请锁的线程优先获得锁,讲究一个先来后到。可以通过构造函数来设置为公平锁。
  • 非公平锁:多线程抢占式竞争锁,默认是非公平锁,因为性能好。
  • 可以设置超时:提供了tryLock(timeout) 的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。
  • 等待可中断:等待线程可以被其他线程中断等待。[[java-基础-中断线程]]
  • 选择性通知:借助 Condition 接口可以更加灵活的控制多线程的通知唤醒。

ReentrantReadWriteLock

ReentrantReadWriteLock类也是 Lock 接口重要的一个,在 ReentrantLock 的基础上,进一步细分了锁的粒度,允许多个线程同时进行读操作,但写操作是互斥的。这种设计在读操作远多于写操作的场景下能显著提升并发性能。

ReentrantReadWriteLock 内部其实有两种锁,分别是读锁和写锁,我们在业务中写数据用写锁,读数据用读锁即可。

  • 读锁(共享锁): 多个线程可以同时持有读锁,即允许多个线程同时进行读操作。
  • 写锁(独占锁): 任何时刻只有一个线程可以持有写锁,持有写锁的线程可以读和写。其他线程不能读也不能写。

AQS

AbstractQueuedSynchronizer(AQS)是 Java 并发包中提供的一个抽象类,它是构建锁或其他同步组件的基础。AQS 定义了一套多线程访问共享资源的同步状态的模板,许多同步类(如ReentrantLock、ReentrantReadWriteLock、CountDownLatch等)都是基于 AQS 实现的。

AQS 内部有以下重要属性构成:

  • state :是一个 int 类型的成员变量,记录了加锁次数,0表示未加锁。通过 CAS 操作原子地更新这个状态。
  • CLH 队列: 一个虚拟的双向队列,用于存放等待获取锁的线程。
  • OwnerThread:记录获取到锁的线程对象。

加锁原理:

  1. 线程 A 通过 CAS 操作修改 state 值,从 0 修改为 1 。如果成功就获取到了锁,并把 OwnerThread 指向线程自己。
  2. 线程 B 也通过 CAS 操作修改 state 值,从 0 修改为 1 。很明显失败了,因为此时 state 的值已经是 1 了,线程 A 获得到了锁。为了实现可重入锁,此时线程 B 还会检查OwnerThread是不是指向自己,如果是自己,再通过 CAS 将 state值从 1 修改为 2 ,很显然不是自己。线程 B 获取锁失败,加入到 CLH 队列中。
  3. 线程 B 加入 CLH 队列后,还会进行一次 CAS 操作将 state值从 0 修改为 1,失败就将线程挂起。

解锁原理:

  1. 线程 A 执行结束,释放锁时会检查 OwnerThread 指向自己,并将 state 值修改为 0 ,OwnerThread 修改为 null 。
  2. 释放锁后会通知 CLH 队列,唤醒队列中头节点的下一个节点的挂起线程,即线程 B 从挂起状态被唤醒。线程 B 按照上面的加锁逻辑尝试获取锁。
  3. 如果是公平锁,线程 B 被唤醒时,如果有其他线程加入了竞争,会被加入到队列尾部。
  4. 如果是非公平锁,线程 B 被唤醒时,有其他线程加入了竞争,其他线程可能抢到锁。

ThreadLocal

ThreadLocal,直译为“线程本地变量”,它为每个线程提供了一个独立的变量副本,使得每个线程都可以访问自己独立的变量,而不会与其他线程的变量发生冲突。

每个 Thread 线程对象中都维护了一个 ThreadLocalMap 的成员变量,当为线程创建一个 ThreadLocal 对象时,就会把key 是 ThreadLocal 对象本身,value 则是线程的本地变量加入到ThreadLocalMap 中。

  1. 设置值: 当调用 ThreadLocal 的 set 方法时,会将当ThreadLocal对象本身作为 key,新设置的值作为 value,存入 ThreadLocalMap 中。
  2. 获取值: 当调用 ThreadLocal 的 get 方法时,会根据 ThreadLocal 从 ThreadLocalMap 中获取对应的 value。

上图是 ThreadLocal 的内存示意图,从图中我们可以看到 ThreadLocalMap 的 key 是指向 ThreadLocal 对象的弱引用。如果 ThreadLocal 被垃圾回收,再加上线程对象在线程池中复用,最终导致 Object 对象无法被回收,发生内存泄漏。解决方案是正确使用 ThreadLocal ,在用完后要记得调用 remove 方法删除它。

ThreadLocal 在子线程中是获取不到值的,如果要实现子线程获取到值,可以使用 InheritableThreadLocal ,它的原理是在初始化子线程的时候复制一份数据。切记不要在线程池中使用 ThreadLocal 。

并发容器

ConcurrentHashMap 是一种高效的线程安全 HashMap 实现,它通过巧妙的设计和优化,在并发环境下提供了出色的性能。

CopyOnWriteArrayList 是一种通过写时复制实现线程安全的 List,写数据时复制原来是集合,在复制集合上修复,这样能实现只在写写之间互斥。在读多写少的场合性能非常好。提醒集合特别大的时候别用写操作会非常耗时,每次写都要复制一次数组。

SystemCaller
SystemCaller

https://gravatar.com/noisily745e35dad0

文章: 47

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注