数据结构论坛

首页 » 分类 » 问答 » Synchronized的原理及其实现
TUhjnbcbe - 2023/7/3 21:01:00

我们知道Java内存模型为了保证多线程安全访问有三个特征:

1.原子性(Atomicity):

JMM保证单个变量读写操作的原子性

但是在多CPU环境引入多级缓存后,写操作的原子性意义扩大了,对一个变量的写,不能实时刷新至主内存,导致别的CPU缓存内的数据是旧的,

volatile修饰的变量保证多CPU下读写操作的原子性

注:与synchronized的原子性不同,因为volatile只修饰变量,volatile的原子性是受限制的,只代表一次读写指令的原子性

像i++,newObject()这种多个读写外的指令操作无法保证其原子性

对于更大范围的原子应用场景,提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,即synchronized关键字

2.可见性(Visibility):

当一个线程修改了共享变量的值,其他线程能够立即得知这个修改

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新

还有两个关键字能实现可见性,即synchronized和final

同步块的可见性

线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性

3.有序性(Ordering):

volatile的有序指禁止指令重排序,

synchronized的有序是指线程互斥

final域也可禁止指令重排

synchronised

互斥同步是常见的并发正确性保障方式。Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

对于普通同步方法,锁是当前实例对象。

对于静态同步方法,锁是当前类的Class对象。

对于同步方法块,锁是Synchonized括号里配置的对象。

方法级的同步是隐式的,无须通过字节码指令来控制,虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法

代码块的同步,在编译时会插入monitorenter和monitorexit两条指令,实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持,如图:

为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令

monitor每个对象都关联着一个monitor,只能被唯一获取monitor权限的线程锁定。锁定后,其他线程请求会失败,进入等待集合,线程随之被阻塞。

monitorenter这个命令就是用来获取监视器的权限,每进入1次就记录次数加1,也就是同一线程说可重入。而其他未获取到锁的只能等待。monitorexit拥有该监视器的线程才能使用该指令,且每次执行都会将累计的计数减1,直到变为0就释放了所有权。在此之后其他等待的线程就开始竞争该监视器的权限monitor是用c++实现的

ObjectMonitor其中:_count:monitor通过维护一个计数器来记录锁的获取,重入,释放情况_owner:指向持有ObjectMonitor对象的线程_WaitSet:处于wait状态的线程,会被加入到_WaitSet_EntryList:处于等待锁block状态的线程,会被加入到该列表ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiterJava对象头java对象由如下三部分组成:1.对象头:Markword和klasspointer两部分组成,如果是数组,还包括数组长度2.实例数据3.对齐填充

1、bit--位:位是计算机中存储数据的最小单位,指二进制数中的一个位数,其值为“0”或“1”。2、byte--字节:字节是计算机存储容量的基本单位,一个字节由8位二进制数组成。在计算机内部,一个字节可以表示一个数据,也可以表示一个英文字母,两个字节可以表示一个汉字3.一字宽等于一个机器码等于4个byte或8个字节,即32bit或64bit1B=8bit1Byte=8bit1KB=Byte(字节)=8*bit1MB=KB1GB=MB1TB=GB

基本类型占用的字节数Markword存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,大小为32Bit或64Bit,被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间KlassWord里面存的是一个地址,是一个指向当前对象所属于的类的地址,可以通过这个地址获取到它的元数据信息。占32Bit4个字节或64Bit8个字节,64位JVM会默认使用选项+UseCompressedOops开启指针压缩,将指针压缩至32Bit。上面截图中的klasspointerf8(),4个字节32BitLength数组长度占4个字节(对象是数组的话)()(3),3个长度实例数据12java.lang.StringString;.elements,因为一个String对象占4个字节,所以3个长度的数组占12个字节对齐填充4(lossduetothenextobjectalignment),Java对象占用空间是8字节对齐的,即所有Java对象占用字节数必须是8的倍数,填充4个字节故共32字节Instancesize:32bytessynchronized用的锁是存在Java对象头里的,即Markword中,而其数据结构是根据对象的状态决定的,其数据结构如图:

lock:锁状态标记位,该标记的值不同,整个markword表示的含义不同。biased_lock:偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁age:JavaGC标记位对象年龄,4位的表示范围为0-15,因此对象经过了15次垃圾回收后如果还存在,则肯定会移动到老年代中,即转10进制1x20+1x21+1x22+1x23,对象年龄阈值可设置(-XX:MaxTenuringThreshold最大值只能是15)identity_hashcode:对象标识Hash码,采用延迟加载技术。当对象使用HashCode()计算后,并会将结果写到该对象头中。当对象被锁定时,该值会移动到线程Monitor中thread:持有偏向锁的线程ID和其他信息。这个线程ID并不是JVM分配的线程ID号,和JavaThread中的ID是两个概念epoch:偏向时间戳。ptr_to_lock_record:指向栈中锁记录的指针。ptr_to_heavyweight_monitor:指向线程Monitor的指针。无锁状态示例:

