1. HashMap开始

问:HashMap是不是有序的
答:不是有序的。
问:有没有有顺序的Map实现类?
答:TreeMap和LinkedHashMap
问:TreeMap和LinkedHashMap是如何保证它的顺序的?
答:
TreeMap是通过实现SortMap接口,能够把它保存的键值对根据key排序,基于红黑树,从而保证TreeMap中所有键值对处于有序状态。LinkedHashMap则是基于双向链表通过插入排序(就是你put的时候的顺序是什么,取出来的时候就是什么样子)和访问排序(改变排序把访问过的放到底部)让键值有序。
问:你觉得它们两个哪个的有序实现比较好?
答:
TreeMap有序但是查询效率低。LinkedHashMap保证有序和查询效率。
TreeMap的特征是Key是有序的(顺序取决于key的比较器),对于put和get复杂度是log(n),时间复杂度平均能达到O(log n)。
LinkedHashMap的key也是有序的(顺序取决于插入顺序),对put和get复杂度是1
但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap会更好。
如果需要输出的顺序和输入的相同,那么用LinkedHashMap 可以实现,它还可以按读取顺序来排列.
问:你觉得还有没有比它更好或者更高效的实现方式?
ConcurrentSkipListMap是线程安全的有序的哈希表,适用于高并发的场景。
ConcurrentSkipListMap和TreeMap,它们虽然都是有序的哈希表。但是,第一,它们的线程安全机制不同,TreeMap是非线程安全的,而ConcurrentSkipListMap是线程安全的。第二,ConcurrentSkipListMap是通过跳表实现的,而TreeMap是通过红黑树实现的。
关于跳表(Skip List),它是平衡树的一种替代的数据结构,但是和红黑树不相同的是,跳表对于树的平衡的实现是基于一种随机化的算法的,这样也就是说跳表的插入和删除的工作是比较简单的。
ConcurrentSkipListMap具有Skip list的性质 ,并且适用于大规模数据的并发访问。多个线程可以安全地并发执行插入、移除、更新和访问操作。与其他有锁机制的数据结构在巨大的压力下相比有优势。
TreeMap插入数据时平衡树采用严格的旋转(比如平衡二叉树有左旋右旋)来保证平衡,因此Skip list比较容易实现,而且相比平衡树有着较高的运行效率。
查找功能在50W数据量以后,TreeMap更有效率,因为ConcurrentSkipListMap自带锁机制,会占用一些效率,但对于多线程并发的环境下,ConcurrentSkipListMap的效率会比TreeMap要好的。

问:**为什么用HashMap?
答:
HashMap是一个散列桶(数组和链表),它存储的内容是键值对(key-value)映射
HashMap采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
HashMap是非synchronized,所以HashMap很快
HashMap可以接受null键和值,而Hashtable则不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以)
问:HashMap的工作原理是什么?
java7和java8在实现HashMap上有所区别,当然java8的效率要更好一些,主要是java8的HashMap在java7的基础上增加了红黑树这种数据结构,使得在桶里面查找数据的复杂度从O(n)降到O(logn),当然还有一些其他的优化,比如resize的优化等。
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Node 。
Node就是实际保存我们的key-value对的数据结构,下面是这个数据结构的主要内容:

final int hash;
final K key;
V value;
Node next;
以下是HashMap初始化 ,简单模拟数据结构

以下是具体的put过程(JDK1.8版)
image.png

  • 对Key求Hash值,然后再计算下标
  • 如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)
  • 如果碰撞了,以链表的方式链接到后面
  • 如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表

image.png

  • 如果节点已经存在就替换旧值
  • 如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排),直到设定的最大值之后就无法再resize了

以下是具体get过程(考虑特殊情况如果两个键的hashcode相同,你如何获取值对象?)
当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。

问:解决哈希冲突的方法?
答:

  • 有开放地址方法
  • 以及链地址方法

问:hashMap的resize机制
答:
HashMap的扩容机制就是重新申请一个容量是当前的2倍的桶数组,然后将原先的记录逐个重新映射到新的桶里面,然后将原先的桶逐个置为null使得引用失效。后面会讲到,HashMap之所以线程不安全,就是resize这里出的问题。
问:为什么HashMap线程不安全
答:上面说到,HashMap会进行resize操作,在resize操作的时候会造成线程不安全。下面将举两个可能出现线程不安全的地方。

  • put的时候导致的多线程数据不一致。

这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

  • 另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%)

resize的核心内容:

  1. void transfer(Entry[] newTable, boolean rehash) {
  2. int newCapacity = newTable.length;
  3. for (Entry<K,V> e : table) {
  4. while(null != e) {
  5. Entry<K,V> next = e.next;
  6. if (rehash) {
  7. e.hash = null == e.key ? 0 : hash(e.key);
  8. }
  9. int i = indexFor(e.hash, newCapacity);
  10. e.next = newTable[i];
  11. newTable[i] = e;
  12. e = next;
  13. }
  14. }
  15. }

