FLink中时间和窗口详解
今天我们就来深入剖析一下锁的存储结构和升级过程。
首先需要明确的一点是,利用 synchronized 实现同步的基础是:Java 中的每一个对象都可以作为锁。
具体表现为以下 3 种形式。
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的 Class 对象。
- 对于同步方法块,锁是 Synchonized 括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁到底存在哪里呢?锁里面会存储什么信息呢?
对象头
synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 64位虚拟机中,1 字宽等于 8 字节,即 64 bit,如下图所示。
Java 对象头 中的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。下图是64位 JVM 对象头 Mark Word 的结构。
锁的升级与对比
锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。 这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(线程交替执行同步块的情况),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized。
轻量级锁加锁
线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
例如,假设有如下两个同步块,利用同一个对象进行加锁。
static final Object obj = new Object(); public static void m1() { synchronized(obj) { // 同步块 A m2(); } } public static void m2() { synchronized(obj) { // 同步块 B } } public static void main(String[] args) { m1(); }
它对应的执行过程如下。
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word。
- 让锁记录中的 Object reference 指向锁对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录。
- 如果 CAS 替换成功,则对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁。
-
如果 CAS 失败,则有两种情况。
-
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程(锁膨胀会在下文讲解)。
-
如果是自己执行了 synchronized 锁重入,那么再添加一条 LockRecord 作为重入的计数。
-
- 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
-
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头。
- 成功,则解锁成功。
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
锁膨胀
在尝试加轻量级锁的过程中,CAS 操作无法成功,会有一种情况是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块
}
}
- 当线程 Thread-1 进行轻量级加锁时,线程 Thread-0 已经对该对象加了轻量级锁。
-
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程。
为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList 阻塞。
- 当线程 Thread-0 退出同步块解锁时,使用 CAS 将Mark Word的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
再谈Monitor
Monitor 被称作监视器或者管程。每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象加上重量级锁之后,该对象头的 MarkWord 中就被设置成指向 Monitor 对象的指针。
Monitor 结构如下所示:
- 初始时,Monitor 中 Owner 为 null。
- 当线程 Thread-5 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-5,Monitor中只能有一个 Owner。
- 在线程 Thread-5 加锁后,如果线程 Thread-0,Thread-1,Thread-2 也来执行 synchronized(obj),就会进入EntryList 阻塞。在线程 Thread-5 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时候是非公平的。
- 图中 WaitSet 中的 Thread-3,Thread-4 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。
偏向锁
轻量级锁在没有竞争时(就一个线程),每次重入仍然需要执行 CAS 操作,所以在 java6 中引入了偏向锁来做进一步的优化。
偏向锁只有第一次会使用 CAS 将线程 ID 设置到对象头的 Mark Word ,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新进行 CAS。以后只要不发生竞争,这个对象就归该线程所有。
当一个线程访问同步块并获取锁时,会在对象头里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
偏向锁的禁用
偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
锁的优缺点对比
深入分析FLink相关技术架构