无锁状态对象头说明:对象头前8个字节按照平时习惯的从高位到低位的展示为二进制机器码进制字节码e所以:无锁状态前25未使用,即0调用hashCode方法后,identity_hashcode31位为:位未使用:04位分代年龄:位偏向锁标志:0两位标记状态:01偏向锁状态示例

延时4秒

延时5秒由上面两图对比可知:偏向锁状态受时间范围影响,在4秒内,即使开启了偏向锁,依然是无锁状态,等待5秒后,锁状态为,但是调用hashCode方法后,状态撤销为,其实,我们可以设置这个时间及关闭偏向锁

VM设置-XX:BiasedLockingStartupDelay=0

设置偏向锁延迟时间为0后,初始锁状态为偏向锁和hashCode方法为保证一个对象的identityhashcode只能被底层JVM计算一次,即保证多次获取到的identityhashcode的值是相同的,当对象的hashCode()方法(非用户自定义,即未重写)第一次被调用时,JVM会生成对应的identityhashcode值,并将该值存储到MarkWord中。后续如果该对象的hashCode()方法再次被调用则不会再通过JVM进行计算得到,而是直接从MarkWord中获取。故需保证在锁升级过程中identityhashcode值不能被覆盖。当一个对象已经计算过identityhashcode,它就无法进入偏向锁状态;当一个对象当前正处于偏向锁状态,并且需要计算其identityhashcode的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁。当一个对象当前正处于可偏向状态,计算identityhashcode的后,则偏向锁标志置为0轻量级锁的实现中,会通过线程栈帧的锁记录存储DisplacedMarkWord;重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的markword,其中可以存储identityhashcode的值那什么时候对象会计算identityhashcode呢?当然是当你调用未覆盖的Object.hashCode()方法或者System.identityHashCode(Objecto)时候了为什么需要偏向锁大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能为什么有BiasedLockingStartupDelay时间控制JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁(必定是有多线程竞争的,引入偏向锁反而消耗时间)。为了减少初始化时间,JVM默认延时加载偏向锁看到上面的示例,可能有个疑问:不是偏向锁会在对象头记录偏向的线程id吗?是指此时对象没有偏向任何线程,仅是标志可偏向状态

加synchronized后,JVM会设置偏向的线程id,01111thread54位:011110epoch2位:位未使用:04位分代年龄:位偏向锁标记标志:1两位标记状态:01调用hashCode()方法后,膨胀或升级为重量锁011110为重量锁,即monitor对象的指针轻量级锁状态示例恢复4s延迟,偏向锁不能设置

代码中有synchronized关键字加锁,但jvm在执行时,不存在并发问题,而偏向锁暂不能设置,这时jvm会优化成轻量级锁(如果代码延迟5秒,锁状态为偏向),调用hashCode()方法后,既要保证记录当前的锁,又要记录hashCode的,故JVM实现锁升级为重量级锁Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的MutexLock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统MutexLock所实现的锁我们称之为重量级锁。JavaSE1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级锁的升级过程偏向锁延迟的时间内且不需要获取锁,无锁状态不需要获取锁,偏向锁状态下,调用hashCode,撤销为无锁状态偏向锁延迟的时间后,不需要获取锁,可偏向状态(未指向线程id),需要获取锁,偏向记录线程id偏向锁延迟的时间内,需要获取锁,轻量级锁状态关闭偏向锁:-XX:-useBiasedLocking,关闭后程序需要获取锁默认会进入轻量级锁状态对象头默认是无锁或可偏向状态,取决于是否开启偏向锁和偏向延迟时间,遇synchronized关键字时,根据是否开启偏向,当前时间与虚拟机开启的时间是否已经超过偏向延迟时间,设置状态位锁的升级与撤销并不一定必须是有其他线程参与竞争,首次调用hashCode,也会影响锁的状态锁的升级并不是严格按级别升级的,偏向状态可直接升级为重量级锁

