博客记录-day029-synchronized的四种锁状态、深入浅出偏向锁+Linux 物理内存管理

141 阅读36分钟

一、沉默王二-并发编程

1、synchronized的四种锁状态

  • Java 中的每一个对象都可以作为一个锁,Java 中的锁都是基于对象的
  • synchronized 关键字可以用来修饰方法和代码块,它可以保证在同一时刻最多只有一个线程执行该段代码。
  • synchronized 关键字在修饰方法时,锁为当前实例对象;在修饰静态方法时,锁为当前 Class 对象;在修饰代码块时,锁为括号里面的对象。
  • Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在 Java 6 以前,所有的锁都是”重量级“锁。所以在 Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态
  • 偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,提高了程序的运行性能。
  • 轻量级锁是通过 CAS 操作和自旋来实现的,如果自旋失败,则会升级为重量级锁。
  • 重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU

首先需要明确的一点是:Java 多线程的锁都是基于对象的,Java 中的每一个对象都可以作为一个锁。

还有一点需要注意的是,我们常听到的类锁其实也是对象锁。

这里再多说几句吧。Class 对象是一种特殊的 Java 对象,代表了程序中的类和接口。Java 中的每个类型(包括类、接口、数组以及基础类型)在 JVM 中都有一个唯一的 Class 对象与之对应。这个 Class 对象被创建的时机是在 JVM 加载类时,由 JVM 自动完成。

Class 对象中包含了与类相关的很多信息,如类的名称、类的父类、类实现的接口、类的构造方法、类的方法、类的字段等等。这些信息通常被称为元数据(metadata)。

可以通过 Class 对象来获取类的元数据,甚至动态地创建类的实例、调用类的方法、访问类的字段等。这就是Java 的反射(Reflection)机制。

所以我们常说的类锁,其实就是 Class 对象的锁。

1.1 锁的基本用法

synchronized 翻译成中文就是“同步”的意思。

我们通常使用synchronized关键字来给一段代码或一个方法上锁,这里简单回顾一下,因为 synchronized 真的非常重要,面试常问,开发常用。它通常有以下三种形式:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}

这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是 synchronized 代码块,那临界区就指的是代码块内部的区域。

通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    synchronized (this) {
        // code
    }
}

同理,下面这两个方法也应该是等价的:

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    synchronized (this.getClass()) {
        // code
    }
}

1.2 锁的四种状态及锁降级

在 JDK 1.6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统的互斥锁,当一个线程持有锁时,其他试图进入synchronized块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。

这也是为什么很多开发者会认为 synchronized 性能很差的原因。

那为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁” 的概念,对 synchronized 做了一次重大的升级,升级后的 synchronized 性能可以说上了一个新台阶。

在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,很好理解。

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World(Java 垃圾回收中的一个重要概念,JVM 篇会细讲)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级

各种锁的优缺点对比:

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗 CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗 CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行时间较长。

1.3 对象的锁放在什么地方

前面我们提到,Java 的锁都是基于对象的。

首先我们来看看一个对象的“锁”是存放在什么地方的。

每个 Java 对象都有一个对象头。如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。对象头的内容如下表所示:

长度内容说明
32/64bitMark Word存储对象的 hashCode 或锁信息等
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的长度(如果是数组)

我们主要来看看Mark Word 的格式:

锁状态29 bit 或 61 bit1 bit 是否是偏向锁?2 bit 锁标志位
无锁001
偏向锁线程 ID101
轻量级锁指向栈中锁记录的指针此时这一位不用于标识偏向锁00
重量级锁指向互斥量(重量级锁)的指针此时这一位不用于标识偏向锁10
GC 标记此时这一位不用于标识偏向锁11

可以看到:

  • 当对象状态为偏向锁时,Mark Word存储的是偏向的线程 ID
  • 当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针
  • 当状态为重量级锁时,Mark Word为指向堆中的 monitor(监视器)对象的指针

在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器

监视器包括两个重要部分,一个是锁,一个是等待/通知机制,后者是通过 Object 类中的wait()notify()notifyAll()等方法实现的(我们会在讲Condition和生产者-消费者模式)详细地讲。

下面分别介绍这几种锁以及它们之间是如何升级的。

1.4 偏向锁

Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。

偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,这极大地提高了程序的运行性能。

大白话就是对锁设置个变量,如果发现为 true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为 false,代表存在其他线程竞争资源,那么就会走后面的流程。

1.4.1 偏向锁的实现原理

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。当下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁

