一、锁升级

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。

随着竞争情况,锁会从偏向锁升级到轻量级锁,再升级到重量级锁。

image

注意:锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

// 示例代码
public class LockUpgrade {
    private static final Object lock = new Object();
    
    public void method() {
        synchronized (lock) {  // 锁升级在此发生
            // 业务代码
        }
    }
}

升级流程图

无锁状态
    ↓ ← 第一个线程访问
偏向锁状态(记录线程ID)
    ↓ ← 有其他线程访问(检测到线程ID不一致)
轻量级锁状态(CAS自旋)
    ↓ ← 自旋超过阈值 或 有第三个线程竞争
重量级锁状态(Monitor)

 1、偏向锁(Biased Lock)

偏向锁只需要检查是否为偏向锁、锁标识为以及ThreadID即可,可以减少不必要的CAS操作。而轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。

设计目标:减少同一线程重复获取锁的开销

触发条件:锁对象第一次被线程获取时

工作流程:

  1. 检查锁对象的 Mark Word 是否处于可偏向状态(偏向锁标志=1,锁标志=01)

  2. 如果是可偏向状态,通过 CAS 将线程 ID 写入 Mark Word

  3. 如果 CAS 成功,获取偏向锁成功

  4. 下次同一线程进入同步块时,只需检查线程 ID 是否一致

撤销偏向锁:

  • 当其他线程尝试获取锁时,会撤销偏向锁到无锁状态

  • 在全局安全点(Safe Point)进行,暂停持有偏向锁的线程

 2、轻量级锁(Lightweight Lock)

在没有多线程竞争的前提下,减少传统的重量级锁sychronized使用操作系统互斥量产生的性能消耗

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。轻量级锁主要使用CAS进行原子操作
  但是对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

设计目标:减少线程阻塞的开销,通过自旋等待

触发条件:多个线程交替执行同步块,没有实际竞争

工作流程:

// 加锁过程:
1. 在当前线程的栈帧中创建锁记录(Lock Record)
2. 将对象头的 Mark Word 复制到锁记录中(Displaced Mark Word)
3. 使用 CAS 尝试将对象头的 Mark Word 替换为指向锁记录的指针
4. 如果 CAS 成功,获得轻量级锁
5. 如果 CAS 失败,检查是否是自己已经持有锁(重入)
6. 如果不是,说明存在竞争,开始自旋等待

// 解锁过程:
1. 使用 CAS 将 Displaced Mark Word 替换回对象头
2. 如果成功,解锁完成
3. 如果失败,说明已膨胀为重量级锁,需要唤醒其他线程

 3、重量级锁(Heavyweight Lock)

使用synchronized的成本高:

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

设计目标:处理真正的线程竞争

触发条件:

  • 自旋超过一定次数(默认10次,可用 -XX:PreBlockSpin 调整)

  • 等待线程数超过CPU核心数的一半

  • 有第三个线程竞争轻量级锁

工作流程:

1. 线程尝试获取锁
2. 如果锁空闲(_owner=null),CAS设置_owner为当前线程
3. 如果锁被占用,线程进入_EntryList阻塞
4. 当持有锁的线程释放锁时,从_EntryList或_cxq中唤醒一个线程
5. 被唤醒的线程尝试获取锁

实际代码示例:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class LockUpgradeExample {
    private static Object lock = new Object();
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        // 阶段1:偏向锁(单线程)
        System.out.println("=== 阶段1:偏向锁 ===");
        synchronized (lock) {
            System.out.println("第一次获取锁 - 偏向锁建立");
        }

        // 等待1秒,确保偏向锁延迟生效(默认4秒延迟开启)
        Thread.sleep(1000);

        // 阶段2:轻量级锁(两个线程交替执行)
        System.out.println("\n=== 阶段2:轻量级锁 ===");
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    counter++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    counter++;
                }
            }
        });

        t1.start();
        t2.start();
        t1.join(); // 等待t1线程执行完毕后再继续
        t2.join(); // 等待t2线程执行完毕后再继续

        // 阶段3:重量级锁(多线程激烈竞争)
        System.out.println("\n=== 阶段3:重量级锁 ===");
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                synchronized (lock) {
                    counter++;
                }
            });
        }
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        System.out.println("最终counter值: " + counter);
    }
}