如图,程序延迟5秒后,创建App对象,JVM设置对象头为无锁状态,遇synchronized关键字时,设置为偏向状态(如果没有延迟5秒执行,此时设置为轻量级状态),首次调用hashCode后,直接升级为重量级锁状态偏向锁要比无锁多了线程ID和epoch,当一个线程访问同步代码块并获取锁时,会在对象头和栈帧的记录中存储线程的ID(CAS操作),等到下一次线程在进入和退出同步代码块时就不需要进行CAS操作进行加锁和解锁,只需要简单判断一下对象头的MarkWord中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的引入偏向锁在无多线程竞争情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取以及释放依赖多次CAS原子指令,而偏向锁只需要在置换线程ID的时候依赖一次CAS原子指令就可以了。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁或者轻量级锁状态。偏向锁在JDK6以及以后的JVM中是默认开启的偏向锁的获取过程首先获取锁对象的Markword,判断是否处于可偏向状态。(biased_lock=1、且ThreadId为空)(偏向延迟时间过后,需要获取锁时,JVM设置为可偏向状态),确认为可偏向状态。如果锁的标志是0,应该有个判断,即获取锁与虚拟机开启的时间是否已经超过偏向延迟时间,超过了,通过CAS操作来竞争获取锁,否则走轻量级锁流程如果是可偏向状态,则通过CAS操作,把当前线程的ID写入到MarkWord–如果cas成功,那么markword就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块–如果cas失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行如果是已偏向状态,需要检查markword中存储的ThreadID是否等于当前线程的ThreadID–如果相等,不需要再次获得锁,可直接执行同步代码块–如果不相等,则表示有竞争。当到达全局安全点(SafePoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头置为无锁状态(标志位为01),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁的状态(标志位为00),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁执行同步代码偏向锁的释放

偏向锁在遇到其他线程竞争锁时,持有偏向锁的线程才会释放锁(保证一个线程重入不需要每次都CAS置换相同的线程id),线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁是否处于被锁定状态,撤销偏向锁后恢复到无锁(标志位为01,线程不会主动释放锁,可能到安全点时,线程已结束)或轻量级锁(标志位为00)的状态

轻量级锁轻量级锁是指当前锁是偏向锁的时候,被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。轻量级锁也就是自旋锁,利用CAS尝试获取锁。如果你确定某个方法同一时间确实会有一堆线程访问,而且工作时间还挺长,那么我建议直接用重量级锁,不要使用synchronized,因为在CAS过程中,CPU是不会进行线程切换的,这就导致CAS失败的情况下他会浪费CPU的分片时间,都用来干这个事了加锁过程在代码进入同步块的时候,如果同步对象锁状态为无锁状态或偏向,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(LockRecord)的空间,用于存储锁对象目前的MarkWord的拷贝,然后拷贝对象头中的MarkWord复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的MarkWord更新为指向LockRecord的指针,并将LockRecord里的owner指针指向对象的MarkWord。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为00,表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象的MarkWord是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,自适应自旋,当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁,锁标志的状态值变为10,MarkWord中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态轻量级锁解锁轻量级解锁时,会使用原子的CAS操作将DisplacedMarkWord替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁,为避免无用的自旋,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争

重量级锁重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

上图简单描述多线程获取锁的过程,当多个线程同时访问一段同步代码时,首先会进入EntrySet当线程获取到对象的monitor后进入TheOwner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒。被唤醒后加入到EntrySet参与竞争(非公平),若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因偏向锁:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。轻量级锁:当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让它申请的线程进入阻塞,性能降低。自旋锁:自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

看synchronized的时候,发现被阻塞的线程什么时候被唤醒,取决于获得锁的线程什么时候执行完同步代码块并且释放锁。那怎么做到显示控制呢?我们就需要借助一个信号机制:在Object对象中,提供了wait/notify/notifyall,可以用于控制线程的状态wait/notify/notifyall基本概念wait:当前线程就会从执行状态转变成等待状态,同时释放在实例对象上的锁。直到其它线程在刚才那个实例对象上调用notify方法并且释放实例对象上的锁,那么当前线程才会加入到EntrySet参与竞争或直接自旋(根据策略)再次尝试获取实例对象锁并且继续执行notify:表示持有对象锁的线程通知jvm唤醒WaitSet中第一个ObjectWaiter节点,根据不同的策略,将取出来的ObjectWaiter节点,加入到_EntryList或则通过Atomic::cmpxchg_ptr指令进行自旋操作cxqnotifyAll:notifyall和notify的区别在于,notifyAll会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程A释放锁之后,所有被唤醒的线程都有可能获得对象锁权限注意:三个方法都必须在synchronized同步关键字所限定的作用域中调用(一定要理解同步的原因),否则会报错java.lang.IllegalMonitorStateException,意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法

1
查看完整版本: Synchronized的原理及其实现