CAS 是比较并设置的意思,用于在硬件层面上提供原子性操作。在 在某些处理器架构(如x86)中,比较并交换通过指令 CMPXCHG 实现((Compare and Exchange),一种原子指令),通过比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

线程竞争偏向锁的过程如下:

图中涉及到了 lock record 指针指向当前堆栈中的最近一个 lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁

1.4.2 撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

下面这个经典的图总结了偏向锁的获得和撤销:

1.5 轻量级锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋:不断尝试去获取锁,一般用循环来实现。

自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。

但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

1.5.1 轻量级锁的释放

在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程

一张图说明加锁和释放锁的过程:

1.6 重量级锁

重量级锁依赖于操作系统的互斥锁(mutex,用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段) 实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU

前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程

  • Contention List:所有请求锁的线程将被首先放置到该竞争队列
  • Entry List:Contention List 中那些有资格成为候选人的线程被移到 Entry List
  • Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck
  • Owner:获得锁的线程称为 Owner
  • !Owner:释放锁的线程

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到 Contention List 队列的队首,然后调用park 方法挂起当前线程。

当线程释放锁时,会从 Contention List 或 EntryList 中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁。

这是因为对于重量级锁,如果线程尝试获取锁失败,它会直接进入阻塞状态,等待操作系统的调度。

如果线程获得锁后调用Object.wait方法,则会将线程加入到 WaitSet 中,当被Object.notify唤醒后,会将线程从 WaitSet 移动到 Contention List 或 EntryList 中去。需要注意的是,当调用一个锁对象的waitnotify方法时如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

1.7 锁的升级流程

每一个线程在准备获取共享资源时:

  • 第一步,检查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

  • 第二步,如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空

  • 第三步,两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作, 把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。

  • 第四步,第三步中成功执行 CAS 的获得资源,失败的则进入自旋

  • 第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败则进入第六步。

  • 第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己

2、深入浅出偏向锁

在 JDK 1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案:

  1. 同步方法,锁上当前实例对象
  2. 同步静态方法,锁上当前类的 Class 对象
  3. 同步块,锁上代码块里面配置的对象

2.1 对象监视器 monitor

这里再简单说一下 monitor 的概念。

在 Java 中,monitor 可以被看作是一种守门人或保安,它确保同一时刻只有一个线程可以访问受保护的代码段。你可以将它想象成一个房间的门,门的里面有一些重要的东西,而 monitor 就是那个保护门的保安。

这里是 monitor 的工作方式:

  • 进入房间: 当一个线程想要进入受保护的代码区域(房间)时,它必须得到 monitor 的允许。如果房间里没有其他线程,monitor 会让它进入并关闭门。
  • 等待其他线程: 如果房间里已经有一个线程,其他线程就必须等待。monitor 会让其他线程排队等候,直到房间里的线程完成工作离开房间。
  • 离开房间: 当线程完成它的工作并离开受保护的代码区域时,monitor 会重新打开门,并让等待队列中的下一个线程进入。
  • 协调线程: monitor 还可以通过一些特殊的机制(例如 wait 和 notify 方法)来协调线程之间的合作。线程可以通过 monitor 来发送信号告诉其他线程现在可以执行某些操作了。

2.2 重量级锁

当另外一个线程执行到同步块的时候,由于它没有对应 monitor 的所有权,就会被阻塞此时控制权只能交给操作系统,也就会从 user mode 切换到 kernel mode, 由操作系统来负责线程间的调度和线程的状态变更, 这就需要频繁的在这两个模式下切换(上下文转换)。

有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它重量级锁,自然效率也很低,这也就给很多小伙伴留下了一个根深蒂固的印象 —— synchronized 关键字相比于其他同步机制性能不好,但其实不然,我们前面也讲过了。

2.3 轻量级锁

如果 CPU 通过 CAS就能处理好加锁/释放锁,这样就不会有上下文的切换。

但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程。

HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,如果还按照 CAS 的方式获取锁,也是有一定代价的,如何让这个代价更小一些呢?

2.4 偏向锁

偏向锁实际上就是「锁对象」潜意识「偏向」同一个线程来访问,让锁对象记住这个线程 ID,当线程再次获取锁时,亮出身份,如果是同一个 ID 直接获取锁就好了,是一种 load-and-test 的过程,相较 CAS 又轻量级了一些。

可是多线程环境,也不可能只有同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,就会有偏向锁升级的过程。

这里可以思考一下:偏向锁可以绕过轻量级锁,直接升级到重量级锁吗?

都是同一个锁对象,却有多种锁状态,其目的显而易见:

占用的资源越少,程序执行的速度越快。

