并发编程-锁

1 基础知识

1.1 锁的宏观分类

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

乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低。每次读数据的时候,都认为别的线程没有修改过数据,所以不会上锁;但是写数据的时候会判断一下其他线程有没有更新过该数据。java中的乐观锁基本上都是使用CAS实现的。

悲观锁就是一种悲观思想,认为写多读少,遇到并发写的可能性高。每次读数据的时候都认为会被其他线程修改,所以每次读写都会上锁。

1.2 java线程阻塞的代价

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

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

  • 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
  • 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

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

1.3 java的对象头

  • 字宽(Word): 内存大小的单位概念, 32 位处理器 1 Word = 4 Bytes, 64 位处理器 1 Word = 8 Bytes
  • 每一个 Java 对象都至少占用 2 个字宽的内存(数组类型占用3个字宽)。
    • 第一个字被称为Markword。 Markword包含了多种不同的信息, 其中就包含对象锁相关的信息。
    • 第二个字是指向类元数据信息(class metadata)的指针_klass,将在jvm部分介绍。

markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

状态 标志位 存储内容
重量级锁 10 执行重量级锁定的指针
轻量级锁 00 指向锁记录的指针
GC标记 11 空(不需要记录信息)
偏向锁 01 偏向线程ID、偏向时间戳、对象分代年龄
未锁定 01 对象哈希码、对象分代年龄

32位虚拟机在不同状态下markword结构如下图所示:
MarkWord

说明:

  • MarkWord 中包含对象 hashCode 的那种无锁状态是偏向机制被禁用时, 分配出来的无锁对象MarkWord 起始状态,无实际用途。
  • 偏向机制被启用时,分配出来的对象状态是 ThreadId|Epoch|age|1|01, ThreadId 为空时标识对象尚未偏向于任何一个线程, ThreadId 不为空时, 对象既可能处于偏向特定线程的状态, 也有可能处于已经被特定线程占用完毕释放的状态, 需结合 Epoch 和其他信息判断对象是否允许再偏向(rebias)。

2. java中的锁

2.1 自旋锁

