Synchronized 底层实现 & 锁升级

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情

synchronized是什么

如果某一个资源被多个线程共享,为了避免因为资源抢占导致资源数据错乱,我们需要对线程进行同步,在Java中,synchronized 就是实现线程同步的关键字。

使用 synchronized 关键字,拿到 Java 对象的锁,保护锁定的代码块。JVM 保证同一时刻只有一个线程可以拿到这个 Java 对象的锁,执行对应的代码块,从而达到线程安全。

synchronized的使用

synchronized关键字可以用来修饰三个地方:

  • 修饰实例方法上,锁对象是当前的 this 对象。
  • 修饰代码块,也就是synchronized(object){},锁对象是()中的对象,一般为this或明确的对象。
  • 修饰静态方法上,锁对象是方法区中的类对象,是一个全局锁。
  • 修饰类,即直接作用一个类。

针对synchronized修饰的地方不同,实现的原理不同。

synchronized修饰实例方法

public class SyncTest {
​
    public synchronized void sync(){
        
    }
}
复制代码

通过javap -verbose xxx.class查看反编译结果:

image-20210917192327640

从反编译的结果来看,我们可以看到sync()方法中多了一个标识符。JVM就是根据该ACC_SYNCHRONIZED标识符来实现方法的同步,即:

当方法被执行时,JVM 调用指令会去检查方法上是否设置了ACC_SYNCHRONIZED标识符,如果设置了ACC_SYNCHRONIZED标识符,则会获取锁对象的 monitor 对象,线程执行完方法体后,又会释放锁对象的 monitor对象。在此期间,其他线程无法获得锁对象的 monitor 对象。

synchronized修饰代码块

public class SyncTest {
​
    private static int count;
​
    public SyncTest() {
        count = 0;
    }
​
    public void sync() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
​
​
    public static void main(String[] args) {
        SyncTest s = new SyncTest();
        Thread t0 = new Thread(new Runnable() {
            @Override
            public void run() {
                s.sync();
            }
        });
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                s.sync();
            }
        });
        t0.start();
        t1.start();
    }
}
复制代码

输出:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-1:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
复制代码

很明显,线程 1 要等到线程 0 执行完之后才会开始执行。再去查看字节码信息:

image-20210917193128458

我们可以看到sync()字节码指令中会有两个monitorentermonitorexit指令:

  • monitorenter: 该指令表示获取锁对象的 monitor 对象,这时 monitor 对象中的 count 会加+1,如果 monitor 已经被其他线程所获取,该线程会被阻塞住,直到 count = 0,再重新尝试获取monitor对象。
  • monitorexit: 该指令表示该线程释放锁对象的 monitor 对象,这时monitor对象的count便会-1变成0,其他被阻塞的线程可以重新尝试获取锁对象的monitor对象。

synchronized修饰静态方法

public class SyncTest {
​
    private static int count;
​
    public SyncTest() {
        count = 0;
    }
​
    public synchronized static void sync() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(100);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
​
​
    public static void main(String[] args) {
        SyncTest s0 = new SyncTest();
        SyncTest s1 = new SyncTest();
        Thread t0 = new Thread(new Runnable() {
            @Override
            public void run() {
                s0.sync();
            }
        });
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                s1.sync();
            }
        });
        t0.start();
        t1.start();
    }
}
复制代码

测试结果:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-1:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
复制代码

我们知道静态方法是属于类的而不属于对象的。同样,synchronized 修饰的静态方法锁定的是这个类的所有对象。因此,尽管是s0s1是2个不同的对象,但在t1t2并发执行时却保持了线程同步,就是因为sync()是静态方法,而静态方法是属于类的,所以s0s1相当于用了同一把锁。

再去看看字节码信息:

image-20210917203701163

可以看到跟放在实例方法相同,也是sync()方法上会多一个标识符。可以得出synchronized放在实例方法上和放在静态方法上的实现原理相同,都是ACC_SYNCHRONIZED标识符去实现的。只是它们锁住的对象不同。

synchronized修饰类

public class SyncTest {
​
    private static int count;
​
    public SyncTest() {
        count = 0;
    }
​
    public void sync() {
        synchronized (SyncTest.class) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
​
​
    public static void main(String[] args) {
        SyncTest s0 = new SyncTest();
        SyncTest s1 = new SyncTest();
        Thread t0 = new Thread(new Runnable() {
            @Override
            public void run() {
                s0.sync();
            }
        });
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                s1.sync();
            }
        });
        t0.start();
        t1.start();
    }
}
复制代码

