JUC并发编程(二):锁原理

129 阅读8分钟

锁相关原理

Monitor

对象头

一个java 对象

  • 对象头
    • mark Word 标记字段
    • klass Word 类型指针(指向class类型)
  • 对象body
    • 成员变量等

  • 对象头示意图

image.png

名称解释

hashcode:对象的hashcode值,对象创建时默认为0,只有当需要使用到hashcode时才会计算 (这也属于一种懒加载的设计理念)
age: 对象年龄,主要跟虚拟机回收有关
biased_lock : 可偏向标识 0 不可偏向, 1 可偏向 thread : 线程ID ptr_to_lock_record : 轻量级锁记录地址 ptr_to_heavyweight_monitor: 重量级锁monitor头

Monitor(锁)

Monitor被翻译为 监视器 或 管程
如果使用 synchronized 给对象上锁 (重量级)之后,该对象就会关联一个Monitor对象 ,该对象头的mark Word就会被设置指向 Monitor对象的指针。

image.png

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor的所有者 Owner设置为 Thread-2 , Monitor中只能有一个 Owner
  • 在Thread-2 上锁过程中,如果Thread-3 ,Thread-4,Thread-5 也来执行 synchronized(obj), 就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的(不会按照先到先得的方式竞争)
  • 图中 WaitSet 中的 Thread-0 ,Thread-1 是之前获得过锁,但条件不满足,进入 WAITING状态的线程 (比如手动调wait 进入等待的线程)

image.png

synchronized 锁

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者的透明的,即语法仍然是 synchronized

  • 假设有两个方法同步块,利用同一个对象加锁
public void method1(){
    synchronized(obj) {
        //同步块A
        method2();
    }
}

public void method2(){
    synchronized(obj) {
        //同步块B

    }
}
  • 加锁过程
    • 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

image.png - 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换Object的 Mark Word,将Mark Word的值存入锁记录

image.png

  • 如果cas替换成功,对象头中存储了 锁记录地址和状态00,表示由该线程给对象加锁,这时图示如下

image.png

  • 如果cas失败,有两种情况
    • 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数

image.png

  • 当退出 synchronized代码块(解锁时) 如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

image.png

  • 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用 cas将 Mark Word的值恢复给对象头
    • 成功,则解锁成功
    • 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

public void method1(){
    synchronized(obj) {
        //同步块A
        method2();
    }
}
  • 加锁过程
    • 当Thread - 1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁 image.png

    • 这时Thread-1 加轻量级锁失败,进入锁膨胀流程

      • 即为 Object对象申请 Monitor锁,让Object指向重量级锁地址
      • 然后自己进入Monitor的 EntryList Blocked
    • 当Thread-0 退出同步块解锁时,使用cas将 Mark Word的值恢复给对象头

      • 如果失败,则进入重量级锁解锁流程

    按照 Monitor地址找到Monitor对象,设置Owner为 null ,唤醒EntryList中的BLOCKED线程

自旋优化

重量级锁竞争的时候,还可以通过自旋来进行优化。

