JAVA高效并发之synchronized关键字和锁优化

多线程编程中,我们往往使用synchronized关键字以及ReentrantLock类来实现线程安全,二者都是基于互斥同步的方式来保障并发安全性,且都可重入。尽管最基本的互斥同步手段是synchronized关键字,它是原生语法层面的互斥锁,而ReentrantLock是API层面的互斥锁;但在JDK1.6之前,synchronized关键字的性能远远不如ReentrantLock。在1.6以及之后的版本中,synchronized关键字加入了很多的优化措施,性能基本与ReentrantLock持平,而且synchronized是原生的,未来可能会得到进一步的优化,所以推荐使用synchronized关键字来进行同步。本篇论文主要讨论synchronized的底层实现原理以及JAVA虚拟机针对synchronized所做的锁优化。

synchronized的底层原理

Java 虚拟机中的互斥同步基于进入和退出管程(Monitor)对象实现, 无论是显式同步(对代码块进行同步)还是隐式同步(对方法进行同步)都是如此。synchronized修饰代码块来实现同步(显示同步)其实是通过两个指令来完成,分别是monitorenter 和 monitorexit 指令。同步方法(隐式同步) 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。所以想要理解synchronized的底层原理先了解Monitor是什么以及理解Monitor与对象的关系,再者我们需要知道synchronized是如何去用,以及用的时候底层原理(分为修饰代码块以及方法的底层实现),最后谈论下为什么说synchronized是一个重量级锁,为什么需要优化。

Monitor

什么是Monitor?Monitor又可被称作管程或者监视器,它实现同步是依赖于底层的操作系统的Mutex Lock来实现的。我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。每一个对象都与Monitor对象相关联。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其数据结构如下:
在这里插入图片描述
Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
Nest:用来实现重入锁的计数。
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
(此部分摘自:Java中synchronized的实现原理与应用

理解对象头与Monitor的关系

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
对象头:对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,被称作"Mark Word",包括哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。第二部分是类型指针,指向给对象所属的类(Java数组的话,对象头还包括了记录数组长度的数据)
实例数据:对象真正存储的有效信息,程序中定义的各种类型的字段内容。
对齐填充:起着占位符的作用,虚拟机的自动内存管理系统要求对象的大小必须是8的倍数,对象头部分满足,但实例数据却不一定满足,此时可通过对齐填充来补全。

这里主要讨论对象头,因为synchronized的锁对象的引用存储在对象头中。jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),synchronized使用的锁是存放在Java对象头里面,具体位置是对象头里面的MarkWord,Mark Word 被设计成为一个非固定的数据结构,MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式,可能值如下所示
在这里插入图片描述
重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(管程/监视器)。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

综上所述,当对象处于重量锁状态,我们常说的锁其实是指的Monitor,常说的锁存储在对象中,其实指的是该对象头的MarkWord中存储着指向Monitor对象的指针。至于如何去获得Monitor以及为什么会争夺失败则会在下面详述。

synchronized的使用场景

synchronized关键字可以用来修饰实例方法、静态方法、代码块。但无论是哪种,其实锁是加到了整个对象上,而不仅仅是其中的一个方法。
修饰实例方法:锁的是当前实例对象
修饰静态方法:–也就是给当前类Class加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类(类名.class)的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,锁两个不同的对象当然不会冲突。
修饰代码块:锁的是Synchronized括号里配置的对象,进入同步代码库前要获得给定对象的锁。

底层原理

这里分别讲述显式同步(同步代码块)以及隐式同步(同步方法)的底层原理。

显式同步

synchronized修饰代码块来实现同步其实是通过两个指令来完成。分别是monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置(二者必须成对存在)。当程序执行monitorenter指令,该线程便开始尝试获取该对象对应的monitor(监视器),当monitor的计数器为0时,表示目前没有线程获得该对象的同步锁,因此可以获得该对象的monitor,成功获取后,monitor计数器变为1(其它线程阻塞,等拥有者释放锁才能获得对象的锁)。当monitorexit 指令被执行,执行线程将释放 monitor 并设置计数器值为 0。其他线程将有机会持有 monitor。

值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

        if(singleinstance==null){
            synchronized(singleInstance.class){
             if(singleinstance==null){
                 singleinstance=new singleInstance();
             }
            }
        }