偏向锁和轻量级锁,都不会调用系统互斥量(Mutex Lock),它们只是为了提升性能多出来的两种锁状态,这样可以在不同场景下采取最合适的策略:

  • 偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁
  • 轻量级锁:多个线程可以交替进入临界区,采用轻量级锁
  • 重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理

这部分内容的确又很需要多花点时间去搞透彻,所以我们这里就从不同的角度切入,多花点时间来盘一下。

到这里,大家应该理解了,但仍然会有很多疑问:

  1. 锁对象是在哪存储线程 ID 的?
  2. 整个升级过程是如何过渡的?

想理解这些问题,就需要先知道 Java 对象头的结构。

2.4.1 Java 对象头

按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。根据奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上。

奥卡姆剃刀原理是一种问题解决原则,简单来说就是:在解释某事物时,没有必要假设更多的东西,当有多个解释时,应选择假设最少、最简单的那个解释。

Java 对象头最多由三部分构成

  1. MarkWord
  2. ClassMetadata Address
  3. Array Length (如果对象是数组才会有这部分

其中 Markword 是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用来存储,在 64 位操作系统中,是这样存储的(注意颜色标记),想看具体注释的可以看 hotspot(1.8) 源码文件 path/hotspot/src/share/vm/oops/markOop.hpp 第 30 行。

有了这些基本信息,接下来我们就只需要弄清楚,MarkWord 中的锁信息是怎么变化的。

从这样的运行结果上来看,偏向锁像是“一锤子买卖”,只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常局限。事实上并不是这样,如果你仔细看标记 2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销(后面在讲批量撤销的时候会细讲这个陌生的 epoch)。

2.4.2 偏向撤销

在讲偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事

  1. 撤销:笼统的说,就是多个线程竞争导致不能再使用偏向模式,主要是告知这个锁对象不能再用偏向模式
  2. 释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束

何为偏向撤销?

从偏向状态撤回到原来的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,从 1 变回 0

如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下

想要撤销偏向锁,还不能对持有偏向锁的线程有影响,就要等待持有偏向锁的线程到达一个 safepoint 安全点 (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程,后续讲 JVM 的时候会详细讲。

在这个安全点,线程可能还是处在不同的状态,先说结论(因为源码就是这么写的)

  1. 线程不存活,或者活着的线程退出了同步块,很简单,直接撤销偏向就好了
  2. 活着的线程但仍在同步块之内,那就升级成轻量级锁

这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。

偏向锁是特定场景下提升程序效率的方案,可并不代表所有程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种 case 下,会导致大量的偏向锁撤销操作
  2. 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销

很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?

既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线

2.4.3 批量重偏向(bulk rebias)

这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,只要 class 的对象发生偏向撤销,该计数器 +1当这个值达到重偏向阈值(默认 20)时,JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 epoch

Epoch,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的epoch字段,每个处于偏向锁状态对象mark word 中也有该字段,其初始值为创建该对象时 class 中的epoch的值(此时二者是相等的)。

每次发生批量重偏向时,就将该值加 1,同时遍历 JVM 中所有线程的栈:

  1. 找到该 class 所有正处于加锁状态的偏向锁对象将其epoch字段改为新值
  2. class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 epoch 字段值不变

这样下次获得锁时,发现当前对象的epoch值和 class 的epoch不同,本着今朝不问前朝事 的原则(上一个纪元),就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;

如果 epoch 都一样,说明没有发生过批量重偏向, 如果 markword 有线程 ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)。

批量重偏向是第一阶梯底线,还有第二阶梯底线

2.4.4 批量撤销(bulk revoke)

当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40)时,JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑。

这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还会给一次改过自新的机会,那就是另外一个计时器:

  1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到 40,就会发生批量撤销(偏向锁彻底 game over)
  2. 如果在距离上次批量重偏向发生超过 25 秒之外,就会重置在 [20, 40) 内的计数, 再给次机会

image.png

2.4.5 偏向锁与 HashCode

上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了?

首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用 Object::hashCode() 或者System::identityHashCode(Object) 才会存储在对象头中的。

  • 第一次生成 hashcode 后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?

    • 即便初始化为可偏向状态的对象,一旦调用 Object::hashCode() 或者System::identityHashCode(Object) ,进入同步块就会直接使用轻量级锁。
  • 假如已偏向某一个线程,然后生成了 hashcode,然后同一个线程又进入同步块,会直接使用轻量级锁。

  • 假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁。

2.4.6 重量级锁和Object.wait

Object 除了提供上述的 hashcode 方法,还有 wait() 方法,这也是我们在同步块中常用的,调用 wait 方法会对锁产生哪些影响呢?

wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦)

最后再继续丰富一下锁对象变化图:

二、小林-图解系统-Linux 物理内存管理

1、从 CPU 角度看物理内存模型

内核是以页为基本单位对物理内存进行管理的,通过将物理内存划分为一页一页的内存块,每页大小为 4K。一页大小的内存块在内核中用 struct page 结构体来进行管理,struct page 中封装了每页内存块的状态信息,比如:组织结构,使用信息,统计信息,以及与其他结构的关联映射信息等。

而为了快速索引到具体的物理内存页,内核为每个物理页 struct page 结构体定义了一个索引编号:PFN(Page Frame Number)。PFN 与 struct page 是一一对应的关系。

内核提供了两个宏来完成 PFN 与 物理页结构体 struct page 之间的相互转换。它们分别是 page_to_pfn 与 pfn_to_page。

内核中如何组织管理这些物理内存页 struct page 的方式我们称之为做物理内存模型,不同的物理内存模型,应对的场景以及 page_to_pfn 与 pfn_to_page 的计算逻辑都是不一样的。

1.1 FLATMEM 平坦内存模型

我们先把物理内存想象成一片地址连续的存储空间,在这一大片地址连续的内存空间中,内核将这块内存空间分为一页一页的内存块 struct page 。

由于这块物理内存是连续的,物理地址也是连续的,划分出来的这一页一页的物理页必然也是连续的,并且每页的大小都是固定的,所以我们很容易想到用一个数组来组织这些连续的物理内存页 struct page 结构,其在数组中对应的下标即为 PFN 。这种内存模型就叫做平坦内存模型 FLATMEM 。

image.png

**内核中使用了一个 mem_map 的全局数组用来组织所有划分出来的物理内存页。mem_map 全局数组的下标就是相应物理页对应的 PFN **。

1.2 DISCONTIGMEM 非连续内存模型

FLATMEM 平坦内存模型只适合管理一整块连续的物理内存,而对于多块非连续的物理内存来说使用 FLATMEM 平坦内存模型进行管理则会造成很大的内存空间浪费。

因为 FLATMEM 平坦内存模型是利用 mem_map 这样一个全局数组来组织这些被划分出来的物理页 page 的,而对于物理内存存在大量不连续的内存地址区间这种情况时,这些不连续的内存地址区间就形成了内存空洞。

由于用于组织物理页的底层数据结构是 mem_map 数组,数组的特性又要求这些物理页是连续的,所以只能为这些内存地址空洞也分配 struct page 结构用来填充数组使其连续

为了组织和管理这些不连续的物理内存,内核于是引入了 DISCONTIGMEM 非连续内存模型,用来消除这些不连续的内存地址空洞对 mem_map 的空间浪费。

在 DISCONTIGMEM 非连续内存模型中,内核将物理内存从宏观上划分成了一个一个的节点 node (微观上还是一页一页的物理页),每个 node 节点管理一块连续的物理内存。这样一来这些连续的物理内存页均被划归到了对应的 node 节点中管理,就避免了内存空洞造成的空间浪费。

image.png

1.3 SPARSEMEM 稀疏内存模型

随着内存技术的发展,内核可以支持物理内存的热插拔了,这样一来物理内存的不连续就变为常态了,在上小节介绍的 DISCONTIGMEM 内存模型中,其实每个 node 中的物理内存也不一定都是连续的。

image.png

而且每个 node 中都有一套完整的内存管理系统,如果 node 数目多的话,那这个开销就大了,于是就有了对连续物理内存更细粒度的管理需求,为了能够更灵活地管理粒度更小的连续物理内存,SPARSEMEM 稀疏内存模型就此登场了。

SPARSEMEM 稀疏内存模型的核心思想就是对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作 section 。物理页大小为 4k 的情况下, section 的大小为 128M ,物理页大小为 16k 的情况下, section 的大小为 512M。

image.png

1.3.1 物理内存热插拔

前边我们介绍 SPARSEMEM 内存模型的时候提到,每个 mem_section 都可以在系统运行时改变 offline ,online 状态,以便支持内存的热插拔(hotplug)功能。 当 mem_section offline 时, 内核会把这部分内存隔离开, 使得该部分内存不可再被使用, 然后再把 mem_section 中已经分配的内存页迁移到其他 mem_section 的内存上

image.png

但是这里会有一个问题,就是并非所有的物理页都可以迁移,因为迁移意味着物理内存地址的变化,而内存的热插拔应该对进程来说是透明的,所以这些迁移后的物理页映射的虚拟内存地址是不能变化的

