Skip to content

同步器(上篇)——面试官问synchronized本质是什么?

这节课我们开始讨论Java中的同步器(Synchronizers)。 并发编程的同步是一个执行层面的同步,就是让线程互相等待,然后在特定时刻完成协同。比如让线程在进入临界区前,进行一次同步,决定谁进入临界区(排队)。

同步部分的底层,就是上节课的原子操作。同步部分有c++写的程序,比如这节课的Monitor。也有Java层的同步框架,AbstractQueuedSynchronizer,还有基于AQS实现的同步工具,比如Phaser, ReentrantLock等。这些工具,平时日常工作中虽然用的不多,我们更多用到的是用这些工具搭建的更高级的工具,比如Executors,比如Spring——但是他们是原理和底层,因此也是高级工程师岗位面试必考点。

本节关联的面试题目有:

  1. 什么是同步?
  2. 简述下Java有哪些同步器?
  3. synchronized是如何实现的?
  4. ReentrantLock和synchronized的区别?
  5. 为什么需要AbstractQueuedSynchronizer?
  6. 偏向锁、轻量级锁、重量级锁是怎么回事?

什么是同步?

‌**同步(Synchronization)**有两个含义:

  1. 执行同步
  2. 数据同步

执行同步就是说多个线程(协同方),在某个时间点汇合起来,然后执行一系列的操作。 Thread.join 就是执行同步。如果用一张图去理解执行同步:

image-20201119032122604

图中:3个线程在同步点(某个时刻)同步。

数据同步则是是多份数据保持一致。比如缓存和存储的同步,分布在不同机房的订单数据的同步。

Java同步器概要

线程同步是执行同步概念,就是在进入临界区(Critical Section)之前,完成一次同步,后续只允许1个或多个线程进入临界区。或者让线程在某个信号、某个内存屏障上发生等待,等待其他线程完成特定的行为同步器(Synchronzier),顾名思义,就是用来实现同步的工具。

下图是Java同步器部分的知识结构:

image-20201119032411643

从整体设计看,这部分分成了5层:

  1. 开发工具层:提供开箱即用的同步工具(Synchronizer)
  2. 开发框架层:提供开发同步工具的框架AQS(注意AQS不仅仅用于开发同步工具,还用于我们之后会讲的Non-Blocking,也称为Lock-Free的工具),所以它是一个开发神器。
  3. JVM层
  4. Java Native Interface(JNI)层(用于调用本地操作系统API)
  5. 操作系统层

大家可以看到,Java同步器部分的设计是一个非对称设计(或者叫做不一致的设计)。对于synchronized关键字,是基于JVM提供的Monitor实现的,需要C/C++程序实现。因此,我们经常把 synchronized 关键字称为内置锁(intrinsic lock), 或者build-in lock。另一部分是建立在同步器开发框架AbstractQueuedSynchronizer(AQS)基础上的同步器,这部分用Java语言开发

synchronized缺陷(和ReentrantLock的区别)

之所以大家会看到这样的设计是因为早期Java的线程设计中,内置锁的能力(synchronized)性能不够;并且使用起来不够灵活,比如:

  1. 缺少高并发处理能力:比如 tryLock 的逻辑(不能实现Non-Blocking的算法)
  2. 缺少获取锁的timeout
  3. 缺少打断当前执行线程的能力(可以打断在Blocking的线程,但是不能打断在执行while循环的线程)-下面的例子
  4. 使用不够灵活,不能跨语句块(基础语法——自学吧……)
  5. 性能不够优化,早期还没有轻量级锁(本节讨论)

举例:缺少获取锁的timeout

var lock = new ReentrantLock();

// ……

try {
    lock.tryLock(1000, TimeUnit.MICROSECONDS);
} catch (InterruptedException e) {
    e.printStackTrace();
}

tryLock会在无锁编程中深入讨论。

举例:缺少打断程序执行的能力

下面程序无法被打断

var t = new Thread(() -> {
    synchronized (lock) {
        for(int i = 0; i < 100000000; i++) { }
        System.out.println("finished.");
    }
});
t.start();
t.interrupt();

当然也可以说是Java的Thread只有在休眠态(WAITING, TIME_WAITING, BLOCKING)的情况下才能触发InterruptedException。而基于AQS的ReentrantLock解决了这个问题:

var lock = new ReentrantLock();

var t = new Thread(() -> {

    int k = 0;
    try {
        lock.lockInterruptibly();
        for(int i = 0; i < 1000000000; i++) {
            k+=1;
        }
        System.out.println("finished:" + k);
    } catch (InterruptedException e) {
        System.out.println("intterrupt:" + k);
        e.printStackTrace();
    }finally {
        lock.unlock();
    }


});
t.start();
t.interrupt();

