赞
踩
在早期版本的 Java 中,synchronized
关键字被认为是重量级锁,效率较低,这与其底层实现机制密切相关。要深入理解这个问题,需要结合计算机系统的相关原理,包括操作系统、线程管理、以及用户态和内核态的转换。
synchronized
关键字的实现依赖于监视器锁(Monitor Lock)。在 Java 中,每个对象都有一个隐含的监视器锁。当一个线程进入一个同步块或同步方法时,它必须首先获得该对象的监视器锁。
监视器锁的底层实现通常依赖于操作系统提供的互斥锁(Mutex Lock)。互斥锁是一种用于在多线程环境中实现互斥访问共享资源的机制,它确保在任意时刻只有一个线程可以访问特定的资源。
互斥锁的基本原理是通过操作系统内核来控制对共享资源的访问,防止多个线程同时访问共享资源造成的数据不一致问题。在实现互斥锁时,操作系统通常使用硬件提供的原子操作来保证锁的正确性。例如,x86架构提供了 LOCK
指令,可以确保对内存的原子操作,从而实现互斥锁。
监视器锁的工作机制可以分为以下几个步骤:
操作系统提供的互斥锁通常依赖于底层硬件的支持。以下是互斥锁的一种简单实现方式:
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,则表示锁已经被其他线程持有,当前线程需要自旋等待。
Java 线程是通过 JVM 实现的,而 JVM 中的线程直接映射到操作系统的原生线程(如在 Windows 上的 Thread,在 Unix 系统上的 pthread)。这意味着 Java 线程的创建、调度和管理完全依赖于底层操作系统的线程管理机制。
Java 线程的实现依赖于 JVM 的线程调度器。JVM 的线程调度器将 Java 线程映射到操作系统的原生线程上,并负责线程的创建、切换和销毁。在 JVM 中,线程的状态可以分为以下几种:
新建(New):线程对象被创建,但尚未启动。
就绪(Runnable):线程已经启动,可以运行,但实际运行时间由线程调度器决定。
运行(Running):线程正在 CPU 上运行。
阻塞(Blocked):线程因等待某个资源而被阻塞,无法运行。
等待(Waiting):线程进入等待状态,等待其他线程显式唤醒。
定时等待(Timed Waiting):线程进入定时等待状态,在指定时间后自动唤醒。
终止(Terminated):线程已经结束执行,无法再次运行。
操作系统的线程管理包括线程的创建、调度和销毁。线程的调度通常依赖于调度算法,如时间片轮转(Round Robin)、优先级调度(Priority Scheduling)等。在多核处理器上,操作系统还需要负责线程的负载均衡,以确保每个 CPU 核心的工作负载均匀。
由于 Java 线程直接映射到操作系统的原生线程,Java 线程的调度和管理完全依赖于操作系统的线程管理机制。这种映射关系使得 Java 线程可以充分利用操作系统提供的多线程能力,但也带来了性能开销。
当一个线程尝试获取一个已经被其他线程持有的锁时,它会被阻塞并进入等待状态,直到该锁被释放。这个过程需要操作系统的介入:
挂起线程:操作系统将当前运行的线程从 CPU 上移除,并将其状态保存到线程控制块(Thread Control Block, TCB)中。这个过程涉及到上下文切换,即保存当前线程的上下文(如寄存器、程序计数器等)并加载下一个要运行线程的上下文。
唤醒线程:当锁被释放时,操作系统将唤醒等待获取该锁的线程,将其状态从等待队列中移除,并重新调度该线程运行。
上下文切换是指操作系统将一个正在运行的线程换出 CPU,并将另一个线程调入 CPU 运行的过程。上下文切换包括保存当前线程的状态(如寄存器值、程序计数器等),并加载下一个线程的状态。上下文切换的主要步骤如下:
保存当前线程的上下文:操作系统保存当前线程的寄存器值、程序计数器等状态到其线程控制块(TCB)中。
选择下一个线程:操作系统根据调度算法选择下一个要运行的线程。
加载新线程的上下文:操作系统从选定线程的 TCB 中恢复其寄存器值、程序计数器等状态。
切换到新线程:操作系统将 CPU 切换到新线程,开始执行。
上下文切换是一个开销较大的操作,主要包括以下几个方面:
CPU 时间:保存和恢复线程的上下文需要耗费 CPU 时间。
缓存失效:上下文切换可能导致 CPU 缓存失效,需要重新加载缓存数据。
TLB 刷新:上下文切换可能导致 TLB(Translation Lookaside Buffer)刷新,从而增加内存访问延迟。
在高并发环境下,频繁的上下文切换会显著降低系统性能。因此,减少上下文切换是提高多线程应用性能的重要手段之一。
线程挂起和唤醒的操作需要从用户态(user mode)转换到内核态(kernel mode),这是因为这些操作需要操作系统内核的支持。
用户态:应用程序运行的模式,具有受限的访问权限,不能直接访问硬件或内核数据结构。
内核态:操作系统内核运行的模式,具有完全的访问权限,可以执行任何 CPU 指令并访问任何内存地址。
用户态到内核态的转换涉及以下步骤:
陷入(Trap)指令:当线程需要进行系统调用(如线程挂起或唤醒)时,会触发一个陷入指令,使 CPU 从用户态切换到内核态。
保存上下文:操作系统内核保存当前线程的上下文,包括寄存器、程序计数器等。
执行内核代码:操作系统内核执行挂起或唤醒线程的代码。
恢复上下文:操作系统内核恢复目标线程的上下文。
返回用户态:执行返回指令,将 CPU 从内核态切换回用户态。
系统调用是用户态程序请求操作系统内核提供服务的接口。常见的系统调用包括文件操作、进程管理、内存管理和网络通信等。系统调用的主要步骤如下:
用户程序发起系统调用:用户程序通过库函数或直接使用系统调用接口发起请求。这通常涉及将系统调用号和参数传递给操作系统内核。
陷入内核态:通过触发陷入(Trap)指令,CPU 从用户态切换到内核态。陷入指令使得当前运行的程序暂停,并将控制权转交给操作系统内核。
内核态处理:操作系统内核根据系统调用号确定要执行的服务例程,并执行相应的内核代码。这可能涉及文件读写、内存分配、进程调度等操作。
返回用户态:内核代码执行完成后,操作系统内核将结果返回给用户程序,并通过返回指令将 CPU 从内核态切换回用户态。
系统调用的过程中,用户态和内核态的频繁切换会带来显著的性能开销。这是因为每次切换都需要保存和恢复上下文,同时涉及到缓存失效和 TLB 刷新等额外开销。
为了减少用户态与内核态之间的频繁切换带来的性能开销,现代操作系统和 JVM 引入了多种优化技术。例如,使用更高效的锁机制(如自旋锁)在用户态处理线程同步,以减少进入内核态的必要性。此外,JVM 的各种优化技术也在很大程度上减少了锁操作导致的用户态和内核态的切换。
用户态到内核态的转换(及其逆过程)是非常昂贵的操作,因为它涉及到多次上下文切换,这些切换会带来额外的开销,如缓存失效、TLB(Translation Lookaside Buffer)刷新等。上下文切换频繁发生会导致系统性能下降,尤其在高并发场景下,这种开销会更加显著。
上下文切换的主要开销来源包括:
CPU 时间:保存和恢复线程的上下文需要耗费 CPU 时间。
缓存失效:上下文切换可能导致 CPU 缓存失效,需要重新加载缓存数据。
TLB 刷新:上下文切换可能导致 TLB(Translation Lookaside Buffer)刷新,从而增加内存访问延迟。
在高并发场景下,频繁的上下文切换会显著降低系统性能。多个线程同时竞争 CPU 资源,导致频繁的线程切换,增加了 CPU 的开销。为了减少上下文切换带来的影响,可以通过优化锁机制和减少锁竞争来提高并发性能。
归纳起来,早期版本的 synchronized 被认为是效率低下的重量级锁,主要原因包括:
依赖操作系统的互斥锁:需要操作系统参与,增加了开销。
Java 线程映射到操作系统原生线程:线程管理和调度完全依赖操作系统。
线程挂起和唤醒:这些操作需要操作系统的介入,并涉及昂贵的用户态和内核态转换。
高昂的时间成本:用户态到内核态的转换和频繁的上下文切换带来显著的性能开销。
早期版本的 synchronized 依赖于操作系统提供的互斥锁,这意味着每次线程获取和释放锁时,都需要通过系统调用进入内核态。这种设计增加了系统开销,并且在高并发环境下,频繁的系统调用会导致性能瓶颈。
Java 线程直接映射到操作系统的原生线程,虽然这种设计使得 Java 线程可以利用操作系统的线程管理机制,但也带来了额外的开销。操作系统的线程调度和管理通常涉及复杂的算法和数据结构,这些操作会增加线程的创建、销毁和调度的成本。
当一个线程尝试获取已经被其他线程持有的锁时,它会被阻塞并进入等待状态,直到该锁被释放。这个过程需要操作系统的介入,导致线程被挂起和唤醒,而线程的挂起和唤醒涉及上下文切换和用户态与内核态的转换,增加了系统开销。
用户态到内核态的转换是一个高昂的操作,特别是在高并发场景下,频繁的上下文切换和系统调用会显著降低系统性能。因此,减少用户态与内核态的转换次数是提高并发性能的关键。
为了改善 synchronized 的性能,现代 JVM(从 JDK 1.6 开始)引入了多种优化技术,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁和轻量级锁。这些技术大大减少了锁操作的开销,提高了 synchronized 的效率。
自旋锁通过忙等待(自旋)来避免线程挂起和唤醒的开销。当一个线程尝试获取已经被其他线程持有的锁时,它不会立即挂起,而是循环检查锁的状态,直到获取锁或达到自旋次数限制。
适应性自旋锁根据前一次自旋的效果动态调整自旋时间。如果前一次自旋等待成功,则延长自旋时间;如果前一次自旋等待失败,则缩短自旋时间或直接挂起线程。这种自适应机制在用户态进行更多判断和操作,减少了不必要的线程挂起。
通过逃逸分析,JVM 可以确定某些锁在单线程环境中是多余的,并在编译时消除这些锁。锁消除技术基于逃逸分析,逃逸分析是一种优化技术,用于确定对象的生命周期和作用范围。如果 JVM 确定某个锁对象不会被其他线程访问,则可以安全地消除该锁。
锁粗化技术通过将多次小范围的锁合并为一次大的锁操作,减少了锁的频繁获取和释放,从而减少了线程切换和进入内核态的机会。例如,在一个循环中反复进行加锁和解锁操作,锁粗化技术会将整个循环的操作合并为一次加锁和解锁。
偏向锁假设大多数情况下锁由同一个线程持有,因此初次加锁后,该锁会偏向于该线程,再次进入同步块时无需真正的锁竞争和切换,避免了进入内核态。只有在其他线程尝试竞争锁时,才会撤销偏向锁并进行锁竞争。
轻量级锁通过使用 CAS(Compare-And-Swap)操作在用户态进行锁竞争。这种操作避免了传统重量级锁需要的内核态的线程挂起和唤醒。轻量级锁适用于短时间持有锁的场景,通过在用户态进行锁竞争,减少了系统调用的开销。
自适应自旋锁在现代 JVM 中得到了广泛应用。以下是自适应自旋锁的一种实现方式:
复制代码 class AdaptiveSpinLock { private AtomicBoolean lock = new AtomicBoolean(false); public void lock() { int spinCount = 0; while (!lock.compareAndSet(false, true)) { if (++spinCount > MAX_SPIN) { // 如果自旋次数超过阈值,则挂起线程 Thread.yield(); spinCount = 0; } } } public void unlock() { lock.set(false); } }
在这个实现中,lock() 方法使用 AtomicBoolean 进行原子操作,如果锁未被持有,则成功获取锁;如果锁已被持有,则自旋等待。自旋次数超过阈值后,线程会主动让出 CPU(Thread.yield()),避免长时间的忙等待。
偏向锁在无竞争情况下性能极高,因为它几乎不需要进行任何同步操作。偏向锁的基本思想是将锁的所有权偏向于第一次获取锁的线程。只要没有其他线程竞争该锁,持有偏向锁的线程在进入和退出同步块时几乎不需要任何操作。
偏向锁的实现依赖于对象头的锁标志位和线程 ID。当一个线程第一次获取偏向锁时,JVM 将该线程的 ID 记录在对象头中,并将锁标志位设置为偏向状态。之后,当该线程再次进入同步块时,不需要进行锁竞争,直接进入临界区。
当有其他线程尝试获取偏向锁时,JVM 会撤销偏向锁,并将其升级为轻量级锁或重量级锁。偏向锁的撤销过程如下:
暂停偏向锁持有线程:JVM 暂停持有偏向锁的线程,确保不会在撤销过程中修改对象头。
检查偏向锁状态:JVM 检查对象头的锁标志位和线程 ID,确定是否需要撤销偏向锁。
升级锁:如果需要撤销偏向锁,JVM 将其升级为轻量级锁或重量级锁,并恢复线程的执行。
###.8.3 偏向锁的性能优势
偏向锁适用于大多数锁在无竞争情况下使用的场景。由于无竞争时不需要进行同步操作,偏向锁的性能极高,适用于高频次、低竞争的锁使用场景。
轻量级锁通过 CAS(Compare-And-Swap)操作在用户态进行锁竞争,避免了重量级锁的高昂开销。轻量级锁适用于短时间持有锁的场景,通过在用户态进行锁竞争,减少了系统调用的开销。
轻量级锁的实现依赖于 CAS 操作。CAS 是一种原子操作,用于比较并交换变量的值,确保多个线程可以安全地更新共享变量而不引入竞争条件。轻量级锁的基本工作流程如下:
尝试获取锁:线程通过 CAS 操作尝试获取锁。如果成功,则进入临界区;如果失败,则进入自旋等待。
自旋等待:在短时间内,自旋等待尝试再次获取锁。如果锁在短时间内被释放,线程可以避免被挂起。
获取锁失败:如果自旋等待失败,线程将被挂起,等待锁被释放。
轻量级锁适用于竞争不激烈、持有时间短的锁操作场景。通过在用户态进行锁竞争,轻量级锁避免了进入内核态的高昂开销,提高了锁的性能。在高并发场景下,轻量级锁可以显著减少上下文切换和系统调用的次数。
自适应自旋锁是一种根据锁的竞争情况动态调整自旋时间的锁机制。相比固定自旋次数的自旋锁,自适应自旋锁更加智能,可以根据前一次自旋的结果动态调整自旋时间,从而提高锁的性能。
自适应自旋锁的工作机制如下:
尝试获取锁:线程通过 CAS 操作尝试获取锁。如果成功,则进入临界区;如果失败,则进入自旋等待。
自旋等待:根据前一次自旋的结果,动态调整自旋时间。如果前一次自旋成功,则延长自旋时间;如果前一次自旋失败,则缩短自旋时间或直接挂起线程。
自适应调整:自适应自旋锁会根据锁的竞争情况动态调整自旋策略,提高锁的获取效率。
以下是自适应自旋锁的一种实现方式:
复制代码 class AdaptiveSpinLock { private AtomicBoolean lock = new AtomicBoolean(false); public void lock() { int spinCount = 0; while (!lock.compareAndSet(false, true)) { if (++spinCount > MAX_SPIN) { // 如果自旋次数超过阈值,则挂起线程 Thread.yield(); spinCount = 0; } } } public void unlock() { lock.set(false); } }
在这个实现中,lock() 方法使用 AtomicBoolean 进行原子操作,如果锁未被持有,则成功获取锁;如果锁已被持有,则自旋等待。自旋次数超过阈值后,线程会主动让出 CPU(Thread.yield()),避免长时间的忙等待。
自适应自旋锁通过动态调整自旋时间,可以在锁竞争不激烈的情况下避免线程挂起,从而提高锁的性能。在高并发场景下,自适应自旋锁可以显著减少上下文切换和系统调用的次数,提高系统的整体性能。
锁消除是一种通过编译器优化来消除不必要锁的技术。通过逃逸分析,JVM 可以确定某些锁在单线程环境中是多余的,并在编译时消除这些锁,从而减少锁操作的开销。
逃逸分析是一种编译器优化技术,用于确定对象的生命周期和作用范围。如果 JVM 确定某个锁对象不会被其他线程访问,则可以安全地消除该锁。例如,局部变量的对象通常不会逃逸出方法的作用范围,因此可以消除对这些对象的锁操作。
以下是锁消除的一种示例:
复制代码
public void example() {
Object lock = new Object();
synchronized (lock) {
// 代码块
}
}
在这个示例中,lock 对象是一个局部变量,不会逃逸出 example 方法的作用范围。因此,JVM 可以在编译时消除对 lock 对象的锁操作,从而减少锁的开销。
锁消除通过减少不必要的锁操作,可以显著提高程序的性能。特别是在高并发场景下,锁消除可以减少锁竞争和上下文切换,从而提高系统的整体性能。
锁粗化是一种通过合并多个小范围的锁操作来减少锁竞争和上下文切换的技术。锁粗化可以减少锁的频繁获取和释放,从而提高锁的性能。
锁粗化通过将多个小范围的锁操作合并为一次大的锁操作,从而减少锁的频繁获取和释放。例如,在一个循环中反复进行加锁和解锁操作,锁粗化技术会将整个循环的操作合并为一次加锁和解锁。
以下是锁粗化的一种示例:
复制代码
public void example() {
for (int i = 0; i < 100; i++) {
synchronized (this) {
// 代码块
}
}
}
在这个示例中,循环中的每次迭代都需要进行加锁和解锁操作。通过锁粗化,JVM 可以将整个循环的操作合并为一次加锁和解锁,从而减少锁的频繁获取和释放。
锁粗化通过减少锁的频繁获取和释放,可以显著提高程序的性能。特别是在高并发场景下,锁粗化可以减少锁竞争和上下文切换,从而提高系统的整体性能。
除了上述的几种优化技术,现代 JVM 还引入了其他一些优化技术来提高锁的性能和并发处理能力。这些技术包括锁膨胀、无锁编程和分段锁等。
锁膨胀是指当一个轻量级锁竞争激烈时,JVM 会将其升级为重量级锁。锁膨胀的目的是减少轻量级锁在高竞争情况下的自旋等待时间,从而提高系统的整体性能。
无锁编程是一种通过使用原子操作(如 CAS)来实现并发控制的技术。无锁编程可以避免传统锁机制带来的锁竞争和上下文切换,从而提高系统的并发性能。无锁编程的核心思想是利用硬件提供的原子操作来确保多个线程可以安全地进行并发操作,而不需要使用传统的锁机制。
CAS(Compare-And-Swap)是一种原子操作,用于比较和交换变量的值。CAS 操作的基本流程如下:
比较:比较变量的当前值是否等于预期值。
交换:如果变量的当前值等于预期值,则将变量的值更新为新值;否则,不做任何操作。
CAS 操作的原子性由硬件提供的指令支持,例如 x86 架构中的 LOCK CMPXCHG 指令。
无锁编程常用于实现高效的并发数据结构,如无锁队列、无锁堆栈和无锁链表等。以下是无锁队列的一种实现:
class LockFreeQueue<T> { private static class Node<T> { final T item; volatile Node<T> next; Node(T item, Node<T> next) { this.item = item; this.next = next; } } private final Node<T> dummy = new Node<>(null, null); private final AtomicReference<Node<T>> head = new AtomicReference<>(dummy); private final AtomicReference<Node<T>> tail = new AtomicReference<>(dummy); public void enqueue(T item) { Node<T> newNode = new Node<>(item, null); while (true) { Node<T> currentTail = tail.get(); Node<T> tailNext = currentTail.next; if (currentTail == tail.get()) { if (tailNext != null) { tail.compareAndSet(currentTail, tailNext); } else { if (currentTail.next.compareAndSet(null, newNode)) { tail.compareAndSet(currentTail, newNode); return; } } } } } public T dequeue() { while (true) { Node<T> currentHead = head.get(); Node<T> currentTail = tail.get(); Node<T> headNext = currentHead.next; if (currentHead == head.get()) { if (currentHead == currentTail) { if (headNext == null) { return null; } tail.compareAndSet(currentTail, headNext); } else { T item = headNext.item; if (head.compareAndSet(currentHead, headNext)) { return item; } } } } } }
在这个实现中,enqueue 和 dequeue 方法使用 CAS 操作来确保多个线程可以安全地并发访问队列,而不需要使用传统的锁机制。
无锁编程通过避免传统锁机制的锁竞争和上下文切换,可以显著提高系统的并发性能。无锁编程适用于高并发场景,特别是在需要频繁访问共享数据结构的情况下。
尽管无锁编程在性能上具有显著优势,但其实现相对复杂,容易引入难以调试的并发错误。此外,无锁编程通常依赖于硬件提供的原子操作,因此在不同硬件平台上的可移植性可能受限。
分段锁是一种将锁分解为多个独立部分的技术,以减少锁竞争和提高并发性能。分段锁常用于并发哈希表和其他需要高效并发访问的数据结构。
分段锁通过将锁分解为多个独立部分,每个部分保护数据结构的一个子集。线程在访问数据结构时,只需要获取与该子集对应的锁,从而减少了锁的竞争范围。例如,在并发哈希表中,分段锁可以将哈希表分为多个段,每个段由一个独立的锁保护。
以下是分段锁哈希表的一种实现:
class SegmentLockHashTable<K, V> { private static final int SEGMENT_COUNT = 16; private final Segment<K, V>[] segments; private static class Segment<K, V> { private final Map<K, V> map = new HashMap<>(); private final ReentrantLock lock = new ReentrantLock(); void put(K key, V value) { lock.lock(); try { map.put(key, value); } finally { lock.unlock(); } } V get(K key) { lock.lock(); try { return map.get(key); } finally { lock.unlock(); } } } public SegmentLockHashTable() { segments = new Segment[SEGMENT_COUNT]; for (int i = 0; i < SEGMENT_COUNT; i++) { segments[i] = new Segment<>(); } } private Segment<K, V> getSegment(Object key) { int hash = key.hashCode(); int segmentIndex = (hash >>> 16) ^ hash & (SEGMENT_COUNT - 1); return segments[segmentIndex]; } public void put(K key, V value) { Segment<K, V> segment = getSegment(key); segment.put(key, value); } public V get(K key) { Segment<K, V> segment = getSegment(key); return segment.get(key); } }
在这个实现中,哈希表被分为多个段,每个段由一个独立的 ReentrantLock 保护。通过这种方式,线程在访问哈希表时只需获取与段对应的锁,从而减少了锁的竞争范围。
分段锁通过将锁分解为多个独立部分,减少了锁竞争和上下文切换,提高了数据结构的并发性能。分段锁特别适用于需要高效并发访问的大型数据结构,如并发哈希表。
尽管分段锁可以显著提高并发性能,但它也有一些局限性。例如,分段锁的实现相对复杂,需要仔细设计和调试。此外,分段锁在某些情况下可能无法完全避免锁竞争,特别是当大量线程同时访问同一个段时。
Java 的 synchronized 关键字在早期版本中被认为是重量级锁,效率较低。这主要是因为 synchronized 依赖于操作系统提供的互斥锁,并且涉及频繁的用户态和内核态转换,导致高昂的性能开销。然而,现代 JVM 通过引入多种优化技术,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁和轻量级锁,大大提高了 synchronized 的性能。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。