Java多线程系列--掌控面试,一文吃透Synchronized锁

前言

各位亲爱的读者朋友,我正在创作 Java多线程系列 文章,本篇我们将分析重点内容:

锁是 面试中的常客 ,也是多线程编码中 必不可少 的内容,无论是为了 "面试胜利" ,还是 "写出高质量的代码" ,我们都需要掌握它。

作者按:本篇按照自己有限的知识进行整理,如有谬误,还请读者在评论区不吝指出

了解系列以及总纲:Java多线程系列

本文篇幅较长,内容较为跳跃,可参考导图阅读:

前文拾遗 -- JAVA对象结构

先前已经发布过一篇系列文章:克服焦虑--图解JVM内存模型和JVM线程模型

其中还有一处关键知识没有展开:"Java对象包含哪些内容",这部分内容和锁的实现有关,本篇将由此继续展开。

单刀直入,在HotSpot虚拟机中,一个对象实例可以被划分出三块信息:

  • 对象头 Header
  • 实例数据 Instance Data,亦有称之对象体
  • 对齐填充 Padding

其中对齐填充可能存在,实例数据是供给 应用程序逻辑 使用的,存储了实例的字段信息。接下来重点探讨对象头。

Java对象头

对象头部分的信息主要面向JVM,包含:

  • Mark Word
  • kclass, 对象对应的类的元数据指针
  • array length ,仅数组对象才拥有

整体划分如图所示:

图片来自网络搜索,水印为Java帮帮

Mark Word

Mark Word 用于存储对象运行时数据: 哈希GC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳 等。

查找 HotSpot中markOop的doc如下:

// The markOop describes the header of an object.
// Note that the mark is not a real oop but just a word.
// It is placed in the oop hierarchy for historical reasons.
//
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//  hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//  size:32 ------------------------------------------>| (CMS free block)
//  PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,开启压缩后为32bit

梳理64位虚拟机五种状态下的对象头:

|------------------------------------------------------------------------------------------------------------------|
|                                     Object Header(128bits)                                                       |
|---------------------|--------------------------------------------------------------------------------------------|
|       State         |                                   Mark Word(64bits)               |  Klass Word(64bits)    |
|---------------------|--------------------------------------------------------------------------------------------|
|       Nomal         | unused:25|identity_hashcode:31|unused:1|age:4|biase_lock:1|lock:2 | OOP to metadata object |
|---------------------|--------------------------------------------------------------------------------------------|
|       Biased        | thread:54|      epoch:2       |unused:1|age:4|biase_lock:1|lock:2 | OOP to metadata object |
|---------------------|--------------------------------------------------------------------------------------------|
|  Lightweight Locked |                     ptr_to_lock_record:62                 |lock:2 | OOP to metadata object |
|---------------------|--------------------------------------------------------------------------------------------|
|  Heavyweight Locked |                    ptr_to_heavyweight_monitor:62          |lock:2 | OOP to metadata object |
|---------------------|--------------------------------------------------------------------------------------------|
|     Marked for GC   |                                                           |lock:2 | OOP to metadata object |
|---------------------|--------------------------------------------------------------------------------------------|

排版不方便的话看下图:

如果运行在 HotSpot openJdk VM 上,可以借助:"org.openjdk.jol:jol-core:0.9" 打印对象头进行对照。

五种状态下分析对象头的一份参考博客

作者按:此处的知识我们留一个印象即可,Android同学可能只能拿来吹牛

归纳后知识概要如图:

注:32位

锁的常见概念、分类

我们平时会听到关于锁的各种概念名词,前文的表中,我们也看到了 偏向锁 轻量级锁 重量级锁 的身影,接下来简单聊一聊分类以及常见概念。

乐观锁 / 悲观锁

乐观锁和悲观锁是一种相对的 态度 。在并发的背景下,访问临界区时 可能会 发生 "冲突",不同的态度演变出不同的策略。