测试结果:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-1:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
复制代码

由结果可知给 class 加锁和给静态方法加锁是一样的,所有对象公用一把锁。

在看看字节码信息:

image-20210917204433767

给类加锁也是通过monitorentermonitorexit指令。其区别在于,给对象加锁是一个对象一把锁,而给类加锁是所有对象共用一把锁

synchronized的同步原理

从上面synchronized放置的位置不同可以得出,synchronized用来修饰方法时,是通过ACC_SYNCHRONIZED标识符来保持线程同步的。而用来修饰代码块时,是通过monitorentermonitorexit指令来完成。

  1. monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
    2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
    3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  2. monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

    注:monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

  3. ACC_SYNCHRONIZE:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致用户态和内核态两个态之间来回切换,对性能有较大影响。

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

而无论synchronized关键字作用在方法上还是对象上:

  • 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。
  • 如果作用在一个静态方法或一个类,则它取得的锁是该类所有的对象同一把锁,即该类的所有对象共用一把锁。 否则就是不同的对象拥有自己的锁。

synchronized关键字用来修饰的位置不同,其实现原理也是不同的。锁住的对象也是不同的。在Java中,每个对象里面隐式的存在一个叫monitor(对象监视器)的对象,这个对象源码是采用C++实现的,那么什么是monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁

锁的状态

尽管在使用synchronized能够帮我们实现线程同步,但同步是要很大的系统开销作为代价的,在Java 6中为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁轻量级锁,在Java中,锁共有4种状态,级别从低到高依次为:无锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

而在对象中,每个对象在对象头中都有自己的锁标识,如下:

image-20210914150807934

因此,了解锁的升级是很有必要的。

下面这张图和解释或许更好理解:

image-20210918144428530

【1】lock: 2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了 lock标记。该标记的值不同,整个 Mark Word表示的含义不同。biased_locklock一起,表达的锁状态含义如上图所示。

【2】biased_lock: 对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lockbiased_lock共同表示对象处于什么锁状态。

【3】age: 4位的 Java对象年龄。在GC中,如果对象在 Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行 GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是**-XX:MaxTenuringThreshold** 选项最大值为15的原因。

【4】identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法 System.identityHashCode() 计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到线程 Monitor中。

【5】thread: 持有偏向锁的线程ID。

【6】epoch: 偏向锁的时间戳。

【7】ptr_to_lock_record: 轻量级锁状态下,指向栈中锁记录的指针。

【8】ptr_to_heavyweight_monitor: 重量级锁状态下,指向对象监视器 Monitor的指针。

来源:Java对象结构详解【MarkWord 与锁的实现原理】

对象头中Mark Word与线程中Lock Record

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是 01,则虚拟机首先在当前线程栈中创建我们称之为锁记录(Lock Record)的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word,整个Mark Word及其拷贝至关重要。

Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。如下图所示为Lock Record的内部结构:

Lock Record 描述
Owner 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
EntryQ 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程;
RcThis 表示blocked或waiting在该monitor record上的所有线程的个数;
Nest 用来实现 重入锁的计数;
HashCode 保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

image-20210920131529042

锁的优化

上面分析的synchronized作用到不同位置,但其底层获取锁的逻辑都是一样的,因此本文讲解的是synchronized代码块的实现,上面也说道了synchronized代码块是由monitorentermonitorexit两个指令实现的。

无锁

一般情况下当我们 new 出来的初始情况就是无锁状态,也就是没有使用 synchronized 的情况。还是通过JOL来分析。

基础类

public class A {
    //没有任何实例数据,因为不需要
}
复制代码