AQS简介

AQS是Java的一个同步器开发框架,解决的一个最核心问题,就是将高端用户人群和真正意义的底层隔离(JVM的机制和底层的JNI)。一种同步器往往不能解决所有问题,用户经常需要自己实现同步器,这个时候,如果没有AQS,用户就会去用底层的JVM或者映射到JNI的API(例如:LockSupport.park、CAS、Unsafe等等),这种情况会让Java编程变得非常复杂。因此Java提供了AQS,让需要实现同步器的用户在一个同步器框架上编程。

比如下面的程序,就可以让用户轻松的实现一个Mutex数据结构:

public class Mutex {
    private final Sync sync = new Sync();
    static class Sync extends AbstractQueuedSynchronizer{
        protected boolean tryAcquire(int arg) {
            return compareAndSetState(0, 1);
        }
        protected boolean tryRelease(int arg) {
            return compareAndSetState(1, 0);
        }
    }
    public void lock(){
        sync.acquire(0);
    }
    public void unlock(){
        sync.release(0);
    }
}

具体这个程序的含义,我们会在后面讲AQS部分给大家再讲。现在大家可以看到这段程序我们只需要重写tryAcquire 方法就可以实现一个互斥的同步器。 这是因为真实复杂的逻辑被AbstractQueuedSynchronizer给屏蔽了,它为我们实现了同步。

思考:synchronized关键字的设计

在讲解synchronzied 的原理之前,我们先对如何实现这个关键字进行一定的思考。我们先看看它的用法:

synchronized(obj) {
  // 临界区程序
}

按照上一节课我们学的知识,我们可以把上面的程序理解成一次锁和释放锁的过程。如果我们用之前学习的enterleave 的语意,可以考虑下面这段伪代码:

enter(&lock){  
  while( !cas(&lock, 0, 1) ) { 
      // 休眠
  }
}

leave(&lock){  
  lock = 0;
}
    
enter(&obj.lock)
// 临界区
leave(&obj.lock)

以上程序,enter 利用cas操作不断尝试获取锁,如果获取失败则休眠线程。这个休眠有3种理解:

  1. 休眠少量CPU周期(自旋锁),类似cpu_relax 指令,让CPU以低功耗的状态工作几个周期,什么都不做,然后再进行一次尝试。缺点就是消耗CPU的计算资源。
  2. 定时休眠(类似Thread.sleep)。让线程睡眠一段预设的时间。缺点就是预设设计达到之前,即便锁已经释放,但是线程不会被唤醒,造成时间浪费。
  3. 信号休眠、信号唤醒(类似wait/notify),这样做的问题是需要实现一个生产者/消费者模型,最简单的方法是需要一个先入先出(FIFO)的队列结构。

因此,综合考虑,Java在实现一个synchronized 关键字的时候,可以考虑下面这个设计:

image-20201119032706344

Enter a caption for this image (optional)

这个设计的优势:先用自旋锁,因为自旋锁开销最小,只需要用少量的CPU指令;如果自旋锁获取不成功,就让线程休眠。线程休眠需要的资源也很少。线程休眠后,需要信号唤醒。上一节课我们提到过,休眠相当于一个生产者,唤醒相当于一个消费者。 每次一个线程执行完成,就唤醒一个新的线程。新的线程就会重新尝试自旋获取锁。

之前我们学习过,操作系统中,线程有3个最基本的状态:

  1. RUNNING
  2. SLEEPING(包括I/O WAIT, 时钟等待,主动休眠等)
  3. READY

休眠就是把线程状态改为SLEEPING,唤醒就是把状态从SLEEPING改为READY。这部分需要调用操作系统API。另一方面,为了实现这个能力,JVM必须知道哪些线程在需要被唤醒,因此这里JVM内部需要一个等待集合——后面我们称这个集合为WaitSet

总结一下,synchronized 关键背后需要这些逻辑:

  1. 需要实现锁/解锁的逻辑
  2. 需要自旋锁到休眠的升级逻辑
  3. 每个对象都可以上锁——synchronized(任何东西)
  4. 线程可以在竞争不到资源时休眠
  5. 线程释放资源时唤醒休眠线程

Monitor:synchronized的实现方案

为了解决我们上面发现的问题,JVM实现了Monitor对象。 每个Java对象被创建后,就为它生成一个关联的Monitor对象。

image-20201119032754109

