关于java:再谈synchronized锁升级

28次阅读

共计 10714 个字符,预计需要花费 27 分钟才能阅读完成。

在图文详解 Java 对象内存布局这篇文章中,在钻研对象头时咱们理解了 synchronized 锁降级的过程,因为篇幅无限,对锁降级的过程介绍的比拟简略,本文在上一篇的根底上,来具体钻研一下锁降级的过程以及各个状态下锁的原理。

1 无锁

在上一篇文章中,咱们提到过 jvm 会有 4 秒的偏差锁开启的延迟时间,在这个偏差提早内对象处于为无锁态。如果敞开偏差锁启动提早、或是通过 4 秒且没有线程竞争对象的锁,那么对象会进入 无锁可偏差 状态。

精确来说,无锁可偏差状态应该叫做 匿名偏差 (Anonymously biased) 状态,因为这时对象的 mark word 中后三位曾经是 101,然而threadId 指针局部依然全副为 0,它还没有向任何线程偏差。综上所述,对象在刚被创立时,依据 jvm 的配置对象可能会处于 无锁 匿名偏差 两个状态。

此外,如果在 jvm 的参数中敞开偏差锁,那么直到有线程获取这个锁对象之前,会始终处于无锁不可偏差状态。批改 jvm 启动参数:

-XX:-UseBiasedLocking

提早 5s 后打印对象内存布局:

