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

在多线程环境下,应该使用Synchronized吗?

时间:2023-03-14 23:59:51 科技观察

在多线程环境下,锁的使用是不可避免的。在使用锁的时候,有很多种锁供我们选择,比如ReentrantLock、CountDownLatch等,但是作为Java开发者,刚接触多线程的时候,最早接触和使用的恐怕要数同步了.那么你真的了解synchronized吗?今天我们将从以下几个方面深入了解synchronized。首先,有一点需要说明。你可能或多或少听过这样一句话:“synchronized的性能不好,比显式锁差很多,开发中慎用”。没有必要有这样的顾虑。据说在JDK1.6之前,synchronized的性能确实有点差,但是在JDK1.6之后,JDK开发团队不断优化synchronized的性能,其性能与其他显式锁基本没有差距。所以,在考虑是否使用synchronized时,只需要根据场景是否合适来决定,并不以性能问题作为评判标准。如何使用synchronized是一个关键词。它的一个明显特点是使用方便,一个关键字就可以搞定。可以用在方法上,也可以用在方法内的某些代码块上,非常方便。publicclassSyncLock{privateObjectlock=newObject();/***直接在方法中添加关键字*/publicsynchronizedvoidmethodLock(){System.out.println(Thread.currentThread().getName());}/***代码块中添加关键字锁定当前实例*/publicvoidcodeBlockLock(){synchronized(this){System.out.println(Thread.currentThread().getName());}}/***在代码块中添加关键字,锁定一个变量*/publicvoidcodeBlockLock(){synchronized(lock){System.out.println(Thread.currentThread().getName());}}}由JVM中的monitorenter和monitorexit指令控制。通过javap-v命令可以在字节码层面看到前面示例代码中synchronized关键字的处理过程。对于在代码块中加入synchronized关键字的情况,同步的开始和退出将由monitorenter和monitorexit指令指示。标识。在为方法添加关键字的情况下,会使用ACC_SYNCHRONIZED作为方法标识,这是一种隐式形式,底层原理是一样的。publicsynchronizedvoidmethodLock();descriptor:()Vflags:ACC_PUBLIC,ACC_SYNCHRONIZEDCode:stack=2,locals=1,args_size=10:getstatic#2//Fieldjava/lang/System.out:Ljava/io/PrintStream;3:invokestatic#3//Methodjava/lang/Thread.currentThread:()Ljava/lang/Thread;6:invokevirtual#4//Methodjava/lang/Thread.getName:()Ljava/lang/String;9:invokevirtual#5//Methodjava/io/PrintStream.println:(Ljava/lang/String;)V12:returnLineNumberTable:line12:0line13:12publicvoidcodeBlockLock();descriptor:()Vflags:ACC_PUBLICCode:stack=2,locals=3,args_size=10:aload_01:dup2:astore_13:monitorenter#4:getstatic#2//Fieldjava/lang/System.out:Ljava/io/PrintStream;7:invokestatic#3//Methodjava/lang/Thread.currentThread:()Ljava/lang/Thread;10:invokevirtual#4//Methodjava/lang/Thread.getName:()Ljava/lang/String;13:invokevirtual#5//Methodjava/io/PrintStream.println:(Ljava/lang/String;)V16:aload_117:monitorexit18:goto2621:astore_222:aload_123:monitorexit24:aload_225:athrow26:return对图像布局为什么介绍synch说到ronized的对象头,这就和它的锁升级过程有关。具体的锁升级过程后面会提到。作为锁升级过程的数据支撑,必须掌握对象头的结构才能理解锁升级过程的完整性。Process在Java中,任何对象实例的内存布局都分为三部分:对象头、对象实例数据和对齐填充数据。对象头包括MarkWord和类型指针。对象实例数据:这部分是对象的实际数据。Alignmentpadding:因为HotSpot虚拟机的内存管理要求对象的大小必须是8字节的整数倍,而对象头恰好是8字节的整数倍,但是实例数据不一定相同,因此需要对齐填充。对象头:Klass指针:对象头中的Klass指针用来指向对象的类型。一个类实例属于哪个类,需要记录在某处,就记录在这里。MarkWord:还有一部分是MarkWord,与synchronized密切相关。主要用来存储对象本身的运行时数据,比如hashcode、gcgenerationalage等信息。MarkWord的位长是JVM一个字的大小,32位JVM的大小是32位,64位JVM的大小是64位。下图为64位虚拟机下的MarkWord结构描述。根据不同的对象锁状态,一些位的含义会动态变化。这样设计的原因是对象头不想占用太多空间。如果给每个标签都分配一个固定的空间,对象头占用的空间会比较大。数组长度:说明一下,如果是数组对象,由于数组无法通过自身的内容获取自己的长度,所以需要在对象头中记录数组的长度。源码中的定义追根溯源,对象在JVM中是怎么定义的呢?打开JVM源码,在里面找到对象的定义文件,可以看到上面提到的对象头的定义。classoopDesc{friendclassVMStructs;friendclassJVMCIVMStructs;private:volatilemarkOop_mark;union_metadata{Klass*_klass;narrowKlass_compressed_klass;}_metadata;}oop是对象的基本类定义,即Java中Object类的定义其实就是oop,任何类继承自对象。oopDesc只是oop的一个别名。可以看到有关于Klass的说法,还有关于markOop的说法。这个markOop对应的就是上面提到的MarkWord。classmarkOopDesc:publicoopDesc{private://Conversionuintptr_tvalue()const{return(uintptr_t)this;}public://Constantsenum{age_bits=4,//生成agelock_bits=2,//锁标志位biased_lock_bits=1,//偏向lockmarkmax_hash_bits=BitsPerWord-age_bits-lock_bits-biased_lock_bits,hash_bits=max_hash_bits>31?31:max_hash_bits,cms_bits=LP64_ONLY(1)NOT_LP64(0),epoch_bits=2};}上面的代码只是其中的一部分,你可以看到有分代年龄、锁标志、偏向锁的定义。虽然我们看不懂源码,但是看到它们的时候,心里都会感叹,原来如此啊。有一种感觉,整个宇宙都在我的掌控之中。过了两天才明白,这只是一种心理安慰。不过,已经不重要了。Tip如果你有兴趣看源码,这部分的定义在/src/hotspot/share/oops目录下,我能告诉你的就这么多了。锁升级到JDK1.6后,对synchronized进行了优化,主要有CAS自旋、锁淘汰、锁扩展、轻量级锁、偏向锁等,这些技术都是为了更高效的线程间共享数据,解决竞争问题,从而提高程序的执行效率,进而产生一套锁升级规则。同步锁升级过程通过动态改变对象MarkWord的各个标志来表达当前的锁状态。那么修改了哪个对象的MarkWord呢?看上面的代码,锁变量中加入了synchronized关键字,那么就会控制锁的MarkWord。如果是synchronized(this)或者在方法中添加了关键字,则控制当前实例对象的MarkWord。synchronized的核心原理可以这样总结。如果可以锁定它,请不要锁定它。尽量偏离。如果可以加轻量级锁,就不需要重量级锁了。无锁转偏向锁偏向锁是指锁会偏向第一个获得它的线程。如果在接下来的执行过程中,锁还没有被其他线程获取到,持有偏向锁的锁Threads就永远不需要再次同步了。当一个线程试图获取一个锁对象时,它首先检查MarkWord中的线程ID是否为空。如果为空,虚拟机会将MarkWord中的biasflag设置为1,lockflagbit为01。同时使用CAS操作尝试在MarkWord中记录线程ID。如果CAS操作成功,那么当持有偏向锁的线程再次进入相关同步块时,就不需要进行任何同步操作了。如果检查线程ID不为空,且不是当前线程ID,或者CAS操作设置线程ID失败,则必须取消偏向状态,此时会升级为偏向锁.偏向锁升级为轻量级锁。当多个线程竞争锁时,偏向锁会升级为轻量级锁。首先,当一个线程试图获取锁时,它首先检查锁标志是否处于01状态,也就是解锁状态。如果处于解锁状态,则在当前线程的栈帧中创建一个锁记录(LockRecord)区域,该区域存储MarkWord的副本。之后尝试使用CAS操作将MarkWord更新为指向锁记录的指针(即上一步线程栈帧中MarkWord的副本)。如果CAS更新成功,则偏向锁正式升级为轻量级锁,锁标志更改为00。如果CAS更新失败,则检查MarkWord是否已经指向当前线程的锁记录。如果它指向自己,就意味着已经获得了锁。否则,轻量级锁将扩展为重量级锁。从轻量级锁升级到重量级锁上图已经有了轻量级锁向重量级锁扩展的逻辑。当锁已经处于轻量级锁状态,并且有其他线程在竞争锁时,轻量级锁会扩展为重量级锁。为什么称重量级锁的实现原理为重量级锁呢?重量级锁中不竞争锁的对象会被park挂起,unpark会在退出同步块时唤醒后续线程。唤醒操作涉及操作系统调度的额外开销,这就是它被称为重量级锁的原因。当锁升级为重量级锁时,MarkWord会指向重量级锁的指针监视器。监视器也称为监视器或监视器锁。每个对象都有一个与之关联的监视器,对象与其监视器的关系有多种实现方式,例如监视器可以与对象一起创建和销毁或者在线程试图获取对象锁时自动生成,但是当监视器由线程持有,它处于锁定状态。ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象的列表(每个等待锁的线程都会封装成一个ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。当多个线程访问一个同步编码时,会先进入_EntryList集合。当线程获得对象的监视器后,进入_Owner区域,将监视器中的owner变量设置为当前线程。同时,monitor中的counter加1,如果线程调用wait()方法,当前持有的monitor会被释放,owner变量恢复为null,count会减1。同时线程进入WaitSet集合等待被唤醒。如果当前线程执行完毕,也会释放管程(锁)并重置变量的值,以便其他线程进入并获取管程(锁)。monitor对象存在于每个Java对象的对象头中(指向存储的指针),synchronized锁会通过这种方式获取锁,这也是Java中任何对象都可以作为锁使用的原因,也是顶层对象Object中存在notify/notifyAll/wait等方法的原因。适用场景偏向锁的优点:加锁和解锁不需要额外的消耗,与执行异步方法相比只有纳秒级的差距。缺点:如果线程之间存在锁竞争,会带来锁取消的额外消耗。适用场景:适用于只有一个线程访问synchronized块的场景。可能有同学会有疑惑,只有一个线程的场景适用什么鬼,一个线程加什么样的锁。要知道,有些锁不是想加就加。例如,您正在使用第三方库并在其中调用API。虽然你知道是在单线程下使用,不需要加锁,但是第三方库并不知道你调用的API恰好是用synchronized同步完成的。在这种情况下,使用偏向锁可以达到最高的性能。轻量级锁的优点:竞争线程不会被阻塞,提高了程序的响应速度。缺点:如果从不竞争锁的线程使用自旋,会消耗CPU。适用场景:追求响应时间。同步块执行得非常快。重量级锁的优点:线程竞争不使用自旋,不消耗CPU。缺点:线程阻塞,响应时间慢。适用场景:追求吞吐量。同步块执行时间更长。总结1.synchronized是可重入锁,是一种非公平可重入锁。所以,如果场景比较复杂,就要考虑其他显式锁,比如Reentrantlock,CountDownLatch等。2.Synchronized有一个锁升级过程。当存在线程竞争时,除了互斥锁本身,还有额外的CAS操作开销。因此,在竞争的情况下,synchronized会有一定的性能损失。本文转载自微信公众号“古风筝”,可通过以下二维码关注。转载本文请联系古风筝公众号。