美文网首页Android开发一些收藏程序员
Java小白系列(三):Synchronized进阶

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

作者: 青叶小小 | 来源:发表于2021-02-03 00:05 被阅读0次

    一、前言

    上一篇,我们介绍了 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 的格式

    MarkWord.png

    我们可以看到:

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

    三、锁的状态介绍

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

    LockStatus.png

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

    3.1、偏向锁

    \color{red}{作用:减少同一个线程获取锁的代价!}
    通常在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时锁就是偏向锁。

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

    3.2、轻量级锁

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

    3.3、重量级锁

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

    3.4、线程的状态

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

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

    os.png

    再来看看 Java 线程的状态:

    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源码):

    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进阶

        本文链接:https://www.haomeiwen.com/subject/aboutltx.html