十八 . synchronized底层如何实现?什么是锁的升级,降级?
前面博客看了后,相信你对线程安全和如何使用基本的同步机制有了基础,接下了,进入synchronized底层机制。
18.1 典型回答
sychronized 代码块是由monitorenter/monitorexit指令实现的,Monitor对象是同步的基本实现单元。
18.1.1 monitorenter
和monitorexit
解释:
monitorenter和monitorexit是Java字节码指令,用于实现Java对象的同步锁机制。具体来说,monitorenter指令用于获取对象的监视器锁,而monitorexit指令用于释放对象的监视器锁。
在Java虚拟机中,每个对象都与一个监视器关联,可以使用synchronized关键字或者Object类的wait()、notify()和notifyAll()方法来对对象的监视器进行操作。在字节码层面,monitorenter和monitorexit指令就是实现这些操作的。
18.1.2 Monitor实现
Java6前,Monitor的实现完全依靠操作系统内部的互斥锁,因为需要从用户态切换到内核态,同步操作是一个无差别的重量级操作。
现代的JDK中,JVM进行了很大的改进,提供了三种不同的Monitor实现:
如:偏斜锁(Biased Locking),轻量级锁,重量级锁,进行了性能的改进。
18.1.3 锁的升级,降级
JVM进行优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级,降级。
当没有竞争出现时,默认使用偏斜锁,JVM会利用CAS操作在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。
这样做的假设是基于很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
18.1.4 偏向锁
当没有竞争出现时,使用偏斜锁可以提供更好的性能表现。
18.1.4.1 偏向锁的原理
- 当一个线程访问一个对象时,JVM会首先在对象的头部中的 Mark Word 字段记录当前线程的 ID,并将对象标记为“偏向锁”。
- 如果其他线程尝试获取该对象的锁时,会发现该对象已经被偏向于某个线程,此时它们会进行自旋等待,而不会立即阻塞。
- 如果其他线程一直自旋等待,而偏斜锁拥有者的线程也不断访问该对象(保持偏斜状态),JVM会消除偏斜锁,使得对象变为无锁状态。
- 如果其他线程成功获取了偏斜锁,或者偏斜锁拥有者的线程退出同步块,JVM会撤销偏斜锁的状态,将对象重新恢复为可重入锁或非重入锁。
18.1.4.2 偏向锁例子
- 一个多线程程序,其中有一个共享的计数器对象。在绝大多数情况下,只有一个线程会访问该计数器对象进行自增操作,其他线程很少去改变它。这时候使用偏斜锁会带来明显的性能优势。
- 初始状态:计数器对象未被任何线程访问,处于无锁状态。
- 线程 A 访问计数器对象:线程 A 首先将对象头部中的 Mark Word 设置为自己的线程 ID,并将对象标记为“偏向锁”。线程 A 自增计数器并完成操作。
- 其他线程尝试访问计数器对象:线程 B、C、D 等尝试获取计数器对象的锁,但发现该对象已经被偏向于线程 A。它们会进行自旋等待,但不会立即阻塞。
- 偏斜锁保持状态:线程 A 再次访问计数器对象,JVM会发现该对象已经是偏斜锁状态,并且访问线程和持有偏斜锁线程是同一个。因此,线程 A 可以直接访问对象,而不需要进行互斥操作。
- 竞争出现:如果线程 B 尝试获取计数器对象的锁,并且与偏斜锁拥有者不是同一个线程,那么偏斜锁就会被撤销,变为可重入锁或非重入锁,进而线程 B 可以成功获取锁。
通过偏斜锁的优化,当只有一个线程访问计数器对象时,不会产生真正的互斥操作,避免了线程切换和锁开销,提高了性能。只有在其他线程尝试获取锁时才会进行额外的操作,从而减少了无竞争的开销。这种设计基于大部分对象生命周期中只被一个线程访问的假设,并且可以适用于很多应用场景,提升程序的执行效率。
18.1.4.2.1例子详细解释:
当线程 A 再次访问计数器对象时,JVM会检查对象的偏斜锁状态和持有偏斜锁的线程是否与当前访问线程一致。如果是一致的,表示线程 A 仍然是该对象的主要访问者,JVM会直接允许线程 A 访问该对象,而无需进行任何互斥操作。这样可以避免线程切换和锁竞争,提高程序的执行效率。
然而,当其他线程(例如线程 B)尝试获取计数器对象的锁时,JVM会检测到存在竞争。竞争发生的条件是:尝试获取锁的线程与持有偏斜锁的线程不一致。在这种情况下,JVM会撤销偏斜锁的状态,将对象转换为可重入锁或非重入锁。
具体而言,线程 B 尝试获取计数器对象的锁时,JVM会在对象头部中更新 Mark Word 的信息:
- 将原先记录持有偏斜锁的线程 ID 清空,表示偏斜锁的状态被撤销。
- 将锁的状态标记为可重入锁或非重入锁。
此时,线程 B 成功获取了计数器对象的锁,并可以执行相应的操作。这个过程称为偏斜锁撤销,对象从偏斜锁状态转换为可重入锁或非重入锁状态。
这种竞争的出现使得原先持有偏斜锁的线程需要重新进行锁争用,而新的竞争线程能够成功获取锁。这种机制保证了当多个线程同时需要访问计数器对象时,能够按照先到先得的原则进行互斥操作,避免数据被错误修改。
总而言之,偏斜锁允许单线程对对象进行快速访问,提高了程序的执行效率。但当其他线程尝试获取锁时,偏斜锁会被撤销,以保证多线程环境下的数据安全性。
18.1.4.3 偏斜锁如何保证多线程环境下数据安全
假设有一个账户对象,包含账户余额信息。初始状态下,该账户对象处于无锁状态。
- 线程 A 获取偏斜锁:
- 线程 A 访问账户对象,JVM将对象头部中的 Mark Word 设置为自己的线程 ID,并将对象标记为“偏斜锁”,并且记录线程 A 是偏斜锁的拥有者。
- 线程 A 对账户余额进行修改操作,完成后释放锁。
此时,账户对象仍然是偏斜锁状态,访问线程和持有偏斜锁的线程是同一个线程(线程 A),因此线程 A 可以直接访问账户对象,而不需要进行互斥操作。
- 线程 B 尝试获取锁:
- 线程 B 也需要对账户对象进行修改操作,并尝试获取锁。
- JVM检测到线程 B 和持有偏斜锁的线程 A 不一致,表示存在竞争。
- 撤销偏斜锁:
- JVM会撤销账户对象的偏斜锁状态,将其转换为可重入锁或非重入锁。
- 对象头部的 Mark Word 会被更新,不再记录持有偏斜锁的线程 ID。
此时,线程 B 成功获取了账户对象的锁,并可以执行相应的操作。通过撤销偏斜锁,保证了在多个线程竞争下,只有一个线程能够持有锁并修改数据,避免了数据的错误修改和不一致性。
偏斜锁允许单线程(线程 A)对账户对象进行快速访问,提高了程序的执行效率。而当另一个线程(线程 B)尝试获取锁时,偏斜锁会被撤销,确保多线程环境下的数据安全性。这种机制保证了同一时间只有一个线程能够修改数据,避免了竞争条件和数据一致性问题的产生。
18.1.4.4 可重入锁或非重入锁解释
可重入锁(Reentrant Lock)是一种线程同步机制,也称为递归锁。它允许一个线程在持有锁的情况下再次请求获取同一个锁,而不会造成死锁。
当一个线程获取到可重入锁后,可以多次重复获取,而不会被自己所持有的锁所阻塞。这意味着线程可以进入由同一个锁保护的代码块,而不会对整个系统的状态造成死锁。
可重入锁通过维护一个持有计数器来实现。线程首次获取锁时,计数器加一;每次释放锁时,计数器减一。只有计数器为零时,锁才会完全释放,其他线程才能获取该锁。
相比之下,非重入锁(Non-Reentrant Lock)则不允许同一线程多次获取同一个锁。如果一个线程已经持有一个非重入锁,再次请求获取同一个锁时,会导致自己被阻塞,形成死锁。
18.1.4.4.1 可重入锁的例子:
有一个对象obj,它有两个方法method1和method2,其中method2需要在获取obj对象的锁后才能被调用。同时,我们希望在method1中调用method2,而不会导致死锁。
使用可重入锁可以很好地解决这个问题,
import java.util.concurrent.locks.ReentrantLock; public class Example { // 定义可重入锁 private ReentrantLock lock = new ReentrantLock(); public void method1() { lock.lock(); // 获取锁 try { // do something method2(); // 调用method2 // do something } finally { lock.unlock(); // 释放锁 } } public void method2() { lock.lock(); // 再次获取锁 try { // do something } finally { lock.unlock(); // 释放锁 } } }
ReentrantLock类提供了可重入锁的实现。method1先获取锁,再通过调用method2获取同一个锁,并在最后释放锁。虽然method2也获取了锁,但由于是在同一个线程内部,因此不会发生死锁。
相反,如果使用非重入锁,则会在第二次尝试获取锁时产生死锁问题。
18.1.4.5 互斥操作
互斥操作指的是一种通过对共享资源的访问进行限制,以确保在同一时间内只有一个线程可以对该资源进行操作的机制。也就是说,当一个线程获得了对某个资源的访问权时,其他线程必须等待该线程释放资源后才能继续执行。
互斥操作的目的是避免多个线程同时对共享资源进行修改导致数据不一致或竞争条件的发生。在多线程环境下,如果没有互斥操作,多个线程可能同时读取或修改共享资源的值,从而引发意料之外的错误和不一致性。
常见的互斥操作包括使用互斥锁(Mutex)或信号量(Semaphore)。互斥锁是一种排他锁,它只允许一个线程在特定时刻获得锁资源,其他线程需要等待。当一个线程完成对共享资源的操作后,再释放锁,其他线程才能获得锁并继续操作。
互斥操作的实现通常依赖于底层的操作系统提供的原子操作、临界区或其他同步机制。这样可以保证在并发环境中,多个线程无法同时对关键资源进行操作,确保了数据的一致性和线程的安全性。
互斥操作是一种通过限制并发访问共享资源来确保数据的一致性和线程安全的机制。它能够有效避免多个线程对共享资源的竞争和冲突,提升多线程程序的正确性和可靠性。
18.1.4.6 偏向锁的优化点小结
偏斜锁的初衷是针对只有一个线程频繁访问同步块的场景而设计的,偏斜锁允许该线程连续地获得锁,而不需要进行互斥操作。这种连续获取锁的过程不会引起竞争和冲突,所以不需要额外的互斥操作。
通过偏斜锁机制,JVM可以避免频繁地进入和退出同步块所带来的性能损失。当只有一个线程在访问同步块时,JVM会将该对象的锁状态设置为偏斜锁,并将持有偏斜锁的线程ID记录下来。之后,该线程再次访问该对象时,会直接允许访问,而无需进行互斥操作。
需要注意的是,当其他线程尝试获取被偏斜锁占用的对象锁时,偏斜锁会自动升级为轻量级锁或重量级锁,从而引入互斥操作,以保证线程安全。
当一个线程再次访问持有偏斜锁的对象时,JVM会直接允许访问,因为此时并没有其他线程与之竞争。这种情况下不需要互斥操作,可以提升性能和效率。
18.1.5 轻量级锁
轻量级锁(Lightweight Locking)是Java虚拟机(JVM)中一种用于实现线程同步的机制,旨在提高多线程并发性能。
当一个线程尝试获取一个对象的锁时,JVM会将对象的锁状态切换为轻量级锁状态。轻量级锁的核心思想是尝试使用CAS(Compare and Swap)操作对对象头中的Mark Word进行加锁。以下是轻量级锁的具体解释:
1.初始状态:对象的锁状态为无锁状态(Unlocked),对象头中的Mark Word存储了一些额外的信息,比如指向当前线程栈中锁记录(Lock Record)的指针。
2.加锁操作:当一个线程希望获取该对象的锁时,它会尝试使用CAS操作将对象头的Mark Word设置为自己的线程ID,表示该线程获取到了锁。这个CAS操作是为了确保只有一个线程能够成功修改Mark Word。
3.CAS操作成功:如果CAS操作成功,表示当前线程成功获取到了对象的轻量级锁。此时,线程可以继续执行临界区代码,不需要进一步同步操作。
4.CAS操作失败:如果CAS操作失败,表示有其他线程竞争同一个锁。这时候,当前线程会尝试自旋(Spin)来等待锁的释放。自旋是一种忙等待的策略,线程会反复检查对象头的Mark Word是否变为无锁状态。
5.自旋失败:如果自旋超过了一定的次数或者达到了阈值,表示自旋失败。这时,JVM会将对象的锁状态升级为重量级锁(Heavyweight Lock)。升级为重量级锁涉及到线程阻塞和内核态的线程切换,比较耗费系统资源。
通过使用轻量级锁,JVM避免了无竞争情况下的阻塞与唤醒,并减少了系统资源的消耗。只有在出现竞争的情况下才需要进行降级为重量级锁,以保证线程安全性。
轻量级锁的具体实现和行为可能因不同的JVM版本和配置而有所差异。此外,轻量级锁只适用于短期的同步,对于长时间持有锁的情况,JVM仍会将其升级为重量级锁以避免资源浪费。
18.1.6 重量级锁
当一个线程获取到对象的轻量级锁后,如果它需要长时间持有该锁(比如执行时间较长的临界区代码),JVM会将其升级为重量级锁。这是因为长时间持有锁可能会导致其他线程长时间等待,造成资源浪费。
理解这一点可以从以下几个方面考虑:
1.自旋消耗资源:轻量级锁使用自旋来等待锁的释放,自旋是一种忙等待的策略,线程反复检查对象头的Mark Word是否变为无锁状态。如果持有锁的线程长时间不释放锁,那么其他线程会不断自旋等待,这会导致CPU资源的浪费。
2.防止饥饿现象:在长时间持有锁的情况下,其他线程将无法获得锁,这可能导致其他线程长时间等待,甚至发生饥饿现象。为了避免这种情况,JVM会将轻量级锁升级为重量级锁,使用阻塞等待的方式,确保其他线程能够公平地获得锁的机会。
3.重量级锁提供更强的互斥性:重量级锁使用操作系统提供的底层机制(如互斥量、信号量等)来实现线程同步,确保只有一个线程能够获取到锁。相比之下,轻量级锁仅使用CAS操作进行加锁,无法提供像操作系统级互斥那样的严格互斥性。对于长时间持有锁的情况,为了避免竞争和数据不一致的问题,JVM会将其升级为重量级锁。
轻量级锁适用于短期的同步,对于长时间持有锁的情况,JVM会将其升级为重量级锁以避免资源浪费和提供更强的互斥性,保证线程之间的公平竞争和顺畅执行。
18.1.7 轻量级锁和重量级锁的比较
轻量级锁和重量级锁都是用于实现线程同步的机制,但它们在性能和实现方式上存在差异。
在轻量级锁中,当一个线程获取到锁时,它会将对象头中的Mark Word修改为指向自己线程栈中锁记录的指针,并使用CAS操作进行加锁。这种方式避免了线程阻塞和内核态的线程切换,对于短期持有锁的情况下具有较好的性能表现。
然而,当一个线程需要长时间持有锁时,也就是执行时间较长的临界区代码时,其他线程可能会长时间等待锁的释放,进而导致饥饿现象的发生。这是因为其他线程持续自旋等待锁的释放,而得不到执行的机会。
为了避免饥饿现象和资源浪费,JVM会将轻量级锁升级为重量级锁。重量级锁是使用操作系统提供的底层机制(如互斥量、信号量等)实现的,通过阻塞等待的方式,确保其他线程能够公平地获得锁的机会。当一个线程持有重量级锁时,其他线程将被阻塞,不会再执行自旋等待,从而避免了饥饿现象的发生。
重量级锁的实现方式可能涉及到线程的阻塞与唤醒、操作系统的内核态切换等,因此会比轻量级锁产生更多的开销。所以,在长时间持有锁的情况下,使用重量级锁可以确保其他线程能够公平竞争锁的机会,但也会导致一定的性能损失。
轻量级锁适用于短期持有锁的情况,对于长时间持有锁的情况,为了避免饥饿现象和资源浪费,JVM会将轻量级锁升级为重量级锁,使用阻塞等待的方式来保证公平竞争。重量级锁虽然确保了公平性,但会带来一定的性能损失。
18.1.8 Java是否会进行锁的降级?
Java 中,锁的升级是指从轻量级锁升级为重量级锁的过程,而锁的降级则指从重量级锁降级为轻量级锁或无锁状态。
Java 并没有提供直接的锁降级机制。一旦锁升级为重量级锁,就不会再自动降级为轻量级锁或无锁状态。
这是因为重量级锁是通过操作系统提供的底层机制实现的,与 Java 对象头中的标记字段无关。
只有当持有重量级锁的线程释放锁后,其他线程才能获取锁,不会再回到轻量级锁或无锁状态。
然而,在某些特定的情况下,我们可以手动进行锁的降级操作。
比如:
如果一个线程在执行临界区代码时,发现临界区的代码执行时间很短,那么它可以选择将重量级锁降级为轻量级锁或无锁状态,以减少性能开销。具体的做法是,线程在临界区代码执行完毕后,将对象头中的标记字段修改为指向自己线程栈中的锁记录,进而实现锁的降级。
需要注意的是,锁的降级需要程序员手动控制和管理,必须保证在临界区代码执行期间没有其他线程竞争同一个锁。否则,降级操作可能会导致数据不一致或并发问题。
Java 并没有内置的锁降级机制,一旦锁升级为重量级锁,就无法自动降级为轻量级锁或无锁状态。但在特定情况下,可以手动进行锁的降级操作,以减少性能开销。但需要注意保证降级操作的正确性和线程安全性。
例子:
共享资源 counter
表示计数器,多个线程需要并发地对其进行操作。我们使用一个重量级锁来保护这个计数器,初始状态下所有线程都无法获取这个锁。
class Counter { private int count; private final Object lock = new Object(); public void increment() { synchronized (lock) { // 进入临界区域 count++; // 临界区域代码执行完毕,可以尝试锁降级 // 将锁降级为轻量级锁或无锁状态 // 需要手动修改对象头中的标记字段 lock.notifyAll(); // 唤醒等待该锁的线程 } } public int getCount() { return count; } }
使用了一个 synchronized
同步块来实现重量级锁,其中对 counter
进行了自增操作,并通过 lock.notifyAll()
来唤醒其他等待该锁的线程。
现在,假设线程 A 获取到了锁,并执行 increment
方法,对 count
自增完毕后,它选择将锁降级为轻量级锁或无锁状态:
public class Main { public static void main(String[] args) { Counter counter = new Counter(); // 线程 A Thread threadA = new Thread(() -> { synchronized (counter.lock) { // 进入临界区域 counter.increment(); // 临界区域代码执行完毕,可以尝试锁降级 // 将锁降级为轻量级锁或无锁状态 // 需要手动修改对象头中的标记字段 // 假设此时没有其他线程竞争同一个锁 counter.lock.notifyAll(); // 唤醒等待该锁的线程 } }); // 线程 B Thread threadB = new Thread(() -> { synchronized (counter.lock) { try { counter.lock.wait(); // 等待线程 A 完成临界区域代码 } catch (InterruptedException e) { e.printStackTrace(); } // 执行其他操作 } }); threadA.start(); threadB.start(); try { threadA.join(); threadB.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Count: " + counter.getCount()); } }
线程 A 获取到了锁,并执行 increment 方法后,它选择将对象头中的标记字段修改为指向自己线程栈中的锁记录(这里是 counter.lock)。然后调用 lock.notifyAll() 唤醒其他等待该锁的线程。
而线程 B 在获取到锁之后,调用 lock.wait() 进入等待状态,等待线程 A 执行完临界区域代码并唤醒它。
线程 A 将锁降级后,线程 B 能够在没有竞争的情况下获取到锁进行后续操作。
需要注意的是,锁的降级操作必须保证在临界区域代码执行期间没有其他线程竞争同一个锁,否则可能会导致数据不一致或并发问题。
实际应用中需要仔细考虑锁的升级和降级策略,并确保线程安全性。
18.1.9 临界区域(Critical Section)解释
指一段代码,其中涉及对共享资源的访问或操作。在多线程编程中,当多个线程并发地访问共享资源时,为了保证数据的一致性和正确性,需要将对共享资源的访问限制在临界区域内。
临界区域代码是指用于对共享资源进行访问或操作的代码片段。它是一个被保护起来的区域,同一时刻只能有一个线程进入并执行其中的代码。其他线程需要等待当前线程执行完毕并退出临界区域后才能进入。
临界区域的目的是确保多个线程不会同时对共享资源进行写操作,避免出现数据竞争和不一致的情况。通过限制对临界区域的互斥访问,可以保证在同一时间只有一个线程在执行对共享资源的操作,从而维护数据的有效性。
例子代码中,count++ 的操作就是一个临界区域代码。在 increment 方法中,使用 synchronized 关键字将这段代码标记为临界区域,以保证同一时间只有一个线程可以执行该操作。其他线程在执行此段代码之前会被阻塞,直到当前线程执行完毕并释放锁后才能继续执行。
所以,临界区域代码指的是多线程并发访问共享资源时需要保护的、只允许一个线程进入执行的代码片段。它起到了保护共享资源的作用,确保并发操作的正确性和数据的一致性。