悲观 地看,可以 认为冲突一定会发生 :访问临界区时,自身线程的写入行为一定会影响其他线程,自身线程的读取行为一定会受到其他线程的写入影响! 所以 必须要进行锁保护 ,通过 "独占","排他" 等特性,保障无冲突。 即:悲观地认为,不加锁的并发操作一定会出现冲突,必须加锁

乐观 地看,可以 认为发生冲突是场景限定的事件 :读不会有冲突,但写入存在冲突可能。所以 只有在写操作时,进行冲突检测,检测到冲突时操作就会失败,返回错误信息

在Java中,悲观锁即各种实际的锁实现,而乐观锁即无锁编程,转为使用CAS算法。

独享锁 / 共享锁

锁可以被访问者独享或者共享,直白地讲:独享锁在某一时间点,只能被一个线程获取,其它线程必须等待锁被持有者释放之后,才可能获取到锁,而共享锁可以被多个线程获取。

独享锁又称独占锁、排它锁。

显然,共享锁认为持有它的线程们在并发操作时并不会发生冲突。

可重入锁 / 非可重入锁

从概念上看:一个线程在持有一个锁的时候,它内部能否再次(多次)申请该锁,如果可以则为可重入锁,否则为不可重入锁。

显然易见:不可重入 将大大增加 死锁 的机率。

作者按:但在线程的基础上实现类似协程的机制时,非可重入将具有重要意义。

公平锁 / 非公平锁

锁需要通过竞争获取,公平 / 非公平指的是 是否按照"先来后到"获取锁。

如果按照申请顺序分配锁,则为公平锁,否则为非公平锁。

我们常用的Synchronized即非公平锁,Java的可重入锁(ReentrantLock)默认为非公平锁,但可以实例化为公平锁。

非公平锁的优点在于吞吐量比公平锁大。

互斥锁、读写锁

独享锁、共享锁的具体实现,读写锁作为读模式时是共享锁。

Java中的 ReentrantLock 是互斥锁,ReadWriteLock 是读写锁实现。

分段锁

分段锁并非是一种锁,而是一种提高效率的设计思路,将临界区进行划分,当某一块区域的写入并不影响其他区域的读取时,就可以采用分段的思路, 对写入区域加锁,读取其他区域则无需竞争锁从而提高效率,例如 ConcurrentHashMap 就采用了这一设计

偏向锁 / 轻量级锁 / 重量级锁

专指 synchronized 的三种锁状态,并且关系到后文中的 锁升级 部分。

在前文中,我们花了很多篇幅梳理 对象头 中的 Mark Word,其中有三种状态:Biased,Lightweight Locked,Heavyweight Locked 于此对应。

  • 偏向锁状态:同步代码一直被同一线程访问时,该线程会自动获取锁,降低获取锁的代价。
  • 轻量级锁状态:在偏向锁状态时,一旦另一线程竞争该锁,则升级为轻量级锁,竞争的线程通过 有限的自旋 尝试获取锁,如果锁的持有者在此过程中 释放了锁 , 并被该线程成功获取锁,则可以避免阻塞,减少线程切换,* 挂起和恢复线程是较为昂贵的*。
  • 重量级锁状态:当线程完成了有限的自旋后依旧未能获得锁,将不得不进行阻塞以免空耗CPU,此时锁升级为重量级锁

不难理解,按照Java的线程模型,仅多核CPU情况下,采用轻量级锁并利用自旋才有意义。如果是单核CPU,并不存在真正的时间意义上的线程并发,自旋时,持有锁的线程是挂起的,并无释放锁的可能

作者按:Java的线程模型可参见拙作:克服焦虑--图解JVM内存模型和JVM线程模型

自旋锁

基于CAS利用自旋去竞争锁实现同步的一种方式,如前文所言,发生竞争而产生自旋时,当前线程不会阻塞,所以不会直接导致系统调用,减少上下文切换的开销, 但如果一直竞争不到锁,将造成CPU空转,所谓的busy-waiting。对于计算密集型程序而言,可能会带来负面效果。

我们可以利用Atomic实现一个简单的 可重入自旋锁

