Java小白系列(三):Synchronized进阶

一、前言

上一篇,我们介绍了 Synchronized 的用法、作用和稍微底层一点的原理:

  • 何时用的是字节码注入?
  • 何时用的是标志位?
  • 什么情况下用的是对象锁?
  • 什么情况下用的又是类锁?

如果忘记了,请查看《Java小白系列(二):关键字Synchronized》

如果我们但凡要进行多线程数据同步,用 Synchronized 就是完全互斥,那么这个锁在我们看来,就比较重,性能就比较低;因此,JDK在1.6之后,对锁进行了优化,使其变的不那么极端,非必要情况下,不需要完全互斥,接下来,我们就会深入到 JVM 底层来聊聊『Java 锁』这件事。

二、锁的优化

2.1、了解操作系统提供的锁

有过 UNIX/Linux 操作系统方面的编程,那么,我们对于线程如何同步,应该不会陌生:

  • 互斥锁(mutex);
  • 条件变量 + 睡眠 / 唤醒 机制(不满足条件,则自动进入睡眠,至到条件满足被唤醒);
  • 读写锁(允许多个线程同时读,可以认为是共享;但处理写时就是独占,且不允许其它读与写);
  • 信号量(sem);

mutex 与 sem 的区别:

  • mutex 只允许一个线程进入临界区,而 sem 允许多个线程进入临界区;
  • sem 强调的是多个线程进入临界区时,要有序;
  • 因此,当有多个资源时,用 sem 会更好;而当资源退化为 1 个时,此时等同于 mutex ;

Java 的 Synchronized 是基于 mutex 而来的,因此,同一时刻只允许有一个线程占用,而其它线程被阻塞。

2.2、Synchronized 锁的存放

多线程同步时,通过锁来控制线程的进入 / 阻塞,那么,锁也需要有个地方保存起来,我们先思考下,一个锁大概会有哪些信息?

  • 锁的状态:无锁、有锁;
  • 当前持有者(线程);
  • 当前等待者(线程);

同时,我们再来想个问题:多个线程并发访问,需要先尝试获取锁,也就是说,多个线程需要去并发申请锁,然后设置锁,那么锁也需要线程同步,这就陷入个死循环。
聪明的 Java 设计师发现这个问题后,想到了一个巧妙的办法:直接将锁的这些信息存到对象中,具体存储的位置则是在对象头中。

2.3、对象头

当 Class 被载入到 JVM 中(即被实例化为对象),该对象在内存中除了本身的数据,还会有一个对象头。

  • 对于一般对象而言,其对象头有两种信息:Mark Word 和 Class Metadata Address;
  • 而如果是数组,则还会有一个 Array length;

注:

  • Mark Word 和 Class Metadata Address 在 32位/64位 系统下分别是 32位/64位;
  • Array length 无论何时都是 32位;

2.4、Mark Word

我们看看 32位/64位 系统下 Mark Word 的格式

Java小白系列(三):Synchronized进阶_第1张图片
MarkWord.png

我们可以看到:

  • Mark Word 除了存储锁信息,还会存有 hashcode 和 GC分代年龄;
  • 锁的状态:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁;(程序依次加深)

三、锁的状态介绍

再介绍四种锁之前,我先上个图,来自官方(openjdk):

Java小白系列(三):Synchronized进阶_第2张图片
LockStatus.png

上图向我们展示了,JDK1.6 对 Synchronized 锁这块的优化,在之前只有无锁与重量级锁两种,因此我们以前会说 Synchronized 较重,性能较差;但是 JDK1.6 对其优化之后,在非极端情况下,Synchronized 是很难『膨胀』到重量级锁状态的。
上面这句话的最后,我用到了一个词:膨胀!锁从无锁到重量级锁的过程,就是一个不断膨胀的过程,且不可逆!

3.1、偏向锁


通常在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时锁就是偏向锁。

如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 记录该线程的 ID;当该线程再次请求锁时,无需做任何同步操作,即需要在获取锁的时候检查一下 Mark Word 的锁标记位 = 偏向锁,并且 threadID = 该线程ID 即可,因此,省去了锁申请的操作。

3.2、轻量级锁

轻量级锁是由偏向锁膨胀(升级)而来,当有第二个线程来申请该对象锁时,偏向锁则立即升级为轻量级锁。
注:此时只是第二个线程来申请锁,并不存在两个线程同时竞争锁;比如两个线程一前一后交替执行同步代码块。

3.3、重量级锁

同理,当同一时间有多个线程竞争锁时,锁就会立即升级为重量级锁,此时申请锁带来的开锁也就变大。

3.4、线程的状态

锁有不同的形态,同样,多线程竞争时,也会有不同的状态。

我们先看看操作系统中线程的各种状态(简单版):

Java小白系列(三):Synchronized进阶_第3张图片
os.png

再来看看 Java 线程的状态:

Java小白系列(三):Synchronized进阶_第4张图片
java_thread.png

为何我会给出操作系统中线程的状态?其实对比两幅图,我们就能看出来,两者没啥区别,如果抽象一点(更简单的概括线程的状态),只有三种:

  • 就绪状态(Ready);
  • 运行状态(Running);
  • 阻塞/休眠状态(Blocked);

