深入synchronized底层原理

目录

一、java对象头

二、synchronized底层原理

三、synchronized底层原理进阶

3.1轻量级锁

3.2锁膨张

3.3自旋优化

3.4偏向锁 

四、总结


一、java对象头

在深入了解synchronized底层原理前我们首先要对Java中的对象有一个大体的了解,因为通过synchronized关键字加锁就是给某个对象加的,所以对对象有个大体的了解我们才能更好的深入理解synchronized。在程序中我们new出来的一个对象在内存中通常来说由两部分组成,一个是对象头,一个是对象体,对象体中存放的就是这个对象的一些属于这个对象的成员变量等,我们重点看一下对象头的部分。

普通对象:

普通对象的头部一般由两部分:MarkWordKlassWord,每部分占用4个字节,所以普通对象的对象头一般占会用8个字节的空间,如下图:

                                  

我们知道每个对象都有属于它的类型,比如 Student s = new Student();s这个对象的类型就是Student,那对象s是怎么知道自己是Student类型的呢?就是通对象头中的KlassWord这部分,它是一个地址,指向的就是这个对象所属的类对象。对象头中的另一个部分就是MarkWord(这是我们要重点关注的结构),它的结构由四部分组成:

    第一部分是这个对象的hashcode值,平常在程序中通过s.getHashcode()拿到的就是这个值;

    第二部分是age分代年龄(垃圾回收时会用到);

    第三部分是偏向锁标记(下文说);

    最后一部分是此对象目前的加锁状态(下文说)。

上面所说的这四个状态是对象处在正常状态normal时候的结构,我在下图中用红色小框画了出来,当对象处在其他状态时都会有不同的结构,下图中小红框下面的四个状态就是对应不同状态下的其他结构形式。

                          

数组对象:

数组对象的头对象比普通对象多了一个array length,它表示的是整个数组的长度,如下图

                              

以上就是对象头的大体介绍,有了上面这些知识点的铺垫,接下来的东西理解起来就会省力一些。

二、synchronized底层原理

要了解synchronized的底层原理,就需要先知道一个概念Monitor,它是操作系统层面的一个概念(操作系统中的东西,不在jdk中),被翻译为“监视器”或“管程”。我们在“多线程环境下”的程序中通过synchronized关键字给一个对象加锁的时候(重量级锁),就会用到这个Monitor,这里你可以简单的理解为Monitor就是重量级锁。在Monitor的内部有三个结构分别是Owner、EntryList和WaitSet。Owner表示这个Monitor的所有者,即这个Monitor归那个线程所有,EntryList是一个阻塞队列,里面放的是被阻塞的线程,WaitSet中存放的是处在waiting状态的线程。关于这些东西的具体用处,我会在下面结合具体案例做进一步解释,现在只需脑子里有相关的概念即可。

                           

如上图所示,现在有个线程Thread-2要这行途中synchronized部分的代码:

1.首先它会尝试把图中obj这个锁对象和操作系统提供的Monitor相关联,那么怎么关联呢?还记得我们在上文中提到的“对象头”概念吗?对象头中有一部分叫做markword,它在对象处于正常状态下由四部分组成,但现在这个obj对象已经不是正常状态了,通过调用代码synchronized(obj)后它现在是处于一个锁对象状态(重量级锁),而在此状态下的markword结构就变成了如下图所示的结构,由两部分组成,前面占30位的ptr_to_heavyweight_monitor和后面占2位的加锁状态,而前面这个30位的ptr_to_heavyweight_monitor就是存放的Monitor的地址,这样obj对象就和Monitor关联了起来。并且后两位表示加锁状态的字段也由正常状态下的01变为了现在加锁状态的10,如下图:

                   

2.在成功建立了obj和Monitor的关联后,Monitor中的Owner就成了Thread-2,表示Monitor的所有者就是Thread-2了。

