文章目录
简介
在早期版本的 Java 中,synchronized
关键字被认为是重量级锁,效率较低,这与其底层实现机制密切相关。要深入理解这个问题,需要结合计算机系统的相关原理,包括操作系统、线程管理、以及用户态和内核态的转换。
1. 监视器锁(Monitor Lock)与操作系统的 Mutex Lock
synchronized
关键字的实现依赖于监视器锁(Monitor Lock)。在 Java 中,每个对象都有一个隐含的监视器锁。当一个线程进入一个同步块或同步方法时,它必须首先获得该对象的监视器锁。
监视器锁的底层实现通常依赖于操作系统提供的互斥锁(Mutex Lock)。互斥锁是一种用于在多线程环境中实现互斥访问共享资源的机制,它确保在任意时刻只有一个线程可以访问特定的资源。
互斥锁的基本原理是通过操作系统内核来控制对共享资源的访问,防止多个线程同时访问共享资源造成的数据不一致问题。在实现互斥锁时,操作系统通常使用硬件提供的原子操作来保证锁的正确性。例如,x86架构提供了 LOCK
指令,可以确保对内存的原子操作,从而实现互斥锁。
1.1 监视器锁的工作机制
监视器锁的工作机制可以分为以下几个步骤:
- 获取锁:当一个线程试图进入一个同步块时,它必须首先获取对象的监视器锁。如果锁已经被其他线程持有,该线程将被阻塞,直到锁被释放。
- 执行同步代码:一旦线程获得了锁,它就可以执行同步块中的代码。在此期间,其他试图进入该同步块的线程将被阻塞。
- 释放锁:当线程退出同步块时,它将释放监视器锁,以便其他线程可以获取该锁并进入同步块。
1.2 互斥锁的实现
操作系统提供的互斥锁通常依赖于底层硬件的支持。以下是互斥锁的一种简单实现方式:
typedef struct {
int locked;
} mutex_t;
void lock(mutex_t *mutex) {
while (__sync_lock_test_and_set(&(mutex->locked), 1)) {
// 自旋等待
}
}
void unlock(mutex_t *mutex) {
__sync_lock_release(&(mutex->locked));
}
在这个实现中,__sync_lock_test_and_set 是一个原子操作,它将 mutex->locked 设置为 1,并返回之前的值。如果之前的值是 0,则表示锁未被持有,线程可以成功获取锁;如果之前的值是 1,则表示锁已经被其他线程持有,当前线程需要自旋等待。
2. Java 线程与操作系统原生线程
Java 线程是通过 JVM 实现的,而 JVM 中的线程直接映射到操作系统的原生线程(如在 Windows 上的 Thread,在 Unix 系统上的 pthread)。这意味着 Java 线程的创建、调度和管理完全依赖于底层操作系统的线程管理机制。
2.1 Java 线程的实现
Java 线程的实现依赖于 JVM 的线程调度器。JVM 的线程调度器将 Java 线程映射到操作系统的原生线程上,并负责线程的创建、切换和销毁。在 JVM 中,线程的状态可以分为以下几种:
新建(New):线程对象被创建,但尚未启动。
就绪(Runnable):线程已经启动,可以运行,但实际运行时间由线程调度器决定。
运行(Running):线程正在 CPU 上运行。
阻塞(Blocked):线程因等待某个资源而被阻塞,无法运行。
等待(Waiting):线程进入等待状态,等待其他线程显式唤醒。
定时等待(Timed Waiting):线程进入定时等待状态,在指定时间后自动唤醒。
终止(Terminated):线程已经结束执行,无法再次运行。
2.2 操作系统的线程管理
操作系统的线程管理包括线程的创建、调度和销毁。线程的调度通常依赖于调度算法,如时间片轮转(Round Robin)、优先级调度(Priority Scheduling)等。在多核处理器上,操作系统还需要负责线程的负载均衡,以确保每个 CPU 核心的工作负载均匀。
2.3 Java 线程与操作系统线程的关系
由于 Java 线程直接映射到操作系统的原生线程,Java 线程的调度和管理完全依赖于操作系统的线程管理机制。这种映射关系使得 Java 线程可以充分利用操作系统提供的多线程能力,但也带来了性能开销。
3. 线程的挂起和唤醒
当一个线程尝试获取一个已经被其他线程持有的锁时,它会被阻塞并进入等待状态,直到该锁被释放。这个过程需要操作系统的介入:
挂起线程:操作系统将当前运行的线程从 CPU 上移除,并将其状态保存到线程控制块(Thread Control Block, TCB)中。这个过程涉及到上下文切换,即保存当前线程的上下文(如寄存器、程序计数器等)并加载下一个要运行线程的上下文。
唤醒线程:当锁被释放时,操作系统将唤醒等待获取该锁的线程,将其状态从等待队列中移除,并重新调度该线程运行。
3.1 上下文切换
上下文切换是指操作系统将一个正在运行的线程换出 CPU,并将另一个线程调入 CPU 运行的过程。上下文切换包括保存当前线程的状态(如寄存器值、程序计数器等),并加载下一个线程的状态。上下文切换的主要步骤如下:
保存当前线程的上下文:操作系统保存当前线程的寄存器值、程序计数器等状态到其线程控制块(TCB)中。
选择下一个线程:操作系统根据调度算法选择下一个要运行的线程。
加载新线程的上下文:操作系统从选定线程的 TCB 中恢复其寄存器值、程序计数器等状态。
切换到新线程:操作系统将 CPU 切换到新线程,开始执行。
3.2 上下文切换的开销
上下文切换是一个开销较大的操作,主要包括以下几个方面:
CPU 时间:保存和恢复线程的上下文需要耗费 CPU 时间。
缓存失效:上下文切换可能导致 CPU 缓存失效,需要重新加载缓存数据。
TLB 刷新:上下文切换可能导致 TLB(Translation Lookaside Buffer)刷新,从而增加内存访问延迟。
在高并发环境下,频繁的上下文切换会显著降低系统性能。因此,减少上下文切换是提高多线程应用性能的重要手段之一。
4. 用户态与内核态的转换
线程挂起和唤醒的操作需要从用户态(user mode)转换到内核态(kernel mode),这是因为这些操作需要操作系统内核的支持。
用户态:应用程序运行的模式,具有受限的访问权限,不能直接访问硬件或内核数据结构。
内核态:操作系统内核运行的模式,具有完全的访问权限,可以执行任何 CPU 指令并访问任何内存地址。
用户态到内核态的转换涉及以下步骤:
陷入(Trap)指令:当线程需要进行系统调用(如线程挂起或唤醒)时,会触发一个陷入指令,使 CPU 从用户态切换到内核态。
保存上下文:操作系统内核保存当前线程的上下文,包括寄存器、程序计数器等。
执行内核代码:操作系统内核执行挂起或唤醒线程的代码。
恢复上下文:操作系统内核恢复目标线程的上下文。
返回用户态:执行返回指令,将 CPU 从内核态切换回用户态。
4.1 系统调用
系统调用是用户态程序请求操作系统内核提供服务的接口。常见的系统调用包括文件操作、进程管理、内存管理和网络通信等。系统调用的主要步骤如下:
用户程序发起系统调用:用户程序通过库函数或直接使用系统调用接口发起请求。这通常涉及将系统调用号和参数传递给操作系统内核。
陷入内核态:通过触发陷入(Trap)指令,CPU 从用户态切换到内核态。陷入指令使得当前运行的程序暂停,并将控制权转交给操作系统内核。
内核态处理:操作系统内核根据系统调用号确定要执行的服务例程,并执行相应的内核代码。这可能涉及文件读写、内存分配、进程调度等操作。
返回用户态:内核代码执行完成后,操作系统内核将结果返回给用户程序,并通过返回指令将 CPU 从内核态切换回用户态。
系统调用的过程中,用户态和内核态的频繁切换会带来显著的性能开销。这是因为每次切换都需要保存和恢复上下文,同时涉及到缓存失效和 TLB 刷新等额外开销。
4.2 用户态与内核态切换的优化
为了减少用户态与内核态之间的频繁切换带来的性能开销,现代操作系统和 JVM 引入了多种优化技术。例如,使用更高效的锁机制(如自旋锁)在用户态处理线程同步,以减少进入内核态的必要性。此外,JVM 的各种优化技术也在很大程度上减少了锁操作导致的用户态和内核态的切换。
5. 时间成本
用户态到内核态的转换(及其逆过程)是非常昂贵的操作,因为它涉及到多次上下文切换,这些切换会带来额外的开销,如缓存失效、TLB(Translation Lookaside Buffer)刷新等。上下文切换频繁发生会导致系统性能下降,尤其在高并发场景下,这种开销会更加显著。
5.1 上下文切换的开销
上下文切换的主要开销来源包括:
CPU 时间:保存和恢复线程的上下文需要耗费 CPU 时间。
缓存失效:上下文切换可能导致 CPU 缓存失效,需要重新加载缓存数据。
TLB 刷新:上下文切换可能导致 TLB(Translation Lookaside Buffer)刷新,从而增加内存访问延迟。
5.2 高并发场景下的影响
在高并发场景下,频繁的上下文切换会显著降低系统性能。多个线程同时竞争 CPU 资源,导致频繁的线程切换,增加了 CPU 的开销。为了减少上下文切换带来的影响,可以通过优化锁机制和减少锁竞争来提高并发性能。
6. 早期版本的 synchronized 效率低的原因
归纳起来,早期版本的 synchronized 被认为是效率低下的重量级锁,主要原因包括:
依赖操作系统的互斥锁:需要操作系统参与,增加了开销。
Java 线程映射到操作系统原生线程:线程管理和调度完全依赖操作系统。
线程挂起和唤醒:这些操作需要操作系统的介入,并涉及昂贵的用户态和内核态转换。
高昂的时间成本:用户态到内核态的转换和频繁的上下文切换带来显著的性能开销。
6.1 依赖操作系统的互斥锁
早期版本的 synchronized 依赖于操作系统提供的互斥锁,这意味着每次线程获取和释放锁时,都需要通过系统调用进入内核态。这种设计增加了系统开销,并且在高并发环境下,频繁的系统调用会导致性能瓶颈。
6.2 Java 线程与操作系统原生线程的映射
Java 线程直接映射到操作系统的原生线程,虽然这种设计使得 Java 线程可以利用操作系统的线程管理机制,但也带来了额外的开销。操作系统的线程调度和管理通常涉及复杂的算法和数据结构,这些操作会增加线程的创建、销毁和调度的成本。
6.3 线程挂起和唤醒的开销
当一个线程尝试获取已经被其他线程持有的锁时,它会被阻塞并进入等待状态,直到该锁被释放。这个过程需要操作系统的介入,导致线程被挂起和唤醒,而线程的挂起和唤醒涉及上下文切换和用户态与内核态的转换,增加了系统开销。
6.4 高昂的时间成本
用户态到内核态的转换是一个高昂的操作,特别是在高并发场景下,频繁的上下文切换和系统调用会显著降低系统性能。因此,减少用户态与内核态的转换次数是提高并发性能的关键。
现代 JVM 的优化
为了改善 synchronized 的性能,现代 JVM(从 JDK 1.6 开始)引入了多种优化技术,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁和轻量级锁。这些技术大大减少了锁操作的开销,提高了 synchronized 的效率。
1 自旋锁(Spin Lock)
自旋锁通过忙等待(自旋)来避免线程挂起和唤醒的开销。当一个线程尝试获取已经被其他线程持有的锁时,它不会立即挂起,而是循环检查锁的状态,直到获取锁或达到自旋次数限制。
2 适应性自旋锁(Adaptive Spin Lock)
适应性自旋锁根据前一次自旋的效果动态调整自旋时间。如果前一次自旋等待成功,则延长自旋时间;如果前一次自旋等待失败,则缩短自旋时间或直接挂起线程。这种自适应机制在用户态进行更多判断和操作,减少了不必要的线程挂起。
3 锁消除(Lock Elimination)
通过逃逸分析,JVM 可以确定某些锁在单线程环境中是多余的,并在编译时消除这些锁。锁消除技术基于逃逸分析,逃逸分析是一种优化技术,用于确定对象的生命周期和作用范围。如果 JVM 确定某个锁对象不会被其他线程访问,则可以安全地消除该锁。
4 锁粗化(Lock Coarsening)
锁粗化技术通过将多次小范围的锁合并为一次大的锁操作,减少了锁的频繁获取和释放,从而减少了线程切换和进入内核态的机会。例如,在一个循环中反复进行加锁和解锁操作,锁粗化技术会将整个循环的操作合并为一次加锁和解锁。
5 偏向锁(Biased Locking)
偏向锁假设大多数情况下锁由同一个线程持有,因此初次加锁后,该锁会偏向于该线程,再次进入同步块时无需真正的锁竞争和切换,避免了进入内核态。只有在其他线程尝试竞争锁时,才会撤销偏向锁并进行锁竞争。
6 轻量级锁(Lightweight Locking)
轻量级锁通过使用 CAS(Compare-And-Swap)操作在用户态进行锁竞争。这种操作避免了传统重量级锁需要的内核态的线程挂起和唤醒。轻量级锁适用于短时间持有锁的场景,通过在用户态进行锁竞争,减少了系统调用的开销。
7 自适应自旋锁的实现
自适应自旋锁在现代 JVM 中得到了广泛应用。以下是自适应自旋锁的一种实现方式:
复制代码
class Adap