反编译分析(黄线处)
在这里插入图片描述

隐式同步

当synchronized修饰方法来实现同步时,底层不是使用的monitorenter 和 monitorexit 指令来实现同步,而是通过隐式的同步机制。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,则会去尝试去获取该对象的对应的Monitor,当Monitor的计数器为0,则线程持有Monitor,然后再去执行方法,方法完成之后(无论是否正常完成)释放持有的Monitor。如果一个同步方法执行期间抛出了异常并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
(仔细观察以下示例,出现的是ACC_SYNCHRONIZED 而不是monitorenter 和 monitorexit 指令)

  public synchronized  void decrease() {
        //i--;
        System.out.println("decrease method is executed");
    }
 public synchronized void decrease();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED //注意该标识
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #4                  // String decrease method is executed
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 11: 0
        line 12: 8

synchronized需要优化的原因

JVM中Monitor依赖于底层的操作系统的Mutex Lock来实现同步,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行。Java 的线程是映射到操作系统的原生线程之上的,如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,甚至超过代码本身执行所需要的时间,没经过优化之前的synchronized关键字是一种重量级锁,因为使用它会加锁解锁的耗时长,且会导致线程之间的频繁切换,吞吐量较差。而经过自旋锁等锁优化机制减少加锁解锁、减少线程切换带来了吞吐量的很大提高。

锁优化

JDK1.6以及之后,经过锁优化,锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着线程对对象的竞争加剧,锁会从无锁状态逐渐膨胀到重量级锁,这个过程是单向的,只会从低级到高级(或者回到未锁定状态)。

锁膨胀的过程

初始状态,对象没有被线程的synchronized加锁,此时对象是未锁定、未偏向但是可偏向的对象,对象头后三位标识为101,当锁对象第一次被线程获取,对象进入偏向模式,通过CAS操作把获得锁的线程ID记录在Mark Word中,CAS操作成功之后,该线程获得偏向锁,该线程每次进入该锁相关的同步块时,虚拟机不进行任何同步操作。此时对象是已偏向的、锁定的对象,后三位标识还是为101。当有另外一个线程去尝试获得该对象的锁时(无论是否获得),该对象的锁偏向模式结束,此时撤销偏向,根据对象是否被锁定,转化成未被锁定、不可偏向的对象(001)和被轻量级锁定的对象(00);当对象锁为轻量级锁,当有另外一条线程去争夺该对象的锁时,抢夺者线程会进行自旋等待占有者线程释放锁,若自旋等待成功,则还是轻量级锁,否则轻量级锁膨胀为重量级锁(10),从此之后该对象就只是重量级锁(当所有线程都不争夺锁,该对象回到未锁定状态),尽管只有一个线程在访问,还是重量级锁。
(这个过程中对象头的数据结构在变化,过程如下图)
在这里插入图片描述

偏向锁

偏向锁的目的是在无竞争情况,消除同步操作,提高程序的运行性能。偏向锁偏向第一个获取对象锁的线程,在接下来的执行过程,只要该对象锁没被其它线程获取,则持有对象锁的线程永远不需要同步。当锁对象第一次被线程获取,对象进入偏向模式,通过CAS操作把获得锁的线程ID记录在Mark Word中,CAS操作成功之后,该线程获得偏向锁。当有另外一个线程尝试获取该锁(无论是否成功获取),偏向模式就结束了,会变成未锁定或者轻量级锁状态。偏向锁通过消除同步以此省去了线程每次申请锁消去锁的步骤,可以提高带有同步但无竞争的程序性能,但对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,偏向模式就是多余的,其中的CAS操作以及锁撤销操作会带来时耗,反而降低了性能。偏向锁适用于存在同步但一直只有一个线程在使用的情况。
(只需要在第一次进入偏向模式的时候进行CAS操作即可,之后处于偏向模式,可不进行CAS直接进入代码块)

轻量级锁

轻量级锁的目的是在无竞争的情况下,用CAS操作来替代互斥量的使用,减少传统的重量级锁的使用操作系统互斥量产生性能消耗。

