JUC-锁相关基础知识

基础知识之一:锁的类型

锁从宏观上分类,分为悲观锁与乐观锁。

乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

Java中乐观锁的案例

ConcurrentHashMap

ConcurrentHashMap是Java中的一个线程安全的哈希表实现,它使用分离锁(Segment)来保证线程安全。每个Segment都是一个独立的哈希表,每个操作只锁定相关的Segment,因此可以支持更高的并发性。

ConcurrentHashMap使用了一种基于CAS的技术来实现乐观锁,它通过比较当前的value和预期的value是否相等来判断是否存在冲突。如果存在,则返回失败;如果不存在,则执行更新操作。

LongAdder

在 JDK1.8 中,Java 提供了一个新的原子类 LongAdder。LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好,代价就是会消耗更多的内存空间。

LongAdder 的原理就是降低操作共享变量的并发数,也就是将对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的 value 值进行 CAS 操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的 value 值相加,返回一个近似准确的数值。

数据库并发控制

在日常开发中,使用乐观锁最常见的场景就是数据库的更新操作了。

为了保证操作数据库的原子性,我们常常会为每一条数据定义一个版本号,并在更新前获取到它,到了更新数据库的时候,还要判断下已经获取的版本号是否被更新过,如果没有,则执行该操作。

CAS 乐观锁在平常使用时比较受限,它只能保证单个变量操作的原子性,当涉及到多个变量时,CAS 就无能为力了。

悲观锁

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。Java中的悲观锁就是Synchronized, AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

Java中的锁机制

在 Java 中主要2种加锁机制:

  1. synchronized 关键字

  2. java.util.concurrent.Lock (Lock是一个接口,ReentrantLock是该接口一个很常用的实现)

这两种机制的底层原理存在一定的差别

  • synchronized 关键字通过一对字节码指令 monitorenter/monitorexit 实现, 这对指令被 JVM 规范所描述。
  • java.util.concurrent.Lock 通过 Java 代码搭配sun.misc.Unsafe 中的本地调用实现的

基础知识之二:Java线程阻塞的代价

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

  • 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;

  • 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.6开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一

基础知识之三:Mark word

Java对象结构

在讲到锁之前,先来简单了解一下Java的对象结构。Java的对象结构主要包括对象头,实例数据,对齐填充三大部分。

对象头

对象头中存储了对象的Mark word,类型指针(元数据指针,指向方法区中的类元信息)和数组长度(只有当前对象为数组对象时才会有)。而Mark word又包括对象的Hashcode码,对象的分代年龄,对象的偏向锁ID,获取偏向锁的时间戳,锁标志位等。

Mark word主要用于存储对象自身运行时的数据,并且Mark word字段的长度与JVM的位数有关,32位的JVM虚拟机中Mark word占用32位的存储空间,64位的JVM虚拟机中占用64位的储存空间。

以32位的JVM虚拟机为例:

  • 是否是偏向锁表示:当值为0时,标记对象没有开启偏向锁,值为1表示开启了偏向锁。
  • 锁标志位表示:当前线程拥有的锁,不同状态下拥有的锁不同。
  • 对象分代年龄表示:当JVM发生GC垃圾回收时,新生代未被回收的对象在Eden区和Survivor之间的复制,每次复制是的分代年龄+1,默认情况下分代年龄达到15会被移到老年代区域,当然这个参数也可以自行设置。
  • 对象的Hashcode值:主要存储对象的Hashcode值。
  • 线程ID:表示在偏向锁状态下,持有偏向锁的线程编号。
  • 指向栈中锁记录的指针:在轻量级锁状态下,指向栈中所记录的指针。
  • 指向(互斥量)重量级锁的指针:在重量级锁状态下,指向对象监视器Monitor的指针。

实例数据

实例数据主要存储了对象的成员变量信息,比如成员变量的具体值,父类成员变量的具体值等。

对象填充

在HotSpot虚拟机中,对象的起始地址必须为8的整数倍,如果当前对象的实例变量占用的储存空间不是8的整数倍,则需要使用填充数据来保证对齐。

基础知识之四:JVM锁升级机制

synchronized 关键字之锁的升级(偏向锁->轻量级锁->重量级锁)

synchronized详解:https://juejin.cn/post/6977744582725681182

前面提到过, synchronized 代码块是由一对 *monitorenter/moniterexit * 字节码指令实现, monitor 是其同步实现的基础, Java SE1.6 为了改善性能, 使得 JVM 会根据竞争情况, 使用如下 3 种不同的锁机制

  1. 偏向锁(Biased Lock )
  2. 轻量级锁( Lightweight Lock)
  3. 重量级锁(Heavyweight Lock)

上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作。这三种机制的切换是根据竞争激烈程度进行, 在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。

synchronized的执行过程:

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

详细图

简略图

文章作者: GeYu
文章链接: https://nuistgy.github.io/2023/04/11/JUC-锁相关基础知识/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Yu's Blog