3.此时如果另外一个线程Thread-1也运行到了途中synchronized(obj)部分,同上面的a步骤类似,Thread-1会检查obj是否和Monitor做了关联,如果已经做了关联,之后它会再进一步去看一下monitor的owner是否已经属于其他线程,这个时候Thread-1发现owner被Thread-2占用了,它就会进入到Monitor中的EntryList,等待Thread-2执行完成后,把Owner释放出来。

4.同样的道理,如果其他线程来了,也会这行上面Thread-1走过的那一整套流程。

5.当Thread-2执行完临界区的代码以后,就会把owner释放出来,这个时候EntryList中的线程就会被唤醒,之后这些线程竞争,胜出者占用owner,成为monitor的新主人,然后执行属于自己线程中的代码。

这里有两点需要注意的地方:

  1. 多个线程的锁对象必须是同一个obj,这样才回去关联同一个Monitor,也才会起到多线程互斥的效果,如果synchronized修饰的不是同一个对象,那么关联的monitor也就不是同一个,也就达不到互斥的效果。
  2. 只有给对象加了synchronized(像synchronized(obj)这样)修饰符才会遵从上述规则。

以上就是多线程竞争情况下给对象通过synchronized加锁时的内部原理,注意这里说的是多个线程同一时间存在竞争的情况,还有一些其他情况在下文会接着讲,他们的原理会有些许不同。

三、synchronized底层原理进阶

3.1轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,语法仍然是 加synchronized关键字。同第二节中做对比,假如程序中同时开启了两个线程,如果我们不做特殊处理那么这两个线程是存在竞争关系的,也就是说在某一时刻即可能是线程1在执行也有可能是线程2在执行,到底谁在执行关键是看谁挣到了CPU的执行权,这种情况下我们通过synchronized给对象加锁其实加的就是重量级锁(Minotor);但如果我们给程序做了一些控制,规定白天线程一去执行,晚上线程二去执行,那么在同一时刻它量是不存在竞争关系的,这个时候我们通过synchronized给对象加锁加的就是轻量级锁。程序的写法是相同的,都是通过synchronized去加锁,但JVM在内部会去判断程序中的线程是否存在竞争关系,进而决定给锁对象加那种类型的锁,如果程序中只有一个线程那就更不存在竞争关系了。现在假设有两个方法同步块,利用同一个对象加锁,代码如下:

static final Object obj = new Object();
    public static void method1() {
        synchronized( obj ) {
            // 同步块 A
            method2();
        }
    }
    public static void method2() {
        synchronized( obj ) {
            // 同步块 B
        }
    }

我们看一下这段代码在运行的过程中都经历了些什么,假如现在线程Thread-0执行了上面的代码。

第一步:

                       

如上图,当它执行到method1()方法的时候,会在属于Thread-0的栈空间中产生一个栈帧,当运行到synchronized(obj)的时候就会在属于method1的栈帧中生成一个锁记录对象LockRecord,这个LockRecord中有两部分组成,一个是Object reference,它存放的就是锁对象obj的地址,通过它就可以找到锁对象obj;另一个是图中所示的“lock record 地址 00”,它的结构就是我们在第一节中提到的Java对象头中的MarkWord,此时它的状态是“轻量级锁”状态,如下图所示,同时如上图所示此时锁对象obj的对象头中的Mark Word的状态是正常的未加锁的normal状态:        

锁记录中Mark Word的状态
锁记录中Mark Word的状态

 

锁对象obj中Mark Word的状态

第二步:

                        

如上图,让锁记录中的Object reference指向锁对象obj,并尝试用cas交换锁对象obj的MarkWord和锁记录MarkWord的值,即将锁对象obj的MarkWord值“ptr_to_lock_record | 00”存入锁记录中,将锁记录中MarkWord的值“hashcode | age| biased_lock | 01”锁对象中。ptr_to_lock_record中存放的就是锁记录LockRecordd的地址,通过它可以找到锁记录(线程)在哪。

第三步:

                             

进行替换,这有两种情况:

1.如果 cas 替换成功,锁对象obj的对象头中就存储了 “锁记录地址|状态 00” ,表示由线程Thread-0给对象加了锁,如上图所示。