加锁过程:
1、判断对象当前是否是未锁定状态,若是则转到2,否则转到3
2、当对象处于不可偏向时,对象没有被锁定,线程去尝试获取该锁,虚拟机会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝,官方称作Displaced Mark Word,然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,更新成功后,就表明线程拥有了该对象的锁,锁标识位变成00(Mark Word的最后两位),此时对象处于轻量级锁定状态。
3、对象锁定,虚拟机就会检查对象的Mark Word是否指向当前线程的栈帧,若是,则直接进入同步块继续执行,若不是则说明这个对象锁被其它线程抢占。此时抢夺者线程会进行自旋等待占有者线程释放锁,若自旋等待成功,则还是轻量级锁,否则该锁就膨胀位重量级锁,锁标志变成10,此时MarkWord存储的就是指向Monitor的指针,抢夺者线程进入阻塞状态。
释放锁过程:
1、用CAS操作将线程栈中的Displaced Mark Word替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
2、如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
(每次线程去获得锁时,都需要用进行CAS操作,再进入同步块)
在这里插入图片描述
(轻量级锁CAS操作之前堆栈与对象的状态)
在这里插入图片描述
(轻量级锁CAS操作之后堆栈与对象的状态)

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,锁竞争将导致除了互斥量的开销,还发生了额外的CAS操作,导致轻量锁比重量级锁更慢。

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

自旋锁与自适应自旋锁

自旋锁的核心是减少线程切换。当多个线程争夺同一个对象时,一个线程占有对象锁,其它线程得阻塞,当拥有者线程使用完毕,则唤醒某个线程。挂起和恢复线程都需要转入到内核态完成,用户态和内核态的切换等操作很大的降低了系统的并发性能。线程对共享数据(对象)的锁定往往只会持续很短一段时间,因此为了少等这点时间而去挂起、恢复线程并不划算;因此我们可以让请求锁的第一个线程,进行一个自旋(忙循环),来等待锁的释放,这就是自旋锁。(JDK1.4自旋锁出现,但默认关闭,JDK1.6默认开启)

自旋锁目的是为了避免线程切换的开销,但天下没有免费的午餐,自旋锁自旋时需要占用CPU的时间,且多线程执行时,若多个线程自旋,会对CPU的数量有要求。若锁长时间不释放,自旋锁不可能一直自旋,所以自旋超过一定次数仍然没有获得锁,则挂起该线程,自旋次数默认值为10。对象锁被占用的时间短,则自旋等待的效果就非常好,但反之,对象锁长时间得不到释放,则自旋的线程就在白白的消耗CPU资源,带来了额外的浪费。
JDK1.6引入了自适应的自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。比如,在B线程请求对象x锁之前,有线程刚刚自旋成功获得锁,则认为B线程很有可能自旋成功获得该对象锁,给予更多的自旋时间;反之,若对于该对象,很少自旋成功,则减少自旋时间甚至省略自旋过程。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

应用场景:自旋锁往往应用于线程获取轻量级锁失败之后,通过自旋来等待获取轻量级锁,若还是失败则阻塞线程,此时多条线程争夺一个对象锁,该对象锁膨胀为重量级锁。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据是逃逸分析技术。若一段代码中,该堆上的所有对象都不可能发生线程逃逸,不会被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,因此无需进行同步加锁,此时就无视同步直接执行代码。

数据是否逃逸,程序员本身应该十分清楚,那我们自己就可以避免不必要的同步,锁消除优化岂不是多此一举。其实并不是,在JAVA中同步措施十分的多,很多时候会进行隐式加锁,例如JDK1.5之前的String类的+以及vector、StringBuffer等线程安全的容器。

//此处StringBuffer为线程安全的可变字符串类,但由于这个方法中sb对象不会逃逸到方法之外,更不会发生线程逃逸,
//因此此处进行了同步消除
    public String  concatString(String s,String s1){
        StringBuffer sb=new StringBuffer();
        for(int i=0;i<10;i++)
        sb.append(s).append(s1);
        return sb.toString();
    }

锁粗化

原则上,编码时,需要让同步块的作用范围尽可能小,只在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
锁粗化概念比较好理解,如果虚拟机检测到有一串零碎的操作都是对同一个对象就行加锁,就会将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,以避免频繁的加锁解锁。例如上面中的StringBuffer对象的append()操作,当虚拟机检测到一串对sb对象的加锁,就将锁的范围粗化到第一个append()之前,以及最后一个append()之后,只需要一次加锁即可。

总结

