同步器(上篇)——面试官问synchronized本质是什么?
这节课我们开始讨论Java中的同步器(Synchronizers)。 并发编程的同步是一个执行层面的同步,就是让线程互相等待,然后在特定时刻完成协同。比如让线程在进入临界区前,进行一次同步,决定谁进入临界区(排队)。
同步部分的底层,就是上节课的原子操作。同步部分有c++写的程序,比如这节课的Monitor。也有Java层的同步框架,AbstractQueuedSynchronizer,还有基于AQS实现的同步工具,比如Phaser, ReentrantLock等。这些工具,平时日常工作中虽然用的不多,我们更多用到的是用这些工具搭建的更高级的工具,比如Executors,比如Spring——但是他们是原理和底层,因此也是高级工程师岗位面试必考点。
本节关联的面试题目有:
- 什么是同步?
- 简述下Java有哪些同步器?
- synchronized是如何实现的?
- ReentrantLock和synchronized的区别?
- 为什么需要AbstractQueuedSynchronizer?
- 偏向锁、轻量级锁、重量级锁是怎么回事?
什么是同步?
**同步(Synchronization)**有两个含义:
- 执行同步
- 数据同步
执行同步就是说多个线程(协同方),在某个时间点汇合起来,然后执行一系列的操作。 Thread.join
就是执行同步。如果用一张图去理解执行同步:
图中:3个线程在同步点(某个时刻)同步。
数据同步则是是多份数据保持一致。比如缓存和存储的同步,分布在不同机房的订单数据的同步。
Java同步器概要
线程同步是执行同步概念,就是在进入临界区(Critical Section)之前,完成一次同步,后续只允许1个或多个线程进入临界区。或者让线程在某个信号、某个内存屏障上发生等待,等待其他线程完成特定的行为同步器(Synchronzier),顾名思义,就是用来实现同步的工具。
下图是Java同步器部分的知识结构:
从整体设计看,这部分分成了5层:
- 开发工具层:提供开箱即用的同步工具(Synchronizer)
- 开发框架层:提供开发同步工具的框架AQS(注意AQS不仅仅用于开发同步工具,还用于我们之后会讲的Non-Blocking,也称为Lock-Free的工具),所以它是一个开发神器。
- JVM层
- Java Native Interface(JNI)层(用于调用本地操作系统API)
- 操作系统层
大家可以看到,Java同步器部分的设计是一个非对称设计(或者叫做不一致的设计)。对于synchronized关键字,是基于JVM提供的Monitor实现的,需要C/C++程序实现。因此,我们经常把 synchronized 关键字称为内置锁(intrinsic lock), 或者build-in lock。另一部分是建立在同步器开发框架AbstractQueuedSynchronizer(AQS)基础上的同步器,这部分用Java语言开发
synchronized缺陷(和ReentrantLock的区别)
之所以大家会看到这样的设计是因为早期Java的线程设计中,内置锁的能力(synchronized)性能不够;并且使用起来不够灵活,比如:
- 缺少高并发处理能力:比如
tryLock
的逻辑(不能实现Non-Blocking的算法) - 缺少获取锁的timeout
- 缺少打断当前执行线程的能力(可以打断在Blocking的线程,但是不能打断在执行while循环的线程)-下面的例子
- 使用不够灵活,不能跨语句块(基础语法——自学吧……)
- 性能不够优化,早期还没有轻量级锁(本节讨论)
举例:缺少获取锁的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) {
// 临界区程序
}
按照上一节课我们学的知识,我们可以把上面的程序理解成一次锁和释放锁的过程。如果我们用之前学习的enter
和leave
的语意,可以考虑下面这段伪代码:
enter(&lock){
while( !cas(&lock, 0, 1) ) {
// 休眠
}
}
leave(&lock){
lock = 0;
}
enter(&obj.lock)
// 临界区
leave(&obj.lock)
以上程序,enter
利用cas操作不断尝试获取锁,如果获取失败则休眠线程。这个休眠有3种理解:
- 休眠少量CPU周期(自旋锁),类似
cpu_relax
指令,让CPU以低功耗的状态工作几个周期,什么都不做,然后再进行一次尝试。缺点就是消耗CPU的计算资源。 - 定时休眠(类似Thread.sleep)。让线程睡眠一段预设的时间。缺点就是预设设计达到之前,即便锁已经释放,但是线程不会被唤醒,造成时间浪费。
- 信号休眠、信号唤醒(类似wait/notify),这样做的问题是需要实现一个生产者/消费者模型,最简单的方法是需要一个先入先出(FIFO)的队列结构。
因此,综合考虑,Java在实现一个synchronized
关键字的时候,可以考虑下面这个设计:
Enter a caption for this image (optional)
这个设计的优势:先用自旋锁,因为自旋锁开销最小,只需要用少量的CPU指令;如果自旋锁获取不成功,就让线程休眠。线程休眠需要的资源也很少。线程休眠后,需要信号唤醒。上一节课我们提到过,休眠相当于一个生产者,唤醒相当于一个消费者。 每次一个线程执行完成,就唤醒一个新的线程。新的线程就会重新尝试自旋获取锁。
之前我们学习过,操作系统中,线程有3个最基本的状态:
- RUNNING
- SLEEPING(包括I/O WAIT, 时钟等待,主动休眠等)
- READY
休眠就是把线程状态改为SLEEPING,唤醒就是把状态从SLEEPING改为READY。这部分需要调用操作系统API。另一方面,为了实现这个能力,JVM必须知道哪些线程在需要被唤醒,因此这里JVM内部需要一个等待集合——后面我们称这个集合为WaitSet
总结一下,synchronized
关键背后需要这些逻辑:
- 需要实现锁/解锁的逻辑
- 需要自旋锁到休眠的升级逻辑
- 每个对象都可以上锁——synchronized(任何东西)
- 线程可以在竞争不到资源时休眠
- 线程释放资源时唤醒休眠线程
Monitor:synchronized的实现方案
为了解决我们上面发现的问题,JVM实现了Monitor对象。 每个Java对象被创建后,就为它生成一个关联的Monitor对象。
线程上锁,其实是抢占Object的Monitor对象。可以考虑的一种抢占方法是在Monitor对象中增加对线程的引用。
Enter a caption for this image (optional)
如果有多个线程,同时竞争Monitor。这个时候,就让多个线程先进行自旋竞争,失败的都休眠。因此,在Monitor中,需要一个等待集合。
以上,构成了一个基本的Monitor对象。 如果用户使用synchronized
关键字,这个时候就考虑将synchronzied
区域前后,调用加锁/解锁方法。JVM的实现更简单一下, JVM将他们抽象成了两条JVM的指令:
monitorenter
//临界区
monitorleave
monitorenter
指令工作原理类似加锁,帮助线程争夺Monitor对象。如果没有争夺到,就把当前线程加入Monitor的等待列表(WaitSet)中。
Monitor的更多设计因素
以上我们对synchronzied
和Monitor
进行了一个基本的思考,但这还不是最终版本,还有很多因素需要考虑,接下来我们讨论其中的一部分:
- 线程间协作
- 锁的升级(偏向锁、轻量级锁和重量级锁)
线程间协作
协作,就是线程间进行分工合作。比如一个线程从数据库读取数据,另一个线程对数据进行加工处理。 这里有两种主要的模型:
- Polling
- 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
块,如图:
在高并发场景,竞争非常激烈。而作为语言的设计者,不能假设synchronized
的块可以迅速完成。图中很多线程竞争一个同步块,按照之前的设计,这个时候这些线程会优先考虑自旋锁。因为同步块需要10ms
,因此多数线程拿不到自旋锁。考虑到CPU执行速度非常快,每10ms
才有一个线程拿到锁。而1ms
可以执行数以10万-百万计的CAS操作。因此这样是非常不划算的:
- 自旋锁消耗大量计算能力(CPU资源)
- 大量线程进入WaitSet
这时JVM的解决方案是增加一个EntrySet。如图:
图中线程先用自旋锁竞争进入EntrySet,竞争进入EntrySet只需要少量的cas操作。
如果像图中那样用链表实现EntrySet,新线程进入EntrySet只需要两条指令,第一步创建一个节点,指向EntrySet的第一个元素。第二步,用cas操作将EntrySet的尾部指向新的节点。 这样需要的时间是非常短的,因此多数线程都可以进入EntrySet。
进入EntrySet之后,如果当前没有持有者(没有线程再执行)。就让这个线程去竞争:其实就是把线程的状态改为READY,让它竞争。如果既没有持有者,WaitSet也是空的,那么就不存在竞争,可以考虑直接让线程执行。但是判断有没有持有者,再判断WaitSet种有没有线程,这个过程不是一个原子操作。因此Java在每个对象的头部,增加了几个标识位,记录这个资源的所有者,如果这个位没有被占领,那么就可以考虑直接执行这个线程。这个方法称为偏向锁(Bias Lock)。
如果有线程占领了偏向锁,那么说明有多个线程在竞争,就升级为轻量级锁(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资源。程序设计,就是在多种多样的因素间找到他们的平衡点。