2.如果 cas 交换失败,有两种情况:

    2.1如果是其它线程已经持有了该 obj 的轻量级锁,这时表明此时有竞争存在,进入锁膨胀过程(下文讲锁膨胀的具体内容)

    2.2如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数

上面提到的第二点自己执行了 synchronized 锁重入其实对应了我们上面代码中线程Thread-0在方法method1()中又调用了method2(),并且在method2()中又进一步通过synchronized(obj)对obj进一步加锁(锁重入)。当调用了method2()的时候,Thread-0又会在属于自己的栈空间中产生一个栈帧,并且这个栈帧中也会有一个锁记录对象Lock Record。同Thead-0第一次加锁一样,这个新的栈帧中的Lock Record中的Object reference也会指向obj对象,同时也尝试把自己的Mark Word和obj中的MarkWord做交换。但是此时锁对象obj的MarkWord已经在之前和同属于Thread-0的另一个LockRecord做过了交换,即此时锁对象obj的MarkWord为“ptr_to_lock_record | 00”,它的状态是以00结尾的,表示已经加了轻量级锁,所以这个cas交换就会失败(只有锁对象obj中MarkWord的状态为01,即未加任何锁的正常状态才可以和锁记录LockRecord中的MarkWord做交换)。但是这种失败没有关系,因为它通过锁对象的“ptr_to_lock_record | 00”得知这个锁其实就是自己所属的Thread-0线程加的,此时它就会把自己的MarkWord置为null,整个流程如下图所示:

                             

第四步:

                              

如上图,当method2()执行完以后,在退出当前synchronized 代码块(解锁时)时如果发现有取值为 null 的锁记录,表示有重入,这时重置锁记录,重入计数减一。

第五步:当method1()执行完以后,在退出当前synchronized 代码块(解锁时)时发现当前锁记录的值不为null,这个时候就会通过cas交换将锁记录中MarkWord的值恢复给锁对象obj的对象头。

    1.如果恢复成功,则解锁成功,Thread-0把锁释放掉,供其他线程再来使用;

    2.如果没有恢复成功,说明轻量级锁进行了“锁膨胀“,已经升级为重量级锁,这个时候就需要进入重量级锁的解锁流程了。

3.2锁膨张

如果一个线程在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时这个线程就需要进行锁膨胀,将轻量级锁变为重量级锁。像上面所讲的,如果这时候来了个Thread-1也想为锁对象obj加锁,但是它发现已经有线程Thread-0为obj加了轻量级的锁,这个时候Thread-1再给obj加轻量级锁就不会成功,它必须进行锁膨胀。具体流程如下:

1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

                     

2.这时 Thread-1 加轻量级锁失败,进入锁膨胀流程

    2.1即为 Object 对象申请 Monitor 锁,让obj指向重量级锁地址。这时obj对象头中的MarkWord会由原来的“ptr_to_lock_record | 00”(指向锁记录地址|00)改为“指向Monitor的地址|10”,具体结构如下:

            

    2.2然后自己(Thread-1)进入 Monitor 的 EntryList阻塞队列中, 同时Moniter中的owner指向的是Thread-0:

                           

3.当 Thread-0 退出synchronized同步块解锁,使用 cas 将 Mark Word 的值恢复给obj对象头时,发现obj中的Mark Word已经被修改(锁膨张),恢复失败。这时就会进入重量级解锁流程,即按照obj对象头中的 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 处于阻塞状态的Thread-1.

3.3自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。这话听起来你可能比较懵逼,我们还拿上面Thread-0和Thread-1做例子来说明一下,当线程1进行锁膨胀将obj升级为重量级锁后,monitor中的owner依然是线程0,线程1并没有执行权限,这个时候他就会切换为阻塞状态进入阻塞队列EntryList,我们知道线程进入阻塞状态的同时需要进行上下文的切换,而上下文的切换对系统的资源消耗比较大,这个时候我们就可以通过“自旋优化”来避免让线程进入阻塞状态,进而避免线程上下文的切换造成的资源消耗。自旋优化就是让线程一直处于自旋的状态,在自旋的过程中不断的去获取锁对象,如果发现线0释放了锁,那么线程1就可以直接拿到锁对象进而执行同步代码块。当然自旋的时候肯定是需要使用cpu来进行的,所以自旋优化适合用于多核的cpu,只有在多核cpu下自旋优化才有意义。如果是单核cpu,人家Thread-0正在执行自己的代码呢,你Thread-1进行自旋就会抢夺cpu,导致线程0的执行中断,这样就没有意义了。以上讲的是自旋成功,当线程1自旋很长时间都没有拿到锁对象的时候,它还是会进入阻塞状态进而被丢进EntryList中。

