Java-并发

线程
线程模型
进程是操作系统进行资源分配和调度的基本单位。每个进程都拥有独立的内存空间、独立的系统资源。对于 Java 来说,一个 main 函数启动的其实就是开启了一个 JVM 进程,而 main 函数所在的线程称之为主线程。
线程是进程的一个执行单元,是 CPU 调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。线程自己也有私有的内存资源。对于 Java 来说,堆内存就是多个线程共享的资源,本地方法栈就是线程自己私有的资源。
进程和线程最大的区别就是,多个进程之间是完全独立的,相互没有影响。而同一个进程中的多个线程极有可能会出现线程安全问题,因为他们可能同时操作同一个数据。
实际上操作系统的线程和 Java 的线程还有有所区别的。
操作系统的线程称之为内核线程(Kernel-Level Thread,KLT),由内核来完成线程切换,内核通过调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。程序一般不会直接去使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),也就是通常意义上的 java 线程。内核线程和 java 线程是一一对应关系。
一一对应的关系简单好用,但也有缺点,当操作系统需要从一个线程切换到另一个线程时,就需要进行线程上下文切换。这个过程通常包括:
- 保存当前线程的 CPU 寄存器状态、程序计数器等信息。
- 加载下一个线程的 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 在任何给定时刻只能执行一个线程。然而,以下几个因素解释了这种现象:
- I/O 密集型任务的并发性:
- 阻塞等待: 程序经常需要进行 I/O 操作,例如读写文件、网络请求或数据库访问。这些操作通常是阻塞的,意味着当前线程必须等待 I/O 操作完成才能继续执行。在单线程程序中,CPU 会在等待 I/O 完成的过程中空闲下来,造成浪费。
- 线程切换: 在多线程程序中,当一个线程阻塞在 I/O 操作上时,操作系统可以切换到另一个线程执行,从而充分利用 CPU 时间。当 I/O 操作完成后,操作系统再切换回原来的线程继续执行。这种线程切换的开销相对较小,通常比等待 I/O 完成的时间要短得多。
- 提高效率: 通过这种方式,多线程程序可以在 I/O 等待期间执行其他任务,提高了程序的整体效率。
- 避免界面卡顿: 在具有用户界面的应用程序中,如果所有任务都在一个线程中执行,那么当执行耗时操作时,界面会卡顿,用户体验很差。
假设一个程序需要从网络下载 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++
操作,它实际上包含三个步骤:
- 读取
count
的值。 - 将
count
的值加 1。 - 将新的值写回
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();
这段代码其实是分为三步执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
乐观锁和悲观锁
乐观锁和悲观锁是并发控制中两种重要的思想,用于解决并发环境下数据竞争的问题。它们并不是某种具体的锁,而是一种设计思想,可以应用于各种编程语言、数据库和分布式系统中。
核心思想
-
悲观锁(Pessimistic Locking): 悲观地认为在数据处理过程中一定会发生并发冲突,因此在整个数据处理过程中都需要进行加锁,确保只有一个线程可以访问和修改数据。就像悲观的人总是担心事情会往坏的方向发展,所以总是提前做好最坏的打算。
-
乐观锁(Optimistic Locking): 乐观地认为在数据处理过程中一般不会发生并发冲突,只有在提交更新时才会进行冲突检测。如果检测到冲突,则返回错误信息,由用户决定如何处理(例如重试)。就像乐观的人总是相信事情会顺利进行,只有在真正遇到问题时才会采取措施。
区别和比较
特性 | 悲观锁 | 乐观锁 |
---|---|---|
加锁时机 | 在数据处理的整个过程中加锁 | 只在提交更新时进行冲突检测 |
并发性 | 低 | 高 |
冲突检测 | 总是加锁,强制串行执行 | 提交时检测,通过版本号或时间戳等机制实现 |
适用场景 | 写操作频繁,并发冲突概率高;对数据一致性要求高 | 读操作频繁,并发冲突概率低;对性能要求较高 |
实现方式 | 数据库的行锁、表锁,Java 的 synchronized 、ReentrantLock 等 |
版本号机制、时间戳机制等 |
优点 | 简单易用,保证数据一致性 | 并发性好,性能高 |
缺点 | 开销大,容易造成死锁,影响并发性能 | 需要应用层处理冲突,实现较为复杂 |
实现方式
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 操作的一个关键类是Unsafe
。Unsafe
类位于sun.misc
包下,是一个提供低级别、不安全操作的类。Unsafe
类提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作。比如 java 的 compareAndSwapInt(V,A,B)
方法实现 CAS 操作包含三个操作数:
- V(Value): 内存地址的实际值。
- A(Expected): 期望的旧值。
- B(New): 要修改的新值。
方法的原理是:
- 比较内存地址 V 的实际值与期望值 A 是否相等。
- 如果相等,则将内存地址 V 的值修改为新值 B。返回 TRUE。
- 如果不相等,则表示在此期间有其他线程修改了该值,返回FALSE,本次操作失败,通常会进行重试。
CAS 可以实现乐观锁,那么它有什么缺点呢?
- ABA 问题: 如果一个值从 A 变为 B,然后又变回 A,CAS 操作会认为该值没有发生变化。虽然值最终变回了原来的值,但中间可能发生了其他操作,这可能会导致一些潜在的问题。
解决 ABA 问题的方法:
- 版本号: 为每个变量增加一个版本号,每次修改变量时,版本号加 1。在进行 CAS 操作时,不仅要比较值,还要比较版本号。这样即使值变回了原来的值,版本号也已经发生了变化,CAS 操作会失败。Java 的
AtomicStampedReference
类就是使用版本号来解决 ABA 问题的。
-
长时间自旋的开销: 如果 CAS 操作一直不成功,就会导致线程长时间自旋,消耗大量的 CPU 资源。
-
只能保证一个共享变量的原子操作: 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 竞争同一个对象的锁:
- 线程 A 第一次访问同步代码块,JVM 将对象头设置为偏向锁状态,并记录线程 A 的 ID。
- 线程 A 再次访问同步代码块,由于是偏向锁状态且偏向线程 ID 是线程 A 的 ID,所以直接获得锁。
- 线程 B 尝试访问同步代码块,发现是偏向锁状态但偏向线程 ID 不是线程 B 的 ID,则发生偏向锁撤销,升级为轻量级锁。线程 B 使用 CAS 操作尝试获取锁。
- 如果线程 B CAS 操作成功,则线程 B 获得锁。如果线程 B CAS 操作失败,则线程 B 进行自旋。
- 如果线程 B 自旋一定次数后仍然没有获得锁,或者线程 C 也来竞争锁,则轻量级锁升级为重量级锁。线程 B 和线程 C 进入阻塞状态,等待线程 A 释放锁后竞争锁。
偏向锁
当一个线程第一次访问一个同步代码块并获取锁时,JVM 会将对象头的 Mark Word 中的锁标志位设置为偏向模式,并将线程 ID 记录在 Mark Word 中。此时,对象就“偏向”了这个线程。
当该线程再次访问同一个同步代码块时,只需要检查对象头的 Mark Word 中记录的线程 ID 是否与当前线程 ID 一致。如果一致,则无需再进行任何同步操作,直接进入同步代码块。这避免了 CAS 操作的开销。
如果有另一个线程尝试获取该锁(无论是否获取到),偏向模式就会被撤销。撤销的过程需要等到持有偏向锁的线程到达安全点(Safe Point),安全点状态下所有线程都是暂停的。然后锁就升级为轻量级锁。
综上所述,偏向锁仅仅在单线程访问同步代码块的场景中可以获得性能收益。撤销过程非常耗性能,实际上在高版本的 jdk 中,偏向锁已经被废弃。
轻量级锁
偏向锁升级为轻量级锁,意味着不再是单线程。而是当多线程之间不存在竞争关系,即轮流交替获得锁。或者存在竞争的时间间隔很短,通过 CAS 来竞争获取锁。一旦进程很激烈导致 CAS 循环次数超过阈值就会触发锁升级为重量级锁,这称之为锁膨胀。
轻量级锁本质是通过 CAS 来获取锁对象,避免了内核态和用户态的转换对性能的消耗。
轻量级锁的逻辑:
- 检查 Mark Word: 检查对象的 Mark Word 中锁状态是轻量级锁。
- CAS 操作: 线程使用 CAS 操作尝试将对象的 Mark Word 更新为指向锁记录的指针。
- 加锁成功: 如果 CAS 操作成功,则线程获得锁,进入同步代码块执行。Mark Word 指针指向获得锁的线程对象。
- 加锁失败: 如果 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:记录获取到锁的线程对象。
加锁原理:
- 线程 A 通过 CAS 操作修改 state 值,从 0 修改为 1 。如果成功就获取到了锁,并把 OwnerThread 指向线程自己。
- 线程 B 也通过 CAS 操作修改 state 值,从 0 修改为 1 。很明显失败了,因为此时 state 的值已经是 1 了,线程 A 获得到了锁。为了实现可重入锁,此时线程 B 还会检查OwnerThread是不是指向自己,如果是自己,再通过 CAS 将 state值从 1 修改为 2 ,很显然不是自己。线程 B 获取锁失败,加入到 CLH 队列中。
- 线程 B 加入 CLH 队列后,还会进行一次 CAS 操作将 state值从 0 修改为 1,失败就将线程挂起。
解锁原理:
- 线程 A 执行结束,释放锁时会检查 OwnerThread 指向自己,并将 state 值修改为 0 ,OwnerThread 修改为 null 。
- 释放锁后会通知 CLH 队列,唤醒队列中头节点的下一个节点的挂起线程,即线程 B 从挂起状态被唤醒。线程 B 按照上面的加锁逻辑尝试获取锁。
- 如果是公平锁,线程 B 被唤醒时,如果有其他线程加入了竞争,会被加入到队列尾部。
- 如果是非公平锁,线程 B 被唤醒时,有其他线程加入了竞争,其他线程可能抢到锁。
ThreadLocal
ThreadLocal,直译为“线程本地变量”,它为每个线程提供了一个独立的变量副本,使得每个线程都可以访问自己独立的变量,而不会与其他线程的变量发生冲突。
每个 Thread 线程对象中都维护了一个 ThreadLocalMap 的成员变量,当为线程创建一个 ThreadLocal 对象时,就会把key 是 ThreadLocal 对象本身,value 则是线程的本地变量加入到ThreadLocalMap 中。
- 设置值: 当调用 ThreadLocal 的 set 方法时,会将当
ThreadLocal
对象本身作为 key,新设置的值作为 value,存入 ThreadLocalMap 中。 - 获取值: 当调用 ThreadLocal 的 get 方法时,会根据 ThreadLocal 从 ThreadLocalMap 中获取对应的 value。
上图是 ThreadLocal 的内存示意图,从图中我们可以看到 ThreadLocalMap 的 key 是指向 ThreadLocal 对象的弱引用。如果 ThreadLocal 被垃圾回收,再加上线程对象在线程池中复用,最终导致 Object 对象无法被回收,发生内存泄漏。解决方案是正确使用 ThreadLocal ,在用完后要记得调用 remove 方法删除它。
ThreadLocal 在子线程中是获取不到值的,如果要实现子线程获取到值,可以使用 InheritableThreadLocal ,它的原理是在初始化子线程的时候复制一份数据。切记不要在线程池中使用 ThreadLocal 。
并发容器
ConcurrentHashMap 是一种高效的线程安全 HashMap 实现,它通过巧妙的设计和优化,在并发环境下提供了出色的性能。
CopyOnWriteArrayList 是一种通过写时复制实现线程安全的 List,写数据时复制原来是集合,在复制集合上修复,这样能实现只在写写之间互斥。在读多写少的场合性能非常好。提醒集合特别大的时候别用写操作会非常耗时,每次写都要复制一次数组。