这个方法的功能是将原来的记录重新计算在新桶的位置,然后迁移过去。
image.png
我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,需要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,我们发现这三个entry都落到了第二个桶里面。
假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。
如果在取链表的时候从头开始取(现在是从尾部开始取)的话,则可以保证节点之间的顺序,那样就不存在这样的问题了。
综合上面两点,可以说明HashMap是线程不安全的。
问:hashMap中hash函数是怎么实现的?
答:
我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
问:但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式。
JDK1.8对此进行了改造

简单来说就是
  1. 高16bt不变,低16bit和高16bit做了一个异或(得到的HASHCODE转化为32位的二进制,前16位和后16位低16bit和高16bit做了一个异或)
  2. (n·1)&hash=->得到下标
  3. 拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
问:有什么方法可以减少hash碰撞?
答:

  • 扰动函数可以减少碰撞,原理是如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这就意味着存链表结构减小,这样取值的话就不会频繁调用equal方法,这样就能提高HashMap的性能。(扰动即Hash方法内部的算法实现,目的是让不同对象返回不同hashcode。)
  • 使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?因为String是final的,而且已经重写了equals()和hashCode()方法了。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。

问:解决hash 碰撞还有那些办法?
答:
开放定址法。
当冲突发生时,使用某种探查技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。
按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。
下面给一个线性探查法的例子
问题:已知一组关键字为(26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。

前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入T[0],T[10),T[2],T[12]和T[5]中。
当插入第6个关键字15时,其散列地址2(即h(15)=15%13=2)已被关键字41(15和41互为同义词)占用。故探查h1=(2+1)%13=3,此地址开放,所以将15放入T[3]中。
当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。
当插入第8个关键字12时,散列地址12已被同义词38占用,故探查hl=(12+1)%13=0,而T[0]亦被26占用,再探查h2=(12+2)%13=1,此地址开放,可将12插入其中。
类似地,第9个关键字06直接插入T[6]中;而最后一个关键字51插人时,因探查的地址12,0,1,…,6均非空,故51插入T[7]中。
问:如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
答:
默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置。
问:重新调整HashMap大小存在什么问题吗?
答:
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。(多线程的环境下不使用HashMap)
HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。

  1. 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
  2. ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

问:**ConcurrentHashMap 原理**
答:
最大特点是引入了 CAS(借助 Unsafe 来实现【native code】)
CAS有3个操作数:

  • 内存值V
  • 旧的预期值A
  • 要修改的新值B。

当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
Unsafe 借助 CPU 指令 cmpxchg 来实现
使用实例:

  • 对 sizeCtl 的控制都是用 CAS 来实现的
  • sizeCtl :默认为0,用来控制 table 的初始化和扩容操作。

-1 代表table正在初始化
N 表示有 -N-1 个线程正在进行扩容操作
如果table未初始化,表示table需要初始化的大小。
如果table初始化完成,表示table的容量,默认是table大小的0.75倍,居然用这个公式算0.75(n - (n >>> 2))。
CAS 会出现的问题:ABA
对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。
问:我们可以使用CocurrentHashMap来代替Hashtable吗?
答:
我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。
问:CocurrentHashMap在JAVA8中存在一个bug,会进入死循环,介绍一下
答:
原因是递归创建ConcurrentHashMap 对象,但是在1.9已经修复了,场景重现如下:

  1. public class ConcurrentHashMapBug {
  2. public static void main(String[] args) {
  3. Map<String, Integer> map = new ConcurrentHashMap<>(16);
  4. map.computeIfAbsent(
  5. "AaAa",
  6. key -> {
  7. return map.computeIfAbsent(
  8. "BBBB",
  9. key2 -> 42);
  10. }
  11. );
  12. System.out.println("====== end =============");
  13. }
  14. }

执行上面的代码片段会产生死锁。当map中不存在key=”AaAa”时,computeIfAbsent会插入该key,并将以下lamda函数的返回值(42)作为它的value。
而这个lamda函数其实会继续去对key=”BBBB”的Node进行同样操作,并设置value=42。但是由于这里的“AaAa”和“BBBB”这个字符串的hashCode一样,导致执行出现死锁
问题出在方法:

  1. public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)
  2. //代码上有注释: must not attempt to update any other mappings of this map.

这句话确定了这个问题应该是已知存在的。
所以应该绝对避免在computeIfAbsent中有递归,或者修改map的任何操作。

这种在computeIfAbsent中,如果尝试修改map的情况下,代码会发生死循环.
由于concurrentHashMap中使用的是cas操作,因此在出现cas嵌套的情况下,就会形成一种『死锁』。举例来说,一个值原来是 1, 我想把它修改成2,正常的cas操作,会比较在修改的那一刻,值是否仍然为1。这种比较,在cas只有一层的情况下,是没有问题的。但是,假如有两层cas,这个值原来是1,第一层把 1 -> 2,在cas还没有生效时,继续进入第二层cas操作,把 2 -> 3,当最终提交时,第二层cas比较当前值是否是2,但由于当前指仍然是1,因此修改无效。最终反复进入循环,形成死锁。
方法:在JDK1.8中使用ConcurrentHashMap时,不要在computeIfAbsent的lambda函数中再去执行更新其它节点value的操作。

2. 并发包的连环炮