线程上锁,其实是抢占Object的Monitor对象。可以考虑的一种抢占方法是在Monitor对象中增加对线程的引用。

image-20201119032808672

Enter a caption for this image (optional)

如果有多个线程,同时竞争Monitor。这个时候,就让多个线程先进行自旋竞争,失败的都休眠。因此,在Monitor中,需要一个等待集合。

image-20201119032832788

以上,构成了一个基本的Monitor对象。 如果用户使用synchronized 关键字,这个时候就考虑将synchronzied 区域前后,调用加锁/解锁方法。JVM的实现更简单一下, JVM将他们抽象成了两条JVM的指令:

monitorenter
//临界区
monitorleave

monitorenter 指令工作原理类似加锁,帮助线程争夺Monitor对象。如果没有争夺到,就把当前线程加入Monitor的等待列表(WaitSet)中。

Monitor的更多设计因素

以上我们对synchronziedMonitor 进行了一个基本的思考,但这还不是最终版本,还有很多因素需要考虑,接下来我们讨论其中的一部分:

  1. 线程间协作
  2. 锁的升级(偏向锁、轻量级锁和重量级锁)

线程间协作

协作,就是线程间进行分工合作。比如一个线程从数据库读取数据,另一个线程对数据进行加工处理。 这里有两种主要的模型:

  1. Polling
  2. Queuing

Polling,就是计算线程不断检测有没有新读进来的数据,然后进行处理。Queuing,就是读取数据的线程将结果存入队列,然后通知计算的线程。Polling类似自旋锁,不断检查状态;Queuing是生产者/消费者模型,队列满了需要休眠,消费者开始工作后要唤醒生产者,因此,需要提供线程间通信手段。

Monitor需要考虑到线程间的写作,因此Java语言为每个对象提供了wait方法用于休眠,并提供了两个用于通知的方法:notify和notifyAll。

这3个方法,属于Monitor的基础能力。在Java中用户不能直接使用Monitor,因此这3个方法是Object在代理。

package test;

import java.util.LinkedList;

public class ProducerCustomerModel {
    final static int MAX = 10;
    LinkedList<Integer> queue = new LinkedList<>();


    int readData() throws InterruptedException {
        Thread.sleep((long)Math.random()*1000);
        return (int)Math.floor(Math.random());
    }



    // Producer
    public void readDb() throws InterruptedException {

        synchronized (queue) {
            if (queue.size() == MAX) {
                queue.wait();
                return;
            }

            var data = readData(); // 1s

            queue.add(data);

            if(queue.size() == 1) {
                queue.notify();
            }
        }

    }

