介绍
Alluxio中有很多并发性的代码,这些并发性的代码需要经过严整的测试,其中这些测试主要依赖于ScheduledTimer.java、HeartbeatScheduler.java、HeartbeatThread.java。
- 上图中SleepingTimer类主要是用于Alluxio Metrics指标上报,用于控制指标周期性进行统计上报;
- ScheduledTimer类主要用于验证系统在高并发场景下的正确性;
Alluxio并发测试调试
下面主要是从ScheduledTimer类出发探讨Java并发在Alluxio中的实践:
主要流程如下图所示,新建一个线程HeartbeatThread thread1(每个HeartbeatThread绑定一个ScheduledTimer),线程thread1会执行timer.tick(),此时tick会先调用HeartbeatScheduler.addTimer(),然后await()阻塞,在thread1父线程会循环执行HeartbeatScheduler.execute(threadName),每次execute,HeartbeatSchduler会判断当前线程是否拥有可重入锁(HeartbeatScheduler.sLock),有则进入,上面提到的调用HeartbeatScheduler.addTimer()会将当前线程thread1绑定的timer加入map中,并signalAll,此时所有线程进入就绪状态,而刚刚拿到可重入锁的线程会判断其timer是否加入到map,如果没有则继续await,下一个线程重复同样的步骤,直到map中记录了该线程的timer(即线程执行了timer.tick()),下面HeartbeatScheduler会调用timer.schedule(),之后timer绑定的线程被唤醒,跳出循环,tick()结束,thread执行executor.heartbeat()计数加一。
多线程并发行为解析:
HeartbeatScheduler -> 所有线程共享一个HeartbeatScheduler静态方法类(类锁)
当前持有r锁的线程 2进入等待队列condition
reentranL: 4 3 7 5 9 10
condition: null
await()=================
当前持有r锁的线程 1进入等待队列condition
reentranL: 4 3 7 5 9 10 6 8
condition: 2
await()=================
当前持有r锁的线程 4进入等待队列condition
reentranL: 3 7 5 9 10 6 8
condition: 2 1
await()=================
当前持有r锁的线程 3进入等待队列condition
reentranL: 7 5 9 10 6 8
condition: 2 1 4
await()=================
当前持有r锁的线程 7进入等待队列condition
reentranL: 5 9 10 6 8
condition: 2 1 4 3
await()=================
当前持有r锁的线程 5进入等待队列condition
reentranL: 9 10 6 8
condition: 2 1 4 3 7
await()=================
当前持有r锁的线程 9进入等待队列condition
reentranL: 10 6 8
condition: 2 1 4 3 7 5
await()=================
当前持有r锁的线程 10进入等待队列condition
reentranL: 6 8
condition: 2 1 4 3 7 5 9
await()=================
当前持有r锁的线程 6进入等待队列condition
reentranL: 8
condition: 2 1 4 3 7 5 9 10
await()=================
当前持有r锁的线程 8进入等待队列condition
reentranL: null
condition: 2 1 4 3 7 5 9 10 6
********************************触发signalAll()
当前持有r锁的线程 2 -> 2跳出await(),并执行schedule()
reentranL: 1 4 3 7 5 9 10 6 8 h5
condition: null
当前持有r锁的线程 1
reentranL: 4 3 7 5 9 10 6 8 h5
condition: null
当前持有r锁的线程 4
reentranL: 3 7 5 9 10 6 8 h5 2(2又重新回到同步队列:上面提到2要执行schedule(),所以需要再次获取lock)
condition: null
当前持有r锁的线程 1(1又获取到锁,是因为它在没有进入同步队列就开始争夺锁,从而进入schedule())
reentranL: 3 7 5 9 10 6 8 h5 2 4
condition: null
### 接上面一步,1进而调用ScheduledTimer.schedule(),此时ScheduledTimer对象中:
### curThread = 1
### reentranL: 1
### condition: h0
### 此时,1执行mTickCondition.signal()唤醒h0,并从HeartbeatScheduler.sTimers中移除h0绑定的当前timer对象
### 1执行完ScheduledTimer.schedule()后再次执行await等待下一次h0 tick()时被唤醒(tick() -> addTimer() -> condition.signalAll())
### 由于上面1唤醒h0线程,所以h0发现mScheduled为true,则跳出循环,h0线程本次tick()结束
当前持有r锁的线程 3
reentranL: 7 5 9 10 6 8 h5 2 4
condition: null
ScheduledTimer -> 每个线程一个ScheduledTimer对象(对象锁)
curThread = h8
reentranL: null
condition: h8
curThread = h6
reentranL: null
condition: h6
curThread = h1
reentranL: null
condition: h1
curThread = h3
reentranL: null
condition: h3
curThread = h4
reentranL: null
condition: h4
curThread = h7
reentranL: null
condition: h7
curThread = h0
reentranL: null
condition: h0
curThread = h9
reentranL: null
condition: h9
curThread = h2
reentranL: null
condition: h2
并发测试分析
上面介绍到Alluxio的并发测试主要涉及三个类,ScheduledTimer.java、HeartbeatScheduler.java和HeartbeatThread.java
这里借助上面的调试,可以总结一下三个类的作用:
-
HeartbeatThread
此类不是线程安全的,其作用就是创建一个绑定了HeartbeatTimer(测试都采用ScheduledTimer)对象和HeartbeatExecutor(HeartbeatThreadTest采用DummyHeartbeatExecutor)对象的线程。其中DummyHeartbeatExecutor对象主要用于任务的执行,即每次heartbeat执行counter计数加一 -
ScheduledTimer
此类主要是延迟(定时)调度HeartbeatThread类型的线程中的任务(HeartbeatExecutor),正如上面的调试提到的tick(),HeartbeatThread中会在执行mExecutor任务之前通过其绑定的ScheduledTimer来执行tick(),tick()在没有HeartbeatScheduler的帮助下会阻塞住,进入condition等待队列。 -
HeartbeatScheduler
正如名字所示,此类主要是起到心跳调度的作用,调度的是HeartbeatThread线程绑定的ScheduledTimer,上面介绍到ScheduledTimer.tick()在没有HeartbeatScheduler帮助下会阻塞进入condition等待队列,此时主线程会执行HeartbeatScheduler.execute() (依次执行await()、schedule()、await())来唤醒阻塞在ScheduledTimer.condition等待队列中的HeartbeatThread线程,此时HeartbeatThread线程走出ScheduledTimer.tick(),并执行mExecutor.heartbeat()做计数加一动作。 -
涉及到哪些Java并发知识点
- AQS: 同步和等待队列
- 可重入锁
- 对象安全、可见性
- 线程中断、取消
- 线程池
并发知识点总结归纳
对于上面涉及到的Java并发知识点,逐个翻阅书籍与博客,深入理解其实现原理、运行机制及应用场景实践
可重入锁与AQS
- 公平锁/非公平锁
简单来说:
非公平锁: 就是后来要获取锁的线程不会老老实实的进入同步队列(FIFO)通过排队获取锁,而是直接尝试获取锁,获取不到再进入同步队列;
公平锁: 与非公平锁是相对的,每个要获取锁的线程都老老实实的进入同步队列(FIFO)通过排队来获取锁。
从源码从面上,公平锁和非公平锁有两处不同:
- 非公平锁在调用lock后,首先就会调用CAS进行一次抢锁,若此时恰巧锁没有被占用,则直接就获取到锁返回
static final class FairSync extends Sync { final void lock() { acquire(1); } // ... } static final class NonfairSync extends Sync { final void lock() { // 1. 和公平锁相比,这里会直接先进行一次CAS,成功就返回了 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } // ... }
- 非公平锁在CAS失败后,和公平锁一样都会进入到tryAcquire方法,在tryAcquire方法中,如果发现锁此时被释放了(state==0,则非公平锁会直接CAS抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,老老实实排在队尾
// FairSync protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 2. 和非公平锁相比,这里多了一个判断:是否有线程在等待 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // ... } //NonfairSync final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 这里没有对阻塞队列进行判断 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // ... }
- 可重入锁
ReentrantLock在构造时默认是非公平的,但可以通过参数来控制。
// ReentrantLock的构造函数
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
- AQS(AbstractQueuedSynchronizer)
同步队列、条件队列中Node节点数据结构:
volatile int waitStatus; // 可取值 0、CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
同步(阻塞)队列和条件队列区别是什么:
- 同步(阻塞)队列是双向链表,条件队列是单向链表;
- 条件队列中的节点需要转移到到同步(阻塞)队列等待获取锁;
- 对于同一个锁(比如可重入锁)可以有多个条件队列,但只能有一个同步(阻塞)队列
同步队列:
同步队列大致流程如上图所示,其中更加细节的点,比如:同一时间有第二个线程想要获取锁的时候,AQS的同步阻塞队列才会得到初始化(通过自旋来设置head节点);以及中间会有线程取消阻塞等待,则其waitstatus会被置为取消(1),后面当阻塞队列中需要有线程被唤醒的时候,这些被取消的线程节点会被忽略;以及线程在抢夺锁失败之后会尝试加入同步阻塞队列尾部,但可能会失败(同一时间会有其他线程尝试加入同步阻塞队列),这时候线程也会通过自旋的方式进入同步阻塞队列等等,大家可以参照源码来细看。
条件队列:
同步队列于阻塞队列的关系大致如上图所示,其中一些细节还需要再看几遍源码才能完全掌握,这里就不擅自总结了。
- 一个 ReentrantLock 实例可以通过多次调用 newCondition() 来产生多个 Condition 实例,这里对应 condition1 和 condition2。注意,ConditionObject 只有两个属性 firstWaiter 和 lastWaiter;
- 每个 condition 有一个关联的条件队列,如线程 1 调用 condition1.await() 方法即可将当前线程 1 包装成 Node 后加入到条件队列中,然后阻塞在这里,不继续往下执行,条件队列是一个单向链表;
- 调用condition1.signal()触发一次唤醒,此时唤醒的是队头,会将condition1对应的条件队列的firstWaiter(队头)移到阻塞队列的队尾,等待获取锁,获取锁后await方法才能返回,继续往下执行。
- 线程中断
线程中断:中断不是类似linux里面的kill -9 pid命令,不是说中断某个线程,线程就停止运行了。中断代表线程状态,每个线程都关联了一个状态,是一个true或false的boolean值,初始值为false。
如果线程处于以下三种情况,那么当线程被中断的时候,能自动感知到:
- 来自Object类的wait()、wait(long)、wait(long, int),
来自Thread类的join()、join(long)、join(long, int)、sleep(long)、sleep(long, int),这几个方法的相同之处是方法上都有: throws InterruptedException,如果线程阻塞在这些方法上,此时如果其他线程对这个线程进行了中断,则这个线程会从这些方法中立即返回,抛出InterruptedException异常,同时重置中断状态为false;- 实现了InterruptedChannel接口的类中的一些I/O阻塞操作,如DatagramChannel中的connect方法和receive方法等(大多数标准等Channel都实现了InterruptibleChannel);
- Selector的异步I/O,若一个线程在调用Selector.select方法时阻塞了,则调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。
附录
- 参考文章
1. AbstractQueuedSynchronizer同步队列与Condition等待队列
2. 基于ReentrantLock理解AQS设计原理 ❌
3. 一行一行源码分析清楚 AbstractQueuedSynchronizer<一>
4. 一行一行源码分析清楚 AbstractQueuedSynchronizer<二>
5. 一行一行源码分析清楚 AbstractQueuedSynchronizer<三>