问:如果想实现所有的线程一起等待某个事件的发生,当某个事件发生时,所有线程一起开始往下执行的话,有什么好的办法吗?
答:可以用栅栏(Java的并发包中的CyclicBarrier)
问:你知道它的实现原理吗?
答:

  • CyclicBarrier是通过ReentrantLock(独占锁)和Condition来实现的。在CyclicBarrier中,同一批的线程属于同一代,即同一个Generation;CyclicBarrier中通过generation对象,记录属于哪一代。
  • cyclicBarrier是一个同步辅助类,允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。因为该 barrier 在释放等待线程后可以重用,所以称它为循环的barrier。

问:你还知道其它的实现方式吗?
答:CountDownLatch

  • CountDownLatch是一个计数器闭锁,通过它可以完成类似于阻塞当前线程的功能,即:一个线程或多个线程一直等待,直到其他线程执行的操作完成。
  • CountDownLatch用一个给定的计数器来初始化,该计数器的操作是原子操作,即同时只能有一个线程去操作该计数器。调用该类await()方法的线程会一直处于阻塞状态,直到其他线程调用countDown()方法使当前计数器的值逐渐减少,到0为止,每次调用countDown计数器的值减1。
  • 当计数器值减至零时,所有因调用await()方法而处于等待状态的线程就会继续往下执行。这种现象只会出现一次,因为计数器不能被重置,如果业务上需要一个可以重置计数次数的版本,可以考虑使用CycliBarrier。
  • CountDownLatch实现的是AQS的共享锁机制。
  • CountDownLatch出现以前,类似功能我们使用线程的join()方法实现。

问:你觉得这些方式里哪个方式更好?
答:
注意比较CountDownLatch和CyclicBarrier:

  • (01) CountDownLatch的作用是允许1或N个线程等待其他线程完成执行;而CyclicBarrier则是允许N个线程相互等待。
  • (02) CountDownLatch的计数器无法被重置;CyclicBarrier的计数器可以被重置后使用,因此它被称为是循环的barrier。

问:那如果让你来写的话,你觉得还有比它更好的实现方式吗?

  • Semaphore也叫信号量,在JDK1.5被引入,用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。还可以用来实现某种资源池,或者对容器施加边界。
  • Semaphore就像一道阀门,可以控制同时进入某一逻辑的线程数量(构造方法中指定),我们使用acquire方法来争取通行票,使用release方法来归还通行票。通行票只是一个比喻,一般我们称之为许可。
  • 我们调用Semaphore方法时,其实是在间接调用其内部类或AQS方法执行的。Semaphore类结构与ReetrantLock类相似,内部类Sync继承自AQS,然后其子类FairSync和NoFairSync分别实现公平锁和非公平锁的获取锁方法tryAcquireShared(int arg),而释放锁的tryReleaseShared(int arg)方法则有Sync类实现,因为非公平或公平锁的释放过程都是相同的。

总结:
CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同:

  • CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行,不支持重用。
  • CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行,支持重用。

Semaphore其实和锁机制有点类似,它一般用于控制对某组资源的访问权限。

3. IO连环炮

问:知道哪些IO
答:NIO、BIO、。。。。
问:NIO的selector的职责和实现原理?
答:
Selector允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。例如,在一个聊天服务器中。
这是在一个单线程中使用一个Selector处理3个Channel的图示:
image.png
要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新连接进来,数据接收等。
Selector 作用
仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。
Selector能够在单个线程中处理多个通道,这样可以减少多个线程造成上下文切换问题。
实现原理:
Java NIO的核心类库多路复用器Selector就是基于epoll的多路复用技术实现的

epoll是Linux下的一种IO多路复用技术,可以非常高效的处理数以百万计的socket句柄。
先看看使用c封装的3个epoll系统调用:

  • int epoll_create(int size)

epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。

  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

epoll_ctl可以操作epoll_create创建的epoll,如将socket句柄加入到epoll中让其监控,或把epoll正在监控的某个socket句柄移出epoll。

  • int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout)

epoll_wait在调用时,在给定的timeout时间内,所监控的句柄中有事件发生时,就返回用户态的进程。
大概看看epoll内部是怎么实现的:

  1. epoll初始化时,会向内核注册一个文件系统,用于存储被监控的句柄文件,调用epoll_create时,会在这个文件系统中创建一个file节点。同时epoll会开辟自己的内核高速缓存区,以红黑树的结构保存句柄,以支持快速的查找、插入、删除。还会再建立一个list链表,用于存储准备就绪的事件。
  2. 当执行epoll_ctl时,除了把socket句柄放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后,就把socket插入到就绪链表里。
  3. 当epoll_wait调用时,仅仅观察就绪链表里有没有数据,如果有数据就返回,否则就sleep,超时时立刻返回。

epoll的两种工作模式:

  • LT:level-trigger,水平触发模式,只要某个socket处于readable/writable状态,无论什么时候进行epoll_wait都会返回该socket。
  • ET:edge-trigger,边缘触发模式,只有某个socket从unreadable变为readable或从unwritable变为writable时,epoll_wait才会返回该socket。

顺便说下在Linux系统中JDK NIO使用的是 LT ,而Netty epoll使用的是 ET。

问:NIO的IO线程池介绍一下
答:
….
问:IO包的设计模式
答:装饰器模式
问:为什么要这样设计
答:
使用装饰器模式的优缺点

  • 优点

