本文摘自 Java并发编程实战:03 | 互斥锁(上):解决原子性问题

本质

解决原子性问题的本质是:保证同一时刻只有一个线程执行,并行的问题串行化。

如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

模型

简易模型

😹 互斥锁 - 图1
我们把一段需要互斥执行的代码称为 临界区。线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()。

上面的模型容易让我们忽视两个非常非常重要的点:我们锁的是什么我们保护的又是什么

改进后的锁模型

我们知道在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。

😹 互斥锁 - 图2
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它 创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上 加锁 操作和 解锁 操作。另外,在锁 LR 和受保护资源之间,特地用一条线做了关联,这个关联关系非常重要。

Java 中提供的锁技术:synchronized

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:

  1. class X {
  2. // 修饰非静态方法
  3. synchronized void foo() {
  4. // 临界区
  5. }
  6. // 修饰静态方法
  7. synchronized static void bar() {
  8. // 临界区
  9. }
  10. // 修饰代码块
  11. Object obj = new Object();
  12. void baz() {
  13. synchronized(obj) {
  14. // 临界区
  15. }
  16. }
  17. }

看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁 lock() 和解锁 unlock() 在哪里呢?加锁和解锁操作是 JVM 隐式控制的。如下面的代码:

  1. public class SynchronizedTest {
  2. private static Object object = new Object();
  3. public static void main(String[] args) {
  4. synchronized (object){
  5. System.out.println("get lock");
  6. }
  7. }
  8. }

使用 javap -c 查看其字节码文件
image.png
可以看到 JVM 分别加入了 monitorentermonitorexit 指令。

更详细的底层原理参考该 链接

受保护 资源 之间的关联关系是 N:1 的关系

Synchronized 四种状态

synchronized的锁升级/锁膨胀

  • 无锁

    很好理解,就是不存在竞争,线程没有获取synchronized锁的状态。

  • 偏向锁

    偏向第一个拿到锁的线程,锁会在对象头的Mark Word通过CAS(Compare And Swap)记录获得锁的线程id,同时将Mark Word里的锁状态置为偏向锁,是否为偏向锁的位也置为1,当下一次还是这个线程获取锁时就不需要通过CAS。 如果其他的线程尝试通过CAS获取锁(即想将对象头的Mark Word中的线程ID改成自己的)会获取失败,此时锁由偏向锁升级为轻量级锁。

  • 轻量级锁

    JVM会给线程的栈帧中创建一个锁记录(Lock Record)的空间,将对象头的Mark Word拷贝到Lock Record中,并尝试通过CAS把原对象头的Mark Word中指向锁记录的指针指向当前线程中的锁记录,如果成功,表示线程拿到了锁。如果失败,则进行自旋(自旋锁),自旋超过一定次数时升级为重量级锁,这时该线程会被内核挂起。

  • 自旋锁

    轻量级锁升级为重量级锁之前,线程执行monitorenter指令进入Monitor对象的EntryList队列,此时会通过自旋尝试获得锁,如果自旋次数超过了一定阈值(默认10),才会升级为重量级锁,等待线程被唤起。 线程等待唤起的过程涉及到Linux系统用户态和内核态的切换,这个过程是很消耗资源的,自选锁的引入正是为了解决这个问题,先不让线程立马进入阻塞状态,而是先给个机会自旋等待一下。

  • 重量级锁

    就是通常说的synchronized重量级锁

锁升级

锁升级的顺序为:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁升级的顺序是不可逆的

线程第一次获取锁获时锁的状态为偏向锁

如果下次还是这个线程获取锁,则锁的状态不变;否则会升级为CAS轻量级锁;

如果还有线程竞争获取锁,如果线程获取到了轻量级锁没啥事了;如果没获取到会自旋;

自旋期间获取到了锁没啥事,超过了10次还没获取到锁,锁就升级为重量级的锁,此时如果其他线程没获取到重量级锁,就会被阻塞等待唤起,此时效率就低了。