当前位置: 首页 > 后端技术 > Java

java开发技术中synchronized的使用分析

时间:2023-04-01 14:50:36 Java

synchronized关键字的重要性不言而喻,几乎可以说是并发和多线程必问的关键字。synchronized会涉及到锁、升级和降级操作、锁撤销、对象头等,因此了解synchronized非常重要。本文将带你从synchronized的基本用法到深入理解synchronized、objectfirst等,为你揭开synchronized的面纱。synchronized浅析Synchronized是Java并发模块中一个非常重要的关键字。它是Java中内置的一种同步机制,代表了某种内部锁的概念。当一个线程锁定共享资源时,其他线程想要获取共享资源。资源的线程必须等待,synchronized也有互斥和排他的语义。什么是互斥体?我们小时候一定玩过磁铁。磁铁有正负极的概念。同性排斥异性吸引。排斥相当于一个相互排斥的概念,即两者互不相容。synchronized也是一个exclusive关键字,但是它的exclusive语义更多的是为了增加线程安全,通过独占某种资源来达到互斥排他的目的。了解了排他和互斥的语义之后,我们先来看看synchronized的用法,先了解用法,再了解底层实现。synchronized的使用你应该对synchronized有一个大概的了解。同步修饰的实例方法等同于锁定类的实例。在进入同步代码之前,需要获取当前实例的锁。同步修饰的静态方法等同于锁定类对象。Lock?synchronized修改代码块,相当于锁定对象。在进入代码块之前,需要先获取对象的锁。下面我们解释一下每个用法。synchronized修饰实例方法synchronized修饰实例方法,实例方法属于类实例。synchronized修饰的实例方法相当于一个对象锁。下面是一个同步修饰实例方法的例子。publicsynchronizedvoidmethod(){//...}上面synchronized修饰的方法是实例方法,下面用一个完整的例子来理解synchronized修饰的实例方法publicclassTSynchronizedimplementsRunnable{staticinti=0;publicsynchronizedvoidincrease(){i++;System.out.println(Thread.currentThread().getName());}@Overridepublicvoidrun(){for(inti=0;i<1000;i++){increase();}}publicstaticvoidmain(String[]args)throwsInterruptedException{TSynchronizedtSynchronized=newTSynchronized();线程aThread=newThread(tSynchronized);线程bThread=newThread(tSynchronized);。开始();aThread.join();bThread.join();System.out.println("i="+i);}}上面输出的结果是i=2000,每次都会打印当前现成的名字解释一下上面的代码,代码中的i是静态变量,也就是全局变量,静态变量存放在方法区。increase方法被synchronized关键字修饰,但没有被static关键字修饰,也就是说increase方法是一个实例方法。每次创建一个TSynchronized类,都会同时创建一个increase方法。increase方法只打印当前访问线程的名称。Synchronized类实现了Runnable接口,并重写了run方法。run方法包含一个0-1000计数器。对此无话可说。在main方法中新建了两个线程,分别是aThread和bThread,Thread.join表示等待这个线程的处理结束。这段代码的主要作用是判断synchronized修饰的方法是否可以独占。synchronized修饰静态方法synchronized修饰静态方法是synchronized和static关键字一起使用publicstaticsynchronizedvoidincrease(){}当synchronized作用于静态方法时,java训练意味着当前类的锁,因为静态方法属于类,它不属于任何实例成员,因此可以通过类对象来控制并发访问。这里要注意一点,synchronized修饰的实例方法属于实例对象,而synchronized修饰的静态方法属于类对象,所以调用synchronized的实例方法不会阻止对synchronized静态方法的访问。synchronized装饰代码块synchronized除了装饰实例方法和静态方法,synchronized还可以用来装饰代码块,可以嵌套在方法体内。publicvoidrun(){synchronized(obj){for(intj=0;j<1000;j++){i++;}}}上面的代码中,obj作为锁对象对其进行加锁,每次thread进入synchronized修改代码块执行时,要求当前线程持有obj实例对象锁。如果其他线程当前持有对象锁,则新到达的线程必须等待。synchronized修饰的代码块不仅可以锁定对象,还可以锁定当前实例对象lock和类对象lock//实例对象locksynchronized(this){for(intj=0;j<1000;j++){i++;}}//classobjectlocksynchronized(TSynchronized.class){for(intj=0;j<1000;j++){i++;}}synchronized的底层原理简单介绍完synchronized,我们再来说说synchronized的的基本原理。我们可能都明白(下面我们会详细分析),synchronized代码块是通过一组monitorenter/monitorexit指令实现的。Monitor对象是同步的基本单位。监视器对象任何对象都与一个监视器相关联,监视器是一种控制对象并发访问的机制。Monitor是一个同步原语,在Java中是指synchronized,可以理解为synchronized是Java中monitor的实现。monitor提供了独占访问机制,也就是互斥。互斥确保在每个时间点,最多有一个线程会执行同步方法。所以你明白了,Monitor对象其实就是一个使用monitor来控制同步访问的对象。对象内存布局在热点虚拟机中,对象在内存中的布局分为三个区域:?对象头(Header)?实例数据(InstanceData)?对齐填充(Padding)这三个区域的内存分布如图下图详细介绍一下上面对象的内容。对象头Header对象头主要包括MarkWord和对象指针KlassPointer,如果是数组,还包括数组的长度。在32位虚拟机中,MarkWord、KlassPointer和arraylength分别占用32位,即4个字节。如果是64位虚拟机,MarkWord、KlassPointer和arraylength分别占用64位,即8个字节。32位虚拟机和64位虚拟机的MarkWord占用的字节大小不同。32位虚拟机的MarkWord和KlassPointer分别占用32位字节,而64位虚拟机的MarkWord和KlassPointer占用Pointer占用64位字节。我们以一个32位的虚拟机为例,看看它的MarkWord的字节是如何分配的。中文翻译,当没有状态,也就是没有锁的时候,对象头开辟25位空间存放对象的hashcode,4位用来存放世代年龄,1位用来存放偏向锁标志。2位用于存储锁标志为01。?偏向锁中的划分更精细,或者开辟25位空间,其中23位用于存储线程ID,2位用于存储epoch,4位用来存储世代年龄,1位存储是否偏向锁,0表示无锁,1表示偏向锁,锁的标识位还是01。?在轻量级锁中,30直接分配bits存放栈中锁记录指针,2bits存放锁标志,为00。?重量级锁和轻量级锁一样,30bits存放重量级指针locks,2位用于存放锁标识位,11?GC标志开辟30位内存空间但未被占用,2位空间存放锁标志位为11。其中,no的锁标志位-lock和biased-lock都是01,只是前面的1位区分是无锁状态还是偏向锁状态。至于为什么要这样分配内存,我们可以看一下OpenJDK中markOop.hpp类中的枚举来解释?age_bits就是我们所说的分代回收标志,占用4个字节?lock_bits是flag锁的bit,占2个字节?biased_lock_bits是锁是否偏向的标识,占1个字节。?max_hash_bits是用于无锁计算的hashcode占用的字节数。如果是32位虚拟机,就是32-4-2-1=25字节。如果是64位的虚拟机,就是64-4-2-1=57byte,但是会有25个字节没有被使用,所以64位的hashcode占用了31个字节。?hash_bits是针对64位虚拟机的,如果最大字节数大于31,取31,否则取真实的字节数?cms_bits我觉得64位虚拟机应该是0字节,或者64-bit占用1byte?epoch_bits是epoch占用的字节大小,2字节。在上面的虚拟机对象头分配表中,我们可以看到锁有几种状态:无锁(stateless)、偏向锁、轻量级锁、重量级锁,其中轻量级锁和偏向锁在JDK1.6中,synchronized锁是在对synchronized锁进行优化后新加入的,其目的是为了大大优化锁的性能。因此,在JDK1.6中,使用synchronized的开销没有那么大了。其实从有无锁的角度来看,还是只有无锁和重量级锁。偏向锁和轻量级锁的出现只是增加了锁的获取性能,并没有出现新的锁。所以我们的重点是同步重量级锁的研究。当监视器被线程持有时,它将处于锁定状态。在HotSpot虚拟机中,监视器的底层代码由ObjectMonitor实现,其主要数据结构如下(位于HotSpot虚拟机源代码的ObjectMonitor.hpp文件中,用C++实现)。这个C++中需要注意几个属性:_WaitSet、_EntryList和_Owner,每个等待获取锁的线程都会被封装为一个ObjectWaiter对象。_Owner是指向ObjectMonitor对象的线程,_WaitSet和_EntryList用来保存各个线程的列表。那么这两个列表有什么区别呢?跟大家说说获取锁的过程,这道题你就明白了。两个锁列表当多个线程同时访问某个同步代码时,会先进入_EntryList集合。线程获取到对象的monitor后,会进入_Owner区域,将ObjectMonitor对象的_Owner指向当前Thread,并使_count+1,如果释放锁的操作(比如wait)被调用,当前持有的监视器将被释放,owner=null,_count-1,该线程将进入_WaitSet列表等待被唤醒。如果当前线程执行完毕,monitor锁也会被释放,但此时不会进入_WaitSet列表,而是直接重置_count的值。KlassPointer表示类型指针,即对象指向其类元数据的指针,虚拟机通过这个指针来判断对象是哪个类的实例。你可能不太理解指针的概念。你可以简单的理解为指针就是指向某个数据的地址。实例数据实例数据部分是对象实际存储的有效信息,也是代码中定义的各个字段的字节大小。比如一个byte占1个字节,一个int占4个字节。AlignmentPadding对齐不一定存在,它仅充当占位符(%d、%c等)。这是JVM的要求,因为HotSpotJVM要求对象的起始地址必须是8字节的整数倍,也就是说对象的字节大小是8的整数倍,如果还不够,还需要用Padding来完成。锁的升级过程先来一张大概的流程图感受下这个过程,接下来我们分别说说无锁和无锁状态。没有锁,资源不被锁定,所有线程都可以访问同一个资源。,但只有一个线程可以成功修改资源。无锁的特点是修改操作是在循环中进行的。线程会不断尝试修改共享资源,直到修改资源成功退出。过程中没有冲突,和我们上一篇介绍的CAS很像。CAS的实现原理和应用就是无锁实现。Lock-free不能完全替代lock,但是在某些场合下lock-free的性能是非常高的。偏向锁HotSpot的作者通过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且锁被同一个线程多次获取。偏向锁就出现在这种情况下。Addresses似乎只有在一个线程执行同步时才能提高性能。从对象头的分配可以看出,偏向锁比无锁拥有更多的线程ID和epoch。下面描述一下偏向锁的获取过程。首先,线程通过检查对象头来访问同步代码块。MarkWord的锁标志判断当前锁的状态。如果是01,则表示无锁或偏向锁,然后根据锁是否偏向的标志来判断是无锁还是偏向锁。如果没有加锁,则进入下一步线程使用CAS操作尝试加锁对象。如果使用CAS替换ThreadID成功,说明是第一次加锁,那么当前线程会获得该对象的偏向锁。此时,当前线程会被记录在对象头的MarkWord中。线程ID和获取锁的时间纪元等信息,然后执行同步代码块。全局安全点(SafePoint):全局安全点的理解会涉及到一些C语言底层的知识。这里简单理解一下SafePoint就是Java代码中一个线程可能暂停执行的位置。当线程下次进入和退出同步代码块时,不需要进行CAS操作来加锁和解锁。只需要简单判断对象头的MarkWord中是否存储了指向当前线程的线程ID即可。判断标志当然是根据锁的标志位来判断的。如果用流程图表示的话,如下关闭偏向锁偏向锁在Java6和Java7中是默认开启的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序中的所有锁通常都处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。epoch-biased锁的对象头中有一个名为epoch的值,它作为偏向有效性的时间戳。轻量级锁轻量级锁是指当当前锁为偏向锁,资源被其他线程访问时,偏向锁将升级为轻量级锁,其他线程尝试通过自旋来获取锁。它不会阻塞,从而提高性能。下面是详细的获取过程。轻量级锁加锁过程紧接着上一步。如果替换ThreadID的CAS操作没有成功获取,则进入下一步。如果替换ThreadID的CAS操作失败(此时切换到另一个线程的视角),则说明该资源已被同步访问。但是此时会执行锁取消操作,取消偏向锁,然后当原来持有偏向锁的线程到达全局安全点(SafePoint)时,原先持有偏向锁的线程就会被挂起,然后检查原来持有偏向锁的线程。偏向锁的状态,如果已经退出同步,持有偏向锁的线程会被唤醒,接下来检查对象头中的MarkWord记录是否为当前线程ID,如果是则执行同步代码,如果没有,则执行偏向锁获取锁过程的Step2。如果用进程表示的话,如下(已经包含了偏向锁的获取)。重量级锁。重量级锁其实就是synchronized最终加锁的过程。JDK1.6之前是无锁->锁的过程。重量级锁的获取过程沿用了上面的偏向锁获取过程,偏向锁升级为轻量级锁。下一步会在原来持有偏向锁的线程的栈中分配锁记录,复制对象头中的MarkWord。在原先持有偏向锁的线程的记录中,原先持有偏向锁的线程获得轻量级锁,然后唤醒原先持有偏向锁的线程,从安全点继续执行。执行完成后,执行下一步。线程执行完第四步后,开始轻量级解锁操作。解锁需要判断两个条件:?判断对象头中MarkWord中的锁记录指针是否指向当前栈中记录的指针?复制当前线程中的锁记录MarkWord信息是否与对象头中的MarkWord。如果满足以上两个判断条件,就会释放锁。如果其中一个条件不满足,就会释放锁,等待的线程会被唤醒,进行新一轮的锁竞争。在当前线程的栈中分配一条锁记录,将对象头中的MarkWord复制到当前线程的锁记录中,执行CAS锁操作,将对象头中MarkWord中的锁记录指针指向当前线程的锁记录,如果成功,获取Lightweight锁,执行同步代码,然后执行步骤3,如果不成功,执行下一步。如果当前线程使用CAS没有成功获取到锁,它会自旋一段时间再尝试获取。如果多次自旋达到上限还没有获取到锁,则轻量级锁升级为重量级锁。如果用流程图来表示,根据上面对锁升级的详细描述,我们可以总结出不同锁的适用范围和场景。synchronized代码块的底层实现为了方便研究,我们简化了synchronized修改代码块的例子,如下代码所示{我++;}}}我们主要关注synchronized的字节码。如下图,从这段字节码我们可以知道,同步语句块使用了monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令指向同步代码块的结束位置代码块。那么为什么会有两个monitorexit呢?不知道你有没有注意到下面的异常表?如果您不知道异常表是什么,那么我建议您阅读这篇文章。看完这个ExceptionandError,跟面试官吵架就没问题了。同步修改方法的底层原理。方法的同步是隐式的,也就是说,据说synchronized修改方法底层不需要字节码控制,是这样吗?让我们反编译看看结果。公共类SynchronizedTest{privateinti;publicsynchronizedvoidsyncTask(){i++;}}这次我们使用javap-verbose来输出详细的结果。从字节码可以看出,synchronized修饰的方法并没有使用monitorenter和monitorexit指令,而是获取了ACC_SYNCHRONIZED标志,表明该方法是一个同步方法,JVM通过ACC_SYNCHRONIZED访问标志来识别一个method声明为同步方法,从而进行相应的同步传输。这就是同步代码块上同步锁的实现和同步方法的区别。作者轩