按照synchronized竞争锁的流程,如果线程竞争失败,则进入BLOCKED状态,等待解锁后自动唤醒,这就有个线程上下文切换的消耗。
因此增加了自旋优化,也就是竞争失败的线程先不阻塞,而是通过自旋一段时间(自旋简单理解为占着CPU空跑)或一定次数,如果当前线程自旋成功(即这时候持有锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

自旋会占用cpu时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
在java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能 java7 之后不能控制是否开启自旋

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作(添加一条锁记录,其中锁记录 null,参照上面的轻量级锁加锁过程)
java6中引入了偏向锁来做进一步优化:只有第一次使用cas将线程ID设置到对象的 Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有

public void method1(){
    synchronized(obj) {
        //同步块A
        method2();
    }
}

public void method2(){
    synchronized(obj) {
        //同步块B
        method3();
    }
}

public void method3(){
    synchronized(obj) {
        //同步块B

    }
}
  • 两种加锁方式比较
    • 加轻量级锁的过程 image.png
    • 加偏向锁的过程 image.png

注意

偏向锁默认是开启的,但默认是有延迟的,不会在程序启动时立即生效,如果想避免延迟,需要添加参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
在jvm中 -XX表示关闭 +XX表示开启某功能

  • 借助第三方工具查看对象头信息
<!--       对象头查询工具类-->
      <dependency>
         <groupId>org.openjdk.jol</groupId>
         <artifactId>jol-core</artifactId>
         <version>0.16</version>
         <scope>provided</scope>
      </dependency>
  • 测试方法
public static void main(String[] args) {
    Room obj = new Room();
    ClassLayout classLayout = ClassLayout.parseInstance(obj);
    new Thread(()-> {
        log.debug("synchronized之前..");
        log.debug(classLayout.toPrintableSimple());
        synchronized (obj) {
            log.debug("synchronized中..");
            log.debug(classLayout.toPrintableSimple());
        }
        log.debug("synchronized之后..");
        log.debug(classLayout.toPrintableSimple());
    },"t1").start();
}

其中 toPrintableSimple 需要手动实现

public String toPrintableSimple() {
    return toPrintableSimple(classData.instance());
}

public String toPrintableSimple(Object instance) {
    StringBuilder sb = new StringBuilder();
    String markStr = "";
    String remind = "";

    int markSize = model.markHeaderSize();

    int markOffset = 0;

    if (instance != null) {
        VirtualMachine vm = VM.current();
        if (markSize == 8) {
            long mark = vm.getLong(instance, markOffset);
            markStr = Long.toBinaryString(mark);
            remind = parseMarkWord(mark);
        } else if (markSize == 4) {
            int mark = vm.getInt(instance, markOffset);
            markStr = Integer.toBinaryString(mark);
            remind = parseMarkWord(mark);
        }
    }

    // 高位补0
    int i = 1;
    for (; i <= 8 * markSize - markStr.length(); i++) {
        sb.append('0');
        if (i % 8 == 0) {
            sb.append(" ");
        }
    }
    for (; i <= 8 * markSize; i++) {
        sb.append(markStr.charAt(i - (8 * markSize - markStr.length()) - 1));
        if (i % 8 == 0) {
            sb.append(" ");
        }
    }

    sb.append(remind);

    return sb.toString();
}
  • 测试结果
    • 对象头 image.png
    • 程序刚启动,未加偏向锁,中途加轻量级锁,解锁后回到正常状态 image.png
    • 程序启动后,等4s
public static void main(String[] args) throws InterruptedException {
    //等待4s
    Room one = new Room();
    ClassLayout test = ClassLayout.parseInstance(one);
    log.debug("程序刚启动的..");
    log.debug(test.toPrintableSimple());
    TimeUnit.SECONDS.sleep(4);
    Room obj = new Room();
    ClassLayout classLayout = ClassLayout.parseInstance(obj);
    new Thread(()-> {
        log.debug("synchronized之前..");
        log.debug(classLayout.toPrintableSimple());
        synchronized (obj) {
            log.debug("synchronized中..");
            log.debug(classLayout.toPrintableSimple());
        }
        log.debug("synchronized之后..");
        log.debug(classLayout.toPrintableSimple());
    },"t1").start();
}

image.png

可以看到最开始的未加锁 001
4秒后是 101 偏向锁
解锁后,还是偏向锁,且线程Id还是保留在对象头里

偏向锁撤销

  • 调用对象的 hashCode 方法会撤销偏向锁
  • 有其他线程竞争对象时,会撤销偏向锁,升级为轻量级锁
  • 调用 wait/notify 也会撤销偏向锁
    • 因为只有在synchronized中才能使用wait/notify,也就是本身就是重量级锁了,所以会升级

批量重偏向

  • 如果对象虽然被多个线程访问,但没有竞争,这是偏向了线程 T1的对象仍有机会重新偏向T2 ,重偏向会重置对象的Thread Id
  • 当撤销偏向锁阈值超过 20次后,jvm会这样觉得,我是不是偏向错了呢? 于是会在给这些对象加锁时重新偏向至加锁线程

例子

  • 在Thread 0 中对30个对象加锁,这时都是偏向锁
  • 在Thread-1 中对上述30个对象再加锁,这时候就会产生锁升级,由偏向锁升级为轻量级锁
    • 一直升级了19个对象的锁之后,jvm就会进行批量重偏向,对于剩下的对象不会再做锁升级,而是改为偏向Thread-1 的偏向锁
    • 最终结果就是30个对象中,前19个是轻量级锁,第20个对象开始都是偏向锁-偏向Thread-1

批量撤销

批量撤销要接上面批量重定向来讲

  • 如果偏向撤销的数量达到了39个,这时候jvm会认为这个类的对象不应该被加偏向锁,第40个对象开始都是不可加偏向锁,从此这个类的所有对象都是不可偏向(后续新增的对象也是如此)

锁粗化

static Object obj = new Object();

@Test
public void test_锁粗化() {
    for (int i=0;i<10;i++){
         synchronized(obj) {
            i++;
        }
    }
   
}

JIT编译器会认为这块没必要如此频繁的加锁/解锁,于是干脆把加锁动作放到循环外面

static Object obj = new Object();

@Test
public void test_锁粗化() {
     syhchronized(obj){
         for (int i=0;i<10;i++){
                i++;
          }
     }
}

锁消除

static Object obj = new Object();

@Test
public void test_锁消除() {
    int i = 0;
    synchronized(obj) {
        i++;
    }
}

JIT编译器会认为这块没有任何竞争,加锁没有任何意义,因此会优化掉加锁动作,等同于

static Object obj = new Object();

@Test
public void test_锁消除() {
    int i = 0;
    i++;
}

有问题的锁

死锁

  • 最简单的场景就是2个线程互相持有了对方需要的锁,双方都无法获得需要的锁
  • 查询办法
    • 1.1 使用jps 查询java进程
    • 1.2 使用 jstack 即可看到详情的死锁信息
    • 2.1 借助 jconsole

image.png

image.png

活锁

活锁是两个线程互相改变对方的结束条件导致双方都无法正常结束

饥饿

饥饿是某个线程始终无法获得锁

  • 使用notify 随机唤醒waitSet中一个线程时,一直没唤醒到某线程
  • 在EntryList中重新竞争的时候某线程一直获得不到锁

wait原理

image.png

  • 原理介绍
    • Owner 线程发现条件不满足,调用 wait方法,进入 waitSet 变为 waiting状态
    • BLOCKED 和 waiting的线程都处于阻塞状态,不占用cpu时间片
    • blocked 线程会在 Owner线程释放锁时唤醒
    • waiting 线程会在Owner线程调用 notify 或 notifyAll时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入EntryList重新竞争