public class ReentrantSpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    private int count;

    public void lock() {
        Thread current = Thread.currentThread();
        if (current == cas.get()) {
            count++;
            return;
        }
        while (!cas.compareAndSet(null, current)) {
            // Do nothing
        }
    }

    public void unlock() {
        Thread cur = Thread.currentThread();
        if (cur == cas.get()) {
            if (count > 0) {
                count--;
            } else {
                cas.compareAndSet(cur, null);
            }
        }
    }
}

很显然,这也是一个非公平锁、独享锁

Synchronized锁

synchronized 作为Java关键字,提供同步能力,其核心依赖于Java对象的对象头,当一个类对象被作为synchronized锁对象时,其即为 Monitor,JVM通过 进入、退出Monitor 来实现同步

class Foo {

    synchronized static foo() {
    }

    synchronized void bar() {
    }

    void baz() {
        synchronized (Foo.class) {
            //同步块
        }
    }
}

如上代码中展示了三种同步的方式:

  • foo() 中锁对应的Monitor为 Foo.class
  • bar() 中锁对应的Monitor为 Foo 的对象实例
  • baz() 中的同步块可以用任意的对象作为Monitor,演示部分使用了 Foo.class 作为Monitor

注意,Monitor的选取务必慎重,不仅仅要从 同步 的需求角度出发,避免性能损耗,也要注意锁无法正常生效的问题。例如:

我们模拟5个线程同时竞争一个数(初始值为6)并做 -- 运算,读者是否认为它会按照 5、4、3、2、1 的顺序输出?

class Foo {

  Integer integer = 6;

  void minus() {
    synchronized (integer) {
      if (integer > 0) {
        integer--;
      }
      System.out.println(Thread.currentThread().getId() + " -> i:" + integer);
    }
  }

  static class MThread extends Thread {
    final Foo foo;
    final CountDownLatch latch;

    MThread(Foo foo, CountDownLatch latch) {
      this.foo = foo;
      this.latch = latch;
    }

    @Override
    public void run() {
      super.run();
      try {
        latch.await();
        foo.minus();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }

    }
  }

