【Java并发编程】锁机制(二):synchronized原理&锁膨胀分析

在文章的开头先明确几个概念:

  • 并发:多个线程同时操作同一个对象,并要修改其实例变量
    • final修饰的实例变量线程安全,因为不可变只能初始化一次
  • 锁:OS的调度无法满足同步的需求,需要程序通过调度算法协助调度
    • synchronized:jvm级别锁
    • Lock:api界别
  • synchronize:对象的锁,锁的代码
    • 通过只允许一个线程执行sync内代码,保证了可见性,有序性,原子性
  • 并发要求线程交替执行(时间片),而拿了锁会一直将任务执行完再释放(即使n时间片)
    • java的线程通信实际是共享内存
  • synchronized是独占锁,悲观锁,非公平锁,可重入锁
    • 独占锁 & 共享锁(读锁、写锁)
    • 公平锁 & 非公平锁
    • 悲观锁 & 乐观锁(CAS:注意ABA问题)
    • 可重入锁 & 不可重入锁

1.synchronized 原理

1.1 偏向锁(Mark Word)

1.1.1 特点&原理

  • 特点:无实际竞争
  • 原理:CAS
  • 注:偏向锁在Java 6和Java 7里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

1.1.2 获取锁的过程

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程
    • 如果是,进入步骤(5)
    • 否则进入步骤(3)
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。
    • 如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5)
    • 如果竞争失败,执行(4)
  4. 如果CAS获取偏向锁失败,则表示有竞争(CAS获取偏向锁失败说明至少有过其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点(safepoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁)
    • 如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;
    • 如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
  5. 执行同步代码。

1.1.3 释放锁过程

如上步骤(4)。偏向锁使用了一种等到竞争出现才释放偏向锁的机制:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

1.2 轻量级锁(Mark Word)

1.2.1 特点&原理

  • 特点:少量竞争且持续时间较短
  • 原理:自旋 + CAS
    • 自适应自旋:一般10次
    • 自定义自旋次数:设置 preBlockSpin
  • 自旋锁
    • 引入自旋锁的原因:互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
    • 自旋锁:让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启;在JDK1.6中默认开启。
    • 自旋锁的缺点:自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,例如让其循环10次,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。通过参数-XX:PreBlockSpin可以调整自旋次数,默认的自旋次数为10
    • 自适应的自旋锁:JDK1.6引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
    • 自旋锁使用场景:从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。(见前面“轻量级锁”)

1.2.2 获取锁

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示。

    img

  2. 拷贝对象头中的Mark Word复制到锁记录中。拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。

    • 如果更新成功,则执行步骤(3)
    • 否则执行步骤(4)
  3. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。

    img

  4. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧

    • 如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行
    • 否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。
      但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就

1.2.3 锁释放过程

  • (1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
  • (2)如果替换成功,整个同步过程就完成了。
  • (3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的程。

1.3 重量级锁(MutexLock)

只有重量级锁才能叫真正意义的锁,偏向锁与轻量级锁相当于无锁(并无线程被阻塞让出CPU)

1.3.1 特点&原理

  • 特点:强竞争,持锁时间长(阻塞状态:不能让这么多竞争线程一致占用CPU啊)
  • 原理:monitor对象(借助mutex互斥量实现调度算法)
    • 每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被 synchronized修饰的同步方法或者代码块时,该线程得先 获取到synchronized修饰的对象对应的monitor。
    • 加了同步代码块以后,在字节码中会看到一个 monitorenter和monitorexit。
      • monitorenter表示去获得一个对象监视器。
      • monitorexit表 示释放 monitor 监视器的所有权,使得其他被阻塞的线程 可以尝试去获得这个监视器 monitor
    • 计数器
      • =0 -> 锁未被持有
      • !=0 -> 该对象的锁已被某一线程持有
      • +1 -> 某一线程获得锁时计数器+1,重入再+1
      • -1 -> 持锁线程释放锁时计数器-1,重入需要再-1

依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能

扫描二维码关注公众号,回复: 11712058 查看本文章

1.3.2 获取锁过程

在这里插入图片描述

2.Mark Word 与锁膨胀

2.1 各种锁状态的Mark Word

下表是各种锁对应的Mark Word情况:

2.2 锁膨胀过程中Mark Word变化

下图是锁膨胀过程中Mark Word 的变化情况:

  1. 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
  2. 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
  3. 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

猜你喜欢

转载自blog.csdn.net/weixin_43935927/article/details/108652924