装饰器模式和继承都能拓展对象的功能,但是装饰器模式有着更大的灵活性。通过使用不同的具体装饰类以及这些装饰类的排列组合,可以创造出很多不同行为的组合。可以使用多个具体装饰类来装饰同一对象,得到功能更为强大的对象。

  • 缺点

这种比继承更加灵活机动的特性,也同时意味着装饰模式比继承更加易于出错,排错也很困难,对于多次装饰的对象,调试时寻找错误可能需要逐级排查,较为烦琐。

4. GC连环炮

问:什么时候一个对象会被GC?
答:对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有要执行finalize()方法。有必要执行finalize()方法的话,会把对象放置在一个叫做F-Queue的队列中,GC会对队列中的对象进行第二次小规模的标记,如果对象没有重新与引用链上的任何一个对象建立关联,则就被真正回收。
说白了就是一个对象不存在任何引用,然后就会被回收。
触发一个对象的回收:

  • 1.对象没有引用
  • 2.作用域发生捕获异常
  • 3.程序在作用域正常执行完毕
  • 4.程序执行了System.exit()
  • 5.程序发生意外终止

问:为什么要在这种时候对象才会被GC?
答:1.新对象生成,在Eden申请空间失败。2.老年代被写满、持久代被写满(已经移除)、System.gc()被显示调用、上一次GC之后Heap的各域分配策略动态变化
问:GC策略都有哪些分类?
答:这边应该是对于每个垃圾回收器的说明了。
新生代收集器:

  1. Serial收集器

单线程执行GC 会引起”Stop The World” 虚拟机运行在Client模式下的默认新生代收集器 简单而高效(与其它收集器单线程比) 适用于用户桌面应用 采用复制算法

  1. ParNew收集器

多线程执行GC 会引起”Stop The World” 虚拟机运行在Server模式下新生代收集器首选 因为需要配合老年代的CMS收集器工作 采用复制算法

  1. Parallel Scavenger收集器(吞吐量优先收集器)

关注点达到一个可控制的吞吐量(CPU用于运行用户代码的时间与CPU总消耗时间的比值) 适用于后台计算而不需要太多交互的任务 GC自适应调节策略(-XX:+UseAdaptiveSizePolicy) 采用复制算法
老年代收集器

  1. Serial Old收集器

单线程 适用于Client模式下 采用标记-整理算法

  1. Parallel Old收集器

Parallel Scavenger的老年代版本 多线程 采用标记-整理算法 配合Parallel Scaverger 吞吐量优先的收集器才名副其实

  1. CMS收集器

关注点尽可能地缩短垃圾收集器用户线程的停顿时间 采用标记-清除算法
缺点:

  • 对CPU资源非常敏感 CPU比较多的时候效果比较好(回收线程:(CPU数量+3)/4)
  • 无法处理浮动垃圾 容易失败产生Concurrent Mode Failure,然后需要Serial Old收集器重新进行垃圾收集。
  • 会产生大量空间碎片
  1. G1收集器

