• 浅谈自旋锁和JVM对锁的优化


    背景

    先上图

    由此可见,非自旋锁如果拿不到锁会把线程阻塞,直到被唤醒;自旋锁拿不到锁会一直尝试

    为什么要这样?

    好处

    阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。

    在很多场景下,可能我们的同步代码块的内容并不多,所以需要的执行时间也很短,如果我们仅仅为了这点时间就去切换线程状态,那么其实不如让线程不切换状态,而是让它自旋地尝试获取锁,等待其他线程释放锁,有时我只需要稍等一下,就可以避免上下文切换等开销,提高了效率。

    用一句话总结自旋锁的好处,那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。

    AtomicLong的实现

    getAndIncrement方法

    1. public final long getAndIncrement() {
    2.    return unsafe.getAndAddLong(this, valueOffset, 1L);
    3. }
    4. 复制代码
    1. public final long getAndAddLong(Object o, long offset, long delta) {
    2.    long v;
    3.    do {
    4.        v = getLongVolatile(o, offset);
    5.        //如果修改过程中遇到其他线程竞争导致没修改成功,死循环,直到修改成功为止
    6.   } while (!compareAndSwapLong(o, offset, v, v + delta));
    7.    return v;
    8. }
    9. 复制代码

    实验

    1. package com.reflect;
    2. import java.util.concurrent.atomic.AtomicReference;
    3. class ReentrantSpinLock {
    4.    private AtomicReference<Thread> owner = new AtomicReference<>();
    5.    private int count = 0;
    6.    public void lock() {
    7.        Thread t = Thread.currentThread();
    8.        if (t == owner.get()) {
    9.            ++count;
    10.            return;
    11.       }
    12.        while (!owner.compareAndSet(null, t)) {
    13.            System.out.println("自旋了");
    14.       }
    15.   }
    16.    public void unlock() {
    17.        Thread t = Thread.currentThread();
    18.        if (t == owner.get()) {
    19.            if (count > 0) {
    20.                --count;
    21.           } else {
    22.                owner.set(null);
    23.           }
    24.       }
    25.   }
    26.    public static void main(String[] args) {
    27.        ReentrantSpinLock spinLock = new ReentrantSpinLock();
    28.        Runnable runnable = new Runnable() {
    29.            @Override
    30.            public void run() {
    31.                System.out.println(Thread.currentThread().getName() + "开始尝试获 取自旋锁");
    32.                spinLock.lock();
    33.                try {
    34.                    System.out.println(Thread.currentThread().getName() + "获取到 了自旋锁");
    35.                    Thread.sleep(4000);
    36.               } catch (InterruptedException e) {
    37.                    e.printStackTrace();
    38.               } finally {
    39.                    spinLock.unlock();
    40.                    System.out.println(Thread.currentThread().getName() + "释放了 了自旋锁");
    41.               }
    42.           }
    43.       };
    44.        Thread thread1 = new Thread(runnable);
    45.        Thread thread2 = new Thread(runnable);
    46.        thread1.start();
    47.        thread2.start();
    48.   }
    49. }
    50. 复制代码

    很多"自旋了",说明自旋期间CPU依然在不停运转

    缺点

    虽然避免了线程切换的开销,但是在避免线程切换开销的同时带来新的开销:不停尝试获取锁,如果这个锁一直不能被释放那么这种尝试知识无用的尝试,浪费处理器资源,就是说一开始自旋锁开销低于线程切换,但是随着时间增加,这种开销后期甚至超过线程切换的开销,得不偿失

    适用场景

    • 并发不是特别高的场景
    • 临界区比较短小的情况,利用避免线程切换提高效率

    如果临界区很大,线程拿到锁很久才释放,那自旋会一直占用CPU但无法拿到锁,浪费资源

    JVM对锁做了哪些优化?

    相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。有了这些优化措施后,synchronized 锁的性能得到了大幅提高,下面我们分别介绍这些具体的优化。

    自适应的自旋锁

    在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。自旋的持续时间是变化的,自旋锁变 “聪明” 了。比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。

    锁消除

    1. public class Person {
    2.    private String name;
    3.    private int age;
    4.    public Person(String personName, int personAge) {
    5.        name = personName;
    6.        age = personAge;
    7.   }
    8.    public Person(Person p) {
    9.        this(p.getName(), p.getAge());
    10.   }
    11.    public String getName() {
    12.        return name;
    13.   }
    14.    public int getAge() {
    15.        return age;
    16.   }
    17. }
    18. class Employee {
    19.    private Person person;
    20.    public Person getPerson() {
    21.        return new Person(person);
    22.   }
    23.    public void printEmployeeDetail(Employee emp) {
    24.        Person person = emp.getPerson();
    25.        System.out.println("Employee's name: " + person.getName() + "; age: " + person.getAge());
    26.   }
    27. }
    28. 复制代码

    在这段代码中,我们看到下方的 Employee 类中的 getPerson() 方法,这个方法中使用了类里面的person 对象,并且新建一个和它属性完全相同的新的 person 对象,目的是防止方法调用者修改原来的 person 对象。但是在这个例子中,其实是没有任何必要新建对象的,因为我们的printEmployeeDetail() 方法没有对这个对象做出任何的修改,仅仅是打印,既然如此,我们其实可以直接打印最开始的 person 对象,而无须新建一个新的。

    如果编译器可以确定最开始的 person 对象不会被修改的话,它可能会优化并且消除这个新建 person的过程。根据这样的思想,接下来我们就来举一个锁消除的例子,,经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。

    例如,我们的 StringBuffffer 的 append 方法如下所示:

    1. @Override
    2. public synchronized StringBuffer append(Object obj) {
    3.    toStringCache = null;
    4.    super.append(String.valueOf(obj));
    5.    return this;
    6. }
    7. 复制代码

    从代码中可以看出,这个方法是被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。

    但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率。

    锁粗化

    释放了锁,紧接着什么都没做,又重新获取锁

    1. public void lockCoarsening() {
    2.    synchronized (this) {
    3.   }
    4.    synchronized (this) {
    5.   }
    6.    synchronized (this) {
    7.   }
    8. }
    9. 复制代码

    那么其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。

    不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。如果在循环中我们也这样做,如代码所示:

    1. for (int i = 0; i < 1000; i++) {
    2.    synchronized (this) {
    3.   }
    4. }
    5. 复制代码

    也就是我们在第一次循环的开始,就开始扩大同步区域并持有锁,直到最后一次循环结束,才结束同步代码块释放锁的话,这就会导致其他线程长时间无法获得锁。所以,这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。

    锁粗化功能是默认打开的,用 -XX:-EliminateLocks可以关闭该功能

    偏向锁/ 轻量级锁 / 重量级锁

    这三种锁是特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态

    • 偏向锁

    对于偏向锁而言,它的思想是如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只要打个标记就行了。一个对象在被初始化后,如果还没有任何线程来获取它的锁时,它就是可偏向的,当有第一个线程来访问它尝试获取锁的时候,它就记录下来这个线程,如果后面尝试获取锁的线程正是这个偏向锁的拥有者,就可以直接获取锁,开销很小。

    • 轻量级锁

    JVM 的开发者发现在很多情况下,synchronized 中的代码块是被多个线程交替执行的,也就是说,并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决。这种情况下,重量级锁是没必要的。轻量级锁指当锁原来是偏向锁的时候,被另一个线程所访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的方式尝试获取锁,不会阻塞

    • 重量级锁

    这种锁利用操作系统的同步机制实现,所以开销比较大。当多个线程直接有实际竞争,并且锁竞争时间比较长的时候,此时偏向锁和轻量级锁都不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

    锁升级

    偏向锁性能最好,避免了 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。


     

     

  • 相关阅读:
    【Nginx25】Nginx学习:连接限制和请求限制
    JWT token
    从零开始搭建oj(ubuntu)
    kafka
    vue项目启动成功浏览器不显示
    辅助知识-第2 章 项目合同管理
    Cenots系统救援
    springboot中Configuration注解和Component注解功能的区别和联系?
    redis6.2(三)Redis事务操作、Redis持久化(RDB、AOF)
    数据结构之二叉搜索树
  • 原文地址:https://blog.csdn.net/java1527/article/details/126990216