Java Virtual Machine (JVM)对锁进行了多方面的优化,以提高多线程程序的性能和并发效率。以下是一些 JVM 对锁进行的优化技术:
偏向锁(Biased Locking):
JVM引入了偏向锁的概念,该锁会在一个线程访问同步块时偏向于该线程 ,从而减少竞争。这对于很多情况下只有一个线程访问同步块的场景非常有效。通过偏向锁,可以避免每次都进行重量级锁的争夺。
一般而言,偏向锁是在对象第一次被加锁时使用的。
轻量级锁(Lightweight Locking):
当多个线程同时访问同步块时,JVM会尝试使用轻量级锁。轻量级锁采用CAS(Compare and Swap)操作来消除锁的争用,避免了传统的互斥量的开销。
CAS是一种多线程编程中常用的原子操作 。CAS 操作涉及到三个操作数: 一个内存位置(通常是一个变量),旧的预期值,以及新的值。操作的含义是,只有当内存位置的值与预期值相等时,才会用新的值更新内存位置,否则不做任何操作。
这个过程可以用下面的步骤描述:
- 读取内存位置的当前值。
- 比较当前值与预期值。
- 如果相等,就使用新值更新内存位置;如果不相等,不做任何操作。
CAS 是一种乐观锁定的机制,因为它假设在操作期间不会有其他线程干扰。如果其他线程干扰了,CAS 操作就会失败,需要重试。这使得 CAS 非常适合用于实现一些并发算法,比如轻量级锁的实现。
在轻量级锁中,使用CAS来尝试获取锁。线程会先尝试使用CAS操作将锁的标记从无锁状态切换到自己的标识,如果成功,表示获取锁成功,如果失败,说明有竞争,需要使用其他机制(例如自旋锁、重量级锁)来解决。
CAS 是一种基于硬件原语的原子操作,它是许多并发算法和数据结构的基础,用于实现无锁编程的一种手段。
当多个线程同时访问同步块时,JVM会在以下时机尝试使用轻量级锁:
- 偏向锁失败: 初始时,JVM会偏向于认为同步块只有一个线程会访问,因此会使用偏向锁。但如果有其他线程尝试获取同步块,偏向锁会失败,JVM会尝试升级为轻量级锁。
- 竞争锁: 当偏向锁失败后,多个线程同时竞争同一个锁时,JVM会尝试使用轻量级锁。轻量级锁使用CAS操作(比较并交换)来尝试获取锁,避免了传统锁的互斥量的开销。
- 自旋等待: 如果第一个线程获取了轻量级锁,而其他线程仍然在竞争锁,这些线程会进行自旋等待。在自旋等待的过程中,其他线程仍有机会通过CAS操作尝试获取锁。
- 适应性自旋: 在自旋等待的过程中,JVM可以根据当前程序运行时的情况动态调整自旋的次数。如果发现锁很容易获得,可以适当减少自旋次数,避免浪费处理器资源。
总体来说,JVM在多个线程同时访问同步块时,会在偏向锁失败或者发生锁竞争时尝试使用轻量级锁,并在自旋等待的过程中动态调整自旋的次数,以提高并发性能。
自旋锁
在使用轻量级锁的情况下,如果其他线程抢占了锁,当前线程不会马上阻塞,而是会进行一定次数的自旋等待 。这有助于避免线程因为短暂的锁争用而被挂起的开销。
那这里的轻量级锁是不是就是自旋锁呢?
不完全是的。轻量级锁和自旋锁是两个概念,尽管它们在某些情况下可能会相互关联,但它们并不是完全一样的。
- 轻量级锁: 轻量级锁是为了解决多线程竞争同一个锁的性能问题而引入的机制 。在开始时,线程尝试通过CAS(Compare and Swap)操作将对象头中的锁标志位修改为指向自己,这时候认为这个线程持有了这个锁,如果失败,则说明有竞争,此时会升级为重量级锁。轻量级锁中包含了一定程度的自旋,但并不是纯粹的自旋锁。
- 自旋锁: 自旋锁是线程在获取锁时,如果发现锁已经被其他线程占用,就不会立即阻塞 ,而是进行一定次数的自旋等待 ,期望锁会在短时间内被释放。自旋锁主要是为了避免线程因为阻塞而进入内核态造成的性能损失 。自旋锁在一定条件下可以提高性能,但在高竞争情况下可能导致额外的资源消耗。
在轻量级锁中,线程会尝试通过CAS自旋来获取锁,但轻量级锁不同于纯粹的自旋锁,它会在自旋一定次数后,如果仍然无法获取锁,会升级为重量级锁,这时候会涉及到线程阻塞。
因此,轻量级锁中包含了自旋的机制,但它不等同于自旋锁 。自旋锁是一种更为一般的概念,而轻量级锁是一种具体的实现。
适应性自旋(Adaptive Spinning)
JVM可以根据当前程序运行时的情况动态调整自旋的次数。如果当前程序一直在争用锁,JVM可以适当增加自旋次数;反之,如果锁很容易获得,可以适当减少自旋次数,以避免浪费处理器资源。
想象你是一名学生,正在图书馆等待使用一个非常热门的图书馆资源,比如一台电脑。
- 低竞争情况(适应性自旋): 如果在一个平常的日子里,图书馆里的电脑相对较多,很少有其他学生在使用它们,你在等待的时候可能会选择适应性自旋。也就是说,你可能会耐心等待一段时间,看看是否有人即将释放电脑,而不是立即选择离开。这样可以在低竞争的情况下更快地获取资源。
- 高竞争情况(减少自旋): 突然间,考试周到了,很多学生都需要使用图书馆的电脑准备报告。这时候,你意识到等待时间可能会变得很长。在这种情况下,你可能会更倾向于减少自旋,即更迅速地决定换个策略,例如去寻找其他可用的电脑或者等待时间较短的队伍。
适应性自旋的核心思想在于,根据当前资源的争夺情况,以及等待者的行为,动态地调整等待策略。在低竞争时,更愿意耐心等待,而在高竞争时,更倾向于迅速做出决定,以提高整体效率。
减少自旋的结果可能会导致锁的升级。在自旋锁的实现中,如果自旋的次数达到一定阈值,而仍然没有获得锁,系统可能会选择将自旋锁升级为其他形式的锁,例如轻量级锁或者重量级锁。
锁消除
当编译器分析代码时,发现某些锁不可能存在竞争时,可以将这些锁消除,从而减少不必要的同步操作。
我们举一个例子:
java
public class LockEliminationExample {
private static final int ARRAY_SIZE = 10000;
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
performOperation();
}
}
private static void performOperation() {
// 假设我们有一个数组,只在单线程中使用
int[] array = new int[ARRAY_SIZE];
// 对数组进行一些操作,没有并发访问
for (int i = 0; i < ARRAY_SIZE; i++) {
array[i] = i;
}
// 锁对象,在单线程情况下没有竞争
Object lock = new Object();
// 没有并发访问,锁是多余的
synchronized (lock) {
for (int i = 0; i < ARRAY_SIZE; i++) {
array[i] *= 2;
}
}
}
}
在上述例子中,我们有一个数组 array
和一个锁对象 lock
。在 performOperation
方法中,我们对数组进行一些操作,但是由于我们知道该方法是在单线程环境中调用的,因此并不存在对数组的并发访问。
编译器可以通过静态分析和程序的上下文来确定在这个情况下锁是多余的 ,因为根本不存在多线程并发访问的可能性。因此,编译器可以选择进行锁消除,将同步块中的锁去除,从而提高程序的性能。
锁粗化
当JVM检测到一系列连续的对同一个对象加锁和解锁 的操作时,可能会将这些操作合并成一个更大的同步块,从而减少加锁和解锁的次数。
比如以下的代码:
java
public class LockCoarseningExample {
private static final Object lock = new Object();
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
performOperations();
}
}
private static void performOperations() {
// 在方法内部多次使用锁
synchronized (lock) {
// 操作1
}
synchronized (lock) {
// 操作2
}
synchronized (lock) {
// 操作3
}
}
}
在上述例子中,performOperations
方法内部有多个独立的同步块,每个同步块使用相同的锁对象 lock
。这种情况下,编译器可以选择进行锁粗化,将这些独立的同步块合并成一个更大范围的同步块,以减少锁的竞争和开销。
经过锁粗化后的代码可能会变成类似下面这样:
java
private static void performOperations() {
// 合并为一个更大范围的同步块
synchronized (lock) {
// 操作1
// 操作2
// 操作3
}
}
通过锁粗化,减少了对锁的获取和释放的次数 ,有助于提高程序的性能,尤其在某些情况下可以减少线程因频繁竞争锁而产生的性能开销。需要注意的是,锁粗化并不是在所有情况下都是合适的,具体效果取决于程序的特性和运行环境。
这些优化策略有助于提高多线程程序的性能,减少锁的争用,以及降低锁操作的开销。