基于标记-整理算法精确控制停顿将Java堆(包括新生代、老年代)划分多个大小固定的独立区域(Region),跟踪这些区域的垃圾堆积情况,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域。
问:这些策略分别都有什么优劣势?都适用于什么场景?
答:
G1回收的第4步,它是“选择一些内存块”,而不是整代内存来回收,这是G1跟其它GC非常不同的一点,其它GC每次回收都会回收整个Generation的内存(Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而G1每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。
由于内存被分成了很多小块,又带来了另外好处,由于内存块比较小,进行内存压缩整理的代价都比较小,相比其它GC算法,可以有效的规避内存碎片的问题。
G1的坏处:如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个Heap的回收,那么G1要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。因此G1比较适合内存稍大一点的应用(一般来说至少4G以上),小内存的应用还是用传统的垃圾回收器比如CMS比较合适。
问:给你举个实际的场景,让你选择一个GC策略?
答:
问:为什么要选择这个策略?

5. 类加载机制连环炮

问:Java的类加载器都有哪些?
答:
问:类加载器都加载哪些类?
答:
问:这些类加载之间的父子关系是怎样的?
答:双亲委派模型
问:什么是双亲委派模型?
答:
问:为什么Java的类加载器要使用双亲委派模型?
答:
问:你如何自定义自己的类加载器,自己的类加载器和Java自带的类加载器关系如何处理?

6. 内存连环炮

问:内存分为哪几部分,这些部分分别都存储哪些数据?
答:Java堆、方法区、Java虚拟机栈、本地方法栈、PC计数器
问:一个对象从创建到销毁都是怎么在这些部分里存活和转移的?
答:
对象的整个生命周期大致可以分为7个阶段:

  • 创建阶段
  • 应用阶段
  • 不可到达阶段
  • 可收集阶段
  • 终结阶段
  • 释放阶段

堆内存是用来存放由new创建的对象和数组,即动态申请的内存都存放在堆内存
栈内存是用来存放在函数中定义的一些基本类型的变量和对象的引用变量

问:内存的哪些部分会参与GC的回收?
答:Java堆,方法区
问:Java的内存模型是怎么设计的?
答:
Java线程之间的通信采用的是过共享内存模型,这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
(1)java内存结构是解决java中的数据如何存放的问题。
(2)java内存模型是解决java中多个线程共享数据的问题。
问:为什么要这么设计?
答:
缓存一致性问题
原子性:
在缓存一致性问题中,两个CPU内核中a的数据不一致,也就是说两个CPU内核读取主存a的值是不一样的。那么对于a的更改这个操作肯定就不是原子性,在A更改的过程中,两个CPU内核同时进行了更改。
可见性:
上面在介绍原子性问题的时候说了,两个线程(CPU内核)访问同一个变量时,线程2修改了这个变量的值,但是线程1却没有看到其修改,所以读的仍然是旧数据。
处理器优化和指令重排问题**
有序性:
可见性问题和有序性问题就是由于处理器优化和执行重排造成的。
问:结合内存模型的设计谈谈validate关键字的作用?
答:内存可见性,防止指令重排序
问:Java还有哪些关键字可以保证可见性
答:synchronized
问:syconized内部使用了什么锁
答:悲观锁

7.Redis连环炮

问:你先来说下redis是什么吧
答:Redis是C语言开发的一个开源的(遵从BSD协议)高性能键值对(key-value)的内存数据库,可以用作数据库、缓存、消息中间件等。它是一种NoSQL(not-only sql,泛指非关系型数据库)的数据库。
Redis作为一个内存数据库。

  • 性能优秀,数据在内存中,读写速度非常快,支持并发10W QPS;
  • 单进程单线程,是线程安全的,采用IO多路复用机制;
  • 丰富的数据类型,支持字符串(strings)、散列(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等;
  • 支持数据持久化。可以将内存中数据保存在磁盘中,重启时加载;
  • 主从复制,哨兵,高可用;
  • 可以用作分布式锁;
  • 可以作为消息中间件使用,支持发布订阅

有五种数据类型
问:你提到redis支持五种数据类型,那你能简单说下这五种数据类型吗?
答:首先redis内部使用一个redisObject对象来表示所有的key和value,redisObject最主要的信息如上图所示:type表示一个value对象具体是何种数据类型,encoding是不同数据类型在redis内部的存储方式。比如:type=string表示value存储的是一个普通字符串,那么encoding可以是raw或者int。

  1. string是redis最基本的类型,可以理解成与memcached一模一样的类型,一个key对应一个value。value不仅是string,也可以是数字。string类型是二进制安全的,意思是redis的string类型可以包含任何数据,比如jpg图片或者序列化的对象。string类型的值最大能存储512M。
  2. Hash是一个键值(key-value)的集合。redis的hash是一个string的key和value的映射表,Hash特别适合存储对象。常用命令:hget,hset,hgetall等。
  3. list列表是简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边) 常用命令:lpush、rpush、lpop、rpop、lrange(获取列表片段)等。

应用场景:list应用场景非常多,也是Redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表都可以用list结构来实现。
数据结构:list就是链表,可以用来当消息队列用。redis提供了List的push和pop操作,还提供了操作某一段的api,可以直接查询或者删除某一段的元素。
实现方式:redis list的是实现是一个双向链表,既可以支持反向查找和遍历,更方便操作,不过带来了额外的内存开销。

  1. set是string类型的无序集合。集合是通过hashtable实现的。set中的元素是没有顺序的,而且是没有重复的。

常用命令:sdd、spop、smembers、sunion等。
应用场景:redis set对外提供的功能和list一样是一个列表,特殊之处在于set是自动去重的,而且set提供了判断某个成员是否在一个set集合中。

  1. zset和set一样是string类型元素的集合,且不允许重复的元素。常用命令:zadd、zrange、zrem、zcard等。

使用场景:sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted set结构。和set相比,sorted set关联了一个double类型权重的参数score,使得集合中的元素能够按照score进行有序排列,redis正是通过分数来为集合中的成员进行从小到大的排序。
实现方式:Redis sorted set的内部使用HashMap和跳跃表(skipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
image.png
数据类型应用场景总结
image.png

问:redis缓存你是怎么用的?
答:我是结合spring boot使用的。一般有两种方式,一种是直接通过RedisTemplate来使用,另一种是使用spring cache集成Redis(也就是注解的方式)
通过RedisTemplate使用
增加下列依赖
spring-boot-starter-data-redis:在spring boot 2.x以后底层不再使用Jedis,而是换成了Lettuce。
commons-pool2:用作redis连接池,如不引入启动会报错
spring-session-data-redis:spring session引入,用作共享session
使用spring cache集成redis
spring cache具备很好的灵活性,不仅能够使用SPEL(spring expression language)来定义缓存的key和各种condition,还提供了开箱即用的缓存临时存储方案,也支持和主流的专业缓存如EhCache、Redis、Guava的集成。
问:你在实际项目中使用缓存有遇到什么问题或者会遇到什么问题你知道吗?
答:缓存和数据库数据一致性问题:分布式环境下非常容易出现缓存和数据库间数据一致性问题,针对这一点,如果项目对缓存的要求是强一致性的,那么就不要使用缓存。我们只能采取合适的策略来降低缓存和数据库间数据不一致的概率,而无法保证两者间的强一致性。合适的策略包括合适的缓存更新策略,更新数据库后及时更新缓存、缓存失败时增加重试机制。
问:Redis雪崩了解吗
答:我了解的,目前电商首页以及热点数据都会去做缓存,一般缓存都是定时任务去刷新,或者查不到之后去更新缓存的,定时任务刷新就有一个问题。举个栗子:如果首页所有Key的失效时间都是12小时,中午12点刷新的,我零点有个大促活动大量用户涌入,假设每秒6000个请求,本来缓存可以抗住每秒5000个请求,但是缓存中所有Key都失效了。此时6000个/秒的请求全部落在了数据库上,数据库必然扛不住,真实情况可能DBA都没反应过来直接挂了,此时,如果没什么特别的方案来处理,DBA很着急,重启数据库,但是数据库立马又被新流量给打死了。这就是我理解的缓存雪崩。
问:那这种情况你都是怎么应对的?
答:处理缓存雪崩简单,在批量往Redis存数据的时候,把每个Key的失效时间都加个随机值就好了,这样可以保证数据不会再同一时间大面积失效。
setRedis(key, value, time+Math.random()*10000);
如果Redis是集群部署,将热点数据均匀分布在不同的Redis库中也能避免全部失效。或者设置热点数据永不过期,有更新操作就更新缓存就好了(比如运维更新了首页商品,那你刷下缓存就好了,不要设置过期时间),电商首页的数据也可以用这个操作,保险。
问:那你了解缓存穿透和击穿么,可以说说他们跟雪崩的区别吗?
答:了解,先说下缓存穿透吧,缓存穿透是指缓存和数据库中都没有的数据,而用户(黑客)不断发起请求,举个栗子:我们数据库的id都是从1自增的,如果发起id=-1的数据或者id特别大不存在的数据,这样的不断攻击导致数据库压力很大,严重会击垮数据库。
至于缓存击穿,这个跟缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿不同的是缓存击穿是指一个Key非常热点,在不停地扛着大量的请求,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发直接落到了数据库上,就在这个Key的点上击穿了缓存。
问:那他们分别怎么解决?
答:缓存穿透我会在接口层增加校验,比如用户鉴权,参数做校验,不合法的校验直接return,比如id做基础校验,id<=0直接拦截。
问:还有别的方法吗?
答:Redis里还有一个高级用法布隆过滤器(Bloom Filter)这个也能很好的预防缓存穿透的发生,他的原理也很简单,就是利用高效的数据结构和算法快速判断出你这个Key是否在数据库中存在,不存在你return就好了,存在你就去查DB刷新KV再return。缓存击穿的话,设置热点数据永不过期,或者加上互斥锁就搞定了。

  1. public static String getData(String key) throws InterruptedException {
  2. //从Redis查询数据
  3. String result = getDataByKV(key);
  4. //参数校验
  5. if (StringUtils.isBlank(result)) {
  6. try {
  7. //获得锁
  8. if (reenLock.tryLock()) {
  9. //去数据库查询
  10. result = getDataByDB(key);
  11. //校验
  12. if (StringUtils.isNotBlank(result)) {
  13. //插进缓存
  14. setDataToKV(key, result);
  15. }
  16. } else {
  17. //睡一会再拿
  18. Thread.sleep(100L);
  19. result = getData(key);
  20. }
  21. } finally {
  22. //释放锁
  23. reenLock.unlock();
  24. }
  25. }
  26. return result;
  27. }

问:redis作为缓存大家都在用,为何这么快
答:基于内存的,官方提供的数据可以达到100000+的QPS(每秒内的查询次数)
问:redis这么快,它的“多线程模型”你了解吗?
答:您是想问Redis这么快,为什么还是单线程的吧。Redis确实是单进程单线程的模型,因为Redis完全是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章的采用单线程的方案了(毕竟采用多线程会有很多麻烦)。
问:你能说说Redis是单线程的,为什么还能这么快吗?
答:第一:Redis完全基于内存,绝大部分请求是纯粹的内存操作,非常迅速,数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度是O(1)。第二:数据结构简单,对数据操作也简单。第三:采用单线程,避免了不必要的上下文切换和竞争条件,不存在多线程导致的CPU切换,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有死锁问题导致的性能消耗。第四:使用多路复用IO模型,非阻塞IO。
问:那你为什么选择Redis的缓存方案而不用memcached呢
答:

  • 存储方式上:memcache会把数据全部存在内存之中,断电后会挂掉,数据不能超过内存大小。redis有部分数据存在硬盘上,这样能保证数据的持久性。
  • 数据支持类型上:memcache对数据类型的支持简单,只支持简单的key-value,,而redis支持五种数据类型。
  • 使用底层模型不同:它们之间底层实现方式以及与客户端之间通信的应用协议不一样。redis直接自己构建了VM机制,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
  • value的大小:redis可以达到1GB,而memcache只有1MB。

问:那你说说你知道的redis的淘汰策略有哪些?
答:Redis有六种淘汰策略
image.png
Redis4.0加入了LFU(least frequency use)淘汰策略,包括volatile-lfu和allkeys-lfu,通过统计访问频率,将访问频率最少,即最不经常使用的KV淘汰。
问:你对redis的持久化机制了解吗?能讲一下吗?
答:redis为了保证效率,数据缓存在了内存中,但是会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件中,以保证数据的持久化。Redis的持久化策略有两种:
RDB:快照形式是直接把内存中的数据保存到一个dump的文件中,定时保存,保存策略。
AOF:把所有的对Redis的服务器进行修改的命令都存到一个文件里,命令的集合。Redis默认是快照RDB的持久化方式。
当Redis重启的时候,它会优先使用AOF文件来还原数据集,因为AOF文件保存的数据集通常比RDB文件所保存的数据集更完整。你甚至可以关闭持久化功能,让数据只在服务器运行时存。
问:那你再说下RDB是怎么工作的?
答:默认Redis是会以快照”RDB”的形式将数据持久化到磁盘的一个二进制文件dump.rdb。工作原理简单说一下:当Redis需要做持久化时,Redis会fork一个子进程,子进程将数据写到磁盘上一个临时RDB文件中。当子进程完成写临时文件后,将原来的RDB替换掉,这样的好处是可以copy-on-write。
RDB的优点是:这种文件非常适合用于备份:比如,你可以在最近的24小时内,每小时备份一次,并且在每个月的每一天也备份一个RDB文件。这样的话,即使遇上问题,也可以随时将数据集还原到不同的版本。RDB非常适合灾难恢复。RDB的缺点是:如果你需要尽量避免在服务器故障时丢失数据,那么RDB不合适你。
问:那你要不再说下AOF??
答:使用AOF做持久化,每一个写命令都通过write函数追加到appendonly.aof中,配置方式如下:
appendfsync yes
appendfsync always #每次有数据修改发生时都会写入AOF文件。
appendfsync everysec #每秒钟同步一次,该策略为AOF的缺省策略。
AOF可以做到全程持久化,只需要在配置中开启 appendonly yes。这样redis每执行一个修改数据的命令,都会把它添加到AOF文件中,当redis重启时,将会读取AOF文件进行重放,恢复到redis关闭前的最后时刻。
使用AOF的优点是会让redis变得非常耐久。可以设置不同的fsync策略,aof的默认策略是每秒钟fsync一次,在这种配置下,就算发生故障停机,也最多丢失一秒钟的数据。缺点是对于相同的数据集来说,AOF的文件体积通常要大于RDB文件的体积。根据所使用的fsync策略,AOF的速度可能会慢于RDB。
问:那我该用哪一个呢?
答:如果你非常关心你的数据,但仍然可以承受数分钟内的数据丢失,那么可以额只使用RDB持久。AOF将Redis执行的每一条命令追加到磁盘中,处理巨大的写入会降低Redis的性能,不知道你是否可以接受。数据库备份和灾难恢复:定时生成RDB快照非常便于进行数据库备份,并且RDB恢复数据集的速度也要比AOF恢复的速度快。当然了,redis支持同时开启RDB和AOF,系统重启后,redis会优先使用AOF来恢复数据,这样丢失的数据会最少。
问:redis单节点存在单点故障问题,为了解决单点问题,一般都需要对redis配置从节点,然后使用哨兵来监听主节点的存活状态,如果主节点挂掉,从节点能继续提供缓存功能,你能说说redis主从复制的过程和原理吗?
答:主从配置结合哨兵模式能解决单点故障问题,提高redis可用性。从节点仅提供读操作,主节点提供写操作。对于读多写少的状况,可给主节点配置多个从节点,从而提高响应效率。
关于复制过程,是这样的:

  • 从节点执行slaveof[masterIP][masterPort],保存主节点信息
  • 从节点中的定时任务发现主节点信息,建立和主节点的socket连接
  • 从节点发送Ping信号,主节点返回Pong,两边能互相通信
  • 连接建立后,主节点将所有数据发送给从节点(数据同步)
  • 主节点把当前的数据同步给从节点后,便完成了复制的建立过程。接下来,主节点就会持续的把写命令发送给从节点,保证主从数据一致性。

问:那你能详细说下数据同步的过程吗?
答:redis2.8之前使用sync[runId][offset]同步命令,redis2.8之后使用psync[runId][offset]命令。
两者不同在于,sync命令仅支持全量复制过程,psync支持全量和部分复制。介绍同步之前,先介绍几个概念:

  • runId:每个redis节点启动都会生成唯一的uuid,每次redis重启后,runId都会发生变化。
  • offset:主节点和从节点都各自维护自己的主从复制偏移量offset,当主节点有写入命令时,offset=offset+命令的字节长度。从节点在收到主节点发送的命令后,也会增加自己的offset,并把自己的offset发送给主节点。这样,主节点同时保存自己的offset和从节点的offset,通过对比offset来判断主从节点数据是否一致。
  • repl_backlog_size:保存在主节点上的一个固定长度的先进先出队列,默认大小是1MB。

    主节点发送数据给从节点过程中,主节点还会进行一些写操作,这时候的数据存储在复制缓冲区中。从节点同步主节点数据完成后,主节点将缓冲区的数据继续发送给从节点,用于部分复制。
    主节点响应写命令时,不但会把命名发送给从节点,还会写入复制积压缓冲区,用于复制命令丢失的数据补救。

从节点发送psync[runId][offset]命令,主节点有三种响应:
FULLRESYNC:第一次连接,进行全量复制
CONTINUE:进行部分复制
ERR:不支持psync命令,进行全量复制
问:那你能具体说下全量复制和部分复制的过程吗?
答:
image.png
上面是全量复制的流程。主要有以下几步:

  • 从节点发送psync ? -1命令(因为第一次发送,不知道主节点的runId,所以为?,因为是第一次复制,所以offset=-1)。
  • 主节点发现从节点是第一次复制,返回FULLRESYNC {runId} {offset},runId是主节点的runId,offset是主节点目前的offset。
  • 从节点接收主节点信息后,保存到info中。
  • 主节点在发送FULLRESYNC后,启动bgsave命令,生成RDB文件(数据持久化)。
  • 主节点发送RDB文件给从节点。到从节点加载数据完成这段期间主节点的写命令放入缓冲区。
  • 从节点清理自己的数据库数据。
  • 从节点加载RDB文件,将数据保存到自己的数据库中。如果从节点开启了AOF,从节点会异步重写AOF文件。

关于部分复制有以下几点说明:

  1. 部分复制主要是Redis针对全量复制的过高开销做出的一种优化措施,使用psync[runId][offset]命令实现。当从节点正在复制主节点时,如果出现网络闪断或者命令丢失等异常情况时,从节点会向主节点要求补发丢失的命令数据,主节点的复制积压缓冲区将这部分数据直接发送给从节点,这样就可以保持主从节点复制的一致性。补发的这部分数据一般远远小于全量数据。
  2. 主从连接中断期间主节点依然响应命令,但因复制连接中断命令无法发送给从节点,不过主节点内的复制积压缓冲区依然可以保存最近一段时间的写命令数据。
  3. 当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行ID。因此会把它们当做psync参数发送给主节点,要求进行部分复制。
  4. 主节点接收到psync命令后首先核对参数runId是否与自身一致,如果一致,说明之前复制的是当前主节点;之后根据参数offset在复制积压缓冲区中查找,如果offset之后的数据存在,则对从节点发送+COUTINUE命令,表示可以进行部分复制。因为缓冲区大小固定,若发生缓冲溢出,则进行全量复制。
  5. 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。

问:那主从复制会存在哪些问题呢?
答:主从复制会存在以下问题:

  • 一旦主节点宕机,从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令所有从节点去复制新的主节点,整个过程需要人工干预。
  • 主节点的写能力受到单机的限制。
  • 主节点的存储能力受到单机的限制。
  • 原生复制的弊端在早期的版本中也会比较突出,比如:redis复制中断后,从节点会发起psync。此时如果同步不成功,则会进行全量同步,主库执行全量备份的同时,可能会造成毫秒或秒级的卡顿。

问:那比较主流的解决方案是什么呢?
答:哨兵模式
问:那你说下哨兵有哪些功能?
答:如图,是Redis Sentinel(哨兵)的架构图。Redis Sentinel(哨兵)主要功能包括主节点存活检测、主从运行情况检测、自动故障转移、主从切换。Redis Sentinel最小配置是一主一从。
Redis的Sentinel系统可以用来管理多个Redis服务器,该系统可以执行以下四个任务:

  • 监控:不断检查主服务器和从服务器是否正常运行。
  • 通知:当被监控的某个redis服务器出现问题,Sentinel通过API脚本向管理员或者其他应用程序发出通知。
  • 自动故障转移:当主节点不能正常工作时,Sentinel会开始一次自动的故障转移操作,它会将与失效主节点是主从关系的其中一个从节点升级为新的主节点,并且将其他的从节点指向新的主节点,这样人工干预就可以免了。
  • 配置提供者:在Redis Sentinel模式下,客户端应用在初始化时连接的是Sentinel节点集合,从中获取主节点的信息。

image.png
问:那你能说下哨兵的工作原理吗?
答:1. 每个Sentinel节点都需要定期执行以下任务:每个Sentinel以每秒一次的频率,向它所知的主服务器、从服务器以及其他的Sentinel实例发送一个PING命令。
2. 如果一个实例距离最后一次有效回复PING命令的时间超过down-after-milliseconds所指定的值,那么这个实例会被Sentinel标记为主观下线。
3. 如果一个主服务器被标记为主观下线,那么正在监视这个服务器的所有Sentinel节点,要以每秒一次的频率确认主服务器的确进入了主观下线状态。
4. 如果一个主服务器被标记为主观下线,并且有足够数量的Sentinel(至少要达到配置文件指定的数量)在指定的时间范围内同意这一判断,那么这个主服务器被标记为客观下线。
5. 一般情况下,每个Sentinel会以每10秒一次的频率向它已知的所有主服务器和从服务器发送INFO命令,当一个主服务器被标记为客观下线时,Sentinel向下线主服务器的所有从服务器发送INFO命令的频率,会从10秒一次改为每秒一次。
6. Sentinel和其他Sentinel协商客观下线的主节点的状态,如果处于SDOWN状态,则投票自动选出新的主节点,将剩余从节点指向新的主节点进行数据复制。
7. 当没有足够数量的Sentinel同意主服务器下线时,主服务器的客观下线状态就会被移除。当主服务器重新向Sentinel的PING命令返回有效回复时,主服务器的主观下线状态就会被移除。