前言本文带读者由浅入深地了解Synchronized,让读者也能跟着面试官一起疯掉。在并发编程中,Synchronized一直扮演着老牌角色。在Jdk1.6之前,大家称它为重量级锁。与JUC包提供的Lock相比,会显得繁琐。不过Jdk1.6优化了Synchronized之后,Synchronized的性能已经非常快了。内容大纲Synchronized用法Synchronized是Java提供的同步关键字。多线程场景下,对共享资源代码段的读写操作(必须包括写操作,轻读不会有线程安全问题,因为读操作天然有线程安全特性),可能存在线程安全问题,我们可以使用Synchronized对共享资源代码段进行加锁,达到互斥(mutualexclusion)的效果,保证线程安全。共享资源代码段也称为临界区。保证临界区的互斥性,即只有一个线程可以执行临界区,其他线程阻塞等待,达到排队的效果。使用Synchronized修改普通函数的方式有3种。监视器锁(monitor)是对象实例(this)修改static静态函数。监控锁(monitor)是对象的Class实例(每个对象只有一个Class实例)修改代码块,监控锁(monitor)是指定一个对象实例普通函数普通函数使用Synchronized的方式很简单,添加在访问修饰符和函数返回类型之间同步。在多线程场景下,thread和threadTwo两个线程执行incr函数。incr函数作为共享资源代码段被多个线程读写。我们称之为关键部分。为了保证临界区互斥,使用Synchronized修改incr函数。能。publicclassSyncTest{privateintj=0;/***自增方法*/publicsynchronizedvoidincr(){//临界区代码--startfor(inti=0;i<10000;i++){j++;}//临界区代码--结束}publicintgetJ(){returnj;}}publicclassSyncMain{publicstaticvoidmain(String[]agrs)throwsInterruptedException{SyncTestsyncTest=newSyncTest();Threadthread=newThread(()->syncTest.incr());ThreadthreadTwo=newThread(()->syncTest.incr());thread.start();threadTwo.start();thread.join();threadTwo.join();//最终打印结果为20000,如果不使用synchronized修饰,会导致线程安全问题,输出不确定的结果System.out.println(syncTest.getJ());}}代码很简单,incr函数被synchronized修饰,函数逻辑是累加j10000次,两个线程执行incr函数,最后输出j个结果。被synchronized修饰的函数称为同步函数。线程在执行同步函数之前,需要获得监听锁,简称锁。同步函数只有在成功获取锁后才能执行。同步函数执行后,线程会释放锁并通知唤醒其他线程获取锁,如果获取锁失败,“阻塞等待通知唤醒线程再次获取锁”,同步函数会使用this作为锁,也就是当前对象。以上面代码段为例,就是syncTest对象。在thread线程执行syncTest.incr()之前,thread线程获取锁成功。在线程threadTwo执行syncTest.incr()之前,线程threadTwo获取锁失败。线程threadTwo阻塞并等待唤醒线程执行完syncTest.incr()。释放锁,通知并唤醒threadTwo线程获取锁线程threadTwo成功获取锁线程threadTwo执行syncTest。功能是一样的,唯一不同的是锁的对象不再是this,而是Class对象。多线程执行Synchronized修改后的静态函数代码段如下。publicclassSyncTest{privatestaticintj=0;/***自增方法*/publicstaticsynchronizedvoidincr(){//临界区代码--startfor(inti=0;i<10000;i++){j++;}//临界区代码--结束}publicstaticintgetJ(){returnj;}}publicclassSyncMain{publicstaticvoidmain(String[]agrs)throwsInterruptedException{Threadthread=newThread(()->SyncTest.incr());ThreadthreadTwo=newThread(()->SyncTest.incr());thread.start();threadTwo.start();thread.join();threadTwo.join();//最终打印结果为20000,如果不使用synchronized修饰,会导致线程安全问题,输出不确定的结果System.out.println(SyncTest.getJ());}}Java的静态资源可以通过类名直接调用。静态资源不属于任何实例对象,只属于Class对象,每个Class在JVM对象中只有一个Class,所以同步静态函数会使用Class对象作为锁,后续的获取和释放过程锁是一样的。代码块前面介绍的普通函数和静态函数,粒度比较大,整个函数的作用域是锁定的。现在如果想缩小范围,灵活配置,就需要使用代码块,使用{}符号来定义Synchronized修改的范围。syncDbData函数在以下代码中定义。syncDbData是一个伪同步数据的函数,耗时2秒,逻辑不涉及共享资源读写操作(非临界区)。还有另外两个函数incr和incrTwo,都是在自增逻辑之前执行syncDbData函数,只是使用Synchronized的姿势不同。一种是修饰函数,另一种是修饰代码块。publicclassSyncTest{privatestaticintj=0;/***同步库数据比较耗时,代码资源不涉及共享资源读写操作。*/publicvoidsyncDbData(){System.out.println("db数据同步------------");try{//同步时间需要2秒Thread.sleep(2000);}catch(InterruptedExceptione){e.printStackTrace();}System.out.println("db数据同步完成------------");}//自增方法publicsynchronizedvoidincr(){//start--临界区代码//同步库数据syncDbData();for(inti=0;i<10000;i++){j++;}//end--临界区代码}//自增方法publicvoidincrTwo(){//同步库数据syncDbData();synchronized(this){//开始——临界区代码for(inti=0;i<10000;i++){j++;}//结束——临界区代码}}publicintgetJ(){returnj;}}publicclassSyncMain{publicstaticvoidmain(String[]agrs)throwsInterruptedException{//incr同步方法执行SyncTestsyncTest=newSyncTest();Threadthread=newThread(()->syncTest.incr());ThreadthreadTwo=newThread(()->syncTest.incr());thread.start();threadTwo.start();thread.join();threadTwo.join();//最终打印结果为20000System.out.println(syncTest.getJ());//incrTwo同步块执行thread=newThread(()->syncTest.incrTwo());threadTwo=newThread(()->syncTest.incrTwo());thread.start();threadTwo.start();thread.join();threadTwo.join();//最终打印结果为40000System.out.println(syncTest.getJ());}}先看incr同步方法的执行,过程是一样的和之前一样,只是Synchronized锁的范围太大了,syncDbData()也被划入了临界区。多线程场景执行会有性能上的浪费,因为syncDbData()完全可以让多个线程并行或者并发执行。我们使用代码块来缩小范围,定义正确的临界区,提高性能,将注意力转向incrTwo同步块的执行,incrTwo函数采用修改代码块的方式进行同步,只对自增代码段加锁。代码块同步方式除了控制范围灵活之外,还可以做线程间的协同工作,因为任何对象都可以接受为Synchronized()括号内的锁,所以可以使用Object的wait、notify、notifyAll等做多线程通信协调(本文不展开线程通信协调,主角是Synchronized,不推荐使用这些方法,因为LockSupport工具类会是更好的选择)。wait:当前线程暂停,释放锁notify:释放锁,唤醒调用wait的线程(如果有多个随机唤醒)notifyAll:释放锁,唤醒所有调用wait的线程同步原理publicclassSyncTest{privatestaticintj=0;/***同步库数据比较耗时,代码资源不涉及共享资源读写操作。*/publicvoidsyncDbData(){System.out.println("db数据同步------------");try{//同步时间需要2秒Thread.sleep(2000);}catch(InterruptedExceptione){e.printStackTrace();}System.out.println("db数据同步完成------------");}//自增方法publicsynchronizedvoidincr(){//start--临界区代码//同步库数据syncDbData();for(inti=0;i<10000;i++){j++;}//end--临界区代码}//自增方法publicvoidincrTwo(){//同步库数据syncDbData();synchronized(this){//开始--临界区代码for(inti=0;i<10000;i++){j++;}//结束--临界区代码}}publicintgetJ(){returnj;}}为了探究Synchronized的原理,我们对上面的代码进行反编译,输出反编译结果,看看底层是如何实现的(环境Java11,win10系统)。只拦截incr和incrTwo函数publicsynchronizedvoidincr();代码:0:aload_01:invokevirtual#11//MethodsyncDbData:()V4:iconst_05:istore_16:iload_17:sipush1000010:if_icmpge2713:getstatic#12//Fieldj:I117:iconst_iadd18:putstatic#12//Fieldj:I21:iinc1,124:goto627:returnpublicvoidincrTwo();代码:0:aload_01:invokevirtual#11//MethodsyncDbData:()V4:aload_05:dup6:astore_17:monitorenter//获取锁8:iconst_09:istore_210:iload_211:sipush1000014:if_icmpge3117:getstatic#12//Fieldj:I20:iconst_121:iadd22:putstatic#12//Fieldj:I25:iinc2,128:goto1031:aload_132:monitorexit//正常退出释放锁36:goto411astore_337:aload_138:monitorexit//异步退出释放锁39:aload_340:athrow41:returnps:对以上指令感兴趣的读者可以百度或者google《JVM虚拟机字节码指令表》先看看incrTwo函数,incrTwo是代码块同步。在反编译的结果中,我们发现有monitorenter和monitorexit指令(acquirelock,releaselock)。monitorenter指令插入同步代码块的开头,monitorexit指令插入同步代码块的末尾。JVM需要保证每个monitorenter都有对应的monitorexit。任何对象都与一个监视器锁(monitor)相关联,一个线程在执行monitorenter指令时试图获取监视器的所有权。如果monitor的entrynumber为0,则线程进入monitor,然后设置entrynumber为1,线程就是monitor的owner。如果线程已经占用了monitor重新进入,则monitor的入口数加1。monitor入口数-1,执行了多少次monitorenter,最后必须执行对应次数的monitorexits.如果其他线程已经占用了管程,则该线程进入阻塞状态,直到管程数为0,然后重新尝试获取管程的所有权。查看incr函数,incr是一个普通的函数同步,虽然在反编译结果中看不到monitorenter和monitorexit指令,但是实际执行过程和incrTwo函数一样,都是通过monitor执行的,但是一种隐式的实现方式,最后放一张流程图。synchronized优化Jdk1.5之后,对Synchronized关键字进行了各种优化。优化后,Synchronized变得更快了。这也是为什么官方推荐使用Synchronized的原因。具体优化点如下。锁粗化锁消除锁升级锁粗化和互斥的临界区应该越小越好。这样做的目的是尽量减少同步操作的次数,缩短阻塞时间。如果存在锁竞争,等待锁的线程也能尽快拿到锁。但是,加锁和解锁也会消耗资源。如果有一系列连续的加锁和解锁操作,可能会造成不必要的性能损失。锁粗化就是“将多个连续的加锁和解锁操作连接在一起”,扩展形成更大的锁,避免频繁的加锁和解锁操作。JVM会检测到一系列操作对同一个对象加锁(for循环执行j++10000次,加锁/解锁10000次不加锁粗化),JVM会将加锁范围加粗到这一系列操作之外(比如asoutsidetheforloop)使得这一系列操作只需要加一次锁。锁消除Java虚拟机在JIT编译时(可以简单理解为某段代码即将第一次执行时编译,也称为即时编译),它会扫描正在运行的上下文并进行逃逸分析(对象在函数中执行)。使用,也可能被外部函数引用,称为函数转义),去除不太可能有共享资源竞争的锁,通过这种方式去除不必要的锁,可以节省无意义的时间消耗。代码中使用了Object作为锁,但是Object对象的生命周期只在incrFour()函数中,不会被其他线程访问,所以会在JIT编译阶段进行优化(这里的Object不是转义对象)。LockUpgradeJava中的每个对象都有一个对象头。对象头由三部分组成:MarkWorld、指向类的指针和数组长度。在本文中,我们只需要关心MarkWorld。MarkWorld记录了对象的HashCode、Generationage和lockflag信息。MarkWorld简化结构lockstatestoragecontentlock标记无锁对象的hashCode,对象世代年龄,是否是偏向锁(0)01偏向锁偏向线程ID,偏向时间戳,对象世代年龄,是否是abiasedlock(1))01轻量级锁指向栈中锁记录的指针00重量级锁指向mutex(重量级锁)的指针10读者只需要知道锁的升级体现在锁对象的对象头的MarkWorld部分,这意味着MarkWorld的内容会随着锁的升级而变化。Java1.5之后,为了减少获取和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。Synchronized的升级顺序是“无锁-->偏向锁-->轻量级锁-->重量级锁只会升级不会降级”。偏向锁大多数情况下,锁总是被同一个线程多次获取,不存在多线程竞争,所以偏向锁就出现了。目标是只在一个线程执行同步代码阻塞时,减少获取锁的消耗,提高性能(偏向锁可以通过JVM参数关闭:-XX:-UseBiasedLocking=false,关闭后程序会进入默认为轻量级锁状态)。线程在执行同步代码或方法之前,只需要判断对象头的MarkWord中的线程ID与当前线程ID是否一致即可。如果一致,则直接执行同步代码或方法。具体过程如下。(0)",lockflag01CAS设置当前线程ID到MarkWord存储内容是否为偏向锁0=>是否为偏向锁1执行同步代码或方法偏向锁状态,并存储内容“是否是偏向锁(1),线程ID”,将锁标志01与线程ID进行比较,看是否一致,一致则执行同步代码或方法,否则进入以下流程.如果不一致,CAS会将MarkWord的线程ID设置为当前线程ID,设置成功则执行同步代码或方法,否则进入下面流程CAS设置失败,证明有一个多线程竞争情况,触发取消偏向锁,当达到全局安全点时,挂起偏向锁的线程,将偏向锁升级为轻量级锁,然后在恢复safe的位置宝int继续执行。轻量级锁轻量级锁考虑的是竞争锁对象的线程不多,锁时间不长的场景。因为阻塞线程需要CPU从用户态切换到内核态,所以成本比较高。如果阻塞后不久就释放了锁,这种代价是得不偿失的,所以索性不要阻塞线程,让它自旋一段时间等待锁释放。当当前线程持有的锁是偏向锁,被其他线程访问时,偏向锁会升级为轻量级锁,其他线程会尝试以自旋的形式获取锁,不会阻塞,从而提高性能.轻量级锁的获取主要有两种情况:①当偏向锁功能关闭时;②多个线程竞争偏向锁,导致偏向锁升级为轻量级锁。无锁状态,存储内容“是否为偏向锁(0)”,锁标志位01当关闭偏向锁功能时,CAS将当前线程栈中锁记录的指针设置为MarkWord,并设置lockflag为00执行同步代码或方法释放锁时,恢复MarkWord内容的轻量级锁状态,存储内容“线程栈中指向锁记录的指针”,锁住flagbit00(存储内容的线程指的是“持有轻量级锁的线程”)CAS将当前线程栈中锁记录的指针指向MarkWord的存储内容,成功获取到轻量级锁,执行同步块代码或方法,否则,下面的逻辑设置失败,证明多个线程之间存在一定的竞争,线程自旋向上一步操作,如果自旋一定次数后仍然失败,轻量级lock升级为重量级锁。MarkWord存储内容替换为重量级锁指针,锁标记位为10。重量级锁扩容后,轻量级锁升级为重量级锁。级别锁,重量级锁是依赖操作系统的MutexLock(互斥锁)实现的,需要从用户态转到内核态。这个成本非常高,这就是为什么SynchronizedbeforeJava1.6效率低下的原因。当升级到重量级锁时,锁标志的状态值变为10,此时MarkWord中存放的内容就是重量级锁的指针,所有等待锁的线程都会进入阻塞状态。以下是锁升级过程的简化版本。