而无锁一般分为两种情况 :

  1. 无锁可偏向(其状态为101)

    JDK 6 之后默认开启偏向锁,但是延时开启,也就是说:

    程序刚启动创建的对象是不会开启偏向锁的,几秒后后创建的对象才会开启偏向锁,但是可以通过参数关闭延迟开启偏向锁XX:BiasedLockingStartupDelay=0

    public class BiasedNoLockTest {
    
        static A a = new A();
    
        public static void main(String[] args) {
            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
    复制代码

    测试结果:

    image-20210917181250501

    此时对象头的结构如下:

    image-20210918145824798

  2. 无锁不可偏向(001)

    在无锁不可偏向的情况下第一个 0 标识偏向标识不可偏向,但是还有一种情况也是 101 这种情况是有锁而且是已经偏向了线程,所以看一把锁(对象)是否有锁不能单纯的看后三位,比如后三位等于 101,他可能是有锁,也有可能是无锁,但是后三位如果是 001 那么肯定是无锁。

自旋锁CAS

通常我们称sychronized锁是一种重量级锁,是因为在互斥状态下,没有得到锁的线程会被挂起阻塞,而挂起线程和恢复线程的操作都需要转入内核态中完成。同时,虚拟机开发团队也注意到,许多应用上的数据锁只会持续很多的一段时间,如果为了这段时间去挂起和恢复线程是不值得的,所以引入了自旋锁。

所以引入自旋锁,何谓自旋锁?

所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

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

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

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

适应性自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

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

偏向锁

在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。

要理解偏向锁并不是看一段文字就能理解的,实际上synchronized和其他锁不同,如AQS是可以在java源码的,而synchronized是一个关键字,是基于c++实现的,我们没办法看他的Java代码,但是我们可以去看它的源码,但此时存在另外一个问题,我会Java但是不会 c 啊,于是这篇文章给我了很大帮助。死磕Synchronized底层实现

JDK1.8 HotSpot_C源码下载(提取码:otxs)

synchronized代码块是由monitorentermonitorexit两个指令实现的,那我们首先要找到monitorenter在 c 中的方法入口。c 的代码我们不用太过细究,大致了解即可,在HotSpot的中有两处地方对monitorenter指令进行解析:一个是在bytecodeInterpreter.cpp#1816 ,另一个是在templateTable_x86_64.cpp#3667。我们这里主要是分析bytecodeInterpreter.cpp#1816的入口。

在这之前我们先看看偏向锁在对象头中的存在。

偏向锁的状态

偏向锁有三种状态:

  • 匿名偏向:这是允许偏向锁的初始状态,其Mark Word中的Thread ID为0,第一个试图获取该对象锁的线程会遇到这种状态,可以通过 CAS 操作修改Thread ID来获取这个对象的锁。
  • 可重偏向:这个状态下 Epoch 是无效的,下一个线程会遇到这种情况,在批量重偏向操作中,所有未被线程持有的对象都会被设置成这个状态。然后在下个线程获取的时候能够重偏向。
  • 已偏向:这个状态最简单,就是被线程持有着,此时Thread ID为其偏向的线程。

一个新建未被任何线程获取的对象Mark Word中的Thread Id为0,是可以偏向但未偏向任何线程,被称为匿名偏向状态

偏向锁加锁

第一次加锁:会走匿名偏向锁的流程,产生一个偏向自己的mark,然后cas替换对象头,成功则加锁,失败则撤销偏向并且升级轻量。

image-20210920160438748

第一次 main 线程对其加锁:

public class BiasedLock {

    public static void main(String[] args) throws InterruptedException {
        //程序刚启动创建的对象是不会开启偏向锁的,几秒后后创建的对象才会开启偏向锁,应该是在是4s之后
        //如果不关闭偏向延时的话,就睡5s
        Thread.sleep(5000);
        Object o = new Object();
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }      
    }
}
复制代码

看对象头的布局:

image-20210918145526109

可以看到是 main 线程来加锁synchronized那么他必然是一个偏向锁,后三位同样还是101,和无锁可偏向的区别是前面的值改了,存了线程 id 和 epoch 等等信息,如下图:

image-20210918145805906

第二次加锁

第二次加锁,即同一线程多次获得。

public class BiasedLock {

    public static void main(String[] args) throws InterruptedException {
        //程序刚启动创建的对象是不会开启偏向锁的,几秒后后创建的对象才会开启偏向锁,应该是在是4s之后
        Thread.sleep(5000);
        Object o = new Object();
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
        //第二次加锁
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}
复制代码

结果发现还是偏向锁

image-20210918154042065

因此,在不存在多线程竞争的情况下,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向线程ID,当在下次该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地检查一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,即确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果指向当前线程,表示线程已经获得了锁,进入步骤 5。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行 5 ;如果竞争失败,执行 4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

偏向锁的撤销

偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。

轻量级锁

引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS竞争锁,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块,即多个线程交替进入同步代码块

轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

public class LightWeightLock {

    public static void main(String[] args) throws InterruptedException {
        //程序刚启动创建的对象是不会开启偏向锁的,几秒后后创建的对象才会开启偏向锁,应该是在是4s之后
        Thread.sleep(5000);
        Object o = new Object();
        synchronized (o) {
            System.out.println("main线程获取:");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
        System.out.println("main线程释放锁之后的锁状态:");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        //第二个线程来申请锁
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (o){
                    System.out.println("第二个线程来申请锁:");
                    System.out.println(ClassLayout.parseInstance(o).toPrintable());
                }
            }
        });
        thread.start();
    }
}
复制代码

