各种锁常见面试题 迷南。 2024-02-05 15:42 2阅读 0赞 ### 1、锁消除 ### 在Java中,锁消除(Lock Elimination)是JVM的一种优化技术,它可以在运行时的即时编译过程中识别出不可能存在共享数据竞争的锁。 如果JVM检测到一个锁的使用是不必要的,因为对象不会被其他线程访问,那么JVM会在编译期间消除这个锁,从而减少不必要的性能开销。 **JVM中的锁消除技术是什么?** 锁消除是JVM的一个优化手段,用于去除程序中那些不可能存在竞争的锁。 JVM在运行时,通过即时编译器对代码进行分析,如果发现某些锁对象不会逃逸出去被其他线程访问到,那么这个锁就是不必要的。 例如,如果在一个方法中创建了一个局部对象,并且在这个对象上的所有同步都是在同一个线程中完成的,则JVM可以安全地消除这些同步措施。锁消除可以减少不必要的同步开销,提高程序的性能。 **JVM如何确定锁可以被消除?** JVM使用逃逸分析来确定锁是否可以被消除。 逃逸分析是一种确定对象的作用域和生命周期的技术。 如果分析结果表明,在方法中创建的对象没有逃逸出该方法(即没有被其他线程访问的可能),则在这个对象上的操作可以不需要同步。 因此,JVM编译器(JIT)就可以在编译期间消除这些锁。这个过程完全自动化,不需要程序员的任何干预。 锁消除是一种优化,目的是减少不必要的同步开销,通过避免对不会被多线程共享的资源执行同步操作来提高程序性能。 在日常编码中,往往不需要程序员直接操作,但了解其概念和工作原理对于编写高效并发代码是非常有帮助的。 ### 2、锁膨胀 ### 锁膨胀(Lock Inflation)指的是Java中同步块(synchronized block)的锁对象在面对多线程竞争时,由轻量级锁升级为重量级锁的过程。 在Java中,对象的锁可以处于多种状态,包括无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。 锁膨胀通常发生在轻量级锁状态下,当一个线程已经持有了锁(轻量级锁),而另一个线程尝试获取这个锁时,锁就会膨胀成重量级锁以保证线程安全。 **解释Java中的锁膨胀以及它是如何工作的?** 锁膨胀是Java中同步锁在多线程竞争下锁状态转换的一种现象。 在Java中,当多个线程尝试访问同一个同步块时,锁机制能够确保每次只有一个线程能执行该同步块。 Java的锁机制初始状态是无锁状态,随着线程的访问,它可能会转换为偏向锁,当这个锁被相同的线程重复获取时,偏向锁可以提高性能。 如果其他线程尝试获取这个偏向锁,偏向模式会暂停,锁会膨胀为轻量级锁。 轻量级锁通过在对象头上的标记字段中存储锁记录(Lock Record)来避免使用操作系统的互斥元(mutex)。 这个锁记录与执行线程的栈帧相关联,从而允许无争用的线程快速进入和退出同步块。 然而,当有多个线程真正竞争同一个锁时,轻量级锁机制就不再高效,因为竞争会导致线程频繁地挂起和唤醒。 在这种情况下,轻量级锁会膨胀为重量级锁。重量级锁依赖于操作系统的互斥元,这会产生更高的延迟,但在高竞争的环境中它是必要的,因为它能够放入等待队列中的线程,减少线程之间的竞争,从而提高总体吞吐量。 **为什么轻量级锁在竞争激烈时会变得不高效?** 轻量级锁在线程之间的竞争激烈时会变得不高效,主要原因是轻量级锁依赖于自旋。 当一个线程尝试获取一个已经被其他线程持有的轻量级锁时,等待的线程会进行自旋,即循环检查锁是否可用,希望很快能获取到锁。在锁的争用不是很激烈的场景下,自旋可以避免线程挂起和恢复带来的开销,因此轻量级锁能够提高性能。 但是,当多个线程竞争同一个锁时,自旋会浪费CPU资源,因为有很多线程在做没有产出的工作。 这个时候,如果将锁升级为重量级锁,线程会被挂起,而不是自旋等待。 这样,操作系统可以更有效地管理线程,让CPU去执行其他有用的工作,直到锁可用。因此,在锁争用激烈的情况下,重量级锁更高效。 ### 3、锁升级 ### Java中的锁升级是指在运行时根据锁竞争的情况,JVM会智能地将锁的状态从轻量级升级到重量级,以保持线程安全并尽可能提高并发性能。 以下是Java锁升级的几个阶段: 偏向锁(Biased Locking) 偏向锁是针对一个线程多次获取同一个锁的情况优化的。它会偏向于第一个获得它的线程,进一步降低获取锁的代价。偏向锁的升级通常发生在第二个线程尝试获取该锁。 轻量级锁(Lightweight Locking) 当锁是偏向模式,但是有另一个线程试图获取这个锁时,JVM会撤销偏向模式,并转换为轻量级锁。轻量级锁通过在栈帧中创建锁记录(Lock Record),尝试使用CAS操作(Compare-And-Swap)来获取锁,避免线程阻塞。 重量级锁(Heavyweight Locking) 如果轻量级锁的尝试失败,即有多个线程竞争同一个锁,轻量级锁会进一步升级为重量级锁。这时,锁的管理将依赖于操作系统的互斥量(Mutex),导致线程真正地阻塞和唤醒,这样做在线程竞争激烈的情况下更为有效和节省资源。 **解释Java中锁升级的概念以及为什么这样做?** 在Java中,锁升级是JVM对锁的竞争进行动态调整的过程,可以根据实际使用情况选择最适合当前竞争水平的锁类型。 JVM最初会使用偏向锁,因为它假设锁通常不会在多个线程之间竞争,而是被单个线程多次获取。 当有其他线程尝试获取这个偏向锁时,JVM会撤销偏向模式,将锁升级为轻量级锁,这种锁适合竞争程度较低的情况,因为它减少了线程的阻塞和唤醒开销。如果竞争进一步加剧,轻量级锁会膨胀为重量级锁,这时锁的管理将由操作系统接管,更适合处理高度竞争的情况。 锁的升级机制旨在在性能和线程等待开销之间找到平衡。 偏向锁和轻量级锁可以在没有多线程竞争的情况下快速获取锁,而在有激烈竞争的情况下,重量级锁可以避免过多的CPU循环等待,从而提高系统的总体性能和吞吐量。 ### 4、死锁 ### 死锁是并发编程中的一个常见问题,它指的是一组进程或线程中的每个成员都在等待另一个成员释放资源,而这个资源又被该组中的另一成员持有,因此导致所有进程或线程都无法向前推进。 简而言之,死锁是一种无限等待的状态,每个线程都在等待永远不可能被释放的资源。 **解释死锁以及如何避免它?** 死锁是指多个并发执行的线程或进程在执行过程中,因为竞争资源而造成的一种僵局(Deadlock),各方都在等待其他方释放资源,从而导致所有的线程或进程都无法继续执行。 要发生死锁,一般必须满足以下四个条件(称为死锁的四个必要条件): 互斥条件:资源不能被多个线程共享,只能由一个线程在任意时刻使用。 请求与保持条件:一个线程已经持有至少一个资源,并且正在等待获取额外的被其他线程持有的资源。 不可抢占条件:资源只能由持有它的线程主动释放,不能被其他线程抢占。 循环等待条件:存在一种线程资源的循环等待链,每个线程都在等待链中的下一个线程所占有的资源。 避免死锁的常用方法包括: 预防死锁:通过破坏死锁的四个必要条件之一来预防死锁的发生。 实现资源一次性分配,避免持有已分配资源时申请新的资源。 可以让线程在开始执行前申请所有必需的资源,避免部分分配。 如果可能,可以实现资源的抢占策略。 定义资源分配顺序,避免循环等待。 避免死锁:通过资源分配算法辨识系统是否可能进入死锁状态,并避免系统进入不安全状态。 银行家算法是一种著名的死锁避免算法。 检测死锁:通过资源分配图等工具定期检测系统状态,识别系统是否已经进入死锁状态。 如果检测到死锁,可以采取一些措施,例如杀死进程、回滚操作等来解除死锁。 死锁恢复:当检测到死锁后,采取一些措施来修复,可能会牺牲某些性能或数据的完整性。 这可能包括杀掉线程、回滚操作或者重新启动系统。 在实际开发中,选择哪种方法取决于应用场景和系统设计的复杂度。通常来说,预防和避免死锁的策略在设计和实现中要求更高,而检测和恢复死锁的策略适用于系统运行时的管理和维护。 ### 5、AQS ### 在Java并发编程中,AQS(AbstractQueuedSynchronizer)是一个提供了一套用于构建锁和其他同步器的框架。 这个框架在java.util.concurrent包中,是构建诸如ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等同步组件的基础。 AQS使用一个int成员变量来表示同步状态,并通过一个FIFO队列来管理那些等待获取这个状态的线程。 AQS定义了一系列的方法来操作这个状态,但是这些方法本身并不执行具体的同步操作,具体的同步类通过继承AQS并实现它的方法来完成这一功能。 **解释Java中AQS的作用及其如何使用?** AQS(AbstractQueuedSynchronizer)是一个在Java并发包中用于构建锁和同步器的框架。它使用一个int变量来代表同步状态,并提供了一系列用于管理这种状态的方法,同时使用一个FIFO队列来管理那些在同步状态上等待的线程。 AQS的核心思想是,如果请求的状态变化成功了,那么操作就成功了,否则请求的线程将会被加入到队列中,并在适当的时候被线程调度器唤醒。AQS为同步器提供了两种资源共享方式: 独占(Exclusive):只有一个线程执行,如ReentrantLock。 共享(Shared):允许多个线程同时执行,如Semaphore、CountDownLatch。 同步器的具体实现需要继承AQS并重写以下方法来管理它们的状态: tryAcquire(int arg):独占方式。尝试获取资源,成功则返回true,失败则返回false。 tryRelease(int arg):独占方式。尝试释放资源,成功则返回true,失败则返回false。 tryAcquireShared(int arg):共享方式。尝试获取资源。负值代表失败;0代表成功,但没有剩余资源;正值代表成功,且有剩余资源。 tryReleaseShared(int arg):共享方式。尝试释放资源,如果释放后允许唤醒后续等待者则返回true,否则返回false。 AQS的这些方法,以及如acquire(int arg)和release(int arg)等高层方法,为同步器的实现提供了强大的能力,使得我们可以仅通过少量的工作就能实现一个完整的同步器。 在使用AQS时,开发者只需要考虑实现资源的获取和释放的逻辑,而将线程的排队、等待和唤醒等底层操作交由AQS处理。这样既减少了编写同步组件时的复杂性,也提高了其可靠性和性能。 **AQS的唤醒策略** 在AQS (AbstractQueuedSynchronizer) 的设计中,当释放一个节点时,通常会唤醒它的后继节点,也就是队列中的下一个节点。 这是因为在AQS的同步队列中,各个节点是按照线程获取同步状态的顺序排列的,是一个FIFO(先进先出)队列。节点的唤醒通常遵循这个顺序,以保证公平性和避免“饥饿”。 然而,在实际实现中,如果你观察doReleaseShared方法(负责释放共享模式下的节点),你会发现在某些情况下,AQS确实会从尾部节点向前遍历同步队列来唤醒后继节点。 这种从后往前的遍历通常发生在共享模式下,原因包括: 性能优化:如果在释放共享状态时,有多个读操作在等待,从尾部向前找到最近的一个需要唤醒的节点可以减少唤醒路径上的CAS操作次数。这样做可以减少不必要的遍历和唤醒,提高性能。 节点状态的稳定性:从后向前遍历可以确保遍历到的节点状态更加稳定。 节点进入队列时,状态可能会发生变化,例如,节点可能由于超时或中断被取消。从尾部向前遍历时,遇到的节点更可能是已经在队列中稳定存在了一段时间的节点,它们的状态较为稳定,这意味着在唤醒过程中更少遇到节点状态变化的情况。 避免头节点的并发修改:当有多个线程同时释放锁时,从后向前可以避免头节点频繁的并发修改,从而减少线程之间的竞争。 值得注意的是,在大多数情况下,唤醒操作其实是简单地唤醒头节点的后继节点。 从后往前的遍历发生在特定的共享模式释放操作中,并不是唯一的唤醒策略。 这种设计是为了在共享模式下提高AQS的效率和响应性,使得并发数据结构能够更好地在多线程环境下工作。 ### 6、ReentrantLock和synchronized ### ReentrantLock和synchronized都是Java中用来实现线程同步的机制,它们有一些相同点,但也有很多不同点。 **相同点:** 互斥性:ReentrantLock和synchronized都保证了在同一时刻只有一个线程可以执行临界区的代码,从而保证了线程安全。 可重入性:它们都支持可重入,即同一个线程可以多次获得已经持有的锁。这对于避免死锁很有必要,特别是在递归调用的场景中。 锁释放:两者在持有锁的线程执行完同步代码块后都会释放锁。 防止线程干扰:它们都用来防止线程干扰和内存一致性错误。 **不同点:** 锁的实现: synchronized是JVM层面上的锁机制,是Java的一个关键字,它在语言层面提供了锁的功能。 它是依赖于监视器对象(monitor)来实现同步的。 ReentrantLock是从JDK 1.5开始提供的一个API层面的锁机制,在java.util.concurrent.locks.Lock接口下实现。它提供了比synchronized更丰富的锁操作,是基于AQS(AbstractQueuedSynchronizer)框架实现的。 锁的操作粒度和灵活性: synchronized没有提供尝试非阻塞获取锁的机制,它要么获取锁成功,要么一直等下去。 ReentrantLock提供了更多的灵活性,如尝试非阻塞地获取锁(tryLock())、可中断地获取锁(lockInterruptibly())以及支持超时的锁获取(tryLock(long timeout, TimeUnit unit))。 公平性: synchronized内置锁是非公平的,即获取锁的顺序并不是按照线程到达的顺序。 ReentrantLock则允许创建公平锁或非公平锁(通过传入true或false到构造函数)。 条件变量: synchronized配合Object类的wait()、notify()和notifyAll()方法实现等待/通知机制。 ReentrantLock使用Condition接口来提供等待/通知功能,可以与多个条件变量一起工作,为每个条件变量提供独立的等待集(wait-set)。 锁绑定多个条件: synchronized关键字不能够直接支持多个等待队列或条件变量。 ReentrantLock可以绑定多个条件,每个条件在逻辑上对应一个等待队列。 性能区别: 在早期版本的Java中,synchronized的性能通常低于ReentrantLock,因为synchronized在实现上优化较少。 但是,随着JVM优化,特别是引入了偏向锁、轻量级锁等技术后,两者的性能差异已经不像以前那么显著了。 锁的释放方式: synchronized块结束时,锁会自动释放,无需手动操作。 ReentrantLock必须在finally块中显式地调用unlock()方法来释放锁,增加了编程复杂性。 根据具体场景和需求,开发者可以选择适合的同步机制。 在简单的同步需求场景下,推荐使用synchronized,因为它更加简洁。 而在需要高级功能如定时锁等待、可中断锁等待、公平锁或者多条件变量等复杂场景下,则应该考虑使用ReentrantLock。 ### 7、ReentrantReadWriteLock ### ReentrantReadWriteLock 是 Java 中的一种读写锁实现,它允许多个线程同时读取共享资源,但在写入资源时需要独占访问。 这种锁是可重入的,意味着已经持有读锁或写锁的线程可以再次获得读锁或写锁而不会发生死锁。 ReentrantReadWriteLock 包含一对锁:一个读锁 (readLock()) 和一个写锁 (writeLock())。这两个锁通过一个共同的AbstractQueuedSynchronizer(AQS)子类来实现其语义,但行为和条件是不同的。 主要特性包括: 读写分离的锁策略:允许多个线程获取读锁,只要没有线程正在写入。如果有线程拥有写锁,那么其他线程要获取读锁或写锁都必须等待。 锁升级与降级:支持锁的升级和降级。锁升级指的是某个线程在持有读锁的情况下申请写锁(但ReentrantReadWriteLock不支持从读锁升级到写锁,这样会导致死锁),锁降级是指在持有写锁时申请获取读锁,然后释放写锁,这是被允许的。 公平性选择:与ReentrantLock类似,ReentrantReadWriteLock也允许创建公平锁或非公平锁。公平锁会按照线程到达的顺序分配锁,而非公平锁可能允许插队。 可重入性:同一个线程可以重复获取已经持有的锁。这对于避免死锁很有必要,尤其是在递归调用时。 锁降级:指的是写线程持有写锁的情况下,获取读锁,然后释放写锁的过程。这允许在保持数据可见性的前提下,减少锁持有时间。 使用场景: ReentrantReadWriteLock 适合于读多写少的场景,因为多个读操作能够同时进行,提高了系统的并发性。此外,使用ReentrantReadWriteLock比使用一个简单的互斥锁可以提供更高的吞吐量和更大的灵活性。 示例代码: import java.util.concurrent.locks.ReentrantReadWriteLock; public class DataStructure { private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock(); public void read() { readLock.lock(); try { // 执行读取操作 } finally { readLock.unlock(); } } public void write() { writeLock.lock(); try { // 执行写入操作 } finally { writeLock.unlock(); } } } 在此示例中,read() 方法获取读锁,允许多个线程同时访问,而 write() 方法获取写锁,在写入数据时保证独占访问。使用这样的锁策略,可以在维护并发安全的同时,提高读操作的并行度。 **读锁重入** ReentrantReadWriteLock的读锁是支持重入的。 这意味着一个线程可以多次获取同一个读锁,而不会导致死锁。 这个特性在实际使用时非常重要,尤其是在递归调用或者一个线程在已经持有锁的情况下需要再次获取锁的场景中。 当一个线程第一次获取读锁时,锁计数器会增加。 每次该线程重新获取锁(重入),计数器都会增加。当线程完成所有的读操作并释放读锁时,必须释放所有的读锁,即需要调用相应次数的unlock()方法,这样锁计数器才会归零,此时其他写线程才能获取写锁。 重入的实现确保了线程在持有读锁时可以安全地调用其他需要相同读锁的方法,而不会发生自我阻塞。以下是一段简单的代码示例,说明了一个线程如何重入地获取读锁: import java.util.concurrent.locks.ReentrantReadWriteLock; public class ReentrantReadExample { private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); public void outerRead() { readLock.lock(); try { // 执行一些需要读锁的操作 innerRead(); } finally { readLock.unlock(); } } public void innerRead() { readLock.lock(); try { // 执行一些其他需要读锁的操作 } finally { readLock.unlock(); } } public static void main(String[] args) { ReentrantReadExample example = new ReentrantReadExample(); example.outerRead(); } } 在这个例子中,当outerRead()方法中线程获取了读锁并调用了innerRead()方法时,它可以再次获取读锁而不会造成死锁。 当线程从innerRead()方法返回时,它会释放其中一个读锁,但仍然持有outerRead()方法中获取的读锁。只有当线程从outerRead()方法返回时,才会释放最后的读锁。 **写锁饥饿** 写锁饥饿是指在读写锁的实现中,写线程长时间得不到锁而发生的一种现象,通常发生在读多写少且使用读写锁(如ReentrantReadWriteLock)的场景中。 在ReentrantReadWriteLock这样的读写锁实现中,读锁通常可以被多个读线程同时持有,只要没有线程持有写锁。 这可能导致写线程在高读负载下很难获得写锁,因为每次读锁释放后,如果有其他线程等待读锁,读锁可能会立刻再次被获取,使得等待的写线程无法介入。这会导致写线程饥饿,即使它已经在等待很长时间。 为了解决这个问题,ReentrantReadWriteLock提供了公平策略和非公平策略: 非公平策略:默认情况下,ReentrantReadWriteLock使用的是非公平策略。 这意味着锁的分配顺序不一定遵循请求的顺序。换句话说,如果锁刚好变得可用,正在等待的线程可能会抢到它,无论等待的时间长短。这种策略可能导致写锁饥饿。 公平策略:如果ReentrantReadWriteLock在构造时使用了公平策略(传入true给构造函数),那么最长时间等待的线程将获得锁。 这可以防止写锁饥饿,因为一旦一个写线程开始等待,读线程将无法再获得锁,直到所有等待的写线程都被处理。这确保了写线程最终会获得锁,但可能以牺牲整体吞吐量和性能为代价。 例如,创建公平策略的ReentrantReadWriteLock: ReentrantReadWriteLock fairLock = new ReentrantReadWriteLock(true); 如果写锁饥饿成为一个问题,可以考虑使用公平的读写锁或者其他的并发控制机制。 在设计系统时,应该权衡公平性和性能,根据实际的应用场景做出选择。在一些情况下,完全不同的同步机制(如StampedLock)可能提供更好的性能,同时减少写锁饥饿的情况。 ### 8、敬请期待 ### ### 9、敬请期待 ### ### 10、敬请期待 ###
还没有评论,来说两句吧...