A Red-Black tree based NavigableMa implementation.
TreeMap,是一种具有排序功能的 Map
,是一种基于红黑树结构的的 NavigableMap
。由于它具有红黑树的结构,所以它的 get
、 put
、 constainsKey
、remove
等操作能够保证在log(n)的时间复杂度。
TreeMap是基于红黑树实现的一种 NavigableMap
。红黑树是一种二叉搜索树,具有以下性质:
性质1
树上的节点要么是红色要么是黑色性质2
根节点一定是黑色性质3
树上不存在连续的红色节点性质4
任一根到叶子节点路径上具有相同数目的黑色节点无论是往红黑树上添加节点还是移除节点,都需要进行必要的balance,以保证以上四条性质不被破坏。
往树上添加节点和移除节点的balance策略是不尽相同的,各自都需要分多钟情况讨论。
在对这两种情况展开讨论之前,先说明两个操作,即 左旋
和 右旋
:
如上图,对G左旋,G原本的右孩子U,成为G的父节点,G成为U的左孩子,U原本的左孩子Ul成为G的右孩子。
如上图,对G右旋,P成为父节点,G为P的右孩子,P原本的右孩子Pr,挪为G的左孩子。
节点上的节点要么是红色要么是黑色,那么添加的节点应该选择哪种颜色呢?答案应该是红色,应为如果是黑色,那么 性质4
就被破坏了,所以为了避免复杂性,应该设定添加的节点颜色为红色。
假设要往树上 P
下添加节点 N
, P
的兄弟节点为 U
, 父节点为 G
。如图:
以上图为例,在插入节点时做以下情况说明:
情况一
N 是头结点。则从red变色为black情况二
N 的父节点P是黑色。则不用调整情况三
N 的父节点P是红色,叔叔节点U也为红色。P、U -> black; then G -> red;可能需要往上递归处理,因为可能存在连续的红色节点,如果G是root,则需要将其颜色置为black情况四
N 的父节点P是红色,叔叔节点U为黑色,且N是P的左孩子。则P、G互换颜色,然后对G右旋情况五
N 的父节点P是红色,叔叔节点U为黑色,且N为P的右孩子。则对P先左旋,然后按 情况四
处理插入的情况一共就以上五种情况,可以自己画一画加深印象。
移除节点的流程一般是这样: 直接删除,找到前驱(删除节点左子树上的最大节点)或后继(删除节点右子树上的最小节点),将找到的前驱或后继赋值到删除节点,最后删除前驱或者后继,因为前驱或后继至多只有一个孩子,因此问题简化为删除至多只有一个孩子的问题。
移除节点的情况就先对复杂一些了,以上图为例分情况说明:
情况一
移除节点 X 为red。则由唯一的孩子顶替即可(红黑树的四个原则并没有遭到破坏)情况二
删除节点是black,path上少了个black节点,分为两种大的情况:s1
: 待删除节点唯一的孩子为红色,则直接顶替并变色即可s2
: 待删除节点唯一的孩子为黑色,需要分为6中情况(以上图例子说明):s2.1
:删除节点为根节点,则子节点直接替换父节点s2.2
:N 父节点G为red,兄弟节点s及其孩子均为black,少了个black x,即N路径上少了个黑,则父节点和兄弟节点换个颜色s2.3
:N 的父节点G可red可black,兄弟节点s为black,s的左孩子SL为可red可black,s的右孩子SR为red,则1.对G左旋;2.再G和S互换颜色,SL移为N的右孩子,SR改变颜色为blacks2.4
:N 的父节点G可red可black,兄弟节点s为black,s的左孩子SL为red,s的右孩子SR为black,则对S右旋,且S和SL互换颜色,则变成情况3s2.5
:N 的兄弟节点S为red,其他节点为黑色,则G和S互换颜色, 对G左旋,这个时候左子树(照具体情况按情况2 3 4处理)s2.6
:N 的父节点,兄弟节点及其兄弟孩子节点均为black,则将S变为red,这时按情况5处理TreeMap的类声明如下:
1 | public class TreeMap<K, V> |
可知TreeMap具有Map的一般行为,同是也是NavigableMap。
private final Comparator<? super K> comparator;
// 用于元素排序的比较器private transient Entry<K, V> root;
// 红黑树的rootprivate transient int size = 0;
// 目前红黑树中容纳的元素个数private transient int modCount = 0;
// 修改计数,failfastprivate static final boolean RED = false;
// 红色private static final boolean BLACK = true;
// 黑色1 | public TreeMap() { |
默认构造方法并没有初始化任何变量,包括树的首尾(并不需要一般链表中的dummy node)
1 | public TreeMap(Comparator<? super K> comparator) { |
带一个维护内部元素顺序的比较器参数
1 | public TreeMap(SortedMap<K, ? extends V> m) { |
要是传入的是一个已经有序的Map,那么可以根据这个Map迭代构造红黑树
1 | public TreeMap(java.util.Map<? extends K, ? extends V> m) { |
要是Map是个有的Map,则在构造时建立红黑树(同上一情况),否则等到第一次对树上元素操作时才会处理
rotateLeft
左旋1 | private void rotateLeft(Entry<K, V> p) { |
rotateRight
右旋1 | private void rotateRight(Entry<K, V> p) { |
predecessor
前驱1 | static <K, V> Entry<K, V> predecessor(Entry<K, V> t) { |
successor
后继1 | static <K, V> Entry<K, V> successor(Entry<K, V> t) { |
getLastEntry
1 | final Entry<K, V> getLastEntry() { |
getFirstEntry
1 | final Entry<K, V> getFirstEntry() { |
getEntry
1 | final Entry<K, V> getEntry(Object key) { |
1 | public V put(K key, V value) { |
1 | public V remove(Object key) { |
put
get
等操作的时间复杂度为log(n)This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g.,a user ID or Transaction ID).
源码分析自 JDK 1.8.171
ThreadLocal 是一个用于暂存线程本地变量的工具数据结构类。
1 | public class ThreadId { |
ThreadLocal 能够存储当前线程可见的本地变量。在ThreadLocal中有一个 ThreadLocalMap
静态类,这个类才是具体存储变量的数据结构。在Thread中有一个声明为 ThreadLocal.ThreadLocalMap threadLocals = null;
的变量,这个变量就是存储的当前线程可见的本地变量,这个threadlocals由ThreadLocal类维护。
private final int threadLocalHashCode = nextHashCode();
// ThreadLocal在线程Thread的hash值private static AtomicInteger nextHashCode = new AtomicInteger();
// 下一个ThreadLocal的hash值,一个线程可能会有多个ThreadLocalprivate static final int HASH_INCREMENT = 0x61c88647;
// 两个ThreadLocal之间hash差值public ThreadLocal()
// 默认构造方法1 | /** |
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier)
// 带初始值的构造方法1 | public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) { |
initialValue方法是在调用get()方法的时候可能会调用的
**
ThreadLocalMap是一个hash map。这个map的 Entry
是在ThreadLocalMap内部的一个静态类,Entry
继承了 WeakReference<ThreadLocal<?>>
。
1 | static class ThreadLocalMap { |
ThreadLocal的一般用法就是set和get方法,就从这两个方法作为入口分析一下逻辑流程。
1 | ThreadLocalMap getMap(Thread t) { |
1 | public void set(T value) { |
1 | public T get() { |
An unbounded {@linkpain java.util.concurrent.BlockingQueue blocking queue} that uses the same ordering rules as class {@link PriorityQueue} and supplies blocking retrieval operations.
源码分析自 JDK 1.8.0_171
PriorityBlockingQueue,无界优先级阻塞队列。队列中的优先级根据提供的独立的一个 Comparator
接口或者实现 Comparable
接口的队列元素决定。
优先级队列是基于堆(小顶堆)是实现的。线程安全性由内部声明的一个ReentrantLock保证,即所有的公共操作都是在锁下完成。
堆实际上是一种完全二叉树,分为大顶堆和小顶堆。大顶堆的堆顶的关键字肯定是所有关键字中最大的,小顶堆的堆顶的关键字是所有关键字中最小的。如下图:
一般堆都是由数组实现,记一个节点的索引下标为n,那么它的左右孩子为 2n+1
和 2(n+1)
。
1 | /** |
队列默认大小为11
**
1 | /** |
未传入comparator,则需要队列元素自身实现ComParable接口
1 | /** |
1 | /** |
有必要的话,需要堆化
PriorityBlockingQueue的最主要的两个动作时,往队列中放入元素,从队列中取出元素。对应的两个核心的方法时 offer
和 poll
。此外还有在上述其中一个构造方法中,设计到一个堆化的操作,对应的方法时 heapify
。
以堆的形式重新组织数组中的元素。
1 | /** |
队列是无界的,所以理论上offer永远返回true。
1 | /** |
扩容的时候有点讲究,见下
1 | /** |
需要在持有锁的前提下,才能从队列中获取元素。获取元素成功后,原则上堆结构是被_破坏_了,所以需要重新调整堆结构。
1 | public E poll() { |
上文提到的调整堆的操作有 siftDownComparable(siftDownUsingComparator)
和 siftUpComparable(siftUpUsingComparator)
。siftDownComparable
和 siftDownUsingComparator
是同一种操作,只是比较元素大小一个是利用元素实现 Comparable
接口,一个是利用构造时传入的 Comparator
。
1 | /** |
1 | /** |
优先级队列内部数据结构是一个数组,这个数组是以堆的形式组织的。线程安全性由内部的一个 ReentrantLock
保证,所有对元素的公共操作是需要在持有锁的前提下才能完成。
出入队后,我们说这个堆结构是被破坏了,所以需要重新调整下这个堆结构。调整堆主要是由两个操作组成:siftUp[Comparable|Comparator]
和 siftDown[Comparable|Comparator]
。分别是从下往上找替换的元素位置,从上往下找替换元素的位置。
An unbounded {*@link *java.util.concurrent.TransferQueue} based on linked nodes.This queue orders elements FIFO (first-in-first-out) with respect to any given producer.
LinkedTransferQueue基于链表实现于TransferQueue接口,是一个遵循FIFO的队列。TransferQueue具有阻塞队列的行为(继承BlockingQueue接口),并且producer也可以阻塞等待consumer从队列中取出该element消费。
源码分析自 JDK 1.8.0_171
1 | public interface TransferQueue<E> extends BlockingQueue<E> { |
LinkedTransferQueue除了有BlockingQueue的行为外,还具有以上行为。
LinkedTransferQueue实现了TransferQueue接口,而TransferQueue又继承自BlockingQueue。
众所周知,阻塞队列是生产者往队列放入元素,消费者往队列取出元素进行消费,如果队列无空闲空间/无可用元素则生产者/消费者会相应阻塞。
一般的阻塞队列,生产者和消费者是互不关心的,即两者完全解耦。正常情况下一般是不会互相阻塞(队列有足够的空间,生产者不会因为队列无空闲空间而阻塞;队列有足够的元素,消费者不会因为队列无元素可取而阻塞)。生产者将元素入队就可以离开了,不关心谁取走了它的元素;消费者将元素取出就可以离开了,不关心谁放的这个元素。但是TransferQueue不是,它的生产者和消费者允许互相阻塞。
LinkedTransferQueue的算法采用的是一种Dual Queue的变种,Dual Queues with Slack。
Dual Queue不仅能存放数据节点,还能存放请求节点。一个数据节点想要入队,这个时候队列里正好有请求节点,此时”匹配”,并移除该请求节点。Blocking Dual Queue入队一个未匹配的请求节点时,会阻塞直到匹配节点入队。Dual Synchronous Queue在Blocking Dual Queue基础上,还会在一个未匹配的数据节点入队时也会阻塞。而Dual Transfer Queue支持以上任何一种模式,具体是哪一种取决于调用者,也就是有不同的api支持。
FIFO Dual Queue使用的是名叫M&S的一种lock-free算法(http://www.cs.rochester.edu/u/scott/papers/1996_PODC_queues.pdf)的变种。
M&S算法维护一个”head”指针和一个”tail”指针。head指向一个匹配节点M,M指向第一个未匹配节点(如果未空则指向null),tail指向未匹配节点。如下
在Dual Queue内部,由链表组成,对于每个链表节点维护一个match status。在LinkedTransferQueue中为item。对于一个data节点,匹配过程体现在item上就是:non-null -> null,反过来,对于一个request节点,匹配过程就是:null->non null。Dual Queue的这个match status一经改变,就不会再变更它了。也就是说这个item cas操作过后就不会动了。
基于此,现在的Dual Queue的组成可能就是在一个分割线前全是M节点,分割线后全是U节点。假设我们不关心时间和空间,入队和出队的过程就是遍历这个链表找到第一个U和最后一个U,这样我们就不会有cas竞争的问题了。
基于以上的分析,我们就可以有一个这样的tradeoff来减少head tail的cas竞争。如下:
将head和第一个未匹配的节点U之间的距离叫做”slack”,根据一些经验和实验数据发现,slack在1-3之间时工作的更好,所以,LinkedTransferQueue将其定为2。
关于BlockingQueue的相关行为不做过多解读,主要看继承自TransferQueue的行为。
1 | /** |
由以上代码能看到,内部维护的变量相对来说还是比较少的,主要是链表的head 和tail。最下边的四个静态场景是作为xfer方法how参数,以区分不通方法用于不同场景的调用。
NOW
用于poll()、tryTransfer(E e)方法调用ASYNC
用于offset()、put()、add()方法调用 SYNC
用于transfer()、take()方法调用TIMED
用于poll(long timeout,TimeUnit unit)、tryTransfer(E e,long timeout,TimeUnit unit)方法调用1 | /* |
以上是主要我们要分析的行为,可以看见,出来 hasWaitingConsumer
和 getWaitingConsumerCount
,其他对队列操作元素的方法,都是通过xfer方法实现的。
先看 hasWaitingConsumer
和 getWaitingConsumerCount
。
1 | public boolean hasWaitingConsumer() { |
1 | public int getWaitingConsumerCount() { |
以上两个方法还是挺清晰的,主要是要注意一个地方,就是第一个未匹配节点的类型,决定了后边未匹配节点的类型。
接下来看看 xfer
方法。
1 | private E xfer(E e, boolean haveData, int how, long nanos) { |
结合xfer注释,对照着前边的几个操作队列元素的方法,多理解几遍,效果更佳。
根据源码总的描述,SynchronousQueue是一个阻塞队列,所有的入队出队操作都是阻塞的。根据作者的描述,这个工具的定位是在线程间同步对象,以达到在线程间传递一些信息、事件、或者任务的目的(They are well suited for handoff designs, in which an object running in one thread must sync up with an object running in another thread in order to hand it some information, event, or task.)。
关于这个工具的描述,作者只介绍了这么多。SynchronousQueue是实现了BlockingQueue,但是有与一般意义上的queue(比如ArrayBlockingQueue)不一样,它内部并没有存放元素的地方。入队阻塞直到出队成功,反之亦然,在线程间同步传递对象,以在线程间同步信息。
SynchronousQueue内部并没有容纳元素的数据结构,也就是说SynchronousQueue并不存储元素。实现采用了一种无锁算法,扩展的Dual stack and Dual Queue算法,算法的大概实现是采用链表,用头结点(head)和尾结点(tail)记录队列状态,而队列可以根据头尾以及当前节点状态,在不需要锁的情况的执行入出队操作。此外,竞争时,支持公平竞争和非公平竞争。公平竞争实现采用先进先出队列( FIFO queue);而非公平竞争实现采用先进后出栈(FILO stack)。
1 | abstract static class Transferer<E> { |
没有用于存储队列元素内部变量,并且有表示自旋时间的静态变量。内部有个Transferer抽象类,抽象类只有一个transfer方法。分别有TransferStack和TransferQueue实现了这个类,表示非公平和公平两种模式。
主要逻辑都在这个transfer方法中,包括出入队也都是通过这个方法实现的。
1 | /** |
1 | /** |
TransferStack是非公平竞争的实现。
1 | E transfer(E e, boolean timed, long nanos) { |
TransferStack总是先进后出,并不保证公平,甚至在一些极端情况会导致一部分请求总得不到调度。
详情:
1 | E transfer(E e, boolean timed, long nanos) { |
TransferQueue总是FIFO,保证了公平。
详情:
作者Doug Lea_如此描述这个类:_A capability-based lock with three modes for controlling read/write access.
这是一个有三种模式的锁。具体哪三种模式,请看源码中的示例。
源码分析自 JDK 1.8.0_171
1 | /** |
在ReentrantReadWriteLock中我们知道,如果在读并发比较高的情况下,那么可能会导致写线程饥饿。而StampedLock并不会发生写饥饿。另外,它也有锁升级的特性(ReentrantReadWriteLock只有锁降级)。那么看看它是怎么实现的吧。
锁算法是借鉴了序列锁(linux内核,参考文章1、文章2)和顺序读写锁(参考文章)。具体算法细节还可以参考源码中的描述。
简单描述一下,用一个long类型(state)的变量表示锁状态(写锁、读锁),低7位表示读锁状态,其他位表示写锁(第8位是否未1表示是否持有写锁,前面说的sequence lock),如果低7位不够表示读锁位,那么会用一个int值表示”溢出”的读锁。那么写锁可以表示为:state+=2^7;读锁可以表示为state+=1。此外,源码中还用了大量自旋来减少饥饿的概率,头部节点表示的线程不会阻塞,而是会一直自旋等待锁释放。
结合内部的变量声明理解一下:
1 | /** The number of bits to use for reader count before overflowing */ |
总结一下,用一个long型变量表示锁的计数,读锁计数用低7位表示,超出部分用一个int值表示。其他位表示写锁状态,第8位如果为1则表示写锁被持有,为0表示未被持有,也就是序列锁,这样即保证了锁(包括读写)的原子性,也能提高效率(操作读或写锁要保证互斥)。用一个CLH队列表示等待的节点,避免饥饿现象,节点首先会采用自旋的方式尝试获取锁。
接下来就跟着3个示例解析一下加解锁的流程。
经过上面的分析,state上的低7位是用来表示reader count,而溢出部分,则用readerOverflow表示。
1 | /** |
首先明确获取读锁并不是互斥的,而是通过cas操作去尝试获取。其次是通过自旋去尝试获取锁,对于头部位置的节点总是在自旋等待锁。
接下来看一下时如何释放读锁的:
1 | /** |
读锁状态也是也是在state上表示,除了表示读锁状态的低7位,剩下的高25位都表示写锁状态。第8位为1表示写锁被占用,否则表示未被占用,写锁状态时往上递增的,也就是说获取读锁state需要+WBIT,释放也是+WBIT。
1 | public long writeLock() { |
由此可见,写锁的获取也是通过自旋,如果队列里有排队的节点,那么入队,保证个公平公正,这也是CLH队列的作用的地方。并不会有开始说的写锁饥饿的现象。
1 | /** |
文章开头的示例中,第三个示例就是读锁升级写锁的例子。在ReentranReadWriteLock中,写锁可以降级为读锁,而StampedLock可以由读锁能直接升级为写锁。首先是需要持有读锁(readLock),接着会尝试升级写锁(tryConvertToWriteLock),如果升级成功,则直接操作业务并在最后释放锁(unlock),否则需要释放读锁(unlockRead)获取写锁(writeLock)。我们主要来看_tryConvertToWriteLock_和unlock,其它的逻辑都已经在上边讨论过了。来看看具体是怎么实现的吧。
1 | /** |
示例中的第二个例子就是乐观读。先尝试获取读锁(tryOptimisticRead),接着校验(validate)下stamp是否变更,如果校验通过未发生变更则直接进行下一步,否则需要获取读锁(readLock)并操作完成之后释放(unlockRead)读锁。主要来看看_tryOptimisticRead和_validate,其它的流程逻辑参考前边的讨论。
1 | /** |
如上,tryOptimisticRead并不会去尝试获取读锁(即不会更改state),而是通过validate验证写锁状态是否在这期间改变过,如果未改变,则可以认为可以共享读锁,否则(即写线程操作过数据)需要实际去获取读锁。
有以下几点值得我们注意一下:
作者Doug Lea_如此描述这个类:An implementation of {@link java.util.concurrent.locks.ReadWriteLock} supporting similar semantics to {@link java.util.concurrent.locks.ReentrantLock}.
ReentratReadWriteLock是一个可重入的读写锁,实现了ReadWriteLock接口,具有与ReentrantLock同样的语义。此外,ReentrantReadWriteLock只支持_65535个可重入写锁和65535个读锁,以及其他一些特性,如下示例中的特性。
源码分析自 JDK 1.8.0_171
接着看一下使用示例:
1 | // 锁降级,write -> read |
以上示例就是ReentrantReadWriteLock的基础用法了。另外的一些用法即特性,如WriteLock也有newCondition的api,写锁降级,可重入,写锁线程能获取读锁但反过来却不行,等等特性。
内部共有三个变量,实现 java.util.concurrent.locks.Lock
接口的 ReadLock
和 WriteLock
;以及继承自AQS的抽象类Sync实例, FairSync
和 NonfairSync
继承自Sync,表示该锁具备公平/非公平语义,有一个带boolean参数的构造函数,根据这个boolean参数决定sync为哪个子类实例。
在展开加解锁流程前,先看一下上述提起的内部类。
内部类Sync继承自AQS,主要功能是通过这个类提供:
1 | abstract static class Sync extends AbstractQueuedSynchronizer { |
同样,也是利用AQS的state变量来标识锁的个数,不同的是,ReentrantReadWriteLock利用高16位表示读锁,低16位表示写锁。两个抽象方法 writerShouldBlock
和 ReaderShouldBlock
,子类 FairSync
和 NonfairSync
通过实现这两个方法来提供公平/非公平锁的功能。
实现 java.util.concurrent.locks.Lock
接口:
1 | public static class ReadLock implements java.util.concurrent.locks.Lock, java.io.Serializable { |
ReadLock是不支持 newCondition
这一api的。
同样,WriteLock也是实现了java.util.concurrent.locks.Lock接口:
1 | public static class WriteLock implements java.util.concurrent.locks.Lock, java.io.Serializable { |
WriteLock是支持newCondition api的,这一api是构造一个AQS的内部类实例,具体可以看我之前的文章。
先看一下写锁的加锁流程。WriteLock.lock()
调用内部类Sync的 acquire(1)
方法,acquire方法AQS提供的一个方法:
1 | // from AQS |
可见写锁加锁过程就是对state变量进行操作的过程,公平/非公平锁主要是通过 writerShouldBlock
这个方法,非公平这个方法直接返回false,也就是抢占式地去获取锁,而公平则是会查看队列里是否有前驱,如有则失败。
接着是写锁的解锁过程。
同样,也是调用Sync内部方法 release(1)
, release也为AQS的一个方法:
1 | public final boolean release(int arg) { |
方法内部调用的是AQS的 acquireShared(1)
方法:
1 | public final void acquireShared(int arg) { |
结合Sync的解析,读锁的获取过程就是对state这一变量的操作过程。解析还是很清晰的,具体过程可以看一下解析。
unlock调用的仍是AQS内部方法, releaseShared(1)
:
1 | public final boolean releaseShared(int arg) { |
可见,解锁读锁,是对state操作,state-=_SHARED_UNIT,_接着再查看队列是否有等待节点,如有则需唤醒。
作者Doug Lea_如此描述这个类:_A reentrant mutual exclusion {@link **java.util.concurrent.locks.Lock} with the same basic behavior and semantics as the implicit monitor lock accessed using {@code **synchronized} methods and statements, but with extended capabilities.
分析自 JDK 1.8.0_171
ReentrantLock是继承 _java.util.concurrent.locks.Lock _的可重入互斥锁,它具有跟隐式监视器锁(Synchronized)同样语义,用于锁定一个方法或代码块,除此之外,它还有一些额外的功能。
ReentrantLock锁,只能被一个线程持有,如果该线程持有的同时尝试去获取该ReentrantLock,会立即返回。当一个线程获取ReentrantLock,即调用lock时,只有当该锁未被其它线程持有时才能成功。
看一下源码中的示例:
1 | class X { |
另外,还有个lock.newCondition api可以使用,看下面用法:
1 | public class Y { |
还是结合示例,看看整个流程是怎么串起来的。
有如下两个构造函数:
1 | public ReentrantLock() { |
构造时通过传入一个boolean参数,可以实例化一个公平Lock。而这里的 FairSync
和 NonfairSync
是继承自内部类Sync,而Sync则继承自AQS。如下代码:
Sync类
1 | abstract static class Sync extends AbstractQueuedSynchronizer { |
内部抽象类 Sync
继承AQS,实现了一些基础功能,内部有个抽象方法lock。子类实现这个方法可提供公平/非公平锁的功能。
NonFairSync类
1 | static final class NonfairSync extends Sync { |
FairSync类
1 | static final class FairSync extends Sync { |
以上代码分析,非公平锁,通过cas操作state去获取锁,即0->1,如果获取失败,则再次尝试(cas操作state,持有者是否为当前线程),如果仍然获取失败,则构造一个node并接入链表尾部,并阻塞当前线程直到被唤醒。公平锁则不会直接去获取锁,而是在非公平锁基础上,会先查看链表是否有前驱,有则阻塞并构造新节点加入链表尾部。
辅助下面的图看源码可能会有帮助。
非公平锁:
公平锁:
释放锁的逻辑相对获取锁要简短很多。unlock方法就是调用AQS的release方法:
1 | public final boolean release(int arg) { |
接下来看一下newCondition这个api的内部流程是什么样的。ReentrantLock.newCondition
方法内部调用Sync类实现的 newCondition
方法,而这个方法是实例化一个AQS的 ConditionObject
类对象:
1 | final ConditionObject newCondition() { |
await方法的比较复杂,需要仔细梳理下,并且结合signal的流程才能清晰起来。
以下为signal的流程:
1 | public class ConditionObject implements Condition, java.io.Serializable { |
signal流程大致就是这样,signalAll其实就是从Condition头结点firstWaiter开始依次调用transferForSignal方法,大同小异。
]]>作者_Doug Lea_如此描述这个类:A counting semaphore. Conceptually, a semaphore maintains a set of permits.
分析自JDK 1.8.0_171
顾名思义。计数信号量,它维护许可数量。acquire一个许可阻塞至池里有可用许可,release一个许可即往池里添加一个许可。
如下为源码中示例代码:
1 | class Pool { |
如上,示例中用到的api就两个,即acquire和release,意为获取一个许可及释放一个许可。
内部抽象类Sync继承自AQS,用AQS中的volatile int state变量表示许可数量。Sync的子类有两个版本,fair和nonfair。
1 | abstract static class Sync extends AbstractQueuedSynchronizer { |
Sync的两个子类,NonfairSync和FairSync,分别表示在获取许可时是非公平式(抢占式)和公平式。
获取许可,调用Sync.acquireSharedInterruptibly(1)。
1 | public final void acquireSharedInterruptibly(int arg) |
需要注意,当没有阻塞的节点时,即链表为空,这时往链表添加节点时是往一个”空”头节点后添加。唤醒时,在阻塞位置恢复再次循环,如果前驱是头结点且当前池中有许可,那么设置当前节点为头结点,并唤醒下一节点,否则再次阻塞。
调用Sync.releaseShared(1)。
1 | public final boolean releaseShared(int arg) { |
唤醒时总是唤醒头结点的下一节点。注意waitStatus这个状态,在阻塞时,会在shouldParkAfterFailedAcquire这个方法里,将当前阻塞节点的前缀设置为SIGNAL。
]]>_作者Doug Lea_如此描述这个类:A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.
分析自JDK 1.8.0_171
这也是一个多线程协调的辅助工具类。barrier可翻译为栅栏,顾名思义,这个类控制先到的线程则在”栅栏”处等待其他线程,直到所有线程都到达,再接着往下执行。此外, CyclicBarrier
如其名,是循环可复用的。
如下是源码中给出的示例代码:
1 | class Solver { |
如以上示例,并行处理每行矩阵元素,待所有行处理结束再对每行处理结果进行合并。
// 可重入锁控制对barrier的访问
private final ReentrantLock lock = new ReentrantLock();
// 控制线程阻塞,直到所有线程”到达”
private final Condition trip = lock.newCondition();
// 多少个参与方(线程)
private final int parties;
// 到达栅栏后执行的线程
private final Runnable barrierCommand;
// 内部类表示目前是哪一代
private Generation generation = new Generation()
// 还有几个参与方(线程)在未到达
private int count;
Generation
为内部类,当触发栅栏或者重置,generation就会改变。
1 | private static class Generation { |
1 | public CyclicBarrier(int parties, Runnable barrierAction) { |
构造方法里就初始化了三个变量,分别是表示多少个线程的parties、还有多少个线程未到达的count、后置线程barrierCommand。
await方法是调用内部私有方法dowait:
1 | private int dowait(boolean timed, long nanos) |
CyclicBarrier利用ReentrantLock控制对barrier的加锁访问,ReentrantLock.condition控制线程的阻塞唤醒。内部类Generation表示栅栏的一次生命周期,而每次栅栏被踢翻,generation要换代,即CyclicBarrier是可循环复用的。
]]>_作者Doug Lea_如此描述这个类:A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.
分析自JDK 1.8.0_171
这是一个多线程协调的辅助类。源码中给出的示例代码:
1 | class Driver { // ... |
通过示例对CountDownLatch的使用场景应该有个清晰的认识。即当有需要线程等待,直到在其他线程的一系列操作完成之后,再接着往下执行。
Sync类是CountDownLatch的一个内部类,继承自 AbstractQueuedSynchronizer
,也就是常说的AQS。内部类重写了AQS的 tryAcquireShared
和 tryReleaseShared
两个方法。此外Sync的构造函数带一个int参数,在构造函数内调用了AQS的 setState
方法,这个方法是对AQS的内部一个int volatile变量赋值。
1 | private static final class Sync extends AbstractQueuedSynchronizer { |
在上边的示例中,我们用到了 CountDownLatch
的 await
方法和 countDown
方法,CountDownLatch的功能也就是通过这两个方法实现。这两个方法其实也就是调用sync。
功能:当前线程等待,直到state等于0,或该线程interrupt。
该方法就是调用 sync.acquireSharedInterruptibly(1)
。
1 | public final void acquireSharedInterruptibly(int arg) |
总结:CountDownLatch.await就是调用LockSupport.park阻塞当前线程,并用一链表表示所有阻塞的线程,方便唤醒时一一唤醒。
功能:令state减1,但state为0时,唤醒所有等待线程。
该方法是调用 sync.releaseShared(1)
。
1 | public final boolean releaseShared(int arg) { |
CountDownLatch源码分析的整个流程就是这样。CountDownLatch的功能是基于AQS展开,在后续的JUC的分析文章中还可以看到AQS的身影。谢谢。
]]>QMQ有关actor的一篇文章阐述了actor的应用场景。即client消费消息的请求会先进入一个RequestQueue,在client消费消息时,往往存在多个主题、多个消费组共享一个RequestQueue消费消息。在这个Queue中,存在不同主题的有不同消费组数量,以及不同消费组有不同consumer数量,那么就会存在抢占资源的情况。举个文章中的例子,一个主题下有两个消费组A和B,A有100个consumer,B有200个consumer,那么在RequestQueue中来自B的请求可能会多于A,这个时候就存在消费unfair的情况,所以需要隔离不同主题不同消费组以保证fair。除此之外,当consumer消费能力不足,造成broker消息堆积,这个时候就会导致consumer所在消费组总在消费”老消息”,影响全局整体的一个消费能力。因为”老消息”不会存在page cache中,这个时候很可能就会从磁盘load,那么表现是RequestQueue中来自消费”老消息”消费组的请求处理时间过长,影响到其他主题消费组的消费,因此这个时候也需要做策略来避免不同消费组的相互影响。所以QMQ就有了actor机制,以消除各个消费组之间因消费能力不同、consumer数量不同而造成的相互影响各自的消费能力。
要了解QMQ的actor模式是如何起作用的,就要先来看看Broker是如何处理消息拉取请求的。
1 | class PullMessageWorker implements ActorSystem.Processor<PullMessageProcessor.PullEntry> { |
能看除在这里起作用的是这个 actorSystem
。 PullMessageWorker
继承了 ActorSystem.Processor
,所以真正处理拉取请求的是这个接口里的 process
方法。请求到达 pullMessageWorker
,worker将该次请求交给 actorSystem
调度,调度到这次请求时,worker还有个根据拉取结果做反应的策略,即如果暂时没有消息,那么 suspend
,以一个timer task定时 resume
;如果在timer task执行之前有消息进来,那么也会即时 resume
。
接下来就看看ActorSystem里边是如何做到 公平调度 。
1 | public class ActorSystem { |
可以看到,用一个线程池处理actor的调度执行,这个线程池里的队列是一个优先级队列。优先级队列存储的元素是Actor。关于Actor我们稍后来看,先来看一下 ActorSystem
的处理调度流程。
1 | // PullMessageWorker调用的就是这个方法 |
actorSystem维护一个线程池,线程池队列具有优先级,队列存储元素是actor。actor的粒度是subject+group。Actor是一个Runnable,且因为是优先级队列的存储元素所以需继承Comparable接口(队列并没有传_Comparator参数_),并且actor有四种状态,初始状态、可调度状态、挂起状态、调度状态(这个状态其实不存在,但是暂且这么叫以帮助理解)。
接下来看看Actor这个类:
1 | public static class Actor<E> implements Runnable, Comparable<Actor> { |
Actor实现了Comparable
,在优先级队列里优先级是Actor里的total和submitTs共同决定的。total是actor执行总耗时,submitTs是调度时间。那么对于处理较慢的actor自然就会在队列里相对”尾部”位置,这时就做到了根据actor的执行耗时的一个动态限速。Actor利用Unsafe机制来控制各个状态的轮转原子性更新的,且每个actor执行时间可以简单理解为5个时间片。
其实工作进行到这里就可以结束了,但是抱着研究的态度,不妨接着往下看看。
Actor内部维护一个Queue,这个Queue是自定义的,是一个Lock-free bounded non-blocking multiple-producer single-consumer queue。JDK里的QUEUE多数都是用锁控制,不用锁,猜测也应该是用Unsafe 原子操作实现。那么来看看吧:
1 | private static class BoundedNodeQueue<T> { |
如上代码,是通过属性在内存的偏移量,结合cas原子操作来进行更新赋值等操作,以此来实现lock-free,这是比较常规的套路。值得一说的是Node里的setNext方法,这个方法的调用是在cas节点后,对”上一位置”的next节点进行赋值。而这个方法使用的是 Unsafe.instance.putOrderedObject
,要说这个putOrderedObject ,就不得不说MESI,缓存一致性协议。如volatile,当进行写操作时,它是依靠storeload barrier来实现其他线程对此的可见性。而 putOrderedObject
也是依靠内存屏障,只不过是 storestore barrier
。storestore是比storeload快速的一种内存屏障。在硬件层面,内存屏障分两种:Load-Barrier和Store-Barrier。Load-Barrier是让高速缓存中的数据失效,强制重新从主内存加载数据;Store-Barrier是让写入高速缓存的数据更新写入主内存,对其他线程可见。而java层面的四种内存屏障无非是硬件层面的两种内存屏障的组合而已。那么可见,storestore barrier自然比storeload barrier快速。那么有一个问题,我们可不可以在这里也用cas操作呢?答案是可以,但没必要。你可以想想这里为什么没必要。
谢谢。
上篇我们分析了QMQ delay-server关于存储的部分,这一篇我们会对投递的源码进行分析。
投递的相关内容在WheelTickManager这个类。提前加载schedule_log、wheel根据延迟时间到时进行投递等相关工作都在这里完成。而关于真正进行投递的相关类是在sender这个包里。
wheel包里一共就三个类文件, HashWheelTimer
、 WheelLoadCursor
、 WheelTickManager
, WheelTickManager
就应该是wheel加载文件,wheel中的消息到时投递的管理器; WheelLoadCursor
应该就是上一篇中提到的schedule_log文件加载到哪里的cursor标识;那么 HashWheelTimer
就是一个辅助工具类,简单理解成Java中的 ScheduledExecutorService
,可理解成是根据延迟消息的延迟时间进行投递的timer,所以这里不对这个工具类做更多解读,我们更关心MQ逻辑。
首先来看提前一定时间加载schedule_log,这里的提前一定时间是多长时间呢?这个是根据需要配置的,比如schedule_log的刻度自定义配置为1h,提前加载时间配置为30min,那么在2019-02-10 17:30就应该加载2019021018这个schedule_log。
1 |
|
recover
这个方法,会根据dispatch log中的投递记录,找到上一次最后投递的位置,在delay-server重启的时候,wheel会根据这个位置恢复投递。
1 | private void recover() { |
恢复基本就是以上的这些内容,接下来看看是如何加载的。
1 | private void load() { |
根据配置的提前加载时间,内存中的wheel会提前加载schedule_log,加载是在一个while循环里,直到加载到until delay segment才退出,如果当前没有until 这个delay segment,那么会在配置的 blockingExitTime
时间退出该循环,而为了避免cpu load过高,这里会在每次循环间隔设置100ms sleep。这里加载为什么是在while循环里?以及为什么sleep 100ms,sleep 500ms 或者1s可不可以?以及为什么要设置个blockingExitTime呢?下面的分析之后,应该就能回答这些问题了。主要考虑两种情况,一种是当之前一直没有delay segment或者delay segment是间隔存在的,比如delay segment刻度为1h,2019031001和2019031004之间的2019031002及2019031003不存在这种之类的delay segment不存在的情况,另一种是当正在加载delay segment的时候,位于该segment的延迟消息正在被加载,这种情况是有可能丢消息的。所以这里加载是在一个循环里,以及设置了两个cursor,即loading cursor,和loaded cursor。一个表示正在加载,一个表示已经加载。此外,上面每次循环sleep 100ms,可不可以sleep 500ms or 1s?答案是可以,只是消息是否能容忍500ms 或者1s的延迟。
1 | private boolean loadUntilInternal(int until) { |
还记得上一篇我们提到过,存储的时候,如果这个消息位于正在被wheel加载segment中,那么这个消息应该是会被加载到wheel中的。
1 |
|
sender包里结构如下图:
通过brokerGroup做分组,根据组批量发送,发送时是多线程发送,每个组互不影响,发送时也会根据实时broker的weight进行选择考虑broker进行发送。
1 |
|
可以看到,投递时是根据server broker进行分组投递。看一下 SenderGroup
这个类
可以看到,每个组的投递是多线程,互不影响,不会存在某个组的server挂掉,导致其他组无法投递。并且这里如果存在某个组无法投递,重试时会选择其它的server broker进行重试。与此同时,在选择组时,会根据每个server broker的weight进行综合考量,即当前server broker有多少消息量要发送。
1 | // 具体发送的地方 |
就是以上这些,关于QMQ的delay-server源码分析就是这些了,如果以后有机会会分析一下QMQ的其他模块源码,谢谢。
]]>本来是固定时间周六更博,但是昨天临时失恋了,所以心情不好,晚了一天。那么上一篇我们梳理了下QMQ延迟消息的主要功能,这一篇在此基础上,对照着功能分析一下源码。
要了解delay-server源码的一个整体结构,需要我们跟着源码,从初始化开始简单先过一遍。重试工作都在startup这个包里,而这个包只有一个ServerWrapper类。
结合上一篇的内容,通过这个类就基本能看到delay的一个源码结构。delay-server基于netty,init方法完成初始化工作(端口默认为20801、心跳、wheel等),register方法是向meta-server发起请求,获取自己自己的角色
为 delay
,并开始和meta-server的心跳。startServer方法是开始HashWheel的转动,从上次结束的位置继续message_log的回放,开启netty server。另外在做准备工作时知道QMQ是基于一主一从一备的方式,关于这个sync方法,是开启监听一个端口回应同步拉取
动作,如果是从节点还要开始向主节点发起同步拉取
动作。当这一切都完成了,那么online方法就执行,表示delay开始上线提供服务了。总结一下两个要点,QMQ是基于netty进行通信,并且采用一主一从一备的方式。
关于存储在之前我们也讨论了,delay-server接收到延迟消息,会顺序append到message_log,之后再对message_log进行回放,以生成schedule_log。所以关于存储我们需要关注两个东西,一个是message_log的存储,另一个是schedule_log的生成。
其实 message_log
的生成很简单,就是顺序append。主要逻辑在 qunar.tc.qmq.delay.receiver.Receiver
这个类里,大致流程就是关于QMQ自定义协议的一个反序列化,然后再对序列化的单个消息进行存储。如图:
主要逻辑在途中标红方法 doInvoke
中。
1 | private void doInvoke(ReceivedDelayMessage message) { |
delay存储层相关逻辑都在facade这个类里,初始化时类似消息的校验等工作也都在这里,而message_log的相关操作都在 messageLog
里。
1 |
|
以上基本就是message_log的存储部分,接下来我们来看message_log的回放生成schedule_log。
MessageLogReplayer这个类就是控制回放的地方。那么考虑一个问题,下一次重启的时候,我们该从哪里进行回放?QMQ是会有一个回放的offset,这个offset会定时刷盘,下次重启的时候会从这个offset位置开始回放。细节可以看一下下面这段代码块。
1 | final LogVisitor<LogRecord> visitor = facade.newMessageLogVisitor(iterateFrom.longValue()); |
注意这里除了offset还有个cursor,这是为了防止回放失败,sleep 5ms后再次回放的时候从cursor位置开始,避免重复消息。那么我们看一下dispatcher.post这个方法:
1 |
|
如以上代码,我们看略过schedule_log的存储,看一下那个callback是几个意思:
1 | private boolean iterateCallback(final ScheduleIndex index) { |
这里的意思是,delay-server接收到消息,会判断一下这个消息是否需要add到内存中的wheel中,以防止丢消息。大家记着有这个事情,在投递小节中我们回过头来再说这里。那么回到 facade.appendScheduleLog
这个方法,schedule_log相关操作在scheduleLog里:
1 |
|
留意 locateSegment
这个方法,它是根据延迟时间定位 DelaySegment
,比如如果延迟时间是2019-03-03 16:00:00,那么就会定位到201903031600这个DelaySegment(注:这里贴的代码不是最新的,最新的是 DelaySegment
的刻度是可以配置,到分钟级别)。同样,具体动作也是 appender
做的,如下:
1 |
|
这里也能看到schedule_log的消息格式。
发现就写了个存储篇幅就已经挺大了,投递涉及到的内容可能更多,那么关于投递就开个下一篇吧。
]]>QMQ是一款去哪儿网内部使用多年的mq。不久前(大概1-2年前)已在携程投入生产大规模使用,年前这款mq也开源了出来。关于QMQ的相关设计文章可以看这里。在这里,我假设你已经对QMQ前世今生以及其设计和实现等背景知识已经有了一个较为全面的认识。
对一款开源产品愈来愈感兴趣,想要了解一款开源产品更多的技术细节的时候,最好的方式自然是去阅读她的源码。那么一个正确阅读开源软件源码的姿势是什么呢?我觉得这完全就像一个相亲过程:
对于delay-server,官方已经有了一些介绍。记住,官方通常是最卖力的那个”媒婆”。qmq-delay-server其实主要做的是转发工作。所谓转发,就是delay-server做的就是个存储和投递的工作。怎么理解,就是qmq-client会对消息进行一个路由,即实时消息投递到实时server,延迟消息往delay-server投递,多说一句,这个路由的功能是由qmq-meta-server提供。投递到delay-server的消息会存下来,到时间之后再进行投递。现在我们知道了存储
和投递
是delay-server主要的两个功能点。那么我们挨个击破:
假如让我们来设计实现一个delay-server,存储部分我们需要解决什么问题?我觉得主要是要解决到期投递的到期
问题。我们可以用传统db做,但是这个性能肯定是上不去的。我们也可以用基于LSM树的RocksDB。或者,干脆直接用文件存储。QMQ是用文件存储的。而用文件存储是怎么解决到期
问题的呢?delay-server接收到延迟消息,就将消息append到message_log中,然后再通过回放这个message_log得到schedule_log,此外还有一个dispatch _log用于记录投递记录。QMQ还有个跟投递相关的存储设计,即两层HashWheel。第一层位于磁盘上,例如,以一个小时一个刻度一个文件,我们叫delay_message_segment,如延迟时间为2019年02月23日 19:00 至2019年02月23日 20:00为延迟消息将被存储在2019022319。并且这个刻度是可以配置调整的。第二层HashWheel位于内存中。也是以一个时间为刻度,比如500ms,加载进内存中的延迟消息文件会根据延迟时间hash到一个HashWheel中,第二层的wheel涉及更多的是下一小节的投递。貌似存储到这里就结束了,然而还有一个问题,目前当投递的时候我们需要将一个delay_message_segment加载进内存中,而假如我们提前一个刻度加载进一个delay_message_segment到内存中的hashwheel,比如在2019年02月23日 18:00加载2019022319这个segment文件,那么一个hashwheel中就会存在两个delay_message_segment,而这个时候所占内存是非常大的,所以这是完全不可接收的。所以,QMQ引入了一个数据结构,叫schedule_index,即消息索引,存储的内容为消息的索引,我们加载到内存的是这个schedule_index,在真正投递的时候再根据索引查到消息体进行投递。
解决了存储,那么到期的延迟消息如何投递呢?如在上一小节存储中所提到的,内存中的hashwheel会提前一段时间加载delay_schedule_index,这个时间自然也是可以配置的。而在hashwheel中,默认每500ms会tick一次,这个500ms也是可以根据用户需求配置的。而在投递的时候,QMQ根据实时broker进行分组多线程投递,如果某一broker离线不可用,导致投递失败,delay-server会将延迟消息投递在其他存活
的实时broker。其实这里对于实时的broker应该有一个关于投递消息权重的,现在delay-server没有考虑到这一点,不过我看已经有一个pr解决了这个问题,只是官方还没有时间看这个问题。除此之外,QMQ还考虑到了要是当前延迟消息所属的delay_segment已经加载到内存中的hashwheel了,这个时候消息应该是直接投递或也应加载到hashwheel中的。这里需要考虑的情况还是比较多的,比如考虑delay_segment正在加载、已经加载、加载完成等情况,对于这种情况,QMQ用了两个cursor来表示hashwheel加载到哪个delay_segment以及加载到对应segment的什么offset了,这里还是挺复杂的,这里的代码逻辑在WheelTickManager
这个类。
我们先来看一看整体结构
以功能划分的包结构,算是比较清晰。cleaner是日志清理工作相关,receiver是接收消息相关,sender是投递相关,store是存储相关,sync是同步备份相关,wheel则是hashwheel相关。
关于QMQ源码阅读前的准备工作就先做到这里,下一篇我们就深入源码分析以上提到的各个细节。
]]>为了给我们提供一个安全
的网络环境,所以先驱前辈们建立了一堵墙。总有些调皮
好奇
的孩子想要翻过墙去看看墙那边的世界。但是存在风险,需谨慎。
免费的vpn有很多,但是速度、稳定性和流量限制是基本不能满足需要的,所以就不推荐了。
收费的vpn一般都在每月10元左右,并且足够稳定。另外,建议大家选择国外的vpn,国内的vpn产商说不定哪天就跑路什么的。在这里,推荐ExpressVPN和PureVPN。前者比较知名,也比较稳健
,价格大概在每月$7+;后者也相对比较好用,每月大概在$3+,说是有中国用户的专线。
详情可参考。
喜欢掌握主动权的我,倾向于采用自建代理的方案。综合来看自建代理都是最实惠,最可控的方案。
目前VPS产商有两家做的最大,分别是BandwagonHost(搬瓦工)和Vultr。有篇文章对比了这两家厂商的产品。
购买VPS都是有优惠的,搬瓦工 Vultr。
因为搬瓦工比较老牌,老而弥坚,所以我选择的是它。如果你不喜欢老而弥坚
的东西,选择了Vultr,那么请移步看搭建SSR。购买时注意是不是支持中国专线,如果没注意,那么购买成功之后也是可以更改线路的。购买完成,你会受到一封邮件,里边有ip port password等信息,连接上vps,安装完一些基础工具(wget等),就可以开干了。
现在用的最多的就是shadowsocks,以及其衍生版本shadowsocks r。我选择的是shadowsockr。这里有个ssr工具网站,客户端,一键安装脚本在这里都能找到。
1 | wget --no-check-certificate -O shadowsocks-all.sh https://raw.githubusercontent.com/teddysun/shadowsocks_install/master/shadowsocks-all.sh |
1 | chmod +x shadowsocks-all.sh |
1 | ./shadowsocks-all.sh 2>&1 | tee shadowsocks-all.log |
其中的规则列表网址:https://raw.githubusercontent.com/gfwlist/gfwlist/master/gfwlist.txt 详情可以参考gfwlist
就是这些,你可以科学上网了。
]]>