    // Comsumer
    public void calculate() throws InterruptedException {

        synchronized (queue) {
            if(queue.size() == 0) {
                queue.wait();
                return;
            }

            var data = queue.remove();

            System.out.println("queue-size:" + queue.size());

            if(queue.size() == MAX-1) {
                queue.notifyAll();
            }

            data *= 100;
        }

    }
    public static void main(String[] argv) {
        var p = new ProducerCustomerModel();
        for(int i = 0; i < 100; i++) {
            new Thread(() -> {
                while (true) {
                    try {
                        p.readDb();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }

        new Thread(() -> {
            while(true) {
                try {
                    p.calculate();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

上面程序,开了100个线程作为生产者,开了一个线程作为消费者。 如果队列满了,会把生产者休眠;队列空了,会把消费者休眠。我们用一个随机等待的程序模拟从数据库读取数据的过程,消费者不断进行计算。每次生产结束后,调用一次notify ,这样如果有消费者休眠,会唤醒它参与竞争。

**陷阱:**上面例子当中的notifyAll会同时唤醒生产者和消费者;如果用notify就会随机唤醒生产者或者消费者。

更好的方案:利用Java的Condition变量

public class ProducerCustomerModel {
    final static int MAX = 10;
    LinkedList<Integer> queue = new LinkedList<>();

    ReentrantLock lock = new ReentrantLock();
    Condition notFull = lock.newCondition();
    Condition notEmpty = lock.newCondition();



    int readData() throws InterruptedException {
        Thread.sleep((long)Math.random()*1000);
        return (int)Math.floor(Math.random());
    }



    // Producer
    public void readDb() throws InterruptedException {

        var data = readData(); // 1s
        lock.lock();
        if (queue.size() == MAX) {
            notFull.await();
            return;
        }


        queue.add(data);

        if(queue.size() == 1) {
            notEmpty.signalAll();
        }
        lock.unlock();

    }

    // Comsumer
    public void calculate() throws InterruptedException {
        lock.lock();
        if(queue.size() == 0) {
            notEmpty.await();
            return;
        }

        var data = queue.remove();

        System.out.println("queue-size:" + queue.size());

        if(queue.size() == MAX-1) {
            notFull.signalAll();
        }

        data *= 100;
        lock.unlock();
    }

    public static void main(String[] argv) {
        var p = new ProducerCustomerModel();
        for(int i = 0; i < 1000; i++) {
            new Thread(() -> {
                while (true) {
                    try {
                        p.readDb();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }

        new Thread(() -> {
            while(true) {
                try {
                    p.calculate();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

上面的程序定义了两个条件变量Condition ,这个是由JVM底层支持的。 条件变量可以让线程在某个主题(条件变量)上等待/唤醒。这对实现线程间的协作非常有帮助。条件变量的底层也是c/c++程序。

高并发场景(EntrySet、偏向锁、轻量级锁、重量级锁)

考虑大量线程竞争一个用户实现的synchronized 块,如图:

image-20201119033012419

在高并发场景,竞争非常激烈。而作为语言的设计者,不能假设synchronized 的块可以迅速完成。图中很多线程竞争一个同步块,按照之前的设计,这个时候这些线程会优先考虑自旋锁。因为同步块需要10ms ,因此多数线程拿不到自旋锁。考虑到CPU执行速度非常快,每10ms 才有一个线程拿到锁。而1ms 可以执行数以10万-百万计的CAS操作。因此这样是非常不划算的:

  1. 自旋锁消耗大量计算能力(CPU资源)
  2. 大量线程进入WaitSet

这时JVM的解决方案是增加一个EntrySet。如图:

image-20201119033047741

图中线程先用自旋锁竞争进入EntrySet,竞争进入EntrySet只需要少量的cas操作。

image-20201119033056857

如果像图中那样用链表实现EntrySet,新线程进入EntrySet只需要两条指令,第一步创建一个节点,指向EntrySet的第一个元素。第二步,用cas操作将EntrySet的尾部指向新的节点。 这样需要的时间是非常短的,因此多数线程都可以进入EntrySet。

进入EntrySet之后,如果当前没有持有者(没有线程再执行)。就让这个线程去竞争:其实就是把线程的状态改为READY,让它竞争。如果既没有持有者,WaitSet也是空的,那么就不存在竞争,可以考虑直接让线程执行。但是判断有没有持有者,再判断WaitSet种有没有线程,这个过程不是一个原子操作。因此Java在每个对象的头部,增加了几个标识位,记录这个资源的所有者,如果这个位没有被占领,那么就可以考虑直接执行这个线程。这个方法称为偏向锁(Bias Lock)

image-20201127081705085

如果有线程占领了偏向锁,那么说明有多个线程在竞争,就升级为轻量级锁(Light Weighted Lock)。轻量级锁,会先观察目前Monitor 中有没有持有者(Owner)——正在临界区的线程。如果没有,就直接去竞争Owner。为什么不直接给Owner? 因为这个时候有可能WaitSet的线程也在竞争Owner,比如用户在这之前刚好调用了notifyAll,通知了所有WaitSet中的线程去竞争Owner。轻量级锁的特点是会先进行自旋,如果若干次自旋后,比如10次,还是无法获取锁,就加入EntrySet,其实就是采用重量级锁。之所以称为重量级锁,是因为接下来每个从EntrySet获取竞争机会的线程,会直接用操作系统的mutex API进行锁的竞争。

一个持有锁的线程离开临界区后,会重新进入WaitSet。如果这个线程休眠,也会进入WaitSet。WaitSet和EntrySet中的线程,当持有线程休眠或者离开时,会一起再次竞争Owner,这个时候不再使用偏向锁和轻量级锁,这个特性我们称为锁的不可降级。 一旦升级为重量级锁,不会再使用轻量级或者偏向锁。 重量级锁,就是利用操作系统API提供的互斥能力进行竞争。

总结

这节课带大家对Monitor部分的整体设计进行了学习。尽管后面我们将要学习的AQS采用了不同的语言(Java实现),但是思想没有发生本质的变化。我们看到了偏向锁、自旋锁(轻量级锁)、重量级锁的实现。这里有很多性能考量,少量自旋锁不消耗太多资源;大量自旋锁,会消耗CPU资源。线程休眠不消耗资源,但是让线程休眠和唤醒线程需要资源(Context-Switch)。一方面,我们希望操作系统给到线程的时间片段中,线程多处理任务。一方面我们希望,等待I/O(锁)的线程尽可能的休眠,让出CPU资源。程序设计,就是在多种多样的因素间找到他们的平衡点。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我