结果:

=== 阶段1:偏向锁 ===
第一次获取锁 - 偏向锁建立

=== 阶段2:轻量级锁 ===

=== 阶段3:重量级锁 ===
最终counter值: 1200

JVM 参数调优

# 锁相关参数
-XX:+UseBiasedLocking           # 启用偏向锁(JDK 15后默认禁用)
-XX:BiasedLockingStartupDelay=0 # 偏向锁延迟时间(默认4000ms)
-XX:BiasedLockingBulkRevokeThreshold=40 # 批量撤销阈值
-XX:BiasedLockingBulkRebiasThreshold=20 # 批量重偏向阈值

# 自旋锁参数
-XX:+UseSpinning                # 启用自旋(JDK 6后默认开启)
-XX:PreBlockSpin=10             # 自旋次数限制(默认10)
-XX:+UseAdaptiveSizePolicy     # 自适应自旋(默认开启)

# 锁膨胀参数
-XX:InflateSpinNumer=200       # 膨胀前自旋次数

锁升级的优缺点

优点:

  1. 减少阻塞:轻量级锁通过自旋避免线程切换

  2. 降低开销:偏向锁避免CAS操作

  3. 适应场景:根据竞争程度自动调整锁策略

缺点:

  1. 升级不可逆(基本不可逆,特殊情况除外)

  2. 偏向锁有开销:撤销偏向锁需要STW

  3. 自旋消耗CPU:过度自旋浪费CPU资源

面试常见问题

Q1:为什么要有锁升级?

A:为了在无竞争或低竞争时减少锁开销,在高竞争时保证线程安全,实现性能与安全的平衡

Q2:偏向锁被撤销的场景?

A:

  1. 调用对象的 hashCode() 方法(无锁状态才有 hashCode)

  2. 其他线程尝试获取锁

  3. 调用 wait()/notify() 方法

  4. 批量撤销/重偏向机制触发

Q3:重量级锁可以降级吗?

A:理论上可以,但条件苛刻:

  1. 只有在全局安全点(Safe Point)才可能降级

  2. 需要没有线程在等待锁

  3. 实际生产中很少见到

Q4:JDK 15为什么默认禁用偏向锁?

A:因为现代应用大多是多线程竞争场景,偏向锁的维护和撤销开销可能大于收益,且与现代垃圾收集器(ZGC/Shenandoah)的兼容性问题。

实际开发建议

  1. 减少锁粒度:使用细粒度锁(如 ConcurrentHashMap 的分段锁)

  2. 减少锁持有时间:只对必要代码加锁

  3. 避免嵌套锁:防止死锁和锁膨胀

  4. 考虑替代方案:ReentrantLock、StampedLock、并发集合等

  5. 监控锁竞争:使用 JFR、Arthas 等工具分析锁竞争

二、锁优化

synchronized是重量级锁,效率不高。但在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了。jdk1.6对锁的实现引入了大量的优化,如自旋锁、自适应自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

1、自旋锁

避免线程切换带来的开销:

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力
同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁

自旋是为了短时间尽快获取锁

  自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

2、自适应自旋锁

自适应自旋锁是为了确定合理的自旋次数:

  JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

3、锁消除

JVM检测到不可能存在共享数据竞争即不需要同步时,JVM会对这些同步锁进行锁消除,不需要我们处理:

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
  如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffffer的append()方法,Vector的add()方法
public void test(){ 
    Vector<Integer> vector = new Vector<Integer>();
     for(int i = 0 ; i < 10 ; i++){ 
        vector.add(i); 
    }
    System.out.println(vector);
 }    
在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除

4、锁粗化

JVM将多个连续的加锁、解锁操作扩展成一个范围更大的锁,不需要我们处理:

  在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外

 
posted on 2021-02-25 11:46  周文豪  阅读(867)  评论(0)    收藏  举报