四、再来聊聊 Monitor

在《Java小白系列(二):关键字Synchronized》中,我们简单浅显的谈到了,JVM 使用 Monitor 来监视线程的进入与退出,并在『锁的状态』一节谈到了锁的存储与膨胀;本小节我们再深入一点,在 HotSpot 即 JVM 的实现层面,来看看大致的实现。

4.1、Java线程状态各对象含义

  • ContentionList:所有请求锁的线程首先都会进入该竞争队列;
  • EntryList:ContentList中有资格成为候选的线程将被移入该队列;
  • WaitSet:调用 wait 方法而被阻塞的线程将放入其中;
  • OnDeck:任何时刻最多只有一个线程正在竞争锁,我们称之为 OnDeck;
  • Owner:锁的所有者;

4.2、虚拟队列:ContentionList

ContentionList 并不是一个真正的队列,而是一个虚拟队列,之所以称为虚拟,是因为它是由 Node 及其 next 指针在逻辑上构成的这么一个看似的队列。我们知道,队列的特点是 FIFO(先进先出),但这里的 ContentionList 却是 LIFO(后进先出),每次新加入的 Node 都在队头,通过 CAS(Compare And Swap,乐观锁,比较后交换)改变第一个节点的指针为新增节点,同时设置新增节点的 next 指向后续节点。

4.3、EntryList

和 ContentionList 作用一个,都是一个等待队列,但是,ContentionList会存在多个线程并发访问,为了降低ContentionList队尾竞争而建立了EntryList,Owner线程在 unlock 时会从 ContentionList中迁移线程到 EntryList,并指定其中某个线程(一般为Head)为 Ready 状态,即 OnDeck。Owner 并不是将锁给 OnDeck 线程,而是将竞争的权利给到了 OnDeck 线程,OnDeck 同样需要竞争锁,虽然牺牲了一定的公平性,但极大的提高了吞吐量,在 HotSpot 中,将 OnDeck 的行为称为『竞争切换』。

4.4、OnDeck

如果 OnDeck 获得了锁,则成为 Owner 线程;如果无法获得则会停留在 EntryList 中,考虑到公平性,其位置不会发生改变(依然在队头)。如果 Owner 线程被 wait 阻塞,则被转移至 WaitSet 队列;如果后续被 notify / notifyAll 唤醒,则再次进入 EntryList 队列。

4.5、Monitor对象的结构:ObjectMonitor

Monitor是个对象,自然是有其自己的结构的(Monitor依赖于OS的实现,会在用户态与内核态之间切换,有一定的性能开销),它的结构如下(参考 openjdk8 hotspot源码):

Java小白系列(三):Synchronized进阶_第5张图片
openjdk8.png
ObjectMonitor::ObjectMonitor() {
  _header       = NULL;
  _count        = 0;
  _waiters      = 0,
  _recursions   = 0;
  _object       = NULL;
  _owner        = NULL;      // 持有该Monitor的线程称之为:Owner
  _WaitSet      = NULL;
  _WaitSetLock  = 0 ;
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;    // ContentionList
  FreeNext      = NULL ;
  _EntryList    = NULL ;    // 多个线程访问同步块或同步方法,会首先被加入 _EntryList
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;       // 自旋!(有时候也叫『忙等待』,说白了就是死循环一定的次数)
  OwnerIsThread = 0 ;
}

这里要提下自旋:
上面说了,线程被阻塞会进入到内核状态,导致用户态与内核态切换,增加了锁的性能开销;如果被阻塞的时间较长,那么这点开销也能接受;但是如果被阻塞的时间很短,则该线程可能刚切换到内核态又马上切换回用户态。因此,解决这种很短暂的阻塞的办法就是自旋。

自旋虽然一定程序上避免了锁的性能开销,但也会在短时间内增加 CPU 的负担(例如:循环100次也是消耗 CPU 的时间片的)。

五、锁的其它优化

5.1、锁消除

它是在JIT编译时,通过对运行上下文的扫描,去除不可能存在的竞争的锁,从而消除了竞争关系。

public class Test {
    public void method() {
        Object object = new Object();
        synchronized (object) {
            System.out.println("运行时发现不存在竞争");
        }
    }
}

在运行时会被优化为:

public class Test {
    public void method() {
        Object object = new Object();
        System.out.println("运行时发现不存在竞争");
    }
}

5.2、锁粗化

这也是一种优化,通过扩大锁的范围,从而减少了锁的加与释放。

public class Test {
    public void method() {
        for (int i = 0; i < 10000; i ++) {
            synchronized (this) {
                System.out.println("运行时发现不存在竞争");
            }
        }
    }
}

被优化为:

public class Test {
    public void method() {
        synchronized (this) {
            for (int i = 0; i < 10000; i++) {
                System.out.println("运行时发现不存在竞争");
            }
        }
    }
}

我们可以认为,上面两种优化,有时候可以人为的提前避免,即在编码之前的阶段,有良好的设计,那么这些情况其实是可以避免的。

六、结语

Synchronized 是并发编程中很重要的组成部分,通过 JDK 对其不断的优化,我们也能看出其重要性;只有真正理解其原理及运作才会提升运行时性能。

你可能感兴趣的:(Java小白系列(三):Synchronized进阶)