这一点在进程的用户空间是没有问题的,因为进程在用户空间访问内存都是根据虚拟内存地址通过页表找到对应的物理内存地址,这些迁移之后的物理页,虽然物理内存地址发生变化,但是内核通过修改相应页表中虚拟内存地址与物理内存地址之间的映射关系,可以保证虚拟内存地址不会改变

image.png

但是在内核态的虚拟地址空间中,有一段直接映射区,在这段虚拟内存区域中虚拟地址与物理地址是直接映射的关系,虚拟内存地址直接减去一个固定的偏移量(0xC000 0000 ) 就得到了物理内存地址。

既然是这些不可迁移的物理页导致内存无法拔出,那么我们可以把内存分一下类,将内存按照物理页是否可迁移,划分为不可迁移页,可回收页,可迁移页

大家这里需要记住一点,内核会将物理内存按照页面是否可迁移的特性进行分类

然后在这些可能会被拔出的内存中只分配那些可迁移的内存页,这些信息会在内存初始化的时候被设置,这样一来那些不可迁移的页就不会包含在可能会拔出的内存中,当我们需要将这块内存热拔出时, 因为里边的内存页全部是可迁移的, 从而使内存可以被拔除。

2、从 CPU 角度看物理内存架构

2.1 一致性内存访问 UMA 架构

CPU 与内存之间的交互是通过总线完成的。

CPU与内存之间的总线结构.png

  • 首先 CPU 将物理内存地址作为地址信号放到系统总线上传输。随后 IO bridge 将系统总线上的地址信号转换为存储总线上的电子信号。
  • 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的物理内存地址 A 读取出来。
  • 存储控制器通过物理内存地址定位到具体的存储器模块,从 DRAM 芯片中取出物理内存地址对应的数据。
  • 存储控制器将读取到的数据放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。
  • CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。

在 UMA 架构下,多核服务器中的多个 CPU 位于总线的一侧所有的内存条组成一大片内存位于总线的另一侧,所有的 CPU 访问内存都要过总线,而且距离都是一样的,由于所有 CPU 对内存的访问距离都是一样的,所以在 UMA 架构下所有 CPU 访问内存的速度都是一样的。这种访问模式称为 SMP(Symmetric multiprocessing),即对称多处理器。

这里的一致性是指同一个 CPU 对所有内存的访问的速度是一样的。即一致性内存访问 UMA(Uniform Memory Access)。

但是随着多核技术的发展,服务器上的 CPU 个数会越来越多,而 UMA 架构下所有 CPU 都是需要通过总线来访问内存的,这样总线很快就会成为性能瓶颈,主要体现在以下两个方面:

  1. 总线的带宽压力会越来越大,随着 CPU 个数的增多导致每个 CPU 可用带宽会减少
  2. 总线的长度也会因此而增加,进而增加访问延迟

为了解决以上问题,提高 CPU 访问内存的性能和扩展性,于是引入了一种新的架构:非一致性内存访问 NUMA(Non-uniform memory access)

2.2 非一致性内存访问 NUMA 架构

在 NUMA 架构下,内存就不是一整片的了,而是被划分成了一个一个的内存节点 (NUMA 节点),每个 CPU 都有属于自己的本地内存节点CPU 访问自己的本地内存不需要经过总线,因此访问速度是最快的。当 CPU 自己的本地内存不足时,CPU 就需要跨节点去访问其他内存节点,这种情况下 CPU 访问内存就会慢很多。

在 NUMA 架构下,任意一个 CPU 都可以访问全部的内存节点访问自己的本地内存节点是最快的但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致,所以叫做非一致性内存访问架构。

image.png

在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,前面介绍的三种内存模型都可以配置使用。

2.3 NUMA 的内存分配策略

NUMA 的内存分配策略是指在 NUMA 架构下 CPU 如何请求内存分配的相关策略,比如:是优先请求本地内存节点分配内存呢 ?还是优先请求指定的 NUMA 节点分配内存 ?是只能在本地内存节点分配呢 ?还是允许当本地内存不足的情况下可以请求远程 NUMA 节点分配内存 ?

内存分配策略策略描述
MPOL_BIND必须在绑定的节点进行内存分配,如果内存不足,则进行 swap
MPOL_INTERLEAVE本地节点和远程节点均可允许分配内存
MPOL_PREFERRED优先在指定节点分配内存,当指定节点内存不足时,选择离指定节点最近的节点分配内存
MPOL_LOCAL (默认)优先在本地节点分配,当本地节点内存不足时,可以在远程节点分配内存