输出结果:

image-20210920151123439

轻量级锁加锁

在偏向锁中,偏向锁默认是延时初始化的,延迟的时间通过参数BiasedLockingStartupDelay控制,默认是4000ms。

为什么要开启偏向延时?

因为在JVM启动的时候,它自己启动的代码也有很多地方使用了synchronized关键字,也就是说JVM知道自己的代码不可能是偏向锁,也不存在,而偏向锁在在源码中的设计有很多判断条件,而我明知道自己又不是偏向锁,所以就没必要去做一些没意义的判断。

因此,如果我们在开启偏向延时的情况下去执行:

public class LightWeightLock {

    public static void main(String[] args) throws InterruptedException {
        //程序刚启动创建的对象是不会开启偏向锁的,几秒后后创建的对象才会开启偏向锁,应该是在是4s之后
        //Thread.sleep(5000);
        Object o = new Object();
        synchronized (o) {
            System.out.println("main线程获取:");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
        //第二个线程来申请锁
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("第二个线程来申请之前的锁状态:");
                System.out.println(ClassLayout.parseInstance(o).toPrintable());
                synchronized (o){
                    System.out.println("第二个线程来申请锁:");
                    System.out.println(ClassLayout.parseInstance(o).toPrintable());
                }
            }
        });
        thread.start();
    }
}
复制代码

image-20210920162114317

由上图可以看到,此时main线程获取的锁是轻量锁,然后把它释放,然后第二个线程来获取锁的时候首先在CPU内存中生成一个无锁的 markword 也就是 001

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(001),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间。

    image-20210920170208745

  2. 拷贝对象头中的Mark Word复制到锁记录中。

    image-20210920170430210

  3. 接着CAS 判断当前对象头当中你 的 markword 是不是和第二个线程在CPU内存当中产生的 markword 相等,如果相等,则把对象头当中的 markword 修改成为一根指针指向锁记录, 然后再把对象头当中的 markword 的后两位改成00。

image-20210920172104802

  1. 如果这个CAS操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

轻量级锁撤销

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
  2. 如果替换成功,整个同步过程就完成了,恢复到无锁状态(01)。
  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的” ,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

重量级锁

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。

前面讲到synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到内核态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为重量级锁。

在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL; // 存储Monitor对象
    _owner        = NULL; // 持有当前线程的owner
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}
复制代码

当 monitor 对象被线程持有时,monitor 对象中的 count 就会进行 +1,当线程释放 monitor 对象时,count又会进行 -1 操作。用 count来表示 monitor 对象是否被持有。

ObjectMonitor中有两个队列,_WaitSet_EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时

  1. 首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,进入 _Owner区域并把 monitor 中的owner 变量设置为当前线程,同时monitor中的计数器 count 加 1;
  2. 若线程调用 wait() 方法,将释放当前持有的 monitor,owner变量恢复为null,count 自减 1,同时该线程进入_WaitSet集合中等待被唤醒;
  3. 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

wait和notify的原理

调用wait()方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁。

当其他线程调用notify后,会选择从等待队列中唤醒任意一个线程,而执行完notify()方法以后,并不会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执行完按monitorexit指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了。

wait和notify为什么需要在synchronized里面?

wait() 方法的语义有两个:

  • 一个是释放当前的对象锁
  • 另一个是使得当前线程进入阻塞队列

而这些操作都和监视器是相关的,所以wait()必须要获得一个监视器锁。

而对于notify()来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里,所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。

wait和sleep的区别

相同点:线程的状态相同,都是阻塞状态。

不同点:

  1. wait 是 Object 的方法,任何对象都可以直接调用,sleep是Thread的静态方法。
  2. wait 必须配合 synchronized 关键字一起使用,如果一个对象没有获取到锁直接调用wait会异常,sleep则不需 要。
  3. wait 可以通过 notify 主动唤醒,sleep只能通过打断主动叫醒。
  4. wait 会释放锁,sleep在阻塞的阶段是不会释放锁的。

猜你喜欢

转载自juejin.im/post/7104985436900032526