为什么要加锁
在多线程编程中,加锁是用来保证线程安全的一种方式。当多个线程同时访问和修改同一个资源(例如,共享数据)时,就有可能发生线程安全问题,因为多个线程的操作可能会相互干扰,导致数据不一致或者状态错误等问题。加锁的目的是确保在同一时间只有一个线程能够执行特定的代码段,这样就可以防止其他线程在未完成操作时进行干扰。
下面是一个简单的Java代码示例,用于说明加锁的必要性:
public class Counter {
private int count = 0;
public void increment() {
count = count + 1;
}
public int getCount() {
return count;
}
}
以上代码中的Counter
类实现了一个简单的计数器。如果多个线程同时调用increment()
方法来增加count
的值,就可能出现线程安全问题。因为count = count + 1;
这个操作不是原子的,它包含了三个步骤:
- 读取
count
的当前值。 - 将值加1。
- 将新值写回到
count
。
如果两个线程几乎同时执行这个操作,它们可能都读取到相同的初始值,并且都将其增加1后写回,这样就只增加了1次,而不是预期的2次。
为了防止这种情况,我们可以使用synchronized
关键字来同步访问count
变量:
public class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count = count + 1;
}
public synchronized int getCount() {
return count;
}
}
在上面的代码中,我们通过将increment
方法和getCount
方法声明为synchronized
,确保了同时只有一个线程可以执行这两个方法中的任意一个。这意味着在一个线程执行increment
方法的过程中,其他线程会被阻塞,直到该方法执行完成。这样就能保证对count
变量的操作是线程安全的。
在Java中,加锁通常有两种方式:
-
对象级锁:通过
synchronized
关键字修饰实例方法或者代码块,锁定的是调用该方法的对象。 -
类级锁:通过
synchronized
关键字修饰静态方法或者一个类的Class对象,锁定的是这个类的Class对象。
如下是使用synchronized
代码块来实现锁定特定对象的例子:
public class ObjectLockCounter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized(lock) {
count = count + 1;
}
}
public int getCount() {
synchronized(lock) {
return count;
}
}
}
在上面的代码中,我们创建了一个lock
对象来作为锁,然后在increment
方法和getCount
方法中使用synchronized(lock)
代码块。这样,我们就能够精确控制同步的范围,只在修改count
的时候进行同步,从而可能提高一些性能。
需要注意的是,加锁虽然可以保证线程安全,但它也会带来一些性能上的开销,因为它限制了程序的并发性。因此,在设计多线程程序时,应该仔细考虑在何处以及如何加锁,以达到线程安全和性能之间的最佳平衡。
1、锁消除
锁消除是Java中的一个运行时(JIT,即时编译器)的优化技术,它目的是去除不必要的同步(synchronized)操作。如果确定某些锁竞争的对象只会被一个线程访问,那么这些同步操作实际上是多余的,可以被安全地移除以提高性能。
锁消除的背景:
同步操作本质上是为了保护共享数据在多线程环境下的一致性和完整性。当同步块里的对象不可能被多个线程并发访问时,同步就是没必要的。锁消除就是基于这样的情况对代码进行优化。
锁消除的机制:
在JIT编译过程中,如果确定某些对象只会被单个线程访问,JVM会去掉这些对象的同步语句。锁消除的判断基于逃逸分析。
逃逸分析:
- 逃逸分析 是一种确定对象的使用范围和生命周期的技术。
- 如果对象在方法或者块级别被创建,并且不会逃逸出这个方法或块外面被其他线程访问,那么对这个对象的操作就不需要同步。
- 在这种情况下,即便代码中有synchronized语句块,实际运行时这些锁是可以去掉的。
锁消除的优点:
-
减少同步的开销:锁操作包含了很多底层操作,如锁的获取和释放,锁的状态变更,监视器的进入和退出等。锁消除可以避免这些不必要的开销。
-
提高并发性能:锁可以导致线程争用限制并行性,锁消除可以减少线程之间的争用,提高系统的并发性能。
-
避免死锁:自动去除不必要的锁还可以降低死锁的风险。
锁消除的例子:
假设我们有下面这样一段代码:
public String concatStrings(List<String> strings) {
StringBuffer sb = new StringBuffer();
for (String s : strings) {
sb.append(s);
}
return sb.toString();
}
在这个方法中,StringBuffer
内部是同步的,但是局部变量 sb
不会被其他线程访问,因为它不会逃逸出 concatStrings
方法。因此,Java虚拟机在执行逃逸分析之后,可以消除这个StringBuffer对象的所有同步操作。
注意:
锁消除是需要JVM的支持的,Java虚拟机的不同实现可能在这方面有差异。而且,开启JVM的逃逸分析通常也需要特定的JVM参数。
限制:
并非所有的锁都可以被消除。只有那些经过程序分析后,确认只可能被单个线程访问的对象上的锁才可以被消除。
需要考虑的因素:
在实现锁消除优化时,程序员通常不需要采取任何行动。锁消除是JIT编译器的内部优化过程的一部分。但是了解锁消除的概念对于写出更高效的多线程代码仍然是有帮助的,例如:
- 在编写代码时避免无意中“逃逸”对象。
- 明白在哪些情况下同步是不必要的。
结论:
锁消除是Java虚拟机为了优化性能,减少不必要的同步开销而做的一个优化。它利用逃逸分析来找出那些只由一个线程访问的对象,并移除这些对象上的同步操作。这是JIT编译器的一个高级特性,能够在不牺牲数据一致性和线程安全的前提下,优化多线程应用程序的性能。
JVM中的锁消除技术是什么?
锁消除(Lock Elimination)是Java虚拟机(JVM)在Just-In-Time(JIT)编译时用来优化代码的一种技术。这项技术专注于去除程序代码中不必要的同步(synchronized)操作,它是基于JVM进行的一项激进的优化措施。以下是锁消除技术的深入详细解释:
工作原理
锁消除的工作原理基于一项称作逃逸分析的技术。逃逸分析的目的是确定对象的作用域和其是否被多个线程访问。
- 逃逸分析:JVM在代码运行时分析对象的动态作用域。如果一个对象在整个生命周期中只能被一个线程访问,那么它就被认为没有“逃逸”。
- 同步的消除:如果逃逸分析结果显示某些同步块中的对象无法从方法中逃逸出来,即它们不会被其他线程看到,那么这些对象上的同步操作就被认定是多余的。因此,JVM就可以安全地取消这些同步操作,以减少不必要的性能开销。
为什么需要锁消除
在多线程编程中,同步是一项重要特性。它保证了当多个线程试图同时读写共享资源时的一致性和可见性。然而,同步操作不是免费的;它们引入了额外的开销:
- 锁的竞争:同步块可能会导致线程之间发生竞争,这会降低程序的并发性能。
- 上下文切换:当线程等待锁时,可能会发生上下文切换,进一步增加延迟和CPU时间的消耗。
- 内存同步:每次锁的获取和释放都伴随着内存屏障,用于确保内存的可见性,但这也增加了性能开销。
锁消除技术就是为了解决这些性能问题,当它检测到某些同步操作实际上是不必要的,就会将它们从执行路径中移除,从而提高程序的性能。
深入逃逸分析
逃逸分析是锁消除技术的关键所在。这个分析过程主要关注以下方面:
- 对象作用域:对象是否在它被创建的线程外面被引用。
- 对象生命周期:对象是否可能在方法调用后被其他线程访问。
- 方法调用:方法调用是否会导致对象引用传递给其他线程。
如果分析的结果是对象不会逃逸,那么就可以考虑优化掉对象上面的同步操作。
优化示例
比如下面的代码片段:
public class Example {
public void exampleMethod() {
Object localObj = new Object();
synchronized(localObj) {
// 同步块,对localObj进行操作
}
}
}
在这个示例中,localObj
是方法内部创建的局部变量,它不会被其他线程访问。由于 localObj
不逃逸到方法之外,同步块中的操作不会出现竞争条件。因此,JVM可以安全地移除 synchronized
块。
挑战与限制
尽管锁消除能带来性能提升,但它在实施时也面临一些挑战:
- 精确性:逃逸分析需要非常精确,任何误判都可能引起并发问题。
- 开销:逃逸分析本身也有计算开销,所以JVM需要权衡是否进行该优化。
JVM的锁优化策略
锁消除是JVM中一系列锁优化措施中的一个,其他的措施还包括锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)。这些优化措施共同提高了JVM在多线程环境下的性能表现。
总结
锁消除是JVM在编译时期进行的一项激进优化,它基于逃逸分析技术,能有效减少不必要的同步开销。对于程序员而言,了解这项优化的存在和工作原理有助于编写更高效的多线程程序。然而,由于这项工作由JVM内部自动完成,因此程序员在编写代码时不应该依赖锁消除的发生,依然需要正确使用同步来确保线程安全。
JVM如何确定锁可以被消除?
JVM确定锁可以被消除的过程涉及到一种称为**逃逸分析(Escape Analysis)**的技术。逃逸分析的目的是分析对象的动态作用域和是否可能被多线程访问。如果JVM检测到一个对象的锁不会被多个线程共享,那么这个锁就是一个候选者可以被消除。
以下是JVM如何确定锁可以被消除的详细过程:
1. 收集对象作用域信息
逃逸分析的第一步通常是确定对象的作用域和生命周期。JVM在运行时对代码进行分析,追踪每个对象的创建和使用。如果对象在其整个生命周期内都不会被其他线程访问,那么它被认为是线程局部的(thread-local),或者说没有逃逸。
2. 分析方法调用
对于每次方法调用,JVM会分析是否有任何对象引用传递出去,这可能是直接传递给其他方法,或者通过返回值返回。如果一个对象在方法外部无法被访问,它就不会被其他线程共享。
3. 评估同步块
JVM会评估同步块内部的代码。如果锁对象没有逃逸,则同步块内的代码不会与其他线程产生交互。在这种情况下,锁定操作被视为不必要的,因为锁定的目的是保护不同线程间对共享资源的访问。
4. 决定锁消除
基于以上分析,如果确认锁对象是非逃逸的,即只被一个线程访问,那么JVM就会在JIT编译阶段去除相关的同步块。这意味着在实际执行的机器代码中,那些原本为了同步而存在的指令(如进入和退出监视器)会被完全移除。
5. 锁消除与JIT编译
在JIT编译阶段,JVM不仅仅是取消synchronized关键字的效果,它会修改方法的机器码,把那些涉及到加锁和解锁的指令省略掉。这个过程完全是自动进行的,通常对于开发者来说是透明的。
逃逸分析的限制
逃逸分析需要假定程序的行为在分析时是可靠的,这在某些高度动态的程序中可能不成立。此外,逃逸分析自身也有一些计算开销,所以JVM实现会对它的应用进行细致的权衡,以避免对程序运行时性能的负面影响。
示例
考虑以下Java代码:
public class Example {
public void exampleMethod() {
Object localObj = new Object();
synchronized(localObj) {
// do something
}
}
}
在这段代码中,localObj
是一个局部变量,它在 exampleMethod
内创建并且在同步块内使用。由于 localObj
在方法外部不可见,它显然不会逃逸到其他线程。逃逸分析将确定localObj
是线程局部的。因此JVM可以决定去除这个同步块的锁操作。
性能提升
通过逃逸分析和锁消除,JVM减少了同步的开销,例如避免了监视器的加锁和解锁操作、减少了上下文切换、降低锁竞争带来的延迟,最终提升了程序的性能。
总结
JVM中的锁消除技术通过逃逸分析来确定哪些锁是不必要的,并在JIT编译时移除这些锁,从而提升多线程程序的性能。这一过程对程序员通常是透明的,但是程序员编写代码时仍需要严格follow线程安全的规范,因为锁消除依赖于JVM的动态行为,我们不能预先假定JVM会执行这些优化。
2、锁膨胀
锁膨胀是Java多线程同步中的一个重要概念,指的是在使用基于监视器的同步块(即synchronized
关键字)时,锁机制从轻量级锁状态转换为重量级锁状态的过程。这通常发生在多个线程竞争同一个锁的场景之下。
在JVM中,synchronized
的实现用到了不同的锁状态,来应对不同级别的线程竞争。这些锁状态包括:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁膨胀是锁状态之间转换的一部分,通常是由轻量级锁向重量级锁的过渡。
偏向锁
- 偏向锁(Biased Locking) 是JVM中的一种锁优化手段。当一个同步块被一个线程访问后,该线程假定将来也会再次访问此同步块,因而JVM将锁偏向于该线程。偏向锁避免了进一步的同步所需的代价。
- 如果另一个线程试图访问这个同步块,偏向锁需要撤销,并可能发生锁膨胀。
轻量级锁
- 轻量级锁(Lightweight Locking) 是在没有锁竞争时使用的锁状态。当代码进入一个同步块时,如果锁对象的标记字(Mark Word)为无锁状态,JVM会用CAS(Compare-and-Swap)操作替换标记字为一个指向线程栈中锁记录(Lock Record)的指针。
- 如果这个CAS操作成功,那么该线程拥有了锁,并且锁为轻量级锁状态。轻量级锁的获取和释放不需要本地操作系统的介入。
锁膨胀过程
-
竞争出现:当有多个线程尝试获取同一个轻量级锁时,轻量级锁上的CAS操作可能失败,这是因为某个线程已经持有了这个锁。
-
重量级锁的转换:当一个线程在尝试获取锁时发现另一个线程已经拥有锁,它会进行自旋,尝试在未来的某个时间点获取锁。若自旋多次后仍未成功获取锁,JVM就会将锁的状态从轻量级锁膨胀为重量级锁。
-
监视器锁(Monitor Lock):调用操作系统的同步机制(如互斥量或信号量)来实现锁定行为。对于锁竞争频繁的同步块,使用轻量级锁可能会导致许多无谓的自旋,因而使用重量级锁在资源管理方面更加高效。
-
膨胀后的操作:一旦一个对象的锁膨胀成为重量级锁后,需要等待锁的线程会被阻塞,并在锁可用时由操作系统唤醒。这个过程称为阻塞或监视器等待。
为什么要避免锁膨胀
- 锁膨胀通常与性能下降相关,因为涉及到操作系统资源的使用,他会引发上下文切换,而上下文切换是昂贵的,特别是在高并发的环境下。
- 自旋等待相较于上下文切换可能比较轻量,但如果自旋失败,它就会导致无谓的处理器时间消耗。
例子
在以下代码中,如果多个线程同时尝试执行这个同步块,轻量级锁可能就会膨胀成重量级锁:
public class SampleClass {
private Object lock = new Object();
public void sampleMethod() {
synchronized (lock) {
// critical section code
}
}
}
避免锁膨胀的策略
-
锁分段(Lock Splitting):将大的同步块分解为几个小块,每一块使用不同的锁。
-
锁粗化(Lock Coarsening):如果有多个连续的同步块都使用同一个锁,JVM可能会将它们合并为一个更大的同步块,减少锁操作的次数。
-
使用非阻塞算法:尝试使用一些无锁的数据结构和算法,如使用
java.util.concurrent
包中的一些类。 -
读/写锁:在某些情况下,读写操作的数量和条件可能允许使用读/写锁来减少锁竞争。
总结
锁膨胀是指当存在线程竞争时,轻量级锁状态升级为重量级锁状态的过程。它是JVM多线程同步机制中的一个自然发展过程。但由于性能考量,开发人员应尝试减少锁竞争,防止锁膨胀的发生。适当的锁优化可以提高应用程序的性能,特别是在高并发的环境下。
Java中的锁膨胀以及它是如何工作的?
在Java中,锁膨胀是指synchronized关键字用来提供互斥锁功能的内部锁(也称为监视器锁)的状态变化过程。这个过程是Java虚拟机(JVM)对synchronized的优化机制的一部分,目的是在不同竞争水平下提供合适的锁机制以优化性能。
Java中的锁有几种状态,它们在不同程度的竞争下表现出不同的性能特点:
-
无锁(No Lock)/偏向锁(Biased Locking):
- 初始状态,没有线程请求锁时为无锁状态。
- 偏向锁是一种优化状态,当锁被一个线程多次获取时,这个锁会被偏向该线程,避免接下来的同步开销。
-
轻量级锁(Lightweight Lock):
- 当一个线程已经获取锁,而另一个线程尝试来获取这个锁时,轻量级锁会被尝试。
- JVM会为持锁线程和尝试获取锁的线程在各自的线程栈上创建一个锁记录(Lock Record)或者称为Displaced Mark Word。
-
重量级锁(Heavyweight Lock):
- 如果持续有多个线程竞争轻量级锁,轻量级锁会膨胀成重量级锁。
- 重量级锁依赖操作系统的互斥量,当锁被占用时,其他线程将会被挂起,进入阻塞状态,直到锁被释放。
锁膨胀的工作流程
以下是描述Java中锁膨胀以及它是如何工作的详细步骤:
检测到锁竞争
当一个线程已经获取了锁的轻量级版本,同时另一个线程尝试获取这个锁时,轻量级锁就无法再满足需求。JVM此时检测到锁竞争的存在。
锁记录(Lock Record)
每个尝试获取轻量级锁的线程都会在自己的线程栈上创建一个锁记录(Lock Record)。
自旋锁和锁挂起
如果另一个线程在尝试获得锁时发现它被占用,JVM会让这个线程在一个很短的时间内自旋(即空循环),以期待锁能够很快被释放。如果自旋超过了一定的限度而锁还未被释放,线程将会停止自旋。
升级为重量级锁
自旋未能成功后,JVM会推动这个锁的状态从轻量级锁膨胀到重量级锁。这会导致锁的标识符(Mark Word)在对象头上的更改,表示该锁现在关联了一个重量级的监视器(Monitor)。
操作系统级别的同步
从此时起,任何需要这个锁的线程都必须进入操作系统级别的阻塞状态。即线程会被挂起,等待操作系统的调度。这是重量级锁相对于轻量级锁和自旋的一个主要区别,它涉及进程/线程的上下文切换,这通常会带来较大的性能开销。
锁的释放和获取
一旦当前的锁拥有者释放了锁,操作系统会选择一个等待中的线程并唤醒它,让它成为新的锁拥有者。
性能考虑
锁膨胀是一种权衡。虽然重量级锁在高并发和锁竞争的情况下更加高效,但在低并发情况下,轻量级锁和偏向锁具有更少的开销。JVM通过不断地在这些锁状态之间调整,以提供尽可能最佳的性能。
锁优化策略
开发人员应尽量使用优雅的并发模式和数据结构来降低锁竞争,避免不必要的锁膨胀。可以采用Java并发包中的类,如ReentrantLock
、StampedLock
和其他高级同步机制,来实现更细粒度的控制和可能更高效的行为。
总结
锁膨胀是Java同步机制的一个自然组成部分,是当多线程环境中存在锁竞争时,JVM优化锁性能的方式之一。它表现为轻量级锁在竞争条件下转变为重量级锁,以便更有效地管理线程间的同步。程序员需要通过合理的并发设计来降低锁竞争的可能性,从而提高应用程序的性能。
为什么轻量级锁在竞争激烈时会变得不高效?
轻量级锁在竞争激烈的情况下变得不高效,原因涉及到轻量级锁工作原理以及线程状态转换带来的开销。了解这一点需要深入探讨轻量级锁的设计和操作系统中线程调度的机制。
轻量级锁的设计
在JVM中,当一个线程尝试获取一个锁时,并且锁不被其它线程持有,该线程会将锁的状态从无锁状态变更为轻量级锁状态。轻量级锁的状态是通过一个称为锁记录(Lock Record)的数据结构在Java线程的栈帧中表示的。
轻量级锁的核心在于CAS(Compare-And-Swap)操作和自旋。以下是工作流程:
- 锁记录:线程创建一个在其栈帧上的锁记录,并尝试使用CAS操作将对象的标记字段(Mark Word)更新为指向该锁记录的指针。
- CAS成功:如果CAS操作成功,则该线程持有锁。
- CAS失败:如果CAS操作失败,这意味着另一个线程已持有锁或进行了锁更新操作。
竞争下的轻量级锁
在没有或较少竞争的情况下,轻量级锁效率很高,因为CAS操作成功率高,线程通常能够快速获取锁,并进入同步块进行工作。然而,当多个线程尝试获取同一个锁时,情况就会变得不同。
- 自旋等待:如果一个线程尝试获取锁失败(因为另一个线程正持有该锁),该线程不会立即挂起,而是会执行自旋,希望持有锁的线程能快速释放锁。
- 锁争用:在激烈的竞争下,失败的CAS操作和接下来的自旋会很多次重复进行,因为多个线程都在尝试获取同一个锁。
- 效率下降:自旋会消耗CPU资源,因为自旋的线程实际上并没有进行有意义的工作,只是在等待。如果自旋很长时间都失败了,这会造成CPU的浪费。
为何竞争激烈会造成不高效
- CPU时间浪费:在竞争激烈的时候,线程自旋消耗CPU时间,但无法做真正的工作,因为它要等待其它线程释放锁。
- 上下文切换:当自旋对于获取锁无效时,线程最终可能要挂起。随后,操作系统必须进行线程上下文切换,将执行权切换到另一个线程。上下文切换是一个开销较大的操作,涉及保存和加载CPU寄存器、更新各种调度数据结构等。
- 同步慢路径:如果线程被迫从自旋转换为阻塞,等待锁的释放,那么轻量级锁的优势(速度快)就失效了。此时,锁的获取就进入了“慢路径”,涉及到了线程的阻塞和唤醒,这比轻量级锁的“快路径”(CAS成功,线程直接进入同步块)要慢得多。
膨胀成重量级锁
为了解决这种CPU资源的浪费,JVM在检测到锁的高度争用时,会将轻量级锁膨胀为重量级锁。这种锁使用操作系统的互斥量来管理,使得无法获取锁的线程会被挂起,而不是无休止地自旋。这减少了CPU资源的浪费,虽然增加了上下文切换的开销,但在竞争激烈的条件下更为高效。
总结
轻量级锁依赖于线程的快速锁操作和自旋希望能够尽快获得锁。在低争用的环境中,这通常能够迅速完成工作并释放锁,效率高。但在高争用的环境中,锁的频繁竞争导致了大量的失败CAS操作和无效的自旋,导致了CPU资源的浪费和可能的上下文切换。因此,在激烈竞争中,轻量级锁的这些优化变得没那么有效,甚至会成为性能障碍。从而可能会触发锁膨胀,转换为操作系统级别的重量级锁,以更有效地管理多线程之间的同步。
3、锁升级
锁升级是Java虚拟机(JVM)用以优化synchronized
关键字同步块锁性能的过程。它是一个动态的过程,对应用程序是透明的,旨在尽可能提高多线程应用的性能。
Java中的内部锁(监视器锁)具有多个状态,锁会根据实际情况在这些状态之间转换,以此来减少同步的开销。主要的锁状态有:
- 无锁状态:对象不含有任何同步声明,任何线程都可进入。
- 偏向锁(Biased Locking):当对象第一次被线程获取时,JVM会把对象标记为偏向该线程,并在对象头部记录线程ID。之后,该线程进入同步块时,不需要进行任何同步操作。
- 轻量级锁(Lightweight Locking):当其他线程尝试获取持有偏向锁的对象时,偏向锁会被撤销,对象会升级为轻量级锁状态。此时锁记录(Lock Record)会被创建在尝试获取锁的线程的栈帧中。
- 重量级锁(Heavyweight Locking):当不同线程竞争锁,轻量级锁的自旋机制无法成功获取锁时,锁会升级为重量级锁。此时,锁机制依赖于操作系统级别的互斥量。
锁升级的详细工作原理如下:
偏向锁撤销
- 当第一个线程访问同步块时,对象会转入偏向锁状态,锁的标识指向了该线程。
- 当第二个线程尝试访问时,会检查对象头的标记是否指向自己。
- 如果不是,偏向模式被撤销,对象头置为无锁状态(Mark Word)。
从无锁到轻量级锁的升级
如果有线程试图获取无锁状态的同步块,都会通过CAS操作这个无锁状态:
- 线程获取对象头的拷贝(Mark Word)。
- 然后它在自己的栈帧中创建锁记录,里面包含了Mark Word的拷贝。
- 通过CAS操作尝试将对象头设置为指向锁记录的指针。
- 如果成功,该线程获得锁,此时状态为轻量级锁。
- 如果CAS操作失败,则检查对象头的指针是否指向当前线程的栈帧,如果是,则已经持有锁;否则进行下一步轻量级锁的自旋操作或升级。
自旋与轻量级锁失败
轻量级锁升级为重量级锁通常在下列情况触发:
- 当有多个线程同时尝试获取同一个轻量级锁时,会有自旋尝试来解决短暂的锁竞争。
- 自旋旨在避免线程挂起的成本,因为线程挂起和唤醒需要从用户态切换到内核态,开销较大。
- 如果自旋能快速获取到锁,会节省线程挂起的开销;但如果自旋失败,会造成CPU资源的空耗。
轻量级锁到重量级锁的升级
当轻量级锁的自旋不断失败,表示锁竞争激烈,轻量级锁变得不高效。此时JVM会采取以下措施:
- 调用操作系统同步机制(如pthread_mutex_lock等),将锁升级为重量级锁。
- 无法获取锁的线程会被挂起,等待操作系统调度。
- 当锁被释放时,JVM会从阻塞在该锁上的线程中选择一个来持有锁,并唤醒它。
锁降级
是指从重量级锁恢复为较轻的锁状态。在JVM中,锁降级不如锁升级那么常见,因为一旦锁升级,往往是因为系统处于较高的竞争状态。而且目前大部分JVM实现不支持锁的降级。
性能影响
锁升级机制提升了多线程环境下的性能:
- 对于竞争不激烈的情况,偏向锁和轻量级锁提供了极低的同步开销。
- 对于竞争激烈的情况,重量级锁虽然增加了调度的开销,但防止了线程的无效自旋,总体上提高了性能。
JVM的锁升级策略是选择在正确的时间用正确的锁类型,最大化地减少竞争冲突和同步开销,优化整体的性能。开发者通常无需直接管理这一锁升级过程,因为它是JVM内部的自动行为。然而,了解背后的锁机制对于编写高性能的多线程代码仍然非常重要。
4、死锁
死锁是多线程编程中的一种常见问题,它发生在一组线程中的每个线程都等待一个被别的线程持有的资源,导致所有的线程都被阻塞,无法继续执行。为了更深入地理解死锁,我们需要分析它的成因、条件、后果以及解决方案。
死锁的成因
死锁通常发生在以下几种情况:
- 系统资源不足:线程竞争的资源数量不足以满足同时执行的线程。
- 资源分配不当:系统资源没有得到合理的分配。
- 线程推进顺序不当:线程以错误的顺序请求资源。
死锁的四个必要条件
按照银行家Edsger Dijkstra的理论,产生死锁必须同时满足以下四个条件:
- 互斥条件:资源中至少有一个不能被多个线程共享,即一次只能一个线程使用。
- 保持并等待条件:一个已经得到某些资源的线程可以请求新的资源。
- 不可抢占条件:一旦资源被分配给某个线程,不可以从该线程那里抢占,只有该线程使用完毕后才能释放该资源。
- 循环等待条件:存在一个线程(包括两个或多个)的集合其中每个线程都在等待下一个线程所持有的资源。
死锁的实例
线程A持有资源1并等待资源2。
线程B持有资源2并等待资源1。
在这个例子中,如果没有外力作用,线程A和B都将永远等待资源释放,从而造成死锁。
死锁的后果
- 系统资源的浪费:涉及死锁的资源无法被有效使用。
- 系统吞吐量下降:引起死锁的进程无法继续推进其工作。
- 系统性能下降:长时间的等待可能导致整个系统的性能急剧下降。
死锁的检测与恢复
在一个系统中,可以通过以下几种方式检测和恢复死锁:
- 死锁预防:静态分配资源,确保至少一项必要条件不成立,比如一次性分配所有资源。
- 死锁避免:使用某种算法(如银行家算法)动态检查资源分配,避免系统进入不安全状态。
- 死锁检测:运行时检测资源分配图中是否存在循环等待。
- 死锁恢复:一旦检测到死锁,可以采取某种措施打破死锁,如中断一些线程、回滚线程状态等。
死锁的解决办法
解决死锁问题的策略主要包括:
- 打破互斥条件:通过让线程共享资源而不是独占资源。
- 打破保持并等待条件:要求线程在运行前一次性地申请所有必须的资源。
- 打破不可抢占条件:允许线程抢占已经被其他线程持有的资源。
- 打破循环等待条件:对所有资源类型进行线性排序,强制每个线程按序请求资源,这样就无法形成循环。
死锁的典型解决方案举例
假设有一个数据库的事务处理系统,事务通常需要锁定多行数据。事务处理系统可以实现死锁检测和解决办法如下:
- 所有事务需要的锁必须一开始就声明。
- 数据库系统按某种顺序管理所有的锁。
- 如果事务请求的锁导致了死锁,则其中一个事务将被选择回滚以解锁资源。
总结
死锁是程序设计中需要仔细考虑的问题。理解和检测死锁至关重要,因为它关系到系统资源的有效利用和程序的正常运行。设计时应采取适当的策略和算法来预防、避免或解决死锁。在复杂的系统中,哪怕是对系统进行微小的修改,也需要评估可能对死锁情形的影响。
5、AQS
AQS(AbstractQueuedSynchronizer,抽象队列同步器)是Java并发包java.util.concurrent
的基础框架之一,它为构建锁和其他同步器(如信号量、事件等)提供了一个可靠的基础。
AQS的核心概念
AQS使用一个int成员变量来表示同步状态,以及一个FIFO队列来管理线程的排队问题。AQS的主要思想是,如果被请求的共享资源被占用,那么其他所有请求这个资源的线程将会进入一个等待队列。而等待的线程会以被阻塞的方式进行睡眠,直到它们被唤醒来检查是否可以获得该资源。
AQS定义了两种资源共享方式:
- 独占模式(Exclusive) : 只有单一线程可执行,如
ReentrantLock
。 - 共享模式(Shared) : 多个线程可同时执行,如
Semaphore
、CountDownLatch
。
AQS的基本使用
AQS提供了一种利用模板方法模式的方式来实现锁和同步器。这意味着如果你想使用AQS来创建自己的同步器,你需要重写以下方法:
isHeldExclusively()
: 判断同步状态是否符合用户预定义的独占条件。tryAcquire(int)
: 独占模式下尝试获取资源。tryRelease(int)
: 独占模式下尝试释放资源。tryAcquireShared(int)
: 共享模式下尝试获取资源。tryReleaseShared(int)
: 共享模式下尝试释放资源。
AQS的内部实现
AQS的内部实现依赖于一个叫做Node
的内部类来构成同步队列,该类包含线程自身的引用,以及指向先前和后续节点的链接。AQS利用一个叫做head
的头节点和一个叫tail
的尾节点来管理这个队列。
同步队列
当一个线程尝试获取资源失败时,AQS会将该线程包装成节点添加到队列的末尾,并且在必要时阻塞该线程。当资源被释放时,头节点的线程将会被唤醒来尝试获取资源。
条件变量
AQS还提供了一个条件(Condition)接口以供有条件阻塞的功能,这个接口能够分离出不同的Condition对象,这些对象能够跟任意AQS同步器协同工作,来实现监视器方法(wait
、notify
和notifyAll
方法)的语义。
AQS的CAS操作
AQS的状态变更依赖于CAS(Compare-And-Swap)操作来保证状态改变的原子性。它通过一个循环过程不断尝试更新同步状态,直到成功为止。
AQS的重要方法
acquire(int)
: 独占模式下获取资源,如果资源没有被占用则获取资源并立刻返回,否则进入等待队列直到获得资源。release(int)
: 独占模式下释放资源。acquireShared(int)
: 共享模式下获取资源。releaseShared(int)
: 共享模式下释放资源。
AQS在JDK中的应用示例
ReentrantLock
:一个基于AQS的可重入独占锁。Semaphore
:一个基于AQS的控制访问多个资源的个数的同步器。CountDownLatch
:一个基于AQS的一次性栅栏。ReadWriteLock
:一个基于AQS实现的读写锁,有一个读锁和一个写锁,它们分别实现了共享和独占模式。
总结
AQS是一个高度灵活的框架,它允许通过简单的方式来实现复杂的同步器,同时它也是Java并发包中很多高级同步工具的基础。深入理解AQS对于使用这些同步工具以及自定义同步组件都极为重要。
Java中AQS的作用及其如何使用?
AQS(AbstractQueuedSynchronizer)作为Java并发包java.util.concurrent
中的一个基础类,是构建锁和其他同步组件的框架。它使用一个整型的volatile变量来表示同步状态,并通过内置的FIFO队列来管理那些获取同步状态失败的线程。
AQS的作用:
AQS定义了一套多线程访问共享资源的同步器框架,具体作用包括但不限于:
- 同步状态管理:通过内部的volatile int来表示资源的状态,提供了状态的获取和释放的方法。
- 阻塞和唤醒线程:能够使没有获取到资源的线程进入等待队列,在合适的时候被唤醒。
- 队列设计:等待队列是一个类型于CLH(Craig, Landin, and Hagersten)锁队列的变体,这个队列满足FIFO(先进先出)原则,保证了公平性。
- 构建独占锁和共享锁:提供一个框架,允许通过实现指定方法来创建公平锁或非公平锁,以及Semaphore(信号量)、CountDownLatch(闭锁)、CyclicBarrier(循环栅栏)、ReadWriteLock(读写锁)等高级同步组件。
AQS的使用:
要使用AQS,你通常需要继承AQS并实现它的一些protected方法来管理同步状态。具体实现步骤如下:
-
定义同步器:定义一个依赖AQS的同步器类,该同步器类将实现具体的同步语义。
-
实现AQS提供的模板方法:至少需要实现以下几个方法:
- 对于独占锁,实现
tryAcquire(int arg)
和tryRelease(int arg)
方法。 - 对于共享锁,实现
tryAcquireShared(int arg)
和tryReleaseShared(int arg)
方法。 - 如果同步组件支持条件变量,还需实现
isHeldExclusively()
方法返回当前同步状态,用于条件变量中。
- 对于独占锁,实现
-
管理同步状态:使用
getState()
,setState(int)
和compareAndSetState(int expect, int update)
这些方法来查询或修改同步状态。 -
控制阻塞和唤醒:使用AQS提供的方法
acquire(int arg)
和release(int arg)
在独占模式下控制线程的阻塞和唤醒;使用acquireShared(int arg)
和releaseShared(int arg)
在共享模式下进行控制。
示例:
下面是一个使用AQS实现的简单的独占锁示例。
class Mutex implements Lock, java.io.Serializable {
// Our internal helper class
private static class Sync extends AbstractQueuedSynchronizer {
// Whether the lock is held by the current thread
protected boolean isHeldExclusively() {
return getState() == 1;
}
// Acquire the lock if state is zero
public boolean tryAcquire(int acquires) {
assert acquires == 1; // Otherwise unused
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
// Release the lock by setting state to zero
protected boolean tryRelease(int releases) {
assert releases == 1; // Otherwise unused
if (getState() == 0) throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
// Provide a Condition
Condition newCondition() {
return new ConditionObject();
}
}
// The sync object does all the hard work. We just forward to it.
private final Sync sync = new Sync();
public void lock() {
sync.acquire(1);
}
public boolean tryLock() {
return sync.tryAcquire(1);
}
public void unlock() {
sync.release(1);
}
public Condition newCondition() {
return sync.newCondition();
}
// ... other Lock methods are forwarded to sync as well
}
在上面的示例中,Mutex
类实现了Lock
接口,并使用Sync
类(AQS的子类)的实例作为代理处理所有的同步状态管理。tryAcquire()
和tryRelease()
方法分别对应AQS中的模板方法,用来控制锁的获取和释放。
总结
AQS是设计用于构建那些在执行多线程访问共享资源时需要阻塞和唤醒线程的同步器的强大框架。虽然它的实现细节相当复杂,但AQS已经作为许多Java并发组件的基础,使这些并发组件的实现变得更加简单和高效,使得开发者能够利用这些同步器来创建更为安全和高效的并发应用程序。
AQS的唤醒策略
AbstractQueuedSynchronizer(AQS)的唤醒策略是指当同步状态(锁)变更时,等待在AQS队列中被阻塞的线程如何被唤醒。
在解释唤醒策略之前,重要的是要了解AQS内部的数据结构。AQS使用一个双端队列(双向链表)来管理所有等待的线程。当一个线程尝试获取锁并失败时,它会被封装成一个Node对象并加入到队列的尾部。对于独占锁,每次只有一个线程可以尝试获取或释放锁,但对于共享锁,多个线程可以同时这么做。
AQS的唤醒流程:
假设线程A持有锁,线程B和C在AQS队列中等待,线程A执行完毕并释放锁,以下是AQS如何唤醒等待线程的:
-
解锁:当线程A释放锁时,它会调用
tryRelease(int arg)
(独占模式)或tryReleaseShared(int arg)
(共享模式)方法。如果锁成功释放,AQS的同步状态会被设置为0或适当的释放状态。 -
寻找后继:线程A的解锁操作接下来会调用
release(int arg)
方法,在该方法中,会调用unparkSuccessor(Node node)
方法来唤醒AQS队列中的后继线程。 -
唤醒操作:
unparkSuccessor(Node node)
会遍历队列,通常是直接唤醒头节点的后继节点。如果后继节点处于等待状态或可能被取消,unparkSuccessor
会向后移动,将唤醒操作移到下一个有效的后继节点。 -
重新获取锁:被唤醒的线程(例如线程B)将会尝试重新获取释放的锁,这一过程是通过调用
acquire(int arg)
(独占模式)或acquireShared(int arg)
(共享模式)进行的。如果尝试成功,被唤醒的线程将会从LockSupport.park(this)
调用中返回并持续执行。
唤醒策略的特点:
-
公平性:默认情况下,AQS为保证锁的公平性,按照FIFO的顺序唤醒线程。当一个线程被unpark时,它将有机会尝试获取之前被释放的锁。
-
效率考量:在高竞争的环境下,如果每次只唤醒一个线程,可能会导致频繁的上下文切换和锁竞争。因此,AQS提供了条件变量(Condition)和共享模式,允许在合适的时候唤醒多个线程。
-
自旋锁(Spinning):在某些情况下,等待队列中的线程会进行自旋尝试获取锁,而不是完全挂起。这可以降低上下文切换的成本,但同时增加了CPU消耗。
-
避免唤醒风暴:若原头节点的后继节点待唤醒线程已不在,或者自己是最后一个,或者设定了超时,AQS避免了不必要的唤醒,这是所谓的唤醒风暴。
共享模式与独占模式的差异:
在共享模式中(如Semaphore
或ReadWriteLock
的读锁),资源可以被多个线程同时访问。当线程释放共享锁时,可能会唤醒多个在等待资源的线程,而不仅仅是队列中的一个。这通常是通过setHeadAndPropagate(Node node, int propagate)
方法完成的,该方法在设置了头节点后,会根据给定的传播参数来决定是否需要继续唤醒其他线程。
总结而言,AQS使用高度优化的唤醒策略来控制线程的同步与调度,减少不必要的竞争和上下文切换,同时保证了同步器的高性能和线程的安全执行。
6、ReentrantLock
ReentrantLock
是 Java 并发 API 中的一种高级同步机制,比起传统的synchronized
关键字,它提供了更高的灵活性、可扩展性和互操作性。在 Java 中,ReentrantLock
是一个实现了 Lock
接口的类,其行为类似于内置的监视器锁,但具有更多的高级特性。
ReentrantLock的特点
- 可重入:即递归锁。线程可以重复获取已经持有的锁,这避免了在递归调用或循环内部出现死锁。
- 公平锁和非公平锁:
- 公平锁:等待时间最长的线程将首先获得锁,遵守FIFO原则。公平锁可通过设置公平性标志为 true 来启用。
- 非公平锁:这是默认设置,允许插队,可能会导致“饥饿”问题,但在大多数场景下拥有更高的性能。
- 可中断的锁获取操作:线程在等待锁的过程中可以被中断。
- 超时获取:允许尝试获取锁,如果在指定时间内没有获取到,线程可以决定放弃等待。
- 锁池:提供了一个条件变量
Condition
类的支持,每个ReentrantLock
实例可以关联一个或多个Condition
对象。
ReentrantLock的基本使用
要使用 ReentrantLock
,通常会在类中声明一个私有的 ReentrantLock
字段,并且在方法中获取并释放锁:
class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // 获取锁
try {
count++;
} finally {
lock.unlock(); // 在 finally 块中释放锁
}
}
}
ReentrantLock的高级用法
-
条件变量(Condition)
ReentrantLock
通过内部类ConditionObject
实现了Condition
接口,允许将线程的等待与唤醒分成不同的组。
public void waitForCondition() throws InterruptedException {
lock.lock();
try {
condition.await(); // 线程挂起,等待条件满足
} finally {
lock.unlock();
}
}
public void signalCondition() {
lock.lock();
try {
condition.signal(); // 唤醒等待条件的线程
} finally {
lock.unlock();
}
}
-
锁获取的可中断
ReentrantLock
提供了lockInterruptibly
方法,该方法在等锁的过程中,允许线程被中断。 -
尝试非阻塞地获取锁
通过
tryLock
方法,线程可以尝试获取锁而不必等待,这可以减少死锁的可能性。 -
带超时的锁获取
使用
tryLock(long timeout, TimeUnit unit)
方法可以实现锁的超时获取。
ReentrantLock的内部原理
ReentrantLock
内部依赖于 AQS(AbstractQueuedSynchronizer)来管理其状态。AQS 使用一个 volatile int (state)来表示同步状态和一个 FIFO 队列来管理等待的线程。
- 对于公平锁:在锁被释放时,AQS 将会按照队列的顺序唤醒线程。
- 对于非公平锁:在锁被释放时,或者在新线程加入队列前,新来的线程有机会去尝试获取锁,这可以减少上下文切换,提高了性能。
ReentrantLock与synchronized
的对比
- 细粒度控制:
ReentrantLock
提供了比synchronized
更丰富的操作,可以根据需要进行有条件地锁定。 - 性能:在 Java 5 之后,
synchronized
的性能优化很多,但在高度竞争的情况下,ReentrantLock
仍然可能显示出更优异的性能。 - 功能更强大:比如尝试带超时的锁,中断等待锁的线程,公平性锁等高级函数。
总的来说,ReentrantLock
是比起内置同步机制更加灵活和强大的并发工具,但它也需要更加谨慎的手动操作。如果使用得当,ReentrantLock
可以构建更复杂的并发结构,并提供更为精细的性能调优选项。
synchronized
synchronized
是Java语言内建的一种同步机制。根据其应用,synchronized
可以用来修饰方法或者代码块,以保证同一时间只有一个线程可以执行该方法或代码块内的代码。这种机制以获取对象的内置锁(monitor lock)的方式实现线程间的同步。
synchronized的主要特点:
-
内置语言支持:它是Java语言的关键字,与Java平台和语言规范紧密集成,不需要依赖额外的库。
-
易用性:使用相对简单,只需在方法签名中添加
synchronized
关键字,或者在代码块前面添加synchronized(this)
即可。 -
自动锁管理:锁的获取和释放由JVM在编译后的字节码中自动完成,开发者不需要进行显式操作。
-
可重入性:和
ReentrantLock
一样,synchronized
也是一个可重入锁。一个具有synchronized
方法或块的线程可以再次进入另一个synchronized
方法或块而不会发生死锁,前提是两者使用的是同一个锁对象。
synchronized的使用方式:
-
同步实例方法:作用于当前对象实例的锁。
public synchronized void method() { // 临界区 }
-
同步静态方法:作用于当前类的Class对象的锁。
public static synchronized void staticMethod() { // 临界区 }
-
同步代码块:可以精确控制同步的范围和对象。
public void method() { synchronized(this) { // 临界区 } }
synchronized的工作原理:
当一个线程试图访问一个同步的代码块时,它需要获取对应的锁。
-
监视器锁(Monitor Lock):当锁关联到对象或类的Class对象时,每个对象都作为监视器锁的一个实现。这个监视器包含两个特性:一个是锁(也叫互斥量),一个是监视器队列。
-
锁的获取与释放:当访问一个
synchronized
块时,线程首先尝试获取锁。如果锁已经被其他线程持有,就进入阻塞状态,直到获得锁。一旦完成方法执行,无论是正常完成还是异常退出,它都会释放锁。 -
内存语义:
synchronized
使用内存屏障来实现内存可见性。当线程释放锁时,其在临界区内对变量所做的更改会在随后的获取同一锁的另一线程看到。
synchronized优化:
随着JVM的发展,synchronized
的性能已经得到了大幅优化,主要通过以下方式:
-
锁粗化(Lock Coarsening):将多个连续的
synchronized
块合并为一个更大的块,减少锁获取和释放的次数。 -
适应性自旋锁(Adaptive Spinning):为了避免线程在等候锁时的上下文切换代价,线程可能会执行自旋等待,即在不放弃CPU的情况下等锁。
-
锁消除(Lock Elimination):JVM在运行时可以消除不可能存在共享数据竞争的锁,例如栈封闭的情况。
-
锁膨胀(Lock Inflation):锁可以从轻量级锁膨胀为重量级锁,根据竞争情况进行调整。
-
偏向锁(Biased Locking):JVM通过偏向锁减少不必要的锁竞争开销,当没有真正竞争时,标记对象为特定线程所有,避免了锁操作。
synchronized和ReentrantLock的选择:
synchronized
是在某些情况下编码更为简约、方便且足以应对多线程问题的同步手段。然而,当需要更高级的锁管理特性时,例如尝试非阻塞地获取锁、公平性锁定、或者可中断锁等,应当选择ReentrantLock
。此外,在极度关注性能的代码块中,ReentrantLock
有可能提供比synchronized
更精细的锁操作。
总之,synchronized是解决线程同步问题的一个重要关键字,深入理解其原理和使用方法对于编写高质量的并发程序十分重要。随着JVM的不断升级和优化,其性能隐患越来越小,已能够满足大多数的同步需求。
ReentrantLock与synchronized对比
ReentrantLock
和synchronized
都是Java中实现线程同步的机制,但它们在语法和功能上有不同之处。以下是它们的深入比较:
基础特性对比
-
语法和易用性:
synchronized
是 Java 语言的关键字,它提供了一种相对简单的方式来实现同步,不需要在代码中显式地声明锁,编译器和 JVM 管理锁的获取和释放。ReentrantLock
是 java.util.concurrent.locks 中的一个 API 类。需要在代码中明确声明,然后在try/finally
块中使用lock()
和unlock()
方法来获取和释放锁,管理起来较为繁琐。
-
锁的公平性:
synchronized
不保证公平性,不能指定获取锁的顺序,JVM 定义了锁的获取顺序。ReentrantLock
允许用户选择公平锁还是非公平锁。公平锁依照线程在等待队列中的等待顺序赋予锁,但一般而言公平锁会比非公平锁性能稍差。
功能差异
-
中断等待锁的线程:
synchronized
获取锁的操作是不能中断的,当一个线程在等待锁时,无法被中断。ReentrantLock
提供了lockInterruptibly()
方法,这允许当线程在等待获取锁时,可以响应中断。
-
尝试非阻塞地获取锁:
synchronized
不支持尝试获取锁,它只能阻塞或者持有锁。ReentrantLock
提供了tryLock()
方法,它可以非阻塞地尝试获取锁,如果锁不可用,立即返回 false。
-
超时获取锁:
synchronized
没有提供超时获取锁的机制。ReentrantLock
提供了带有超时时间的tryLock(long timeout, TimeUnit unit)
方法,如果在指定时间内未获取到锁,则返回 false。
-
条件变量:
synchronized
与Object
类的wait()
,notify()
和notifyAll()
方法结合使用来实现等待/通知机制。ReentrantLock
更推荐使用Condition
接口,提供更丰富的功能,比如可以实现多路(多个不同条件)等待和通知。
功能实现的内部差异
-
锁的实现:
synchronized
在 JVM 内部基于监视器锁 (Monitor) 实现,是 JVM 实现的一部分,JVM 和操作系统最终是通过一个叫作对象监视器(Object Monitor)的机制来完成对锁的管理。ReentrantLock
是基于 AQS(AbstractQueuedSynchronizer)实现的,AQS 使用一个 volatile int 成员变量来表示同步状态,使用一个 FIFO 队列来管理获取同步状态失败的线程。
-
性能:
- 早期版本的 JVM 中,
synchronized
锁比ReentrantLock
性能要差,因为synchronized
锁是重的系统级别锁。 - 现代 JVM 通过偏向锁、轻量级锁(锁膨胀)以及自旋锁等优化拥有了比以往更佳的性能表现,特别是在低竞争情况下。这些优化让
synchronized
性能显著提高,而在高竞争情况下,ReentrantLock
仍可以提供更细粒度的控制和更一致的性能表现。
- 早期版本的 JVM 中,
选择建议
通常,synchronized
用于那些实现简单的同步需求,它是一种方便且不易出错的内置同步方式。但当你需要高级特性(如超时等待,或者公平锁,或者响应中断,等等)时,ReentrantLock
是一个更好的选择。在具体的性能和功能需求下,你应该选择最适合应用场景的锁。
尽管 ReentrantLock
在功能上更加强大,但是下面是一些必须考虑的缺点:
- 复杂性:
ReentrantLock
需要手动获取和释放锁,这增加了出错的可能性。 - 性能:未必一直比
synchronized
好,特别是在锁竞争不激烈或锁定时间十分短暂的情况下,synchronized
已经足够好了。
在作出决策时,为了代码简洁和降低出错率,应当优先考虑使用 synchronized
。只有当需要 ReentrantLock
提供的高级功能,或者在特定压力测试中 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、自旋锁什么时候升级成重量级锁
了解自旋锁与重量级锁的转换,首先需要了解Java中锁的状态,以及JVM如何在这些状态之间转换,特别是在HotSpot JVM中。这里的"自旋"通常指的是轻量级锁自旋,在尝试获取锁时不断重试,而不是立即挂起线程。
锁的状态
在HotSpot JVM中,对象的头部包含了锁的状态信息。锁可以有以下几种状态:
- 无锁状态 (Unlocked): 对象未被锁定,任何线程都可以尝试锁定它。
- 偏向锁状态 (Biased): 偏向模式允许一个线程多次无障碍地获得同一锁。
- 轻量级锁状态 (Lightweight Locked): 当有线程尝试获取已经偏向某个线程的锁时,锁可以升级为轻量级锁。
- 重量级锁状态 (Heavyweight Locked): 当轻量级锁失败时,锁可以进一步升级为重量级锁。
锁的升级
锁的状态会根据线程对锁的争用情况升级。以下是锁升级的通常流程:
-
无锁到偏向锁: 当第一个线程访问同步块时,JVM会设置一个偏向锁,将该对象头部的标记字段偏向第一个获取它的线程。这个过程几乎没有竞争,因此开销非常低。
-
偏向锁到轻量级锁: 如果一个偏向的锁被另一个线程访问,JVM将会撤销偏向锁,并将其升级为轻量级锁。此时,对象头中的锁记录指向一个栈内存区域,称为锁记录(Lock Record),用于记录锁的状态。
-
轻量级锁的自旋: 线程在尝试获取轻量级锁时,会先进行自旋尝试。自旋是指当线程尝试获取锁,而锁已经被占用时,线程会在一个循环中不断检查锁是否被释放,而不是立即进入阻塞状态。
-
轻量级锁到重量级锁: 如果自旋一定次数后(这个次数可以通过JVM参数
-XX:PreBlockSpin
调整)线程仍未能获取锁,JVM将停止线程的自旋并将轻量级锁升级为重量级锁。此时,对象头的标记字段指向一个重量级锁结构,如一个监视器(Monitor)或互斥量(Mutex),并且该线程会被挂起,直到锁可用。
考虑因素
锁的升级并不是轻易发生的,因为升级过程有一定的成本。重量级锁涉及到操作系统级别的线程挂起与唤醒,这比轻量级锁的CAS操作要昂贵得多。因此,JVM将尽量保持锁在轻量级状态,只有在明显的线程争用发生时才会升级到重量级锁。
优化点
-
自旋次数与自适应自旋: JVM会根据锁的实际争用情况自动调整自旋的次数。如果线程经常在自旋后成功获取锁,那么JVM可能会增加自旋次数;反之,如果自旋很少成功获取锁,JVM可能会减少自旋次数或者直接挂起线程。
-
锁消除和锁粗化: JVM通过即时编译器可以进行锁消除(如果它确定某些代码块中的锁是不必要的)和锁粗化(如果它发现一系列连续的锁可以合并为一个更大的锁)等优化。
-
锁的选择: 开发者在设计同步策略时应当选择合适的锁类型,如
ReentrantLock
的公平锁/非公平锁,或者利用java.util.concurrent
包中的其他并发工具,根据具体场景制定最优的并发策略。
在实际应用中,锁的选择和使用应根据具体的并发模式和性能指标来决定。对于高度竞争的锁,使用重量级锁可能更合适,因为频繁的自旋只会浪费CPU资源。对于锁竞争不激烈的场景,轻量级锁或自旋锁可能会提供更好的性能。同时,通过JVM参数和代码设计来精细控制这些行为是提高多线程程序性能的重要方面。
9、为什么自旋锁还需要重量级锁
自旋锁与重量级锁的使用是为了在不同的竞争情况下平衡性能和资源消耗。理解它们两者之间存在的必要性,首先需要认识到它们各自的特点和适用场景。
自旋锁的优点和局限性
自旋锁利用的是忙等待的方式来等待锁的释放。其主要优点包括:
- 避免上下文切换的开销:当线程被挂起时,操作系统需进行上下文切换,保存当前线程的状态,并激活另一个线程的状态,这一过程相对消耗资源。
- 适用于锁占用时间短的场景:如果预计锁会在很短时间内被释放,自旋等待可以减少线程切换的开销,并且线程可以更快地恢复执行。
然而,自旋锁也有其限制:
- 处理器时间的浪费:如果锁不是在短时间内被释放,自旋锁将会在循环中浪费宝贵的CPU时间,特别是在多核处理器上,这可能会引起更多的性能问题。
- 不适用于锁占用时间长的场景:在锁被长时间占用时,自旋等待不如挂起线程来得有效率。
重量级锁的作用
当自旋锁不再有效时,就需要一种能够更好地管理线程等待的机制——这就是重量级锁的用武之地。其主要特点包括:
- 交由操作系统管理:重量级锁通常是操作系统提供的原语,例如互斥量(mutex)或监视器(monitor),它们能够将等待锁的线程置于休眠状态,让出CPU供其他线程使用。
- 适用于长时间等待场景:当锁被持有较长时间,或者竞争激烈时,通过挂起线程,重量级锁避免了无效的CPU资源消耗。
为什么需要两者
现代的JVM智能地结合了这两种锁的优点,通过所谓的锁膨胀(lock inflation)机制,在不同的争用水平下动态调整:
- 低争用情况:自旋锁在此场景下性能更佳,因为线程很快就能获得锁,无需上下文切换。
- 中等争用情况:自旋锁可能仍然适用,但JVM会根据前面操作的统计信息来决定是否进行自旋,这是自适应自旋的概念。
- 高争用情况:在这种情况下,自旋变得没有效率,因为锁被占用的时间太长,锁膨胀到重量级锁可以避免CPU资源的浪费,将线程挂起直到锁可用。
锁膨胀的过程
在JVM中,这个过程是自动发生的:
- 初始状态:对象通常开始于无锁状态。
- 偏向锁:当有线程第一次获取锁时,对象会进入偏向模式,偏向该线程。
- 轻量级锁:当有其他线程尝试获取这个锁时,偏向锁会膨胀成轻量级锁。
- 自旋尝试:其他线程试图通过自旋来获取轻量级锁。
- 重量级锁:如果自旋失败并且锁的竞争变得激烈,轻量级锁会进一步膨胀成重量级锁。
总结
自旋锁和重量级锁的结合使用,允许JVM更有效地处理不同的争用情况。在争用程度较低时,自旋锁可以提供更高的性能,但当争用程度增加时,转换为重量级锁可以避免无谓的CPU资源消耗。这种动态的锁策略在性能和资源利用之间提供了一个平衡点,让JVM能够适应不同的运行时条件。开发者通常不需要手动管理这些细节,因为JVM已经为他们优化了锁的行为。然而,了解这些概念对于调优高并发应用和诊断性能问题是有帮助的。
10、HotSpot JVM
HotSpot JVM 是 Java 虚拟机 (JVM) 最为广泛使用的实现之一,它因其高性能监控热点代码(频繁执行的代码段)而得名。HotSpot JVM 的设计使得 Java 应用能够实现高性能和高度的可移植性。以下内容将提供对 HotSpot JVM 更为深入和详细的概述,包括它的架构、特性、以及执行模型等方面。
架构概览
HotSpot JVM 的架构包含以下几个主要部分:
-
类加载器(Class Loaders): 负责加载 Java 类文件(.class 文件)。类加载器按照父级委托模型工作,首先检查父加载器,如果父加载器找不到指定的类,才会尝试自己加载。
-
运行时数据区(Runtime Data Areas): 包括方法区(Method Area)、堆(Heap)、栈(Java Stacks)、程序计数器(Program Counter Registers)和本地方法栈(Native Method Stacks)。每个部分都有其独特的用途,如堆用于分配类实例和数组,而栈用于存储局部变量和方法调用。
-
执行引擎(Execution Engine): 包括解释器(Interpreter)和即时编译器(JIT Compiler)。解释器逐条执行字节码,而 JIT 编译器将热点代码编译为本地代码,提高效率。
-
本地接口(Native Interface): 用于与本地库交互,如 Java Native Interface (JNI)。
-
垃圾收集器(Garbage Collectors): 负责管理 JVM 堆内存中对象的生命周期,自动回收不再使用的对象所占用的内存。
执行模型
HotSpot JVM 的执行模型可以简化为以下几步:
- 加载: JVM 启动时,首先加载指定的类文件。
- 链接: 加载后,进行验证、准备以及(如果适用)解析阶段,以确保类的正确性并分配内存。
- 初始化: 初始化类变量和静态代码块。
- 执行: 使用解释器和 JIT 编译器执行字节码。
JIT 编译器和性能优化
HotSpot JVM 的 JIT 编译器是它性能优化的关键组件之一,包括多个级别的编译器:
- C1 Compiler (Client Compiler): 面向客户端应用,优化启动时间。
- C2 Compiler (Server Compiler): 面向服务器端应用,优化最大执行速度。
JIT 编译器还利用了各种优化技术,如:
- 方法内联: 减少方法调用的开销。
- 循环优化: 包括循环展开和向量化来提高循环效率。
- 逃逸分析: 确定对象的作用域和生命周期,以便进行栈上分配。
- 公共子表达式消除: 减少重复计算。
- 死代码消除: 删除不会执行的代码路径。
垃圾收集
HotSpot JVM 提供了多种垃圾收集器,以适应不同的应用场景和需求:
- 串行收集器: 单线程垃圾收集,适合小内存和单核处理器的应用程序。
- 并行收集器: 多线程垃圾收集,优化吞吐量,适合中到大型多核处理器的应用程序。
- CMS(Concurrent Mark-Sweep)收集器: 以最小化停顿时间为目标,适合希望避免长时间垃圾收集停顿的应用程序。
- G1收集器: 一种面向服务器的垃圾收集器,旨在平衡吞吐量和停顿时间,特别适合大内存机器。
内存管理
HotSpot JVM 的内存管理是通过分代假设来优化的,其中 Java 堆分为年轻代(Young Generation)和老年代(Old Generation):
- 年轻代: 包括伊甸园(Eden)区和两个幸存者(Survivor)区。大多数新创建的对象首先分配在伊甸园区。
- 老年代: 存放生命周期较长的对象,通常在年轻代中经过多次垃圾收集后仍然存活的对象会被移动到老年代。
线程同步
线程同步是另一个 JVM 需要处理的关键方面。HotSpot 使用了多级锁定机制来优化线程同步:
- 轻量级锁: 最初尝试以较小的开销解决线程争用。
- 偏向锁: 当锁定对象主要由一个线程访问时使用。
- 重量级锁: 在高争用情况下使用,会导致线程阻塞。
诊断和监控
HotSpot JVM 提供了一组丰富的诊断和监控工具,包括:
- Java Mission Control (JMC): 用于监控、管理和分析 JVM。
- Java Flight Recorder (JFR): 用于收集细粒度的运行时信息和性能指标。
- JVM Tool Interface (JVMTI): 用于构建调试和监控工具。
安全性
Java 平台的安全模型是 JVM 设计的一个重要部分。HotSpot 实现了沙箱机制,限制了代码的执行域,保护了系统免受恶意软件的影响。类加载器和字节码验证器是实现这一安全模型的关键组件。
互操作性
HotSpot JVM 通过 JNI 支持与本地应用程序或库的互操作性,允许 Java 代码调用本地方法,并允许本地代码创建和操作 Java 对象。
以上详述提供了对 HotSpot JVM 架构和执行模型的深入理解,这些特性和机制共同确保了 Java 应用程序的高性能和平台独立性。随着新技术的发展,HotSpot JVM 也在不断进化,以适应现代硬件和软件环境的需求。