当前位置: 首页 > 科技观察

简单说说Synchronized底层实现原理

时间:2023-03-19 15:57:49 科技观察

1.前言synchronized关键字用于保证只有一个线程可以同时执行它修改的变量或代码块。本文只涉及synchronized的底层实现原理,不涉及synchronized的效率以及如何优化的讨论。2、使用方法(1)锁定静态方法publicclassMain{publicstaticsynchronizedvoidstaticSynPrint(Stringstr){System.out.println(str);}}静态方法不属于任何实例,而是属于这个类。无论类被实例化多少次,静态成员都只有一份。同时无论是使用instance.staticSynPrint方法还是直接类名.staticSynPrint方法,都会进行同步。(2)给静态变量加锁和(1)一样,都是本类的静态成员。(3)synchronized(xxx.class)publicclassMain{publicvoidclassSynPrint(Stringstr){synchronized(Main.class){System.out.println(str);}}}锁定当前类(注意是当前类,不是instanceobject),将作用于该类的所有实例对象,访问Main类中所有同步方法的多个线程需要先同步。(4)synchronized(this)publicclassMain{publicvoidthisSynPrint(Stringstr){synchronized(this){System.out.println(str);}}}this表示实例对象,所以现在当前实例对象被锁住,所以多线程同步了访问不同实例的方法不需要同步。(5)锁定实例方法publicclassMain{publicsynchronizedvoidsynPrint(Stringstr){System.out.println(str);}}不同线程访问同一个实例下的方法,才需要同步。3、实际使用方法之一:单例模式下的doublechecklock。更多类型的单例模式可以参考我的另一篇博文【设计模式】单例模式instance==null){instance=newSingletonDCL();}}}returninstance;}}有几个问题:(1)为什么要测试两个null?本来的想法是直接用synchronized锁住整个getInstance方法,但是这样效率太低了。考虑到实际代码比较复杂,我们应该缩小锁的范围。在单例模式下,你只需要一个单例,newSingletonDCL()只能执行一次。因此,初步考虑采用如下方法:publicstaticSingletonDCLgetInstance(){if(instance==null){synchronized(Singleton.class){//一些耗时的操作instance=newSingletonDCL();}}returninstance;}但是这样,就有了是个问题。线程1判断实例为null,然后拿到锁,执行一个耗时操作,阻塞了一段时间,还没有实例化实例,实例还是null。线程2判断instance为null,尝试获取锁。线程1实例化instance后,释放锁。线程2获得锁后,同样进行实例化操作。线程1和线程2得到两个不同的对象,违反了单例原则。因此,在获取到锁之后,又进行了一次空检查。(2)为什么要用volatile修饰单例变量?volatile和synchronized的区别可以参考我的另一篇文章【JAVA】volatile和synchronized的区别这段代码,instance=newSingletonDCL(),在虚拟机层面,其实分为3条指令:分配内存空间例如,相当于在堆中开辟一块空间来实例化instance,相当于将实例化后的SingletonDCL对象放在上一步开辟的空间上,并将实例变量引用指向第一个的首地址一步打开空间,但是由于虚拟机做的一些优化,可能会导致指令重排序,从1->2->3到1->3->2。这种重排序在单线程下不会有任何问题,但是在多线程的情况下,可能会出现如下问题:线程1获得锁后,执行到instance=newSingletonDCL()阶段。这时,恰好因为虚拟机对指令进行了重新排序,所以执行第一步开辟内存空间,然后执行第三步。实例指向空间的首地址。第二步还没有执行。这时候线程2恰好执行了getInstance方法。最外层判断instance不为null(instance已经指向某个地址,所以不为null),直接返回单例对象,然后线程2在获取单例对象的属性时出现空指针错误!所以使用volatile修饰单例变量,可以避免虚拟机的指令重排序机制可能导致的空指针异常。4.实现原理这里可以分两种情况来讨论:(1)同步语句块}}是用javaMain.java,然后用javap-cMain.class(-c代表反汇编)得到:publicclasscom.yang.testSyn.Main{publicstaticfinaljava.lang.Objectobject;publiccom.yang.testSyn.Main();Code:0:aload_01:invokespecial#1//Methodjava/lang/Object."":()V4:returnpublicvoidprint();Code:0:getstatic#2//Fieldobject:Ljava/lang/Object;3:dup4:astore_15:monitorenter6:getstatic#3//Fieldjava/lang/System.out:Ljava/io/PrintStream;9:ldc#4//String12311:invokevirtual#5//Methodjava/io/PrintStream.println:(Ljava/lang/String;)V14:aload_115:monitorexit16:goto2419:astore_220:aload_121:monitorexit22:aload_223:athrow24:returnExceptiontable:fromtotargettype61619any192219anystatic{};代码:0:new#6//classjava/lang/Object3:dup4:invokeMethod/java#1//Object."":()V7:putstatic#2//字段对象:Ljava/lang/Object;10:return}其中monitorenter和monitorexit出现在print方法的第5行和第15行,这两行中的字节码代表了同步语句块中的内容。当线程执行到monitorenter时,表示即将进入同步语句块。线程首先需要获取Object的对象锁,对象锁在每个java对象的对象头中。对象头中会有一个锁定计数器。当线程查询对象头中的计数器时,发现内容为0时,说明该对象没有被任何线程占用。此时线程可以占用对象,计数器加1。线程占用对象后,即获得了对象的锁,可以执行同步语句块中的方法。这时候如果有其他线程进来,查询对象头,发现计数器不为0,则进入对象的锁等待队列,阻塞直到计数器为0,才继续执行。第一个线程执行到enterexit后,Object的对象锁被释放,此时第二个线程可以继续执行。这里还有几个问题:[1]为什么只有一条monitorenter指令,而有两条monitorexit指令?因为编译器必须保证无论同步代码块中的代码如何结束(正常返回或异常退出),代码中每次调用monitorenter时,都必须执行相应的monitorexit指令。为了确保这一点,编译器会自动生成异常处理程序。这个异常处理器的作用是在同步代码块抛出异常时执行monitorexit。这就是为什么字节码中只有一个monitorenter而有两个monitorexit的原因。当然,这也可以从Exception表中看出。如果字节码中从6(from)到16(to)的偏移量发生了任何类型的异常,就会跳转到第19行(target)。(2)同步方法publicclassMain{publicsynchronizedvoidprint(Stringstr){System.out.println(str);}}使用javap-vMain.class查看-v选项可以显示更详细的内容,比如版本号,类访问权限,constants池的相关信息,是一个很有用的参数。publicclasscom.yang.testSyn.Mainminorversion:0majorversion:52flags:ACC_PUBLIC,ACC_SUPERConstantpool:#1=Methodref#5.#14//java/lang/Object."":()V#2=Fieldref#15.#16//java/lang/System.out:Ljava/io/PrintStream;#3=Methodref#17.#18//java/io/PrintStream.println:(Ljava/lang/String;)V#4=Class#19//com/yang/testSyn/Main#5=Class#20//java/lang/Object#6=Utf8#7=Utf8()V#8=Utf8Code#9=Utf8LineNumberTable#10=Utf8print#11=Utf8(Ljava/lang/String;)V#12=Utf8SourceFile#13=Utf8Main.java#14=NameAndType#6:#7//"":()V#15=Class#21//java/lang/System#16=NameAndType#22:#23//out:Ljava/io/PrintStream;#17=Class#24//java/io/PrintStream#18=NameAndType#25:#11//println:(Ljava/lang/String;)V#19=Utf8com/yang/testSyn/Main#20=Utf8java/lang/Object#21=Utf8java/lang/System#22=Utf8out#23=Utf8Ljava/io/PrintStream;#24=Utf8java/io/PrintStream#25=Utf8println{publiccom.yang.testSyn.Main();描述符:()Vflags:ACC_PUBLICCode:stack=1,locals=1,args_size=10:aload_01:invokespecial#1//Methodjava/lang/Object."":()V4:returnLineNumberTable:line3:0publicsynchronizedvoidprint(java.lang.String);描述符:(Ljava/lang/String;)vflags:ACC_PUBLIC,ACC_SYNCHRONIZEDCode:stack=2,locals=2,args_size=20:getstatic#2//Fieldjava/lang/System.out:Ljava/io/PrintStream;3:aload_14:invokevirtual#3//Methodjava/io/PrintStream.println:(Ljava/lang/String;)V7:returnLineNumberTable:line32:0line33:7}只看最后两个方法,第一个方法是编译后自动生成的默认构造函数,第二个方法是我们同步的方法,可以看到同步方法比默认构造方法多了一个ACC_SYNCHRONIZED标志位。与同步语句块不同,虚拟机不会在字节码层面实现锁同步,而是会先观察方法是否包含ACC_SYNCHRONIZED标志。如果存在,线程将首先尝试获取锁。如果是实例方法,会尝试获取实例锁;如果是静态方法(类方法),会尝试获取类锁。最后不管方法执行是否异常,都会释放锁。