  public static void main(String[] args) {
    Foo foo = new Foo();
    CountDownLatch latch = new CountDownLatch(1);

    for (int i = 0; i < 5; i++) {
      new MThread(foo,latch).start();
    }
    latch.countDown();
    try {
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

多执行几次就会发现 可能 会像下面的结果,它并非是一个同步情况的结果,如果想要更顺利的模拟问题的出现,增大线程数即可

> Task :Foo.main()
13 -> i:3
15 -> i:4
12 -> i:3
14 -> i:1
11 -> i:2

如果读者注意了lint,会注意到提示:Synchronization on a non-final field 'integer'

很显然,上面的代码并未采用正确的Monitor,在 integer-- 的过程中,integer实例已经发生变化:

System.out.println(Thread.currentThread().getId() + 
        " -> i:" + integer+","+System.identityHashCode(integer));

我们增加identityHashCode,并增大并发数量到10,输出如下:

13 -> i:0,482590393
15 -> i:1,1722967101
11 -> i:3,1130778910
16 -> i:0,482590393
17 -> i:0,482590393
20 -> i:0,482590393
12 -> i:4,685260083
14 -> i:2,1983109557
19 -> i:0,482590393
18 -> i:0,482590393

显然,锁了个寂寞当然,该问题深入研究下去,读者可以一直挖到动态常量池,不再展开

所以,编码中一定要保持好习惯:Monitor对象不可变,无形中就避开了bug。

锁升级

前文已经提到了锁的升级:

偏向锁 -> 轻量级锁 -> 重量级锁 , 锁的升级是单向的

这其实是 synchronized 内部实现上对锁方式的优化

转换原因

很显然,这一过程充分考虑各种锁的优点和缺点,在相应场景下以期获得最好的性能。

  • 偏向锁:没有自旋、没有系统调用,所以 消耗少性能高 ,并且具有可重入特性,在 同一线程执行同步代码 场景下是最优选择,但它具有 撤销锁消耗高 的缺点。
  • 轻量级锁:前面已经提到,用CAS取代了阻塞,在 锁持有时间短 的场景下是最优选择,可追求 快速响应 ,但缺点是短时间内无法获取锁时,自旋消耗CPU资源。
  • 重量级锁:具备 大吞吐量 ,未竞争到锁的线程不会自旋消耗资源,适合 锁占用时间长,吞吐量需求大 的场景

而从 偏向锁到轻量级锁,意味着多线程竞争,假定锁的持有时间不会太长,有限的自旋可以等到持有者释放锁,而当 轻量级锁升级到重量级锁 ,意味着这一 假设不成立 ,自旋只是在白白消耗,通过挂起和等待唤醒以提升吞吐量

转换过程

从OpenJdk的 WIKI -- Synchronization 可以得到这张图。

为广泛流传的下图的原版

一图胜千言,图中表现的非常明确,读者可以结合WIKI内容自行理解,不再多做赘述。

经常在课程广告中出现的一张图如下,收藏不等于学会:

图片水印Blog.Dreamtobe.cn

一段很长的后记

在拟定系列大纲时,本篇的原名为:Java多线程系列--掌控面试,一文吃透锁,然而写至 JDK中的Lock接口 时, 后知后觉的意识到将 不得不深入源码 并且涉及到 AQS,AQS的内容在大纲中已有单篇计划,展开则篇幅过长不利于阅读,不展开则实在无内容可写。

原章节内容权且作为 开端引子 留于文末:

JDK中的Lock接口

在Jdk1.5之后,存在Lock接口:

public interface Lock {
    //获取锁
    void lock();

    //获取锁,如果线程阻塞状态(未获取到而进入阻塞)被中断则抛出异常
    void lockInterruptibly() throws InterruptedException;

    //尝试获取锁
    boolean tryLock();

    //在给定时间内尝试获取锁,
    boolean tryLock(long var1, TimeUnit var3) throws InterruptedException;

    void unlock();

    Condition newCondition();
}

前四个API均为获取锁的API,unlock 释放锁,通过Condition提供线程通信能力. Condition将在以后的文章中展开

有别于语言关键字 synchronized , 在使用角度上,Lock将需要使用者自行获取锁、释放锁。在内部实现上,区别于 Monitor模式 ,增加了更丰富的功能:

  • 支持 锁的公平性
  • 获取 当前线程调用lock的次数
  • 获取 等待锁的线程数
  • 查询 是否存在线程等待获取该锁
  • 查询 指定的线程是否在等待获取该锁
  • 查询 当前线程是否持有该锁
  • 判断 锁是否已被持有
  • 加锁时如果中断则不加锁,抛出异常
  • 尝试获取锁 的机制,如果锁未被其他线程持有则成功,否则返回失败,不会直接进入阻塞

很显然,限于目标和篇幅,这篇文章不会再和诸位读者一同探索源码,写至此处,我意识到真的无法做到标题中说的:"一篇文章吃透锁"。 JDK中还有锁实现,例如常用的:

  • ReentrantLock
  • ReadWriteLock,一般使用实现类 ReentrantReadWriteLock

作者按:一旦开始深入,将势必谈及AQS,按照计划,这将于后续文章中展开,故本篇不再展开。望读者见谅

真正的后记

这一篇又断断续续写了超过一周,期间我也一度怀疑,这个系列要不要继续下去,对于读者而言,买本书钻研可能是比看本系列更加实在的行为,勘校后的知识准确性文字的准确性表达的结构性 都更胜一筹,博客仅能靠碎片化特性占点便宜。

但一件事情坚定了我将它写下去的信念,我翻看了往年的博客,清晰的意识到:

  • 将脑海中的知识,整理后再做 结构化的输出 ,可以牢靠地掌握这些知识,并且在任意时刻都可以完成流畅的表达
  • 通过对比,清晰的看到自己的成长
  • 文字功力可以通过锻炼得到长足提升