3.4偏向锁 

通过上面的讲解我们对轻量级锁有了一定认识,我们知道轻量级锁在没有竞争时(就自己一个线程),每次重入仍然需要执行 CAS 操作(检查)。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。我们举个栗子再来体会一下上面这段话,看下面代码:

static final Object obj = new Object();
    public static void m1() {
        synchronized (obj) {// 同步块 A
            m2();
        }
    }

    public static void m2() {
        synchronized (obj) {// 同步块 B
            m3();
        }
    }

    public static void m3() {
        synchronized (obj) {
        }// 同步块 C
    }

当一个线程(没有其他竞争线程)调用了m1()方法后,它会进行加锁(轻量级锁)操作,m1()中又调用了m2(),在m2()中也会进行加锁操作(重入锁),m2()中调用了m3(),m3()中同样还会进行加锁操作(重入锁)。像我们在上面讲的那样,重入锁的时候每个方法对应的栈帧中的锁记录每次还是会通过cas操作和锁对象进行markword的交换,只不过交换不会成功会将锁记录中的markword置为null。而cas操作对系统的性能也是有一定的影响的,所以为了避免同一线程重入锁的时候频繁进行cas操作,就引入了“偏向锁”。偏向锁在第一次使用 CAS做mark word交换的时候将线程 ID 设置到锁对象的 Mark Word 头中,之后同一线程下的其他方法在遇到类似synchronized(obj)代码的时候,发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。看图理解一下:

                                                                  没有引入偏向锁时:

                              

                                                                         引入偏向锁:

                              

我们看一下对象头中的markword字段,与正常状态相比,启用了偏向锁的mark word中biased_lock为变为1,表示开启了偏向锁,同时在最开始有了thread部分(覆盖了原来的哈希值部分),专门用来记录线程的ID:

                            

关于偏向锁有以下几点需要注意:

1.如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0;

2.偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数-XX:BiasedLockingStartupDelay=0 来禁用延迟;

3. 代码运行时在添加 VM 参数  -XX:-UseBiasedLocking 禁用偏向锁;

4.处于偏向锁的对象解锁后,线程 id 仍存储于对象头中。

四、总结

上面说了这么多你可能已经早就懵逼了,什么重量级锁、轻量级锁、偏行锁的,其实这篇博文本来是自己总结给自己看的,但既然写出来了,就尽量让偶然看到的读者也能尽量看懂,在这里关于这几个锁再简单总结一下,就是我们通常使用synchronized进行加锁的时候,JVM会根据当前程序中线程的状况来决定到底加什么锁,不同情况加的锁就不同,而不是一改而论只要用了synchronized那就加的是同一种锁,具体的情况如下:

  • 重量级锁:当程序中存在多个(2个及以上)线程的时候,并且这些线程存在竞争关系,每个线程都会抢夺cpu时间片来执行自己的代码,这个时候加的就是重量级锁。我们程序中的多线程一般都是这种情况。
  • 轻量级锁:虽然程序中有多个线程,但他们之间不存在竞争关系,比如我们通过一些控制方法让线程一白天去执行,让线程二晚上去执行,那么这两个线程同一时间就不存在竞争,这个时候加锁就加的是轻量级锁。
  • 偏向锁:当程序中只有一个线程的时候,如果我们还用synchronized关键字,那这个时候假得锁一般都是偏向锁。

以上,有什么不正确的地方大家指出来一起讨论。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章