public static void main(String[] args) throws InterruptedException {User user=new User();
    TimeUnit.SECONDS.sleep(5);
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

能够看到,即便通过肯定的启动延时,对象始终处于 001 无锁不可偏差状态。大家可能会有疑难,在无锁状态下,为什么要存在一个不可偏差状态呢?通过查阅材料失去的解释是:

JVM 外部的代码有很多中央也用到了 synchronized,明确在这些中央存在线程的竞争,如果还须要从偏差状态再逐渐降级,会带来额定的性能损耗,所以 JVM 设置了一个偏差锁的启动提早,来升高性能损耗

也就是说,在无锁不可偏差状态下,如果有线程试图获取锁,那么将跳过降级偏差锁的过程,间接应用轻量级锁。应用代码进行验证:

//-XX:-UseBiasedLocking
public static void main(String[] args) throws InterruptedException {User user=new User();
    synchronized (user){System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

查看后果能够看到,在敞开偏差锁状况下应用 synchronized,锁会间接降级为轻量级锁(00 状态):

在目前的根底上,能够用流程图概括下面的过程:

额定留神一点就是匿名偏差状态下,如果调用零碎的 hashCode() 办法,会使对象回到无锁态,并在 markword 中写入hashCode。并且在这个状态下,如果有线程尝试获取锁,会间接从无锁降级到轻量级锁,不会再降级为偏差锁。

2 偏差锁

2.1 偏差锁原理

匿名偏差状态是偏差锁的初始状态,在这个状态下第一个试图获取该对象的锁的线程,会应用 CAS 操作(汇编命令 CMPXCHG)尝试将本人的threadID 写入对象头的 mark word 中,使匿名偏差状态降级为 已偏差 (Biased)的偏差锁状态。在已偏差状态下,线程指针threadID 非空,且偏差锁的工夫戳 epoch 为有效值。

如果之后有线程再次尝试获取锁时,须要查看 mark word 中存储的 threadID 是否与本人雷同即可,如果雷同那么示意以后线程曾经取得了对象的锁,不须要再应用 CAS 操作来进行加锁。

如果 mark word 中存储的 threadID 与以后线程不同,那么将执行 CAS 操作,试图将以后线程的 ID 替换 mark word 中的threadID。只有当对象处于上面两种状态中时,才能够执行胜利:

  • 对象处于匿名偏差状态
  • 对象处于 可重偏差 (Rebiasable)状态,新线程可应用 CAS 将threadID 指向本人

如果对象不处于下面两个状态,阐明锁存在线程竞争,在 CAS 替换失败后会执行 偏差锁撤销 操作。偏差锁的撤销须要期待全局平安点Safe Point(平安点是 jvm 为了保障在垃圾回收的过程中援用关系不会发生变化设置的平安状态,在这个状态上会暂停所有线程工作),在这个平安点会挂起取得偏差锁的线程。

在暂停线程后,会通过遍历以后 jvm 的所有线程的形式,查看持有偏差锁的线程状态是否存活:

  • 如果线程还存活,且线程正在执行同步代码块中的代码,则降级为轻量级锁
  • 如果持有偏差锁的线程未存活,或者持有偏差锁的线程未在执行同步代码块中的代码,则进行校验是否容许重偏差:

    • 不容许重偏差,则撤销偏差锁,将 mark word 降级为轻量级锁,进行 CAS 竞争锁
    • 容许重偏差,设置为匿名偏差锁状态,CAS 将偏差锁从新指向新线程

实现下面的操作后,唤醒暂停的线程,从平安点继续执行代码。能够应用流程图总结下面的过程:

2.2 偏差锁降级

在下面的过程中,咱们曾经晓得了匿名偏差状态能够变为无锁态或降级为偏差锁,接下来看一下偏差锁的其余状态的扭转

  • 偏差锁降级为轻量级锁
public static void main(String[] args) throws InterruptedException {User user=new User();
    synchronized (user){System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
    Thread thread = new Thread(() -> {synchronized (user) {System.out.println("--THREAD--:"+ClassLayout.parseInstance(user).toPrintable());
        }
    });
    thread.start();
    thread.join();
    System.out.println("--END--:"+ClassLayout.parseInstance(user).toPrintable());
}

查看内存布局,偏差锁降级为轻量级锁,在执行实现同步代码后开释锁,变为无锁不可偏差状态:

  • 偏差锁降级为重量级锁
public static void main(String[] args) throws InterruptedException {User user=new User();
    Thread thread = new Thread(() -> {synchronized (user) {System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
            try {user.wait(2000);
            } catch (InterruptedException e) {e.printStackTrace();
            }
            System.out.println("--THREAD END--:" + ClassLayout.parseInstance(user).toPrintable());
        }
    });
    thread.start();
    thread.join();
    TimeUnit.SECONDS.sleep(3);
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

查看内存布局,能够看到在调用了对象的 wait() 办法后,间接从偏差锁升级成了重量级锁,并在锁开释后变为无锁态:

这里是因为 wait() 办法调用过程中依赖于重量级锁中与对象关联的 monitor,在调用wait() 办法后 monitor 会把线程变为 WAITING 状态,所以才会强制降级为重量级锁。除此之外,调用 hashCode 办法时也会使偏差锁间接降级为重量级锁。

在下面剖析的根底上,再加上咱们上一篇中讲到的轻量级锁降级到重量级锁的常识,就能够对下面的流程图进行欠缺了:

2.3 批量重偏差

在未禁用偏差锁的状况下,当一个线程建设了大量对象,并且对它们执行完同步操作解锁后,所有对象处于偏差锁状态,此时若再来另一个线程也尝试获取这些对象的锁,就会导偏差锁的 批量重偏差(Bulk Rebias)。当触发批量重偏差后,第一个线程完结同步操作后的锁对象当再被同步拜访时会被重置为可重偏差状态,以便容许疾速重偏差,这样可能缩小撤销偏差锁再降级为轻量级锁的性能耗费。

首先看一下和偏差锁无关的参数,批改 jvm 启动参数,应用上面的命令能够在我的项目启动时打印 jvm 的默认参数值:

-XX:+PrintFlagsFinal

须要关注的属性有上面 3 个:

  • BiasedLockingBulkRebiasThreshold:偏差锁批量重偏差阈值,默认为 20 次
  • BiasedLockingBulkRevokeThreshold:偏差锁批量撤销阈值,默认为 40 次
  • BiasedLockingDecayTime:重置计数的延迟时间,默认值为 25000 毫秒(即 25 秒)

批量重偏差是以 class 而不是对象为单位的,每个 class 会保护一个偏差锁的撤销计数器,每当该 class 的对象产生偏差锁的撤销时,该计数器会加一,当这个值达到默认阈值 20 时,jvm 就会认为这个锁对象不再适宜原线程,因而进行批量重偏差。而间隔上次批量重偏差的 25 秒内,如果撤销计数达到 40,就会产生批量撤销,如果超过 25 秒,那么就会重置在 [20, 40) 内的计数。

下面这段实践是不是听下来有些难了解,没关系,咱们先用代码验证批量重偏差的过程:

private static Thread t1,t2;
public static void main(String[] args) throws InterruptedException {TimeUnit.SECONDS.sleep(5);
    List<Object> list = new ArrayList<>();
    for (int i = 0; i < 40; i++) {list.add(new Object());
    }

    t1 = new Thread(() -> {for (int i = 0; i < list.size(); i++) {synchronized (list.get(i)) {}}
        LockSupport.unpark(t2);
    });
    t2 = new Thread(() -> {LockSupport.park();
        for (int i = 0; i < 30; i++) {Object o = list.get(i);
            synchronized (o) {if (i == 18 || i == 19) {System.out.println("THREAD-2 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
                }
            }
        }
    });
    t1.start();
    t2.start();
    t2.join();

    TimeUnit.SECONDS.sleep(3);
    System.out.println("Object19:"+ClassLayout.parseInstance(list.get(18)).toPrintable());
    System.out.println("Object20:"+ClassLayout.parseInstance(list.get(19)).toPrintable());
    System.out.println("Object30:"+ClassLayout.parseInstance(list.get(29)).toPrintable());
    System.out.println("Object31:"+ClassLayout.parseInstance(list.get(30)).toPrintable());
}

剖析下面的代码,当线程 t1 运行完结后,数组中所有对象的锁都偏差 t1,而后t1 唤醒被挂起的线程 t2,线程t2 尝试获取前 30 个对象的锁。咱们打印线程 t2 获取到的第 19 和第 20 个对象的锁状态:

线程 t2 在拜访前 19 个对象时对象的偏差锁会降级到轻量级锁,在拜访后 11 个对象(下标 19-29)时,因为偏差锁撤销次数达到了 20,会触发批量重偏差,将锁的状态变为偏差线程t2。在全副线程完结后,再次查看第 19、20、30、31 个对象锁的状态:

线程 t2 完结后,第 1 -19 的对象开释轻量级锁变为无锁不可偏差状态,第 20-30 的对象状态为偏差锁、但从偏差 t1 改为偏差 t2,第 31-40 的对象因为没有被线程t2 拜访所以放弃偏差线程 t1 不变。

2.4 批量撤销

在多线程竞争强烈的情况下,应用偏差锁将会导致性能升高,因而产生了批量撤销机制,接下来应用代码进行测试:

private static Thread t1, t2, t3;
public static void main(String[] args) throws InterruptedException {TimeUnit.SECONDS.sleep(5);

    List<Object> list = new ArrayList<>();
    for (int i = 0; i < 40; i++) {list.add(new Object());
    }

    t1 = new Thread(() -> {for (int i = 0; i < list.size(); i++) {synchronized (list.get(i)) {}}
        LockSupport.unpark(t2);
    });
    t2 = new Thread(() -> {LockSupport.park();
        for (int i = 0; i < list.size(); i++) {Object o = list.get(i);
            synchronized (o) {if (i == 18 || i == 19) {System.out.println("THREAD-2 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
                }
            }
        }
        LockSupport.unpark(t3);
    });
    t3 = new Thread(() -> {LockSupport.park();
        for (int i = 0; i < list.size(); i++) {Object o = list.get(i);
            synchronized (o) {System.out.println("THREAD-3 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
            }
        }
    });

    t1.start();
    t2.start();
    t3.start();
    t3.join();
    System.out.println("New:"+ClassLayout.parseInstance(new Object()).toPrintable());
}

对下面的运行流程进行剖析:

  • 线程 t1 中,第 1 -40 的锁对象状态变为偏差锁
  • 线程 t2 中,第 1 -19 的锁对象撤销偏差锁降级为轻量级锁,而后对第 20-40 的对象进行批量重偏差
  • 线程 t3 中,首先间接对第 1 -19 个对象竞争轻量级锁,而从第 20 个对象开始往后的对象不会再次进行批量重偏差,因而第 20-39 的对象进行偏差锁撤销降级为轻量级锁,这时 t2t3线程一共执行了 40 次的锁撤销,触发锁的批量撤销机制,对偏差锁进行撤销置为轻量级锁

看一下在 3 个线程都完结后创立的新对象:

能够看到,创立的新对象为无锁不可偏差状态001,阐明当类触发了批量撤销机制后,jvm 会禁用该类创建对象时的可偏差性,该类新创建的对象全副为无锁不可偏差状态。

2.5 总结

偏差锁通过打消资源无竞争状况下的同步原语,进步了程序在 单线程 下拜访同步资源的运行性能,然而当呈现多个线程竞争时,就会撤销偏差锁、降级为轻量级锁。

如果咱们的利用零碎是高并发、并且代码中同步资源始终是被多线程拜访的,那么撤销偏差锁这一步就显得多余,偏差锁撤销时进入 Safe Point 产生 STW 的景象应该是被竭力防止的,这时应该通过禁用偏差锁来缩小性能上的损耗。

3 轻量级锁

3.1 轻量级锁原理

1、在代码拜访同步资源时,如果锁对象处于无锁不可偏差状态,jvm 首先将在以后线程的栈帧中创立一条锁记录(lock record),用于寄存:

  • displaced mark word(置换标记字):寄存锁对象以后的 mark word 的拷贝
  • owner指针:指向以后的锁对象的指针,在拷贝 mark word 阶段临时不会解决它

2、在拷贝 mark word 实现后,首先会挂起线程,jvm 应用 CAS 操作尝试将对象的 mark word 中的 lock record 指针指向栈帧中的锁记录,并将锁记录中的 owner 指针指向锁对象的mark word

  • 如果 CAS 替换胜利,示意竞争锁对象胜利,则将锁标记位设置成 00,示意对象处于轻量级锁状态,执行同步代码中的操作

  • 如果 CAS 替换失败,则判断以后对象的 mark word 是否指向以后线程的栈帧:

    • 如果是则示意以后线程曾经持有对象的锁,执行的是 synchronized 的锁重入过程,能够间接执行同步代码块
    • 否则阐明该其余线程曾经持有了该对象的锁,如果在自旋肯定次数后仍未取得锁,那么轻量级锁须要降级为重量级锁,将锁标记位变成10,前面期待的线程将会进入阻塞状态

4、轻量级锁的开释同样应用了 CAS 操作,尝试将 displaced mark word 替换回mark word,这时须要查看锁对象的mark wordlock record指针是否指向以后线程的锁记录:

  • 如果替换胜利,则示意没有竞争产生,整个同步过程就实现了
  • 如果替换失败,则示意以后锁资源存在竞争,有可能其余线程在这段时间里尝试过获取锁失败,导致本身被挂起,并批改了锁对象的 mark word 降级为重量级锁,最初在执行重量级锁的解锁流程后唤醒被挂起的线程

用流程图对下面的过程进行形容:

3.2 轻量级锁重入

咱们晓得,synchronized是能够锁重入的,在轻量级锁的状况下重入也是依赖于栈上的 lock record 实现的。以上面的代码中 3 次锁重入为例:

synchronized (user){synchronized (user){synchronized (user){//TODO}
    }
}

轻量级锁的每次重入,都会在栈中生成一个lock record,然而保留的数据不同:

  • 首次调配的 lock recorddisplaced mark word 复制了锁对象的 mark wordowner 指针指向锁对象
  • 之后重入时在栈中调配的 lock record 中的 displaced mark wordnull,只存储了指向对象的 owner 指针

轻量级锁中,重入的次数等于该锁对象在栈帧中 lock record 的数量,这个数量隐式地充当了锁重入机制的计数器。这里须要计数的起因是每次解锁都须要对应一次加锁,只有最初解锁次数等于加锁次数时,锁对象才会被真正开释。在开释锁的过程中,如果是重入则删除栈中的lock record,直到没有重入时则应用 CAS 替换锁对象的mark word

3.3 轻量级锁降级

在 jdk1.6 以前,默认轻量级锁自旋次数是 10 次,如果超过这个次数或自旋线程数超过 CPU 核数的一半,就会降级为重量级锁。这时因为如果自旋次数过多,或过多线程进入自旋,会导致耗费过多 cpu 资源,重量级锁状况下线程进入期待队列能够升高 cpu 资源的耗费。自旋次数的值也能够通过 jvm 参数进行批改:

-XX:PreBlockSpin

jdk1.6 当前退出了 自适应自旋锁Adapative Self Spinning),自旋的次数不再固定,由 jvm 本人管制,由前一次在同一个锁上的自旋工夫及锁的拥有者的状态来决定:

  • 对于某个锁对象,如果自旋期待刚刚胜利取得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次胜利,进而容许自旋期待继续绝对更长时间
  • 对于某个锁对象,如果自旋很少胜利取得过锁,那在当前尝试获取这个锁时将可能省略掉自旋过程,间接阻塞线程,避免浪费处理器资源。

上面通过代码验证轻量级锁降级为重量级锁的过程:

public static void main(String[] args) throws InterruptedException {User user = new User();
    System.out.println("--MAIN--:" + ClassLayout.parseInstance(user).toPrintable());
    Thread thread1 = new Thread(() -> {synchronized (user) {System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
            try {TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {e.printStackTrace();
            }
        }
    });
    Thread thread2 = new Thread(() -> {
        try {TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {e.printStackTrace();
        }
        synchronized (user) {System.out.println("--THREAD2--:" + ClassLayout.parseInstance(user).toPrintable());
        }
    });

    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    TimeUnit.SECONDS.sleep(3);
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

在下面的代码中,线程 2 在启动后休眠两秒后再尝试获取锁,确保线程 1 可能先失去锁,在此基础上造成锁对象的资源竞争。查看对象锁状态变动:

在线程 1 持有轻量级锁的状况下,线程 2 尝试获取锁,导致资源竞争,使轻量级锁降级到重量级锁。在两个线程都运行完结后,能够看到对象的状态复原为了无锁不可偏差状态,在下一次线程尝试获取锁时,会间接从轻量级锁状态开始。

下面在最初一次打印前将主线程休眠 3 秒的起因是锁的开释过程须要肯定的工夫,如果在线程执行实现后间接打印对象内存布局,对象可能仍处于重量级锁状态。

3.4 总结

轻量级锁与偏差锁相似,都是 jdk 对于多线程的优化,不同的是轻量级锁是通过 CAS 来防止开销较大的互斥操作,而偏差锁是在无资源竞争的状况下齐全打消同步。

轻量级锁的“轻量”是绝对于重量级锁而言的,它的性能会稍好一些。轻量级锁尝试利用 CAS,在降级为重量级锁之前进行补救,目标是为了缩小多线程进入互斥,当多个线程交替执行同步块时,jvm 应用轻量级锁来保障同步,防止线程切换的开销,不会造成用户态与内核态的切换。然而如果适度自旋,会引起 cpu 资源的节约,这种状况下轻量级锁耗费的资源可能反而会更多。

4 重量级锁

4.1 Monitor

重量级锁是依赖对象外部的 monitor(监视器 / 管程)来实现的,而 monitor 又依赖于操作系统底层的Mutex Lock(互斥锁)实现,这也就是为什么说重量级锁比拟“重”的起因了,操作系统在实现线程之间的切换时,须要从用户态切换到内核态,老本十分高。在学习重量级锁的工作原理前,首先须要理解一下 monitor 中的外围概念:

  • owner:标识领有该 monitor 的线程,初始时和锁被开释后都为 null
  • cxq (ConnectionList):竞争队列,所有竞争锁的线程都会首先被放入这个队列中
  • EntryList:候选者列表,当 owner 解锁时会将 cxq 队列中的线程挪动到该队列中
  • OnDeck:在将线程从 cxq 挪动到 EntryList 时,会指定某个线程为 Ready 状态(即 OnDeck),表明它能够竞争锁,如果竞争胜利那么称为owner 线程,如果失败则放回 EntryList
  • WaitSet:因为调用 wait()wait(time)办法而被阻塞的线程会被放在该队列中
  • count:monitor 的计数器,数值加 1 示意以后对象的锁被一个线程获取,线程开释 monitor 对象时减 1
  • recursions:线程重入次数

用图来示意线程竞争的的过程:

当线程调用 wait() 办法,将开释以后持有的 monitor,将 owner 置为 null,进入 WaitSet 汇合中期待被唤醒。当有线程调用 notify()notifyAll()办法时,也会开释持有的 monitor,并唤醒 WaitSet 的线程从新参加 monitor 的竞争。

4.2 重量级锁原理

当降级为重量级锁的状况下,锁对象的 mark word 中的指针不再指向线程栈中的lock record,而是指向堆中与锁对象关联的 monitor 对象。当多个线程同时拜访同步代码时,这些线程会先尝试获取以后锁对象对应的 monitor 的所有权:

  • 获取胜利,判断以后线程是不是重入,如果是重入那么recursions+1
  • 获取失败,以后线程会被阻塞,期待其余线程解锁后被唤醒,再次竞争锁对象

在重量级锁的状况下,加解锁的过程波及到操作系统的 Mutex Lock 进行互斥操作,线程间的调度和线程的状态变更过程须要在用户态和外围态之间进行切换,会导致耗费大量的 cpu 资源,导致性能升高。

总结

在 jdk1.6 中,引入了偏差锁和轻量级锁,并应用锁降级机制对 synchronized 进行了充沛的优化。其实除锁降级外,还应用了锁打消、锁粗化等优化伎俩,所以对它的意识要脱离“重量级”这一概念,不要再单纯的认为它的性能差了。在某些场景下,synchronized的性能甚至曾经超过了 Lock 同步锁。

只管 java 对 synchronized 做了这些优化,然而在应用过程中,咱们还是要尽量减少锁的竞争,通过减小加锁粒度和缩小同步代码的执行工夫,来升高锁竞争,尽量使锁维持在偏差锁和轻量级锁的级别,防止降级为重量级锁,造成性能的损耗。

最初不得不再提一句,在 java15 中曾经默认禁用了偏差锁,并弃用所有相干的命令行选项,尽管说不确定将来的 LTS 版本会怎么改变,然而理解一下偏差锁的根底也没什么不好的,毕竟你发任你发,我用 java8~

如果文章对您有所帮忙,欢送关注公众号 码农参上

正文完
 0