1. 首页
  2. >
  3. 编程技术
  4. >
  5. Java

Synchronized的原理及其实现

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

1.原子性(Atomicity):

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

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

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

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

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

对于更大范围的原子应用场景,提供了更高层次的字节码指令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虚拟机两者共同协作支持,如图:

Synchronized的原理及其实现

Synchronized的原理及其实现

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

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

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


    Synchronized的原理及其实现

    ObjectMonitor

    其中:

    _count:monitor通过维护一个计数器来记录锁的获取,重入,释放情况

    _owner:指向持有ObjectMonitor对象的线程

    _WaitSet:处于wait状态的线程,会被加入到_WaitSet

    _EntryList:处于等待锁block状态的线程,会被加入到该列表

    ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter


    Java对象头

    java对象由如下三部分组成:

    1. 对象头:Mark word和klasspointer两部分组成,如果是数组,还包括数组长度

    2. 实例数据

    3. 对齐填充


    Synchronized的原理及其实现

    Synchronized的原理及其实现

    1、bit --位:位是计算机中存储数据的最小单位,指二进制数中的一个位数,其值为“0”或“1”。
    2、byte --字节:字节是计算机存储容量的基本单位,一个字节由8位二进制数组成。在计算机内部,一个字节可以表示一个数据,也可以表示一个英文字母,两个字节可以表示一个汉字

    3.一字宽等于一个机器码等于4个byte或8个字节,即32bit或64bit

    1B=8bit
    1Byte=8bit
    1KB=1024Byte(字节)=8*1024bit
    1MB=1024KB
    1GB=1024MB
    1TB=1024GB

    Synchronized的原理及其实现

    基本类型占用的字节数

    Mark word

    存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,大小为32Bit或64Bit,被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间

    Klass Word

    里面存的是一个地址,是一个指向当前对象所属于的类的地址,可以通过这个地址获取到它的元数据信息。占32Bit4个字节或64Bit8个字节,64位 JVM会默认使用选项 +UseCompressedOops 开启指针压缩,将指针压缩至32Bit。上面截图中的klass pointer

    43 37 00 f8 (01000011 00110111 00000000 11111000),4个字节32Bit

    Length

    数组长度占4个字节(对象是数组的话)

    03 00 00 00 (00000011 00000000 00000000 00000000) (3),3个长度

    实例数据

    12 java.lang.String String;.<elements>,因为一个String对象占4个字节,所以3个长度的数组占12个字节

    对齐填充

    4 (loss due to the next object alignment),

    Java对象占用空间是8字节对齐的,即所有Java对象占用字节数必须是8的倍数,填充4个字节

    故共32字节 Instance size: 32 bytes


    synchronized用的锁是存在Java对象头里的,即Mark word中,而其数据结构是根据对象的状态决定的,其数据结构如图:

    Synchronized的原理及其实现

    Synchronized的原理及其实现

    lock: 锁状态标记位,该标记的值不同,整个mark word表示的含义不同。

    biased_lock:偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁

    age:Java GC标记位对象年龄,4位的表示范围为0-15,因此对象经过了15次垃圾回收后如果还存在,则肯定会移动到老年代中,即转10进制1x20+1x21+1x22+1x23对象年龄阈值可设置

    -XX:MaxTenuringThreshold最大值只能是15

    identity_hashcode:对象标识Hash码,采用延迟加载技术。当对象使用HashCode()计算后,并会将结果写到该对象头中。当对象被锁定时,该值会移动到线程Monitor中

    thread:持有偏向锁的线程ID和其他信息。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念

    epoch:偏向时间戳。

    ptr_to_lock_record:指向栈中锁记录的指针。

    ptr_to_heavyweight_monitor:指向线程Monitor的指针。

    无锁状态示例:

    Synchronized的原理及其实现

    无锁状态对象头

    说明:

    对象头前8个字节按照平时习惯的从高位到低位的展示为

    二进制机器码

    00000000 00000000 00000000 01001000 01100100 00111110 01010011 00000001

    16进制字节码

    00 00 00 48 53 3e 64 01

    所以:无锁状态前25未使用,即00000000 00000000 00000000 0

    调用hashCode方法后,identity_hashcode31位为:1001000 01100100 00111110 01010011

    1位未使用:0

    4位分代年龄:0000

    1位偏向锁标志:0

    两位标记状态:01


    偏向锁状态示例

    Synchronized的原理及其实现

    延时4秒


    Synchronized的原理及其实现

    延时5秒

    由上面两图对比可知:

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

    Synchronized的原理及其实现

    Synchronized的原理及其实现

    VM设置-XX:BiasedLockingStartupDelay=0

    Synchronized的原理及其实现

    设置偏向锁延迟时间为0后,初始锁状态为 1 01

    偏向锁和hashCode方法

    为保证一个对象的identity hash code只能被底层JVM计算一次,即保证多次获取到的identity hash code的值是相同的,当对象的hashCode()方法(非用户自定义,即未重写)第一次被调用时,JVM会生成对应的identity hash code值,并将该值存储到Mark Word中。后续如果该对象的hashCode()方法再次被调用则不会再通过JVM进行计算得到,而是直接从Mark Word中获取。故需保证在锁升级过程中identity hash code值不能被覆盖。

    当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁

    当一个对象当前正处于可偏向状态,计算identity hash code的后,则偏向锁标志置为0

    轻量级锁的实现中,会通过线程栈帧的锁记录存储Displaced Mark Word;重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值


    那什么时候对象会计算identity hash code呢?当然是当你调用
    未覆盖的Object.hashCode()方法或者System.identityHashCode(Object o)时候了

    为什么需要偏向锁

    大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能

    为什么有BiasedLockingStartupDelay时间控制

    JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁(必定是有多线程竞争的,引入偏向锁反而消耗时间)。为了减少初始化时间,JVM默认延时加载偏向锁

    看到上面的示例,可能有个疑问:不是偏向锁会在对象头记录偏向的线程id吗?

    是指此时对象没有偏向任何线程,仅是标志 可偏向状态

    Synchronized的原理及其实现

    synchronized后,JVM会设置偏向的线程id,

    00000000 00000000 00000000 00000000 00000010 11011011 11101000 00000101

    thread 54位:00000000 00000000 00000000 00000000 00000010 11011011 111010

    epoch 2位:00

    1位未使用:0

    4位分代年龄:0000

    1位偏向锁标记标志:1

    两位标记状态:01

    调用hashCode()方法后,膨胀或升级为重量锁

    00000000 00000000 00000000 00000000 00011100 11000000 00000110 01001010

    00000000 00000000 00000000 00000000 00011100 11000000 00000110 010010

    为重量锁,即monitor对象的指针

    轻量级锁状态示例

    恢复4s延迟,偏向锁不能设置

    Synchronized的原理及其实现

    代码中有synchronized关键字加锁,但jvm在执行时,不存在并发问题,而偏向锁暂不能设置,这时jvm会优化成轻量级锁(如果代码延迟5秒,锁状态为偏向),调用hashCode()方法后,既要保证记录当前的锁,又要记录hashCode的,故JVM实现锁升级为重量级锁


    Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁

    Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁轻量级锁:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级

    锁的升级过程

    偏向锁延迟的时间内且不需要获取锁,无锁状态

    不需要获取锁,偏向锁状态下,调用hashCode,撤销为无锁状态

    偏向锁延迟的时间后,不需要获取锁,可偏向状态(未指向线程id),需要获取锁,偏向记录线程id

    偏向锁延迟的时间内,需要获取锁,轻量级锁状态

    关闭偏向锁:-XX:-useBiasedLocking,关闭后程序需要获取锁默认会进入轻量级锁状态

    对象头默认是无锁或可偏向状态,取决于是否开启偏向锁和偏向延迟时间,遇 synchronized关键字时,根据是否开启偏向,当前时间与虚拟机开启的时间是否已经超过偏向延迟时间,设置状态位

    锁的升级与撤销并不一定必须是有其他线程参与竞争,首次调用hashCode,也会影响锁的状态

    锁的升级并不是严格按级别升级的,偏向状态可直接升级为重量级锁

    Synchronized的原理及其实现

    如图,程序延迟5秒后,创建App对象,JVM设置对象头为无锁状态,遇 synchronized关键字时,设置为偏向状态(如果没有延迟5秒执行,此时设置为轻量级状态),首次调用 hashCode后,直接升级为重量级锁状态

    偏向锁要比无锁多了线程IDepoch,当一个线程访问同步代码块并获取锁时,会在对象头和栈帧的记录中存储线程的ID(CAS 操作),等到下一次线程在进入和退出同步代码块时就不需要进行 CAS 操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的

    引入偏向锁在无多线程竞争情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取以及释放依赖多次CAS原子指令,而偏向锁只需要在置换线程ID的时候依赖一次CAS原子指令就可以了。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁或者轻量级锁状态。偏向锁在JDK6以及以后的JVM中是默认开启的

    偏向锁的获取过程

    1. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)偏向延迟时间过后,需要获取锁时,JVM设置为可偏向状态), 确认为可偏向状态。如果锁的标志是0,应该有个判断,即获取锁与虚拟机开启的时间是否已经超过偏向延迟时间,超过了,通过 CAS 操作来竞争获取锁,否则走轻量级锁流程
    2. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
      – 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块
      – 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
    3. 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
      – 如果相等,不需要再次获得锁,可直接执行同步代码块
      – 如果不相等,则表示有竞争。当到达全局安全点(SafePoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头置为无锁状态(标志位为01),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁的状态(标志位为00),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁
    4. 执行同步代码

    偏向锁的释放

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

    Synchronized的原理及其实现

    轻量级锁

    轻量级锁是指当前锁是偏向锁的时候,被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

    轻量级锁也就是自旋锁,利用CAS尝试获取锁。如果你确定某个方法同一时间确实会有一堆线程访问,而且工作时间还挺长,那么我建议直接用重量级锁,不要使用synchronized,因为在CAS过程中,CPU是不会进行线程切换的,这就导致CAS失败的情况下他会浪费CPU的分片时间,都用来干这个事了


    加锁过程

    在代码进入同步块的时候,如果同步对象锁状态为无锁状态或偏向,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。

    Synchronized的原理及其实现

    拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record里的 owner 指针指向对象的 Mark Word。

    如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为 00 ,表示此对象处于轻量级锁定状态。

    Synchronized的原理及其实现

    如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,自适应自旋,当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁,锁标志的状态值变为 10 ,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态

    轻量级锁解锁

    轻量级解锁时,会使用原子的CAS操作将Displaced MarkWord替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁,为避免无用的自旋,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争

    Synchronized的原理及其实现

    重量级锁

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

    Synchronized的原理及其实现

    上图简单描述多线程获取锁的过程,当多个线程同时访问一段同步代码时,首先会进入 Entry Set当线程获取到对象的 monitor 后进入 The Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner变量恢复为 null,count自减1,同时该线程进入 WaitSet 集合中等待被唤醒。被唤醒后加入到Entry Set参与竞争(非公平), 若当前线程执行完毕也将释放 monitor (锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

    由此看来,monitor 对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象Object中的原因

    偏向锁:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

    轻量级锁:当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

    重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让它申请的线程进入阻塞,性能降低。

    自旋锁:自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

    Synchronized的原理及其实现

    看synchronized的时候,发现被阻塞的线程什么时候被唤醒,取决于获得锁的线程什么时候执行完同步代码块并且释放锁。那怎么做到显示控制呢?我们就需要借助一个信号机制:在 Object 对 象 中,提供了wait/notify/notifyall,可以用于控制线程的状态

    wait/notify/notifyall 基本概念

    wait:当前线程就会从执行状态转变成等待状态,同时释放在实例对象上的锁。直到其它线程在刚才那个实例对象上调用notify方法并且释放实例对象上的锁,那么当前线程才会加入到Entry Set参与竞争或直接自旋(根据策略)再次尝试获取实例对象锁并且继续执行


    notify:表示持有对象锁的线程通知 jvm
    醒WaitSet中第一个ObjectWaiter节点,根据不同的策略,将取出来的ObjectWaiter节点,加入到_EntryList或则通过Atomic::cmpxchg_ptr指令进行自旋操作cxq


    notifyAll:notifyall 和 notify 的区别在于,notifyAll 会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限


    注意:三个方法都必须在synchronized同步关键字所限定的作用域中调用(一定要理解同步的原因),否则会报错java.lang.IllegalMonitorStateException,意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法

    微服务失败的 11 个原因
    « 上一篇 2021年01月31日 am09:28
    一个支持将html转为PDF、图片,且支持PDF加水印的项目
    下一篇 » 2021年02月01日 pm13:10

    相关推荐