线程并发锁

32

synchronized关键字的底层原理

概念:Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。

synchronized 依赖 JVM 内部的 Monitor 对象来实现线程同步。使用的时候不用手动去 lock 和 unlock,JVM 会自动加锁和解锁。

synchronized 加锁代码块时,JVM 会通过 monitorentermonitorexit 两个指令来实现同步:

Monitor

Monitor 被翻译为监视器,是由jvm提供,c++语言实现

在代码中想要体现monitor需要借助javap命令查看clsss的字节码,比如以下代码:

public class SyncTest {
​
    static final Object lock = new Object();
    static int counter = 0;
    public static void main(String[] args) {
        synchronized (lock) {
            counter++;
        }
    }
}

在class文件目录下执行javap -v SyncTest.class,反编译效果如下:

  • monitorenter 上锁开始的地方

  • monitorexit 解锁的地方

  • 其中被monitorenter和monitorexit包围住的指令就是上锁的代码

  • 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁

在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁

在 Hotspot 虚拟机中,Monitor 由 ObjectMonitor 实现:

ObjectMonitor() {
    _count        = 0; // 记录线程获取锁的次数
    _owner        = NULL;  // 指向持有ObjectMonitor对象的线程
    _WaitSet      = NULL;  // 处于wait状态的线程,会被加入到_WaitSet
    _cxq          = NULL ;  //阻塞队列,用于存放刚进入 Monitor 的线程(还未进入 _EntryList)。
    _EntryList    = NULL ;  // 处于等待锁block状态的线程,会被加入到该列表
  }

monitor主要就是跟这个对象产生关联,如下图

Monitor内部具体的存储结构:

  • Owner:存储当前获取锁的线程的,只能有一个线程可以获取

  • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程

  • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程

具体的流程:

  1. 代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有

  2. 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功

  3. 如果已经有其他线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)

  4. 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待

总结

  • Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】

  • 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor

  • 在monitor内部有三个属性,分别是owner、entrylist、waitset

  • 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程

Monitor实现的锁属于重量级锁,你了解过锁升级吗?

Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

对象的内存结构

在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充

重点分析MarkWord对象头

synchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。

当一个线程获取对象锁时,JVM 会将该线程的 ID 写入 Mark Word,并将锁计数器设为 1。

如果一个线程尝试再次获取已经持有的锁,JVM 会检查 Mark Word 中的线程 ID。如果 ID 匹配,表示的是同一个线程,锁计数器递增。

当线程退出同步块时,锁计数器递减。如果计数器值为零,JVM 将锁标记为未持有状态,并清除线程 ID 信息。

MarkWord 对象头

  • hashcode:25位的对象标识Hash码

  • age:对象分代年龄占4位

  • biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁

  • thread:持有偏向锁的线程ID,占23位

  • epoch:偏向时间戳,占2位

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位

  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位

我们可以通过lock的标识,来判断是哪一种锁的等级

  • 后三位是001表示无锁

  • 后三位是101表示偏向锁

  • 后两位是00表示轻量级锁

  • 后两位是10表示重量级锁

Monitor重量级锁

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针

轻量级锁

在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

static final Object obj = new Object();
​
public static void method1() {
    synchronized (obj) {
        // 同步块 A
        method2();
    }
}
​
public static void method2() {
    synchronized (obj) {
        // 同步块 B
    }
}

比如上面的代码:两段代码块都是同一个对象锁obj,假如这时候来了一个线程,调用了method1方法,然后又调用了method2,也就是进入了同一个对象锁obj两次,也就是锁重入,因为是同一个线程持有的锁,所以不存在竞争,这时候就不需要monitor锁实现,所以优先使用轻量级锁。

加锁的流程

1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。

2.通过CAS指令将Lock Record的地址存储在对象头的mark word中(数据进行交换),如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。

4.如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。

解锁过程

计数减1

method2执行完成了,就要去执行method1,但是mehod1的锁记录不为null,这时候就会再来一次CAS操作把各自的值交换回来

这时候Mark Word中就是无锁状态,也就是解锁成功了!

轻量级锁总结

加锁流程
  1. 在线程栈中创建一个Lock Record,其中obj字段指向锁对象。

  2. 通过CAS指令将Lock Record的地址存储在对象头的Mark Word中,如果对象无锁则修改成功,代表该线程获得了轻量级锁。

  3. 如果当前线程已经持有了该锁,也就是锁重入。设置Lock Record第一部分为null,起到了一个重入计数器作用。

  4. 如果CAS失败,说明锁竞争了,就会膨胀为重量级锁。

解锁流程
  1. 遍历线程栈,找到所有当前线程的Lock Record的obj字段等于当前锁对象的。

  2. 如果Lock Record的Mark Word为null‘,说明是锁重入,将obj设置为null后continue。

  3. 如果Lock Record的Mark Word不为null,则利用CAS指令将对象头的Mark Word恢复成无锁状态,如果失败了就膨胀为重量级锁。

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现

这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有

static final Object obj = new Object();
​
public static void m1() {
    synchronized (obj) {
        // 同步块 A
        m2();
    }
}
​
public static void m2() {
    synchronized (obj) {
        // 同步块 B
        m3();
    }
}
​
public static void m3() {
    synchronized (obj) {
​
    }
}

上面的代码,线程如果执行m1方法则需要锁重入三次,这时候就需要偏向锁提升性能。

加锁的流程

1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。

2.通过CAS指令将Lock Record的线程id存储在对象头的mark word中,同时也设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行cas操作,只是判断对象头中的线程id是否是自己,因为缺少了cas操作,性能相对轻量级锁更好一些

总结

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

描述

重量级锁

底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁

线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。第一次获得锁会有CAS操作,后面只会创建锁记录。

偏向锁

一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,不会再增加锁记录。

一旦锁发生了竞争,都会升级为重量级锁