1、当对象处于重量锁状态,我们常说的锁其实是指的Monitor,常说的锁存储在对象中,其实指的是该对象头的MarkWord中存储着指向Monitor对象的指针,monitorenter指令 和 ACC_SYNCHRONIZED 标志的作用只是让线程去尝试获取该对象的Monitor。

2、synchronized实现互斥同步其实是依靠底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行。用户态与内核态之间的切换耗能过大,因此synchronized在没优化前称作重量级锁,而轻量级锁没有使用Monitor,而是通过CAS操作来使线程获得对象的锁(Lock Record),消除了不必要的底层同步,在无竞争的情况下,提高了性能,而偏向锁其实相当于没有用到锁(利用了绝大部分锁,在同步周期内都是不存在竞争的原理)

3、注意偏向锁与轻量级锁的区别,偏向锁指的是某对象锁第一次被线程获取,该对象锁进入偏向模式,其实这时是单线程访问数据,十分安全,因此若没有其它线程访问对象数据,就一直是单线程访问,因此不需要同步,消除同步。当另外有别的线程尝试获取锁,偏向线程就结束了,偏向撤销之后就不可恢复到偏向锁,而是转为未锁定或者轻量级锁。偏向锁消除了同步所以没有锁指针,相当于没有用到同步锁,轻量级锁首先得是在不可偏向模式,然后它是用CAS操作替代了互斥量,用Lock Record取代了Monitor,而不是消除了整个同步。偏向锁适用于该对象一直只有一个线程使用的情况(无竞争),而轻量级锁适用于线程交替执行的情况(有竞争但无同时竞争)

4、对象锁在我的理解其实指的是存储在Mark Word中的锁指针,重量锁中,它存储的是Monitor初始地址的指针,轻量级锁中,它存储的是指向锁记录(Lock Record)的指针,而偏向锁,则相当于没有用到锁,对象头中只存储了获得对象的线程ID,因为此时它不需要同步。

5、各种锁优化其实都是应用于特定情况,当不满足该情况,使用该锁优化反而会带来额外的性能损耗。偏向锁适用于大多数对象锁只被一个线程使用(无竞争);轻量级锁适用于大多数对象锁只会被线程交替使用(有竞争,但无同时竞争);自旋锁适用于对象锁不会被线程长时间持有。

知识拓展

1、可重入锁:synchronized和ReentrantLock都是可重入的。即使用synchronized关键字和ReentrantLock类实现同步时,当线程拥有该对象锁,则线程可以随时利用该对象进入同步块,无需再次加锁进入,不会出现把自己锁死的情况。例如类中有两个被synchronized关键字修饰的方法,分别是methodA()和methodB(),方法methodA()中执行methodB方法,若为不可重入锁,则会把自己锁死,而重入锁则不会。

    //检验synchronized的可重入性
    public synchronized  void methodA(){
        methodB();
    }
    public synchronized  void methodB(){
        System.out.println("methodB执行成功");
    }
    @Override
    public void run(){
        methodA();
    }

//执行结果
methodB执行成功

Process finished with exit code 0

2、CAS操作是一种原子操作,称作比较并交换(Compare-and-Swap),CAS指令有三个操作数,分别是内存地址V、旧的预期值A、新值B。CAS执行指令时,仅当V存储的值符合旧的预期值A时,CPU才会用新值B更新V的值,否则不执行更新。但无论是否更新了V的值,都会返回V的旧值。CAS具有原子性,因此我们可以通过循环做CAS操作来保证线程安全。

3、相比synchronized关键字,ReentrantLock增加的高级性能:
1、公平锁–所谓的公平锁就是指当多个线程等待同一个锁时,按时间顺序依次获得锁,先等待的线程先获得锁。ReenTrantLock可以指定是公平锁还是非公平锁(默认非公平)。而synchronized只能是非公平锁。
2、 等待可中断–当持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待,去处理其他事情,通过lock.lockInterruptibly()来实现这个机制。
3、 锁绑定多个条件–ReenTrantLock提供了一个Condition(条件)类,用来实现分组有选择的唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。

(以上内容若有错误,请帮忙指出,万分感谢!若有问题欢迎在评论区讨论。)

发布了14 篇原创文章 · 获赞 3 · 访问量 1485

猜你喜欢

转载自blog.csdn.net/qq_41008202/article/details/104589927