Synchronized、偏向锁、自旋锁、轻量级锁以及锁的升级过程

之前写过一篇文章,简单介绍了一下 线程安全问题以及线程安全的实现方法

在上篇文章中我们提到了, synchronized,以及synchronized是Java里一个重量级的操作,但是同时我们又说从JDK1.6之后synchronized性能有了大幅上升,那,为什么JDK1.6之后它的性能就大幅提升了呢?

所以,今天我们就来探讨一下

一、synchronized的实现原理

在Java中最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和要解锁的对象。

如果Java代码里synchronized同步块儿明确指定了这个对象参数,那这个参数就是明确指定的这个对象。如果没有明确指定的话,那就根据synchronized关键字修饰的实例方法还是静态方法(类方法),去取对应的对象实例或者取当前类对应的Class类的对象来作为锁对象。

根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取锁对象,如果这个对象没被锁定,或者当前线程已经拥有这个对象锁的话,就把锁计数器加1,相应的在执行monitorexit指令的时候,锁计数器减1,当计数器为0时,锁被释放。如果获取锁对象失败的话,那当前的线程就需要阻塞等待,直到对象锁被另一个线程释放为止。

那当一个线程在进入synchronized同步代码块儿之前,它是怎么获取这个锁对象呢?

想要搞明白这个,我们就需要回顾一下之前讲的 对象的内存结构

如图所示,对象的结构包括三部分:对象头、实例数据、对齐填充

  • 对象头:又分为两部分
  1. Mark Word (标记字段):如用于存储对象自身的运行时数据,哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别位32bit和64bit
  2. Klass Pointer(类型指针)。Klass Point 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 实例数据:实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的
  • 对齐填充:对齐填充并不是必然存在的,也没有特别的含义,由于虚拟机要求对象起始地址必须是8字节的整数倍,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

了解了对象的内存结构后,我们继续想:那synchronized锁对象是储存在哪里的呢?

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

答案是存在对象头的Mark Word里,所以,我们来看一下Mark Word存储了哪些内容?

因为对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

也就是说,Mark Word会随着程序的运行发生变化,变化状态如下 (32位虚拟机):

二、为什么说Synchronized是重量级的锁

Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从操作系统的用户态转换到核心态,这个转换需要消耗很多的资源和处理器时间,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

对于简单的代码同步块儿,很可能执行代码的时间还没这个状态转换消耗的时间长。所以synchronized是Java里一个重量级的操作,一般有经验的程序员都会在确实有必要的情况下才会去使用这种操作。而JDK1.6之后,虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞一个线程之前,会加入一段自旋等待的过程(自旋锁)来避免过于频繁的切入到核心态。

三、JDK1.6之后的优化以及锁的升级过程

JDK1.6之后加入了各种锁优化技术,其中就包括偏向锁,轻量级锁等。

首先要明白一点:偏向锁,自旋锁,轻量级锁等这些锁,不是来取代替换synchronized重量级锁的,它们是来优化synchronized重量级锁的。在Java早期版本中,synchronized属于重量级锁,因为操作系统实现线程之间的切换时需要从用户态转换到核心态,这个切换需要消耗CPU资源和CPU时间,效率低下。所以在JDK1.6之后Java官方对从JVM层面对synchronized进行了较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后之后,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁等一系列的锁优化手段。

synchronized锁有四种状态,无锁,偏向锁,轻量级锁,重量级锁,这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态

偏向锁
为什么要引入偏向锁?

因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。它的目的是消除有同步但无竞争的程序性能。有一点需要注意:偏向锁不会主动释放锁。

偏向锁原理和升级过程

当线程1进入同步代码块儿时,它首先需要获取锁对象,如果当前的锁对象的状态是无锁的时候,线程1就可以顺利的获取到锁对象,然后它会把当前线程的ThreadID记录到锁对象的对象头Mark Word里,下次再进入同步代码块的时候,会拿着Mark Word里存的ThreadID和当前的线程ID进行对比,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;

如果不一致,也就是其他线程,如线程2要竞争锁对象,由于偏向锁不会主动释放锁,因此还是存储的线程1的ThreadID,所以此时需要查看锁对象对象头Mark Word中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争,并将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么此时需要撤销偏向锁,升级为轻量级锁。

轻量级锁
为什么要引入轻量级锁?

偏向锁考虑的是有同步无竞争时程序的效率,而轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

轻量级锁原理和升级过程

线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;

这里为什么要使用CAS来替换呢?

因为如果在线程1复制对象头的同时(在线程1CAS之前),其他线程(比如线程2)也在竞争这把锁,所以需要用CAS的方式来保证操作的安全性。

如果线程1复制对象头MarkWord的同时(在线程1CAS之前),线程2也复制了MarkWord到线程2的栈帧中,但是线程2没有线程1快,线程1先抢到了这把锁(意思就是线程1先CAS更新替换成功),那此时线程2怎么办呢?此时,线程2会进入自旋等待,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次。如果线程2的自旋次数到了上限了,线程1还没有释放锁,此时轻量级锁就会膨胀为重量级锁;或者线程1还在执行中且线程2自旋次数还未达上限,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁也要膨胀为重量级锁。升级成重量级锁之后,重量级锁会把除了拥有锁的线程都阻塞掉,防止CPU空转。

几种锁的优缺点以及适用场景

发布了108 篇原创文章 · 获赞 103 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/ju_362204801/article/details/104559007