原理简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要重复执行获取锁操作(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

缺点是线程自旋是需要消耗cup时间片,即cup在空转,若持有锁的线程需要长时间占用锁,线程自旋的消耗大于线程阻塞挂起操作的消耗,会造成CPU浪费,因此需要设定一个自旋等待的最大时间。

适用性 自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!

JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁。

适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

同时JVM还针对当前CPU的负荷情况做了较多的优化:

  1. 如果平均负载小于CPUs则一直自旋
  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  3. 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  4. 如果CPU处于节电模式则停止自旋
  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  6. 自旋时会适当放弃线程优先级之间的差异

2.2 偏向锁

Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。偏向锁,它会偏向于第一个访问锁的线程,如果在运行过程中,同步对象只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会让同步对象偏向该线程。
偏向线程运行过程中,若其他线程请求锁,则持有偏向锁的线程会被挂起,JVM会撤销该偏向锁,升级为轻量级锁。

2.2.1 jvm开启/关闭偏向锁

开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

关闭偏向锁:-XX:-UseBiasedLocking

2.2.2 偏向锁获取过程
  1. 访问对象的Mark Word确认是否为可偏向状态。(偏向锁的标识是为1,锁标志位为01,并且ThreadID为null表示可偏向状态)

    1
    2
    3
    4
    5
    // Indicates that the mark has the bias bit set but that it has not
    // yet been biased toward a particular thread
    bool is_biased_anonymously() const {
    return (has_bias_pattern() && (biased_locker() == NULL));
    }

    has_bias_pattern() 返回 true 时代表 markword 的可偏向标志 bit 位为 1 ,且对象头末尾标志为 01。

    biased_locker() == null 返回 true 时代表对象 Mark Word 中 bit field 域存储的 Thread Id 为空。

  2. 如果为可偏向状态,尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord

  3. 如果 CAS 操作失败, 则说明, 有另外一个线程 Thread B 抢先获取了该对象的偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。 该操作需要等待全局安全点 JVM safepoint ( 此时间点, 没有线程在执行字节码) 。

    1
    注意:到达安全点safepoint会导致stop the word,时间很短。
  4. 如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID 。

    • 如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块
    • 如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁
2.2.3 偏向锁的撤销

偏向锁只有遇到其他线程尝试竞争偏向锁时,偏向锁才会撤销,线程不会主动去释放偏向锁。

偏向锁的撤销,需要等待全局安全点,此时没有字节码正在执行。

jvm会根据markword中的偏向线程ID来判断锁对象是否处于被锁定状态,从而决定撤销偏向锁后恢复到未锁定“01”状态(偏向的线程已死)或轻量级锁“00”状态(偏向的线程还在执行)。

2.2.4 偏向锁的批量再偏向(Bulk Rebias)机制

那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗?

答案是可以, JVM 提供了批量再偏向机制机制(Bulk Rebias)

在偏向机制的工作原理如下:

  • 引入一个概念 epoch,其本质是一个时间戳 , 代表了偏向锁的有效性,从前文描述的对象头结构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。
  • 除了对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值
  • 每当遇到一个全局安全点时, 如果要对 class C 对象进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一个新的 epoch_new
  • 然后扫描所有持有 class C 对象的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_new 的值赋给被锁定的对象中。
  • 退出安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等, 则说明该对象的偏向锁已经无效了(更新epoch时,该对象处于未锁定状态), 可以尝试对此对象重新进行偏向操作。

总结如下:使用epoch,将已锁定的偏向锁和已失效的偏向锁区分开来,失效的偏向锁可以重新偏向。

2.2.5 升级成轻量级锁

偏向锁撤销后, 对象可能处于两种状态。

  • 无锁状态
  • 轻量级锁定状态

之所以会产生两种状态,是因为撤销偏向锁时,偏向锁可能处于两种状态:

第一种情况:原来已经获取了偏向锁的线程可能已经执行完了同步代码块, 使得对象处于 “闲置状态”,相当于原有的偏向锁已经过期无效了。此时该对象就应该被直接转换为无锁状态,无锁状态下有线程请求锁将进入轻量级锁定状态。

第二种情况:原来已经获取了偏向锁的线程也可能尚未执行完同步代码块, 偏向锁依旧有效, 此时对象就应该直接被转换为轻量级加锁的状态,具体做法是先阻塞偏向线程,在线程的栈桢中创建锁记录,再将markword修改为轻量级锁状态。

2.2.6 偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,所以高并发的应用会禁用掉偏向锁

2.3 轻量级锁

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

轻量级锁的加锁过程:

  1. 在代码进入同步块之前,如果同步对象锁状态为无锁状态(偏向锁标志为“0”,锁标志位为“01”),JVM会在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的MarkWord复制到锁记录中,官方称为Displaced Mark Word。

  1. 然后线程尝试使用CAS将对象头中的Mark Word替换为指向该线程锁记录的指针,并将Lock record里的owner指针指向object mark word。

在这里插入图片描述

  1. 如果成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”

  2. 如果失败,表示有其他线程竞争锁,当前线程自旋来获取锁,若自旋获取锁失败,将Markword区替换为重量级指针,并挂起竞争线程。

轻量级锁解锁过程:

  1. 轻量级解锁时,会使用CAS操作将Displaced Mark Word替换回对象头
  2. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀成重量级),那就要在释放锁的同时,唤醒被挂起的竞争线程。

2.4 重量级锁

重量级锁是通过对象内部的监视器锁monitor实现的,而监视器的本质是依赖操作系统的mutex lock实现的。

轻量级锁在向重量级锁膨胀的过程中, 一个操作系统的互斥量(mutex)和条件变量( condition variable )会和这个被锁的对象关联起来。
具体而言, 在锁膨胀时, 被锁对象的 markword 会被通过 CAS 操作尝试更新为一个数据结构的指针,即重量级锁指针。这个数据结构中进一步包含了指向操作系统互斥量和条件变量的指针。

获取重量级锁失败,线程会被阻塞,需要等待操作系统的唤醒才能继续执行。

2.5 锁升级过程

image