收割Offer:互联网大厂面经
上QQ阅读APP看书,第一时间看更新

1.1 综合知识

互联网后端技术岗位面试涉及的范围特别广泛,笔者将无法归类的内容作为综合知识,以面试官可能提出的问题为线索,采用一问一答的形式讲述核心高频考点,具体内容包括:单元化高可用架构、IO多路复用、Syschronized锁与ReentrantLock锁、DDD领域驱动、SPI机制、GC垃圾回收算法、分布式柔性事务、线程池、限流算法、HashMap底层原理、零复制技术、CAP理论、双亲委派类加载机制、服务网格ServiceMesh、保证可见性和有序性的Volatile关键字、Threadlocal内存泄漏、Happens-Before语义、Caffeine缓存高性能分析、缓存行填充与伪共享,以及高可用建设方法论等。

作为求职者,以上知识会经常被问到,只有做到了然于心,才能在面试中从容应对。

1.1.1 单元化高可用架构演进历程

面试官提问

● 项目中存在哪些单点问题,怎么解决?

● 请介绍一下同城多机房、异地多活技术方案?

● 为什么需要单元化部署?解决了什么问题?

随着用户规模的不断增长,后端系统会面临各种单点问题,它们在不同发展阶段的表现形式为单体应用→单数据库→单机房→单地部署等;解决单点问题对应的方法是微服务→分库分表→同城多机房→单元化,如图1-1所示。

图1-1 系统发展过程遇到的单点问题与解决方法

1.单体应用与单机房微服务

单体应用把所有的功能模块耦合在一起,它存在的问题是领域边界模糊,无法根据业务模块的需要进行伸缩扩容。除此之外,还存在需求开发分支冲突、线上问题定位困难、整体打包编译费时等问题,不适用于大型复杂项目。单体应用典型系统架构如图1-2所示,用户发起请求至服务器端,应用进程内部处理业务逻辑并访问数据库。由于系统内部处理耗时很小,并且后端服务与数据库部署在同一机房,因此该架构下整个请求链路上的耗时主要发生在用户到机房的物理距离上。

图1-2 单体应用

微服务化时代,巨大的单体应用被拆分为模块化的服务,每个服务都围绕特定的业务领域构建,微服务之间通过远程过程调用(Remote Procedure Call,RPC)实现通信,这就是单机房微服务,如图1-3所示。尽管服务拆分使得原本进程内部的调用变成了网络调用,但是应用都部署在同一个机房,因此RPC网络开销可以忽略。

图1-3 单机房微服务

微服务解决了应用层的瓶颈,但随着业务的发展,数据库又成为制约系统扩容的瓶颈。

2.单数据库与分库分表

随着业务的发展数据量不断增多,出现了数据存储、读取方面的问题:一方面单机物理服务器的资源(如CPU、磁盘、内存、IO等)有限,磁盘读取和网络IO出现瓶颈;另一方面单表的数据量太大,查询时扫描的数据很多,造成SQL执行效率低下。为了解决上述问题,引入数据库中间件—实现对上层业务透明的分库分表。

分布式数据库的数据分区一般采用Hash函数+Map映射的方式来实现:首先根据数据的分表字段(一般为userid)计算出该数据的Hash桶位置,然后使用事先定义好的映射表将这个Hash桶中的数据映射到数据库物理节点。如图1-4所示,在逻辑上提前将数据一次性拆到n个分表中,底层有3个数据库存储数据,逻辑表与数据库存在多对一映射,将来增加新的物理节点不需要rehash全量数据。访问数据库时中间件会屏蔽表与库的映射关系,应用层无感知。

图1-4 分库分表解决数据库单点瓶颈

微服务和分库分表分别解决了单体应用和单数据库的问题,但物理机房又成为制约系统扩容的因素。

3.单机房演进为同城多机房

为了解决单机房的容量限制,可在同城新建多个机房,机房之间通过专线连接,将应用服务部署在多个机房,数据库主库和备库部署到不同的机房,依靠不同的服务注册中心将应用层逻辑隔离,实现应用层请求不跨机房处理,如图1-5所示。

图1-5 同城多机房应用架构

数据库主库只在其中一个机房内,数据写入时只写主库,主备数据同步,异地机房备库可提供读服务。该方案存在的缺点是访问数据库存在跨机房调用、主备数据同步延迟的问题,但该方案的优点也很多,列举如下:

● 容量不受单机房限制,数据层与应用层均可自由扩容。

● 避免不可测因素导致单机房故障,使得全域产品服务不可用,比如地震、火灾、洪水等灾害使得机房断电或者网线被施工方意外挖断等。

● 用户请求就近接入,优先被物理距离较近的机房处理,减少网络耗时,保障用户体验。

4.同城多机房演进为单元化部署

因为应用层流量是随机的,任何一个应用节点都可能访问任意一个数据库节点,所以应用层每增加一台服务器实例都需要与数据库建立连接,数据库连接数量存在上限,这又制约了系统的水平扩容,如图1-6所示。

图1-6 每台服务器都需要和数据库建立连接

针对这个问题,提出了单元化的架构,如图1-7所示。该种架构的应用层也像数据层一样分片,但从应用层到数据层组成一个封闭的单元,一次请求处理收敛在一个单元内部,数据库只负责本单元的应用请求,从而大大节省了连接数;而每个单元可以作为一个独立整体进行部署或挪动,甚至还可以将单元部署到异地来实现容灾。

图1-7 服务单元化

单元化设计的原则如下:

● 业务是可分片的,常以用户id或者地区作为分片维度。

● 整个系统要面向逻辑分区进行设计,方便单元挪动。

● 理想状态下单元内部是自封闭的,单元内可以完成业务的所有处理。

缺点是,有时跨单元调用是无法避免的,比如转账场景,用户A和B分别属于单元1和单元2,数据也存储在不同单元,扣减用户A的账户余额需要在单元1执行,增加用户B的账户余额需要在单元2执行,因此跨单元调用增加网络耗时这一问题无法避免。

1.1.2 Java中5种重要的队列

面试官提问

● 请对几种重要队列进行对比,并说明它们的使用场景和实现方式。(偶尔也会要求求职者写代码实现一个队列。)

Java有5种重要的队列,下面就对这5种队列分别进行说明。

1.ArrayBlockingQueue队列

基于数组实现的有界阻塞队列,添加和删除操作使用同一把锁。元素先进先出,常与线程池结合使用。

2.LinkedBlockingQueue队列

基于链表实现的有界阻塞队列,不设置大小时是Integer.MAX_VALUE,添加和删除操作是两把独立的锁,锁竞争较小。元素先进先出,常与线程池结合使用。

3.SynchronousQueue队列

同步握手队列,内部容量为零,笔者在工作中遇到的使用场景是定时任务的触发与执行。当执行时机到来时,交由线程池执行任务,如果队列很长,那么任务可能因为排队等待而与执行时机不符。队列长度为0,通过不断地新建线程来处理任务的方式可以保证定时任务的按时执行。

4.无锁队列

比如MPSC、Ring Buff等,常使用缓存行的填充避免伪共享,以及CAS操作避免锁竞争来优化性能。

5.PriorityBlockingQueue队列

一个支持优先级排序的无界阻塞队列,线程安全,底层采用堆结构来实现。在具有优先级的业务场景中使用广泛。

1.1.3 IO多路复用

面试官提问

● 什么是IO多路复用,请展开说明。

● 请说明多路复用的3种实现方式,从复制次数、工作效率、支持文件描述符数、使用的数据结构等方面进行优缺点对比。

IO多路复用就是通过一种机制来监听多个文件描述符,某个文件描述符一旦就绪,它就能通知应用程序进行相应的处理。多路复用的3种实现方式有select、poll和epoll。为了更好地体会IO多路复用的优势,下面首先介绍多路复用技术出现之前的阻塞IO,以及运用线程池对阻塞IO进行的技术优化。

1.阻塞IO

阻塞IO的伪代码如图1-8所示。

图1-8 阻塞IO代码片段

代码中有两个地方会阻塞,分别是accept和read函数。read函数又阻塞在两个阶段:从网卡复制数据到内核缓冲区,从内核缓存区复制数据到用户缓存区,如图1-9所示。

如果客户端一直不发送数据,那么服务器端的处理线程就会一直阻塞在read函数上。

2.引入线程池

为了避免上面提到的阻塞问题,在每个请求到来时都新创建一个线程去调用read函数并处理业务逻辑,伪代码如图1-10所示。

图1-9 传统IO阻塞的两个阶段

图1-10 引入线程池缓解阻塞问题

但是每个请求都对应新建一个线程,这很容易耗尽系统资源,因此一个自然的想法就是使用线程池来管理线程资源。

3.IO多路复用3种实现方式

上述多线程的技术优化只是用户态的小把戏,系统调用read函数依然是阻塞的,操作系统能否提供某个系统调用函数直接返回文件描述符是否就绪的状态呢?答案当然是能提供,下面就来看IO多路复用的3种实现方式。

1)select实现方式

select实现方式如图1-11所示,首先服务器端不断接收客户端连接,要监听的文件描述符添加到比特数组(fd_set)中,对应比特位置1;然后调用系统函数select方法;在内核态,操作系统将已就绪文件描述符在fd_set中对应比特位置1,未就绪文件描述符在fd_set中对应比特位置0;在用户态,用户遍历fd_set即可判断文件描述符是否就绪。

图1-11 IO多路复用之select实现

select方式存在的缺如下:

● select系统调用存在文件描述符集合(fd_set)从用户态到内核态的复制开销。

● 用户需要轮询获取已就绪的文件描述符,效率低。

● 在内核中操作系统需要遍历传进来的fd_set,逐一检查文件描述符的就绪状态。

● 对被监控的fd_set,大小默认限制为1024个。

2)poll实现方式

poll实现方式和select的主要区别在于poll去除最多监听1024个文件描述符的限制。

3)epoll实现方式

epoll实现方式如图1-12所示。

图1-12 IO多路复用之epoll实现

epoll提供了3个函数:epoll_create、epoll_ctl和epoll_wait。

● epoll_create负责创建EventPoll结构体,其中包含两个重要成员:红黑树,用于存储epoll_ctl传进来的文件描述符;双向链表,用于存储已就绪的文件描述符。

● epoll_ctl向红黑树中添加、修改或删除文件描述符节点。添加文件描述符节点时会给内核中断处理程序注册回调函数,当有文件描述符就绪时,通过回调函数将就绪文件描述符的引用加入就绪链表中。

● epoll_wait返回就绪链表中的文件描述符。

综上所述,epoll克服了select IO多路复用存在的缺点。

1.1.4 ReentrantLock锁与Syschronized锁

面试官提问

● 公平锁与非公平锁的区别是什么?

● 什么是可重入锁?

● 什么是死锁,怎样避免死锁?

● ReentrantLock与Syschronized实现原理是什么?两者有什么区别?

● 请说明ReentrantLock获取锁与释放锁的流程。

● 请说明Syschronized锁升级的过程。

● 锁性能优化方法是什么?

● 介绍一下AbstractQueuedSynchronizer(AQS)。

1.公平锁与非公平锁

公平锁是指多个线程竞争锁时直接进入队列排队,根据申请锁的顺序获得锁,先到先得。而非公平锁则是多个线程竞争锁时,首先尝试直接抢锁,失败后再进入等待队列。

使用公平锁,先到先得,线程获取锁时不会出现饥饿现象。使用非公平锁,整体的吞吐效率比较高。

ReentrantLock默认是非公平锁,在构造方法中传入参数true则为公平锁;Synchronized是非公平锁。

2.可重入锁

可重入锁是指一个线程可以多次获取同一把锁,其实现原理是,为每个锁关联一个计数器,线程首次获取锁时,计数器置为1,再次获取该锁时,计数器加1;线程每退出同步块一次,计数器就减1。计数器为0则代表锁被当前线程释放。

Synchronized和ReentrantLock都是可重入锁。

3.ReentrantLock锁

ReentrantLock锁的特点是可重入,支持公平锁和非公平锁两种方式。

阅读ReentrantLock代码可知,它主要利用CAS+AQS队列来实现。以非公平锁为例,当线程竞争锁时首先使用CAS抢占锁,成功则返回,失败则进入AQS队列并且挂起线程;当锁被释放时,唤醒AQS中的某个线程,从被挂起处再次尝试获取锁(当AQS队列头节点的下一个节点不为空时,直接唤醒该节点;否则从队尾向前遍历,找到最后一个不为空的节点并唤醒),获取锁失败则再次进入队尾。图1-13详细描述了ReentrantLock非公平锁的获取与释放流程。

图1-13 ReentrantLock非公平锁的获取与释放流程

下面通过源码来分析ReentrantLock的实现。非公平锁首先使用CAS检测锁是否空闲并抢占锁,当多个线程同时抢占同一把锁时,CAS操作保证只有一个线程执行成功。

     final void lock() {
//state为0则计数器设为1,表示抢占锁成功
         if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
         else
             acquire(1);
     }

假设3个线程T1、T2和T3同时竞争锁,线程T1执行CAS成功,线程T2和T3则会进入acquire方法:

     public final void acquire(int arg) {
         if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
     }

接下来分别阅读tryAcquire、addWaiter和acquireQueued的实现代码。进入tryAcquire方法,若锁空闲(state = 0),则当前线程通过CAS直接抢锁,抢锁成功则返回true;抢锁失败则判断持有锁的线程是否为自己,如果是自己的话就记录重入锁的次数,并返回获取锁成功,否则返回获取锁失败。

     protected final boolean tryAcquire(int acquires) {
         return nonfairTryAcquire(acquires);
     }

     final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
         int c = getState();
//锁处于空闲状态,没有被任何线程持有
         if (c == 0) {
//忽略AQS队列中的等待线程,当前线程直接通过CAS抢锁体现了非公平性
             if (compareAndSetState(0, acquires)) {
//抢锁成功,设置独占线程为当前线程
                 setExclusiveOwnerThread(current);
                return true;
            }
        }
//检查持有锁的线程是否为当前线程
        else if (current == getExclusiveOwnerThread()) {
//可重入锁,记录重入次数
            int nextc = c + acquires;
if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
//获取锁失败
        return false;
     }

若tryAcquie获取锁失败,则执行addWaiter方法,线程加入AQS队列尾部,具体代码如下:

     private Node addWaiter(Node mode) {
//初始化节点,模式设置为独占
Node node = new Node(Thread.currentThread(), mode);
         Node pred = tail;
//tail不为null,说明队列已被初始化
         if (pred != null) {
             node.prev = pred;
//通过CAS将Node对象加入AQS队列,成为尾节点
             if (compareAndSetTail(pred, node)) {
                 pred.next = node;
                 return node;
             }
         }
//队列未初始化或者CAS操作失败则进入enq函数
         enq(node);
         return node;
     }

T2和T3线程抢锁失败,假设它们同时加入AQS队列,由于队列尚未初始化(tail ==null),因此至少有一个线程进入enq()方法,代码如下:

这段代码通过自旋和CAS来实现非阻塞的原子操作,保证线程安全。假设T2和T3线程同时执行enq方法,第一轮循环,CAS操作确保只有一个线程创建head节点;第二轮循环,AQS队列完成初始化,tail非空,T2和T3线程都进入else逻辑,通过CAS操作将当前节点加入队尾。若T2线程执行compareAndSetTail成功,则T3线程需要在下一次循环时入队,最终AQS队列如图1-14所示。

图1-14 AQS队列

T2和T3线程进入队列后执行acquireQueued()方法,AQS队列头节点的后继节点可以再次尝试获取锁,获取锁失败后被挂起,代码如下:

如果T1线程一直持有锁,那么T2和T3线程最终会进入shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,代码如下:

最终T2和T3线程在状态为Node.SIGNAL的前驱节点后挂起,保证前驱节点获取锁后能唤醒自己。AQS队列中节点的状态及说明如表1-1所示。

表1-1 AQS节点的状态及说明

锁的释放过程比较简单,代码如下:

     public void unlock() {
         sync.release(1);
     }

     public final boolean release(int arg) {
        if (tryRelease(arg)) {
//释放锁成功
            Node h = head;
//唤醒AQS队列中的某个节点(一般是头节点)
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
     }

核心方法是tryRelease和unparkSuccessor,先看一下tryRelease的执行过程,代码如下:

     protected final boolean tryRelease(int releases) {
//重入锁,每重入一次则state加1,每释放锁一次则state 减1
         int c = getState() - releases;
// 若当前线程不是持有锁的线程则抛出异常
         if (Thread.currentThread() != getExclusiveOwnerThread())
             throw new IllegalMonitorStateException();
         boolean free = false;
//state 减为 0,代表释放锁成功
         if (c == 0) {
             free = true;
             setExclusiveOwnerThread(null);
         }
         setState(c);
         return free;
     }

释放锁成功后会唤起AQS队列中被挂起的线程,代码如下:

     private void unparkSuccessor(Node node) {
     int ws = node.waitStatus;
     if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);

     Node s = node.next;
     if (s == null || s.waitStatus > 0) {
// 如果节点为null或者处于取消状态
// 那就从后往前遍历寻找距离头节点最近的非取消节点
         s = null;
         for (Node t = tail; t != null && t != node; t = t.prev)
              if (t.waitStatus <= 0)
                  s = t;
     }
// 唤醒线程
     if (s != null)
         LockSupport.unpark(s.thread);
}

被唤醒的线程也不能保证抢锁成功,失败后依然会放置在队尾,这里也体现了锁的“非公平”性。

4.Syschronized锁

在HotSpot虚拟机中,对象内存布局主要分为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding),如图1-15所示。

图1-15 虚拟机中对象的内存布局

当线程访问同步块时,首先需要获得锁并把相关信息存储在对象头中,对象头由以下两部分组成:

● Mark Word:存储自身运行时数据,例如HashCode、GC年龄、锁相关信息等内容。Mark Word信息结构如表1-2所示。

表1-2 Mark Word信息结构表

● Klass Pointer:Class对象的类型指针,指向的位置是对象对应的Class对象(对应的元数据对象)的内存地址。

总体上来说,锁升级过程如图1-16所示。

图1-16 锁升级的过程

1)偏向锁

线程获取偏向锁的流程如下:

● 检查Mark Word中的线程id。

● 若线程id为空,则通过CAS设置为当前线程id:成功则获取锁,失败则撤销偏向锁。

● 若线程id不为空且为当前线程,则获取锁成功,否则撤销偏向锁。

持有偏向锁的线程每次进入这个锁相关的同步块时,只需判断Mark Word中记录的线程id是否为自己。在没有竞争时,一个线程反复申请获得同一把锁,使用偏向锁效率极高。

2)轻量级锁

多个线程竞争偏向锁导致锁升级为轻量级锁,获取轻量级锁的流程如下:

● 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间Lock Record,并将对象头中的Mark Word复制到Lock Record。

● 利用CAS操作将对象头中的Mark Word更新为指向Lock Record的指针,若操作成功则竞争到锁,锁标志位变为00,表示当前为轻量级锁状态。

● CAS执行失败且自旋一定次数后仍未成功,则说明该锁已被其他线程抢占,这时轻量级锁会膨胀为重量级锁,锁标志位变成为10。

使用轻量级锁提升性能的前提:多个线程交替执行同步块,锁在整个生命周期内基本不会存在竞争或者出现锁竞争的概率很低,减少了使用重量级锁产生的性能消耗。

轻量级锁与偏向锁的比较:轻量级锁每次申请、释放都至少需要一次CAS操作,但偏向锁只有在初始化时需要一次CAS操作,后续当前线程可以几乎零成本地直接获得锁(仅需比较线程id是否为自己)。

3)自旋锁

如果持有锁的线程能在很短时间内释放锁,那么竞争锁的线程就没有必要阻塞挂起,它们只需要自旋等待持有锁的线程释放锁,然后再尝试获取锁,这样就能减少传统的重量级锁因使用操作系统互斥量而产生的性能开销。因此,在轻量级锁膨胀为重量级锁之前,一般会尝试通过自旋的方式获取锁。假如当前持有锁的线程一直不释放锁,那么自旋就是在无意义地浪费CPU时间,当自旋多次始终无法获取锁时,轻量级锁会膨胀为重量级锁。

4)重量级锁

没有竞争到锁的线程会被挂起,只有在持有锁的线程退出同步块之后才会唤醒这些线程。唤醒操作涉及操作系统调度,开销很大。

1.1.5 Java SPI机制

面试官提问

● 你了解SPI机制吗?

● SPI在哪些场景中使用?

Java SPI的全称为Service Provider Interface(服务提供者接口),SPI机制为某个接口寻找服务的实现。实现步骤是:首先编写接口的实现类,然后在classpath的META-INF/services目录下创建以接口全限定名命名的文件,并在该文件中写入实现类的全限定名;最后调用java.util.ServiceLoader中的load()方法,根据上述文件来发现并加载具体的服务实现。

例如,有一个内容搜索接口,假设它的实现可能是基于ElasticSearch的搜索,也可能是基于Lucene的搜索。实现代码如下:

(1)定义接口:

     public interface Search {
         public List<String> searchContent (String keyword);
     }

(2)ElasticSearch搜索实现:

     public class ElasticSearch implements Search{
         @Override
         public List<String> searchContent (String keyword) {
System.out.println("ES搜索 "+keyword);
             return null;
         }
     }

(3)Lucene搜索实现:

     public class LuceneSearch implements Search{
         @Override
         public List<String> searchContent (String keyword) {
System.out.println("Lucene搜索 "+keyword);
             return null;
         }
     }

(4)在resources下新建META-INF/services/目录,然后新建以接口全限定名命名的文件com.spi.learn.Search,在文件中写入我们希望用到的实现类:

     com.spi.learn.Search.ElasticSearch

(5)测试:

     public class TestCase {
         public static void main(String[] args) {
ServiceLoader<Search> s = ServiceLoader.load(Search.class);
             Iterator<Search> iterator = s.iterator();
           while (iterator.hasNext()) {
               Search search =  iterator.next();
               search.searchDoc("hello world");
           }
        }
     }

输出结果:

     ES搜索hello world

1.1.6 限流算法

面试官提问

● 你知道哪些限流算法?请比较其优缺点?

● 怎样解决临界问题?

● 如果业务场景存在突发流量,应该使用那种限流算法?

常用的限流算法有3种:计数器、漏桶算法和令牌桶。

1.计数器

使用计数器实现限流,可限制在指定时间间隔内请求数小于阈值的情况,但存在临界问题。如图1-17所示,假设每分钟系统限流500个请求,在XX:00:59时刻系统接收到500个请求,在XX:01:00时刻系统又接收到500个请求,那么系统在1秒内就处理了1000个请求,超出了1分钟限流500个请求的要求。

为此引入滑动窗口解决该问题,如图1-18所示,加粗黑色竖线为滑动窗口左右边界,窗口大小为1分钟,窗口被划分成6个格子,每个格子代表10秒钟,每过10秒钟,时间窗口往右滑动一格。每个格子都有独立的计数器,比如一个请求在XX:00:06时刻到达,那么XX:00:00~XX:00:09对应的计数器就会加1。

图1-17 计数器限流存在临界问题

图1-18 滑动窗口解决临界问题

结合图1-17和图1-18,XX:00:59时刻到达的500个请求会落在第6个灰色格子里,而XX:01:00到达的500个请求会落在第7个格子中,但当时间到达XX:01:00时,窗口会往右滑动一格,此时时间窗口内的总请求数为1000个,可以触发系统500个请求的限流。因此,滑动窗口能够解决计数器临界问题,窗口中的格子时间粒度越细,限流的统计就会越准确。

2.漏桶算法

漏桶算法如图1-19所示,把请求当作水流,桶为系统容量,水来了先存入桶里,并以最大恒定速率放水,桶满水溢出则代表拒绝服务。

当桶中没有积水时:

● 若进水速度小于或等于出水速度,则出水速率等于进水速率。

● 若进水速度大于出水速度,则多余的水积压在桶中。

当桶中有水时:

● 若漏桶未满,则进水会积压在漏桶中。

● 若漏桶已满,则进水溢出桶外。

3.令牌桶算法

令牌桶算法如图1-20所示。令牌桶算法的思想是以恒定速率生产令牌并放入令牌桶中,用户每次请求都得申请令牌,如果令牌不足,则拒绝请求;当令牌桶已满时,若再向桶中投放令牌,则多余的令牌会被丢弃。

图1-19 漏桶算法

图1-20 令牌桶算法

该算法的特点是以恒定速率生产令牌,可以接收突发流量。

1.1.7 领域驱动设计

面试官提问

● 什么是贫血模型?

● 谈谈你对领域驱动设计的理解。传统设计方法存在哪些问题?领域驱动设计解决了什么问题?

● 领域驱动设计在你项目开发中的实践是怎样的?

1.领域模型、数据模型

领域模型关注领域知识,将数据和行为封装在一起,是业务领域中的核心实体,建模时要考虑业务语义的表征能力。

数据模型关注数据存储,建模时要考虑存储方案、扩展性、性能等非功能性因素。

贫血领域对象是指仅用于数据载体,没有行为和动作的领域对象。

2.架构分层

相对于传统的三层架构,领域驱动设计(Domain Driven Design,称为DDD)把软件系统架构分成四层,如图1-21所示。

图1-21 传统三层架构与领域驱动四层架构

经典三层架构表示将系统划分为用户界面层、业务逻辑层和数据访问层三层,各层之间通过接口访问,对象模型的实体作为数据载体,一般与数据库表相对应。领域驱动设计将传统三层架构中的业务逻辑层拆分为应用服务层和领域服务层,每层的主要职责如下:

● 用户接口层:界面展示。

● 应用服务层:应用服务层很薄,不处理核心业务逻辑,通常做一些参数验证、错误处理、日志监控以及权限认证等工作。

● 领域服务层:关注领域逻辑。

● 基础设施层:提供基础设施能力,比如数据存储、事件总线以及缓存等。

3.领域驱动设计的基本概念

(1)实体:通过标识而不是属性区分的对象称为实体,例如居民系统中的人就是实体,他具有唯一标识(身份证id)。

(2)值对象:通过属性值来识别,对事物进行描述但没有唯一标识,这种对象称为值对象,例如颜色、地址信息等。

(3)聚合和聚合根:聚合是由业务和逻辑紧密关联的实体和值对象组成的。每个聚合都有一个根,聚合之间通过聚合根关联引用,访问外部聚合的实体时只能先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内部实体。

(4)领域事件:领域内的业务行为发生后通常会触发下一步的业务操作,这类行为事件称为领域事件。比如下单后触发支付,支付后触发仓储出货,出货后触发快递运输。

(5)限界上下文:限定领域边界,一般一个上下文对应一个子域。领域模型在这个边界内语义无二义性。

(6)限界上下文之间的映射关系:

● 合作关系(Partnership):两个上下文紧密合作,一荣俱荣,一损俱损。

● 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。

● 遵循者(Conformist):下游上下文只能盲目依赖上游上下文。

● 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。

● 开放主机服务(Open Host Service):定义一种协议来让其他上下文对本上下文进行访问。

● 发布语言(Published Language):通常与OHS(Open Host Service,开放主机服务)一起使用,用于定义开放主机的协议。

● 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。

● 另谋他路(SeparateWay):两个完全没有联系的上下文。

4.领域驱动实践

领域驱动设计开发的一般步骤如下:

步骤01 根据需求划分领域、限界上下文,以及上下文之间的关系。

步骤02 分析识别出上下文内的实体、值对象。

步骤03 对实体、值对象进行关联和聚合,识别出聚合的范畴和聚合根。

步骤04 为聚合根、实体等设计仓储。

步骤05 建立领域模型,并在实践中检验模型的合理性。

以笔者在网易云音乐开发的抽奖系统为例,C端(客户端)页面如图1-22所示,具体需求详情如下:

● 抽奖有限制条件:购买一张数字专辑获得n次抽奖机会,活动有开始和过期时间等。

● 一个抽奖活动包含多个奖品,奖品类型有云音乐会员、音乐周边、游戏礼包码等。

● 奖品配置信息有封面、文案、库存、被抽中的概率、每个用户抽中的次数限制等。

● 活动需要进行风控管理,限制非法用户盗刷奖品。

图1-22 购买数字专辑抽奖

知道了具体需求,下面开始进行领域驱动设计开发。

1)梳理需求,提取关键性信息,形成领域限界上下文及上下文之间的关系

抽奖系统可划分为C端抽奖和B端(商家界面)运营后台两个子域,如图1-23所示。B端运营后台用于配置奖品,逻辑相对简单。抽奖核心逻辑在于C端模块,其领域划分如图1-24所示,主要包括:抽奖核心域、活动准入支撑子域、库存管理支撑域、风控支撑域以及计数通用域等。

图1-23 抽奖系统B端与C端子域

图1-24 抽奖系统C端领域划分

每个子域负责的领域职责如下:

(1)抽奖上下文限定的子域属于用户抽奖的核心业务。

(2)用户参与抽奖和获取抽奖机会需要满足一定条件,因此划分出活动准入上下文。

(3)库存管理与奖品履约属于通用模块,需要根据不同的业务场景,考虑数据的强一致性与高可用,因此定义一个独立的库存上下文。

(4)为了解决黑产盗刷奖品问题,引入反作弊能力,这里定义一个风控上下文。

(5)活动准入(每购买一张专辑获取n次抽奖机会)、风控(监控用户发起抽奖频率)、抽奖(每个用户抽中奖品的次数上限)等领域都依赖一些频次统计,因此定义一个计数上下文。

根据上面的分析划分出抽奖、活动准入、风控、计数和库存5个上下文,每个上下文在系统中都是高内聚的,上下文之间的映射关系如图1-25所示。

图1-25 C端抽奖上下文的映射关系

抽奖、风控、活动准入、库存和计数五个上下文同处于抽奖领域,它们之间是“一荣俱荣,一损俱损”的合作关系。用户抽中礼包码奖品时,抽奖上下文依赖用户信息上下文获取用户信息(头像、昵称)和私信上下文发放兑换码。抽奖上下文通过防腐层(Anti Corruption Layer,ACL)对这两个上下文进行了隔离,私信与用户上下文通过开放主机服务(Open Host Service)作为发布语言(Published Language)对抽奖上下文提供访问机制。划分出上下文并理清上下文之间的关系,在需求开发时便于任务拆解,每个独立的上下文遴选出一个技术所有者,职责清晰,沟通高效。

2)分析识别出上下文内的实体、值对象

系统的核心上下文是抽奖上下文,以它为例,分析识别出上下文内的实体和值对象,如图1-26所示。

图1-26 抽奖上下文中的实体、值对象

3)对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根

如图1-26所示,通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,一个抽奖玩法包含抽奖id(LotteryId)和奖池(AwardPool),一个奖池包含多个奖品(Award)。此外抽奖结果(UserLotteryResult)用于发放奖品。

4)为聚合内部的聚合根、实体等设计仓储

对于数据存储表设计细节,这里不展开讲解。

5)建立领域模型

领域驱动设计要解决的一个重要问题就是对象贫血,抽奖聚合根(DrawLottery)持有抽奖活动的id和该活动下的所有可用奖池(AwardPool)值对象。抽奖系统的核心领域功能抽奖聚合根(DrawLottery)根据抽奖上下文(DrawLotteryContext)携带的场景信息(比如用户是否为会员,是否为新激活用户等)匹配一个AwardPool,实现代码如下:

     @Data
     public class DrawLottery {
//抽奖id
         private int lotteryId;
//奖池列表
         private List<AwardPool> awardPools;

//根据抽奖上下文匹配奖池
         private AwardPool matchAwardPool(List<AwardPool> awardPools,
                                          DrawLotteryContext context) {
           for(AwardPool awardPool: awardPools) {
               if(awardPool.matchedUserType(context.getUserType())) {
                   return awardPool;
               }
//后续可以增加其他匹配逻辑
           }
//返回null,也可以设置默认奖池
           return null;
        }
     }

选中奖池后,用户中奖概率、抽中奖品次数上限等这部分领域功能在AwardPool内实现,代码如下:

     public class AwardPool {

//奖池中包含的奖品
         private List<Awrad> awards;

//中奖概率、频次控制等
         public Award drawAward() {
//生成随机数在奖池中选奖以及频控等具体逻辑不在此展开
             return null;
         }
     }

与只有get、set方法的贫血对象不同,领域对象有了行为后更加丰满,领域功能的内聚性更强,职责更加明确。将领域行为封装到领域对象中,将资源管理行为封装到资源库中,将与外部上下文的交互行为封装到防腐层中,领域服务本身所承载的职责也就更加清晰,示例代码如下:

     public class LotteryServiceImpl implements LotteryService {
         @Autowired
         private DrawLotteryRepository drawLotteryRepo;
         @Autowired
         private UserPortraitInfoFacade userPortraitInfoFacade;
         @Autowired
         private SendAwardService sendAwardService;
         @Autowired
         private CounterFacade counterFacade;
         @Override
         public boolean drawLottery(LotteryContext lotteryContext) {
//获取抽奖配置聚合根
            DrawLottery                           drawLottery        =
drawLotteryRepo.getDrawLotteryById(lotteryContext.getLotteryId());
//计数
            counterFacade.incrCount(lotteryContext);
//匹配奖池
            AwardPool                             awardPool          =
drawLottery.matchAwardPool(bulidDrawLotteryContext(drawLottery,
lotteryContext));
//从奖池中选奖品
            Award award = awardPool.drawAward ();
//发放奖品
            return awardSendService.sendAward(award);
         }
    }

说明:此处参考了美团的文彬、子维的文章,详情见参考文献[9]。

1.1.8 HashMap的底层原理

面试官提问

● HashMap读写流程是怎样的?

● 谈谈解决Hash冲突的几种方式,HashMap是怎么解决Hash冲突的?

● 为什么要引入红黑树?

● 链表转红黑树的阈值为什么是8,了解泊松分布吗?

● 什么是扩容机制?扩容时机是什么?

● 为什么HashMap遵循两倍扩容,为什么容量总为2的n次幂?

● HashMap线程安全吗?

1.HashMap的get与put方法的执行过程

HashMap用于存放键值对,用于非线程安全,其数据结构由数组、链表和红黑树构成。链表主要是为了解决哈希碰撞问题,而红黑树则是避免为链表长度过长而导致查询效率低的问题。HashMap数据结构如图1-27所示。

图1-27 HashMap数据结构

1)get方法的执行流程

(1)计算key的hash值,通过hash&(length-1)的方式计算key对应的数组下标。

(2)若首节点为null,则返回null。

(3)若首节点key与目标值相同,则直接返回key对应的value。

(4)若首节点key与目标值不同,且首节点的下一个节点为null,则返回null;否则判断首节点类型,首节点类型如果是链表,则遍历查找O(n),如果是红黑树,则效率查找O(logn)。

2)put方法的执行流程

(1)如果数组为空就进行第一次扩容。

(2)计算key的hash值,通过hash & (length -1)的方式计算key对应的数组下标。

(3)如果数组下标处首节点为null,则直接插入。

(4)若首节点key与目标值相同,则直接覆盖,否则:

● 若首节点为红黑树节点类型,则在红黑树中插入键值对。

● 若首节点为链表节点类型,则遍历链表执行插入操作。如果链表的长度大于或等于8,那么需要将链表转化为红黑树。

(5)插入成功后,若键值对数量大于阈值则触发扩容。

2.key的hash值的计算过程

hash值的计算过程:(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)。

解释如下:

(1)计算hash值,int h = key.hashCode()。

(2)哈希值无符号右移16位与原哈希值做异或h ^(h >>> 16)处理。

把高位与低位进行混合运算,提高低位的随机性,降低碰撞概率。

3.HashMap是如何解决Hash冲突的

HashMap底层使用数组来存储数据,当添加键值对时,首先计算key的哈希值,然后通过hash & (length -1)的方式确定key在数组中的下标。但多个key经过hash &(length-1)计算后位置可能相同,也就是存在哈希冲突问题。HashMap使用拉链法来解决哈希冲突问题,即所有存在冲突的key组成一个单向链表,当链表长度大于8时转化为红黑树,以提高查询效率。

4.为什么链表转换为红黑树的阈值是8,而红黑树转换为链表的阈值却是6

一方面红黑树的查找效率为O(logn),优于链表的O(n),另一方面红黑树占用的内存空间大于链表,同时当键值对数量较少时链表的遍历查询与红黑树的搜索效率差异不大。综合空间和时间的考量,哈希冲突元素的个数达到某个阈值时红黑树和链表数据结构才会互相转化。HashMap的源码如下:

     Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

理想情况下,使用随机哈希码,在扩容阈值为0.75的情况下,桶中节点的数量遵循参数为0.5的泊松分布,即:

由此可知,链表长度为8的概率为0.00000006,此时树化。一方面遍历长度小于8的链表的时间效率尚可接受;另一方面每个桶的树化概率很小,节约了内存空间。红黑树退化为链表的阈值为6,中间留有空间,可以防止链表和红黑树之间频繁转换。

泊松分布(poisson distribution)是一种离散型概率分布,通常用于描述一个固定时间内事件发生次数的概率分布,如在一个小时内某个网站的访问次数、一天内某个交通路口的车辆通过次数等。

5.JDK8为什么使用红黑树

JDK7使用数组+链表来存储键值对,当数据量较多或哈希算法散列不均匀时,会导致链表长度很长,遍历查询的时间复杂为O(n),如果将链表转换为红黑树,那么读写时间复杂度可降低为O(log n)。

6.HashMap扩容机制

首先解释几个与扩容有关的参数:

● capacity容量,即数组长度,默认为16。

● loadFactor加载因子,默认为0.75。

● threshold阈值。阈值=容量×加载因子,默认为12。

一般情况下,当HashMap中包含的键值对数量大于threshold时会触发扩容,容量扩大为原来的两倍。

JDK1.7版本,HashMap底层数据结构为数组和链表,扩容时新建数组,遍历旧数组的每个桶和桶中的每个键值对,将其rehash到扩容后的新数组,然后插入链表。

JDK1.8版本,底层数据结构为数组、链表和红黑树,当HashMap中键值对数量大于阈值、HashMap数组长度为0或升级成红黑树时,数组长度小于6都会触发扩容。扩容流程如下:

● 若数组索引位置对应的数据结构是链表,则生成low和high两条链表,low链表插入新数组中的下标为[当前数组下标],high链表插入新数组中的下标为[当前数组下标+旧数组长度]。

● 若数组索引位置对应的数据结构是红黑树,则生成low和high两棵红黑树,若树中元素个数小于或等于6则会退化成链表。low红黑树插入新数组中的下标为[当前数组下标],high红黑树插入新数组中的下标为[当前数组下标+旧数组长度]。

同一个桶中的键值对属于low还是high,详细说明见“为什么HashMap是二倍扩容,容量总为2的n次幂”。

7.为什么HashMap是两倍扩容,容量总为2的n次幂

原因如下:

(1)在数组长度为2的幂次方时hash % length才等价于hash & (length-1),定位key所在的哈希桶时位运算比求余更高效。

(2)避免破坏hash函数的散列均匀性。如果容量是2的n次幂,那么容量减1对应的二进制形式为***1111,key的hash值与它进行与运算时,结果完全取决于与key的哈希值进行的与运算。

举个例子,HashMap的容量是16,容量减1的二进制是1111,与不同key的hash值的与运算的结果如图1-28所示。

图1-28 HashMap的容量是16时与不同的hash值进行与运算的结果

假如容量为10(非2的n次幂),容量减1的二进制是1001,与不同key的hash值进行与运算的结果如图1-29所示。

图1-29 HashMap的容量是10时与不同的hash值进行与运算的结果

可以看出,当容量不是2的n次幂时,4个不同的哈希值的与运算得到的结果相同,发生了严重的哈希碰撞,这是因为容量减1对应的二进制低位存在0比特位。

(3)HashMap遵循两倍扩容,扩容后元素在新数组的下标为当前数组下标或者当前数组下标加旧数组长度,JDK1.8版本根据这个规律实现快速rehash。

举例说明,扩容前数组的容量为16,元素a和b在扩容前处于同一索引位置,如图1-30所示。

图1-30 HashMap的容量是16时元素a、b在同一个桶内

扩容后的数组长度为32,新数组长度减1对应的二进制只比旧数组长度(oldCap)减1高位多了一个1(00011111和00001111),如图1-31所示。

图1-31 扩容后元素在新数组的下标为当前数组下标或者当前数组下标加旧数组长度

扩容前后,key的哈希值对数组长度求余,对比结果发现,同一个桶中的元素a和b在扩容后的新位置取决于新数组长度减1对应的二进制的最高位,即00010000,其十进制正巧为旧数组长度。总结一下,若(e.hash & oldCap) == 0,则扩容后元素在新数组的下标为当前数组下标;若(e.hash & oldCap ) != 0,则扩容后元素在新数组的下标为当前数组下标加旧数组长度。

1.1.9 JVM垃圾回收机制

面试官提问

● 哪些对象可以作为GC Roots?

● 引用计数用于垃圾回收存在什么问题?什么是环形引用?

● 了解JVM中的三色标记算法吗?

● 分代垃圾回收怎么解决跨代引用问题?什么是卡表,有什么作用?

● 说说CMS垃圾回收过程,存在什么缺点,如何解决?

● 什么是浮动垃圾?

● 说明G1垃圾回收器的重要参数与意义。

● 说明G1垃圾回收过程。

● CMS与G1的区别和优缺点各是什么?

● 垃圾回收算法的标记清除与标记整理的区别是什么?

● ZGC垃圾回收的特点是什么?

● 说明ZGC垃圾回收过程。

● 为什么ZGC可以支持TB级别的堆、最大10ms的STW?使用ZGC为什么会吞吐下降?

1.可作为GC Roots的对象

常选以下对象作为GC Roots:

● 虚拟机栈/本地方法栈引用的对象。

● 静态成员引用的对象。

● 常量引用的对象。

2.引用计数

引用计数法为每个对象引入计数器,当对象被引用时计数器加1,当引用失效时计数器减1,计数器为0则代表对象不可达,即为垃圾。引用计数的算法实现简单,但存在循环引用问题,如图1-32所示,对象obj1与obj2互相引用而产生环,如果obj1与obj2不被其他任何对象引用,那么此时obj1与obj2为垃圾对象,但环形引用的存在使得对应的计数器不为0。

图1-32 引用计数法无法识别循环引用垃圾

此种情况,JVM无法回收它,因此一般不使用它进行垃圾回收(GC)。

3.三色标记算法

三色标记算法是一种并发的可达性分析算法,对象可能被标记为以下3种颜色:

● 黑色:根对象或者该对象与它的子对象均被扫描。

● 灰色:对象本身被扫描,但该对象的子对象未完成扫描。

● 白色:未被扫描到的对象,可达性分析后最终为白色的对象就是垃圾对象。

垃圾收集器扫描对象,根对象被置为黑色,与根节点直接关联的子对象被置为灰色,如图1-33所示。

进一步遍历,将完成了子对象扫描的对象置为黑色,如图1-34所示。

图1-33 垃圾回收开始扫描对象

图1-34 从根节点遍历对象

完成可达性分析,存活对象都被设置为黑色,不可达的对象依然为白色,即为垃圾,如图1-35所示。

上述过程是理想状态,垃圾回收时应用线程也在运行,对象之间的引用关系可能会发生变化,此种情形可能将存活的对象误判为垃圾。

举个例子,假如此刻可达性分析状态如图1-36所示。

图1-35 完成可达性分析

图1-36 当前对象间的引用关系

然后应用程序改变对象间的引用关系,如图1-37所示。需要指出的是,对象A之前被完全扫描,所以是黑色的,现在又指向了白色对象C。

垃圾收集器在图1-37的基础上继续标记扫描剩余对象B,结果如图1-38所示。

图1-37 应用程序改变了对象的引用关系

图1-38 对象C被误判为垃圾

意外发现,虽然对象C不是垃圾,但最终它是白色,根据三色标记的规定白色对象会被认定为垃圾。如何保证应用线程与GC线程同时运行时可达性分析结果的正确性?针对这种情况,垃圾收集器CMS和G1有两种不同的解决方案:

● CMS采用的策略是增量更新(Incremental Update),只要在写屏障里发现一个白色对象的引用被赋值给黑色对象,就记录引用关系,在重新标记阶段以这些引用关系中的黑色对象为根,重新扫描一次,避免对象被漏标。

● G1采用的策略是快照标记(snapshot-at-the-beginning,STAB),在并发标记时所有被改变的对象通过写屏障把所有旧的引用所指向的对象都变成非白色,这种做法会产生浮动垃圾。在图1-37的示例中,应用线程执行B.C = null,写屏障会直接将C对象置为黑色,默认对象C不是垃圾,如果C确实是垃圾,在下一次垃圾回收时才会被清理。

4.跨代引用与卡表

为了便于解释跨代引用,我们假设一个两分代内存模型。经过多轮次的垃圾回收,当前内存中的对象现状如图1-39所示。

图1-39 堆内存对象现状

YGC(Young GC)时经过可达性分析判定年轻代中5、6两个对象已不可达,但其实存在老年代对象2到年轻代对象6的引用,即跨代引用。如果对象5、6在YGC时被回收掉,那么跨代引用的指针就变成了空指针,此时会引起程序运行崩溃。为了避免错误地回收存活对象,YGC时必须把存在跨代引用的老年代对象也设为GC Roots。经过全面的可达性分析,标记存活的对象(黑色)如图1-40所示。

图1-40 标记存活对象需要考虑跨代引用

垃圾回收后存活对象转移至老年代,如图1-41所示。

图1-41 完成垃圾回收

这里有一个问题,即不可能在YGC时扫描一遍老年代找出跨代引用,因此有了记忆集。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,卡表就是记忆集的一种具体实现,通俗地讲它是一个字节数组,每一个数组元素标识一块特定内存区域是否存在跨代引用。当进行垃圾回收时,遍历卡表就能知道哪些内存区域包含跨代指针,把它们加入GC Roots中一并扫描即可。当有其他分代区域中的对象引用了本区域对象时,写屏障切入卡表维护的逻辑。

5.CMS垃圾回收过程

CMS垃圾回收的核心流程如下:

● 初始标记:标记与GC Roots直接关联的对象,进入STW(Stop-the-world,是在垃圾回收算法执行过程中,JVM内存冻结、应用程序停顿的一种状态)状态。

● 并发标记:遍历整个对象图,标记所有存活对象,该阶段GC线程和应用线程同时运行。

● 重新标记:标记并发标记阶段产生的垃圾,进入STW状态。

● 并发清除:清理垃圾,GC线程和应用线程同时运行。

6.CMS的缺点

(1)产生浮动垃圾:CMS并发清除阶段GC线程和应用线程同时运行,该阶段应用线程产生的垃圾CMS无法回收(因为在标记之后产生的),只能等到下次垃圾回收时清理,这部分垃圾称作浮动垃圾。CMS与G1都存在这种问题。

(2)有空间碎片:CMS采用的是标记清除算法,所以会产生空间碎片。

(3)CPU资源敏感,CMS默认启动的回收线程数是(CPU数量+3)/4,所以CPU数量少会导致应用程序变慢,吞吐降低。

(4)可能出现并发模式失败。CMS的目标就是在回收老年代对象时与用户线程并发运行,如果垃圾回收时应用线程向老年代请求分配的空间超过预留空间,就会抛出“concurrent mode failure”(并发模式失败),此时CMS会暂停用户线程的执行,启用Serial Old收集器来重新进行老年代的垃圾收集。解决这个问题的办法如下:

● 执行一定次数Full GC(标记清除)后进行一次标记整理算法压缩内存空间,减少内存碎片。CMS提供了以下参数来进行控制:

◆ -XX:UseCMSCompactAtFullCollection

◆ -XX:CMSFullGCBeforeCompaction = xx

● 调低触发CMS GC的阈值,CMS GC触发主要由CMSInitiatingOccupancyFraction参数决定,默认情况下老年代已用空间为92%时才触发CMS GC,调小阈值提前触发GC,保证老年代有足够的空间,可以在一定程度上避免出现并发模式失败。

7.G1重要参数及其意义

● -XX:+UseG1GC:启用Garbage First(G1)垃圾收集器。

● -XX:NewRatio = n:设置年轻代与老年代在堆内存中的占比,默认值为2。

● -XX:SurvivorRatio = n:设置年轻代eden与survivor空间的占比,默认值为8。

● -XX:MaxTenuringThreshold = n:对象晋升老年代的最大阈值,默认值为15。

● -XX:G1HeapRegionSize = n:设置G1 Region大小。

● -XX:MaxGCPauseMillis = n:期望最大的GC暂停时间,JVM会尽可能地满足预期。

● -XX:ParallelGCThreads = n:JVM在并行GC时参与垃圾回收的线程数。

8.G1垃圾回收过程

G1垃圾回收的主流程与CMS相似,但存在以下区别:

● 垃圾回收时CMS采用标记清除算法,G1采用标记整理算法,降低了空间碎片。

● G1将整个堆空间划分成大小相等的Region,Region大小在1~32MB,并且为2的n次幂,一种典型的内存空间分布如图1-42所示。除了年轻代(E,S)和老年代(O),G1有专门分配大对象的Region,叫作Humongous,一个对象的大小超过了Region的一半就可以称为大对象。

图1-42 G1内存空间分布

● 可预测的停顿时间,G1会通过一个合理的计算模型评估回收每个Region所获得的空间大小,以及回收所耗费的时间,优先选择价值最大的Region进行回收,努力在用户指定的停顿时间内完成垃圾回收。

9.ZGC的特点

ZGC(The Z Garbage Collector)是JDK11推出的一款追求极致低延迟的新一代并发垃圾收集器,其主要特点如下:

(1)支持TB级别的堆,最大10ms STW。

(2)单代。

(3)基于Region设计的垃圾回收器,不同于G1垃圾回收器每个Region大小完全一样,ZGC Region的大小分为3种:2MB、32MB和n×2MB,如图1-43所示。

图1-43 ZGC内存空间分布

(4)部分压缩。ZGC在垃圾回收时只选择一部分Region使用标记压缩算法进行垃圾回收,GC时间可控。

(5)颜色指针。传统垃圾回收器的GC信息存储在对象头中,而ZGC的GC信息保存在对象指针中,比如对象是否被GC线程移动过等。

(6)读屏障。通俗地讲,读屏障是JVM向应用程序插入一段子程序的技术。当应用线程从堆中读取对象引用时,就会触发执行这段插入的代码,我们通过读屏障可以插入额外的处理逻辑。在ZGC中,通过颜色指针和读屏障技术能使GC线程在转移对象的过程中保证应用线程访问正确的对象地址,实现并发转移。如果对象发生转移但对象地址未更新,那么应用线程访问旧地址会带来不可预测的错误。为了避免这种现象,可使应用线程访问对象触发读屏障,通过颜色指针若发现对象被移动了,就修正指针到对象的新地址上,这样可确保应用线程永远访问更新后的有效指针。并发转移避免了ZGC在对象转移时的STW。

(7)ZGC参数简洁,性能调优简单。

10.ZGC垃圾回收过程

ZGC垃圾回收过程分为以下几个阶段:

(1)初始标记STW。标记与GC Roots直接关联的对象,如图1-44所示。

图1-44 初始标记

(2)并发标记。并发标记用于遍历对象图做可达性分析,以扫描垃圾,如图1-45所示。

图1-45 并发标记,可达性分析搜索存活对象

(3)重新标记STW。标记并发标记期间产生的垃圾,ZGC为了保证低延迟,若该阶段执行时间超出预期,则会再次进入并发标记阶段。

(4)并发预备重分配。该阶段分析选择出需要清理的Region,这些Region组成重分配集(Relocation Set)。选择图1-46中间的两个Region进行清理。

(5)并发重分配。将重分配集中存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。如图1-46所示,4、7、8是垃圾对象,陆续将5、6、9对象复制到新的Region。

图1-46 并发重分配复制存活对象至新Region

(6)并发重映射,修正被移动对象的指针。并发重分配移动了对象,对象地址发生了变化,但引用该对象的指针并未被修正,颜色指针和读屏障技术可以保证应用线程在读取移动对象时通过转发表访问到移动后的对象地址,ZGC避免了移动对象的STW,这是它实现STW < 10ms的重要原因,但是读屏障降低了系统吞吐(相当于把整体GC STW平摊到了每次访问的对象上),图1-47是修正指针后的理想情况。需要说明的是,在下一次垃圾回收的并发标记阶段才会顺便修正上次垃圾回收移动对象的指针。

图1-47 并发重映射修正移动对象的指针

1.1.10 零复制

面试官提问

● 什么是零复制?零复制技术解决了什么问题?

● 说一下实现零复制的几种方式?

从磁盘读取数据写入网卡进行发送,传统的IO方式会进行4次数据复制和4次上下文切换,执行过程如图1-48所示。

图1-48 传统IO方式完成数据发送需要4次上下文切换和4次数据复制

说明如下:

(1)用户进程执行read系统调用,上下文从用户态切换为内核态。

(2)DMA控制器把数据从磁盘复制到内核空间的读缓存中。

(3)CPU把内核空间读缓冲区的数据复制到用户缓冲区,上下文从内核态切换为用户态,read系统调用返回。

(4)用户进程执行write系统调用,上下文从用户态切换为内核态。

(5)CPU将用户缓冲区中的数据复制到socket缓冲区。

(6)DMA控制器将socket缓冲区的数据复制到网卡。

(7)write系统调用返回,上下文从内核态切换为用户态。

零复制(也叫零拷贝)技术可以减少上下文切换与数据复制的次数,常见的零复制技术如下:

1.mmap+write零复制技术

mmap + write零复制技术将内核读缓冲区地址和用户缓冲区地址进行映射,实现了内核缓冲区与应用程序内存的共享,避免了一次数据从内核读缓冲区到用户缓冲区的复制,整个过程出现4次上下文切换和3次数据复制,具体执行流程如图1-49所示。

图1-49 mmap+write方式完成数据发送需要4次上下文切换和3次数据复制

说明如下:

(1)用户进程执行mmap系统调用,上下文从用户态切换为内核态。

(2)DMA控制器把数据从磁盘复制到内核读缓冲区。

(3)mmap系统调用返回,上下文从内核态切换为用户态。

(4)用户进程执行write系统调用,上下文从用户态切换为内核态。

(5)CPU将内核读缓冲区的数据复制到socket缓冲区。

(6)DMA控制器将socket缓冲区的数据复制到网卡。

(7)write系统调用返回,上下文从内核态切换为用户态。

2.SendFile零复制技术

SendFile零复制技术通过SendFile系统调用,将数据直接在内核空间进行I/O传输,避免了数据在用户空间和内核空间之间的复制,整个过程发生2次上下文切换和3次数据复制,具体执行流程如图1-50所示。

图1-50 SendFile方式完成数据发送需要2次上下文切换和3次数据复制

说明如下:

(1)用户进程执行SendFile系统调用,上下文从用户态切换为内核态。

(2)DMA控制器把数据从磁盘复制到内核读缓冲区。

(3)CPU将内核读缓冲区中的数据复制到socket缓冲区。

(4)DMA控制器将socket缓冲区的数据复制到网卡。

(5)SendFile系统调用返回,上下文从内核态切换为用户态。

3.SendFile+DMA scatter/gather零复制技术

SendFile+DMA Scatter/Gather零复制技术是在DMA复制的基础上加入scatter/gather操作,DMA根据文件描述信息将数据从内核读缓冲区直接复制到网卡,相比之前SendFile减少了一次CPU数据复制,整个过程发生了2次上下文切换和2次数据复制,具体执行流程如图1-51所示。

图1-51 DMA scatter/gather方式完成数据发送需要2次上下文切换和2次数据复制

说明如下:

(1)用户进程执行SendFile系统调用,上下文从用户态切换为内核态。

(2)DMA控制器把数据从磁盘复制到内核读缓冲区。

(3)CPU把内核读缓冲区中的文件描述信息发送到socket缓冲区。

(4)DMA控制器根据文件描述信息直接把数据从内核缓冲区复制到网卡。

(5)sendFile系统调用返回,上下文从内核态切换为用户态。

1.1.11 TCC柔性事务

面试官提问

● 简述TCC事务模型。

● TCC在业务中使用的场景有哪些?

● 如何设计与实现TCC框架?

TCC(Try-Confirm-Cancel)是预处理(Try)、确认(Confirm)和取消(Cancel)3种操作的缩写,事务执行流程如图1-52所示。

● Try:对业务系统做检测和资源预留。

● Confirm:确认提交。一般来说Try成功,Confirm在幂等重试下也会保证成功。

● Cancel:当出现错误时取消执行,幂等重试保证最终释放Try阶段预留的资源。

图1-52 TCC柔性事务执行流程

1.1.12 CAP与BASE

面试官提问

● 什么是CAP理论?

● 什么是BASE理论?

● 在你的业务场景中是怎么运用或者体现CAP的,可以举个例子吗?

CAP理论是指一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三者中的两个。

● 一致性:所有节点在同一时间数据完全一致。

● 可用性:服务可用且系统在正常时间内返回结果。

● 分区容错性:分布式系统在遭遇某节点或网络分区故障时,仍然能够对外提供满足一致性和可用性的服务。

BASE理论由CAP演化而来,其基本内涵是基本可用(Basically Available)、软状态(Soft State)和最终一致性(Eventual Consistency)。

● 基本可用:系统出现故障时,允许服务合理降级,损失部分可用性,保证核心场景链路可用。比如搜索服务响应时间1秒退化为3秒;秒杀抢购请求激增,部分用户被引导至降级页面。

● 软状态:允许系统中的数据存在中间状态,允许多个不同节点的数据副本存在同步延时,但这些不影响系统的整体可用性。比如消息队列为了保证高可用和数据不丢失,在写入数据时并不要求主备所有节点消息持久化后才返回ACK(确认字符)。

● 最终一致性:经过一定时间后,系统中所有数据副本最终能够达到一致的状态。

1.1.13 Volatile关键字

面试官提问

● 了解Java内存模型吗?

● Volatile关键字的作用是什么?

● Volatile能保证原子性吗?

● 说一下Volatile的应用场景。

Java内存模型如图1-53所示,变量存放在主存中,每个线程都有自己的工作内存。线程运行时将主存中的数据复制到工作内存中,对数据的任何操作都是基于自己的工作内存,之后再将更新的数据刷新到主存。注意当前线程不能直接操作主存和其他线程工作内存中的数据。

图1-53 Java内存模型

线程A与线程B并发运行时可能出现这种情况,如果线程A修改了工作内存中的数据且还未同步到主存,那么线程B读取的主存中的数据就不是最新值。当变量被Volatile关键字修饰后,任何线程对它的写操作都会强制将工作内存中的最新值刷新到主存,同时使其他线程工作内存中的变量缓存无效,这样其他线程使用缓存时就会重新到主存中读取最新值,因此Volatile保证了立即可见性。

1.1.14 双亲委派类加载器

面试官提问

● 类加载器有哪些?

● 请说明类加载的过程。

● 什么是双亲委派模型,JVM为什么这么设计?

双亲委派机制是指类加载器收到类加载请求时,首先将请求委派给父类加载器,最终所有类的加载都会委托给顶层父类加载器,即启动类加载器(Bootstrap ClassLoader),当父类加载器无法完成这个请求时,子类加载器才会尝试去加载。这种类加载机制能够保证多加载器加载某个类时,最终都由同一个加载器加载(不同类加载器生成的对象实例会被JVM认定为不同类型的对象)。类加载的流程如图1-54所示。

图1-54 双亲委派类加载机制

● 启动类加载器(Bootstrap ClassLoader):负责加载<JAVA_HOME>/lib目录下被虚拟机识别的类库,如java.util.**、java.io.**等。

● 扩展类加载器(Extention ClassLoader):负责加载<JAVA_HOME>\lib\ext目录下的类库。

● 应用类加载器(Application ClassLoader):负责加载用户路径(ClassPath)上所指定的类库,编写的代码以及引用的第三方JAR包都是由它来加载的。

● 自定义类加载器(Custom ClassLoader):为了某些特殊用途实现的自定义加载器,比如为了防止代码被反编译,可以将编译后的代码加密,然后实现自定义的类加载器,解密还原后再进行类的加载。

1.1.15 从微服务到Service Mesh

面试官提问

● 什么是Service Mesh?

● 为什么需要Service Mesh,它解决了什么痛点?

微服务架构使用RPC框架实现服务间的通信,如图1-55所示。

为了提升服务治理能力,中间件团队会持续开发RPC框架的新能力,比如负载均衡(轮询、随机、一致性哈希等)、收集接口响应时间用于监控告警等,如图1-56所示。中间件团队的这些基础设施优化需要业务方的配合并频繁升级RPC客户端。

图1-55 服务A调用服务B

图1-56 RPC框架引入负载均衡、监控告警等非业务性功能

如果要做全链路追踪,RPC客户和服务器端都需要升级,如图1-57所示。

图1-57 RPC框架引入全链路追踪等非业务性功能

可以看到,基础设施和业务耦合在一起,一方面业务方需要关注非业务需求,不断配合中间件团队升级客户端;另一方面还需要不断学习RPC框架本身的新特性,因此业务开发不能集中精力在业务本身上。为了解决这样的痛点,将服务拆分成两个进程(见图1-58):一个进程实现业务逻辑(Biz);一个进程通过代理(Proxy)实现基础设施能力,比如负载均衡、监控告警、服务发现、全链路追踪等。

图1-58 引入代理实现远程通信

Biz和Proxy之间是本地通信,具有同生共死的关系。Biz之间的通信要通过Proxy完成,Proxy之间是远程通信,这样一来就实现了“业务是业务,技术是技术”的解耦。如果所有服务都引入如上所说的代理实现远程通信,那么整个架构会演变为如图1-59所示的形式,看上去整个服务集群变成了服务网格,这就是Service Mesh(服务网格)的由来。

图1-59 Service Mesh

1.1.16 进程、线程与协程

面试官提问

● 进程、线程与协程之间的区别是什么?

进程是系统进行资源调度和分配的基本单位;线程是CPU调度和分配的基本单位;协程比线程更加轻量级,它是一个特殊的函数,该函数可以在某个地方被挂起,也可以在挂起处继续执行。

从包含关系来讲,一个进程可以拥有多个线程,一个线程可以拥有多个协程,如图1-60所示。

从上下文切换方面来讲,进程和线程的切换者都是操作系统,切换时机也由操作系统决定,用户无感知;协程的切换者是用户(编程者),切换时机也由用户程序决定。

从并行运行方面来讲,多个进程或一个进程内的多个线程是可以并行运行的,一个线程的多个协程是串行执行的。

图1-60 进程、线程、协程之间的关系

1.1.17 强引用、软引用、弱引用、虚引用

面试官提问

● 什么是强引用、软引用、弱引用、虚引用?它们之间有什么区别?

● 请说明强引用、软引用、弱引用和虚引用的典型使用场景?

1.强引用(StrongReference)

当内存空间不足时,即使JVM抛出OutOfMemoryError错误,垃圾回收器也不会回收具有强引用的对象。类似Object object = new Object()这种使用new操作符创建的对象,将它赋值给一个变量,这个变量就是指向该对象的一个强引用。

2.软引用(SoftReference)

若对象只具有软引用,则内存空间充足时垃圾回收器不回收它,而当堆空间不足时,对象就会被回收。软引用可以和一个引用队列(Reference Queue)联合使用,如果软引用指向的对象被垃圾回收,那么JVM会把这个软引用加入与之关联的引用队列中。

3.弱引用(WeakReference)

若对象只具有弱引用,则垃圾回收器就会立即回收它的内存。弱引用可以和一个引用队列联合使用,如果弱引用所引用的对象被垃圾回收,那么JVM会把这个弱引用加入与之关联的引用队列中。

经常使用的Caffeine缓存就利用了对象软引用、弱引用的特性实现了基于引用的内存回收策略。

4.虚引用(PhantomReference)

虚引用与软、弱引用的区别在于它必须和引用队列联合使用。当垃圾回收器准备回收某对象时,发现它还有虚引用,那么在回收对象内存前,JVM会把这个虚引用加入与之关联的引用队列中。它的主要作用是跟踪垃圾回收过程。

1.1.18 ThreadLocal

面试官提问

● 谈谈你对ThreadLocal的理解(实现原理)。

● 为什么会出现内存泄漏?

● 给出一个ThreadLocal的最佳实践。

ThreadLocal为每个使用该变量的线程提供一个副本,每个线程都可以独立修改自己的副本,而不会与其他线程的副本产生冲突。

一些情况下,ThreadLocal会出现内存泄漏,如图1-61所示,每个线程(Thread)拥有一个映射表(ThreadLocalMap),key是ThreadLocal实例自己,value是用户存储的对象。需要特别说明的是,ThreadLocalMap使用ThreadLocal的弱引用作为key(图中虚线代表弱引用),当ThreadLocal没有被外部强引用指向,垃圾回收时该ThreadLocal会被垃圾回收掉,那么ThreadLocalMap中就出现了key为null的Entry,若当前线程持续运行,这些key为null的Entry的value就一直存在一条如图1-61所示的强引用链(Thread Reference→Thread→ThreaLocalMap→Entry→value)中,从而产生了内存泄漏(该对象不会被访问到,但又无法被垃圾回收)。

图1-61 ThreadLocal内部引用链

ThreadLocal的最佳实践:使用完ThreadLocal后手动调用remove()方法清除数据。

1.1.19 线程池

面试官提问

● 使用线程池可带来哪些好处?

● 线程池核心参数的解释。

● 介绍几种饱和策略。

● 熟悉线程池的状态与生命周期吗?

● 设置线程数的一般方法有哪些?

1.使用线程池的好处

使用线程池有如下好处:

● 复用已创建的线程,避免频繁创建和销毁线程带来的性能开销。

● 提高了线程的可管理性,避免了无节制创建线程而耗尽系统资源。

● 提高任务响应速度,任务到达时可立即执行,节省了线程创建的时间。

2.线程池ThreadPoolExecutor的设计与实现

ThreadPoolExecutor的构造方法如图1-62所示。

图1-62 ThreadPoolExecutor构造方法

从图中可知,ThreadPoolExecutor的构造参数有corePoolSize、maximumPoolSize、keepAliveTime、TimeUnit、workQueue和handler。

(1)corePoolSize与maximumPoolSize:

● 若当前线程数小于核心线程数(corePoolSize),则创建新线程执行任务。

● 若当前线程数大于核心线程数(corePoolSize),则将任务放入队列中排队执行。

● 若队列已满,当当前线程数小于最大线程数(maximumPoolSize)时,创建新线程执行任务;当当前线程数大于最大线程数(maximumPoolSize)时,执行饱和策略。

向线程池提交的任务的执行流程如图1-63所示。

图1-63 任务提交后的执行流程

(2)KeepAliveTime与TimeUnit:若线程空闲时间超过KeepAliveTime个时间单位(TimeUnit),线程就会被销毁,直到剩下corePoolSize个线程为止。

(3)BlockingQueue<Runnable> workQueue:提交的任务在队列中排队执行。

(4)RejectedExecutionHandler handler:当队列已满且线程数达到maximumPoolSize时,继续提交任务就会触发饱和策略。常见的饱和策略有:

● AbortPolicy丢弃任务并抛出RejectedExecutionException异常。

● DiscardPolicy丢弃任务,但不抛出异常。

● DiscardOldestPolicy丢弃队列中最旧的任务,并将新任务加入队列。

● CallerRunsPolicy:由提交任务的线程执行刚提交的任务

3.线程池的状态与生命周期

线程池的生命周期如图1-64所示,其中每种状态解释如下:

● RUNNING:可以接收新提交的任务,也会处理队列中的任务

● SHUTDOWN:不接收新提交的任务,仅可以处理队列中的任务。

● STOP:不接收新任务,也不处理队列中的任务,且会中断正在处理的任务。

● TIDYING与TERMINATED:当所有任务都终止,有效线程数为0时,线程池就处于TIDYING状态。线程池状态在转换为TIDYING时会执行钩子方法terminated(),方法执行结束后,线程池的状态会由TIDYING变为TERMINATED。

图1-64 线程池状态流转

4.如何设置线程池核心线程数量

● 对于CPU密集型任务,核心线程数设置为CPU核数+1。

● 对于I/O密集型任务,核心线程数设置为2×CPU核数。

这是一个不会错得很离谱的经验值,由于应用中可能存在多个线程池,以及具体场景的不同,因此合理的线程数需要压测才能决定。

1.1.20 控制访问某个资源或方法的并发数

面试官提问

● 如何控制访问某个资源或者方法的并发数?

控制访问某个资源或者方法的并发数有以下两种方法:

(1)使用线程池异步处理任务,通过设置线程池最大线程数来限制执行方法的最大并发数,示例代码如下:

(2)使用信号量semaphore控制同时访问共享资源的线程个数,示例代码如下:

控制态输出如下:

     Thread No - 0 acquire semaphore !
     Thread No - 1 acquire semaphore !
     Thread No - 2 acquire semaphore !
     Thread No - 0 release semaphore ,after 5s.
     Thread No - 2 release semaphore ,after 5s.
     Thread No - 1 release semaphore ,after 5s.
     Thread No - 3 acquire semaphore !
     Thread No - 4 acquire semaphore !
     Thread No - 5 acquire semaphore !
     Thread No - 3 release semaphore ,after 5s.
     Thread No - 4 release semaphore ,after 5s.
     Thread No - 5 release semaphore ,after 5s.
     Thread No - 7 acquire semaphore !
     Thread No - 8 acquire semaphore !
     Thread No - 6 acquire semaphore !
     Thread No - 7 release semaphore ,after 5s.
     Thread No - 6 release semaphore ,after 5s.
     Thread No - 8 release semaphore ,after 5s.

思考一下,在分布式环境下,如何控制某一个资源被并发消费的线程数,是否能实现?

1.1.21 Happens-Before

面试官提问

● 谈谈几种Happens-Before原则?

Happens-Before关系描述了前一个操作的结果对后续操作的内存可见性,比如操作X Happens-Before Y,那么X的结果对Y可见。具体原则如下:

(1)在同一个线程中,写在前面的操作Happens-Before后面的操作。

(2)同一把锁的解锁操作Happen-Before加锁操作。

(3)对于一个用Volatile修饰的变量,写操作Happen-Before该变量的读操作。

(4)若X Happens-Before Y,Y Happens-Before Z,那么X Happens-Before Z。

(5)若线程A调用线程B的start()方法来启动线程B,则start()操作Happens-Before线程B中的任意操作。

(6)对线程interrupt()方法的调用Happen-Before被中断线程检测到中断事件的发生。

(7)若线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作Happens-Before线程A从ThreadB.join()操作成功返回。

(8)对象的初始化完成Happens-Before它的finalize()方法调用。

1.1.22 对Java的理解

面试官提问

● 谈谈你对Java的理解。

● 可以谈谈Java与Go的区别吗?

(1)Java是一种跨平台的语言,一次编译到处运行。

(2)Java提供了垃圾回收机制,不用手动释放内存。

(3)Java具有独特的语言特性:泛型、反射、Lamda表达式等。

(4)Java是面向对象的语言,其三大特征是封装、继承、多态。

(5)Java原生自带一些并发库、网络库、集合类等,便于上层应用开发。

1.1.23 缓存穿透、雪崩、击穿

面试官提问

● 什么是缓存穿透、缓存雪崩与缓存击穿?它们之间有什么区别?

● 怎样解决缓存穿透、雪崩与击穿?

1.缓存穿透

穿透是指查询一个底层存储不存在的key,每次查询时缓存中都不存在该数据,进而穿透到底层存储进行二次查询。流量高峰时,缓存失去了意义,底层存储可能被打挂。

解决方案:

● 使用布隆过滤器拦截对底层存储不存在的key的读请求,减轻对底层存储的查询压力。

● 缓存空值,查询底层存储返回空时,将空值缓存起来,下次同样的查询就可避免穿透。

● 缓存预热,有些场景可以认为缓存不存在,底层数据也不会存在,比如广告特征数据从HBase预热到Redis中,Redis未命中,没必要穿透查询HBase。

2.缓存雪崩

雪崩是指大量的key过期时间设置得相同或者近似,导致某一时刻缓存同时失效,请求全部打到底层存储,引起系统崩溃。

解决方案:在缓存原有失效时间基础上加一个随机值,将失效时间随机打散。

3.缓存击穿

击穿是指缓存在某时刻过期,恰好此时对该key有大量的并发请求。击穿和雪崩的区别在于:缓存击穿是同一时刻对某一个key进行大量请求但没命中缓存,缓存雪崩是同一时刻对不同的过期的key进行大量请求。

解决方案:

● 抢锁成功的线程去底层存储获取数据并放入缓存,后续其他线程等待读取缓存中的数据。

● 一些场景允许这样的假设:缓存中没有数据,底层存储也没有。

1.1.24 虚拟机与容器对比

面试官提问

● 说明一下虚拟机与容器的区别?

● 思考一下容器会不会取代虚拟机?

在虚拟机和容器之前,用户无法实现在同一台物理机上运行不同操作系统下的应用,也很难做好应用程序之间的隔离。基于硬件的虚拟化使得在同一台物理机上同时运行多个独立的操作系统实例成为可能;基于容器的虚拟化可以在同一个操作系统实例中运行多个应用程序,同时保持应用程序之间的隔离。虚拟机和容器的对比如表1-3所示。

表1-3 虚拟机和容器的对比

1.1.25 保障系统高可用的一般方法

面试官提问

● 提到稳定性保障,你脑海中想到了什么?是GC优化、慢SQL优化、缓存优化、扇出优化,或者单机压测、全链路压测,或者预案降级、限流、熔断,还是监控、报警?

重大项目稳定性保障的一般步骤可按图1-65来考虑。

图1-65 重大项目稳定性保障的一般步骤

总的来说:

● 明确核心功能系统容量、瓶颈点。

● 提前预估系统的流量模型、日常、热点事件以及活跃周期。

● 预期内和预期外对系统的控制能力。

具体来讲:

(1)在需求评审阶段,与产品和运营讨论需求的背景、意义、价值,追问需要的系统容量(TPS),确定需求上线后的日、周、月的周期性流量模型。

(2)在项目开发阶段,注意线程安全、幂等重试、异常处理,保证功能的完整性、正确性。使用合理的设计模式、抽象复用来保证系统的可扩展性,使用合理的领域划分与系统解耦来保证资源隔离,避免风险传播。

(3)在系统优化阶段,关注系统瓶颈点,比如是否存在依赖反转(高优先级服务依赖低优先级服务),是否存在烂SQL、热点key、大key、缓存穿透,以及潜在的GC问题。

(4)最后进行全链路压测,探查系统容量峰值,根据压测结果设置合理的限流熔断阈值和服务降级策略,做好监控报警。必要的话可执行故障注入,观察系统表现,进行故障预案演练。

1.1.26 伪共享

面试官提问

● 什么是伪共享(false sharing)?

● 如何避免伪共享?

● 数组的访问速度为什么比链表更快一些?在你的代码实现中会不会考虑伪共享问题?你阅读的哪些框架有特别关注到伪共享问题?

● 伪共享是发生在多线程并发执行情况下的一种性能问题。

由于CPU运算速度与内存读写速度不匹配,因此引入CPU高速缓存来解决该问题。CPU高速缓存分为L1、L2、L3级,多级缓存架构如图1-66所示。

图1-66 CPU多级缓存L1、L2、L3

从L1到L3再到RAM,访问速度越来越慢,但存储容量越来越大。L1、L2被单核独享,L3被CPU的所有核共享。当CPU访问数据时,由近及远先从L1上加载,若L1上数据不存在则访问L2,L2上数据依然不存在则继续访问L3,直至RAM,甚至硬盘。CPU以缓存行为单位读取数据,一个缓存行的大小一般为64字节,即使CPU只需要读取1字节数据,也要读取包含该数据的整个缓存行。顺便说明一下,数组中的数据一般是连续存储的,访问时相邻元素可能被高速缓存,因此数组的访问速度比链表更快一些。

如图1-67所示,变量V1和V2在同一个缓存行中,两个线程并行运行在Core1和Core2上,假设Core1修改V1,Core2读取V2,当Core1修改完成后,Core2对应缓存行失效,Core2读取V2时仍然需要,因为自己不关心的数据变更而重新加载整个缓存行,影响了自己的访问性能。更糟糕的场景是,两个线程同时修改V1和V2,Core1上的线程对V1执行写操作会导致Core2对应的缓存失效,Core2上的线程对V2执行写操作会导致Core1对应的缓存失效,写与写之间的互相影响导致对方缓存失效,数据再次访问时需要经过CPU三级缓存。开头说引入CPU高速缓存是为了解决CPU运算速度与内存读写速度不匹配的问题,但从这些场景来看并没有高效利用高速缓存。这就是CPU缓存伪共享问题,常常使用缓存行填充来解决伪共享问题。

图1-67 缓存行带来伪共享问题

1.1.27 Caffeine缓存高性能分析

面试官提问

● Caffeine是怎么统计词频的?

● 为什么Caffeine缓存命中率高?

● 为什么Caffeine缓存读写速度快?

缓存在日常开发中被广泛使用,Caffeine是目前为止命中率最高、读写速度最快的高性能缓存框架,下面逐一说明它的独特设计。

1.Caffeine缓存淘汰策略采用W-TinyLFU,缓存命中率显著提高

缓存淘汰算法主要有LRU和LFU两种,Caffeine作者设计了W-TinyLFU缓存淘汰策略,解决了LRU和LFU算法的不足。Caffeine的优点可通过表1-4来了解。

表1-4 缓存淘汰策略算法对比

2.Caffeine统计词频采用count-min-sketch方法,节省内存

就统计词频方面来看,Caffeine统计词频时会对key进行哈希得到index,然后在对应位置上加1,每个key一般都要重复上述操作进行多次哈希。查询时取最小频次作为预估值。count-min-sketch统计词频的思想来自布隆过滤器,如图1-68所示。

图1-68 count-min-sketch统计词频

count-min-sketch统计词频的方法牺牲了统计的精确性,节省了内存空间。占用空间越小,hash碰撞越严重,频次统计误差越大。

3.Caffeine保鲜机制,淘汰历史热点key

Caffeine有一个保鲜机制(Freshness Mechanism):当整体的统计计数(当前所有记录的频率统计之和)达到某一个值时,所有记录的频率均除以2。这样可以解决历史热点key占用内存的问题,即删除过往频率很高但之后不经常访问的缓存,同时对新key的接纳十分友好。

4.Caffeine窗口缓存,提高突发流量的缓存命中率

Caffeine通过测试发现,TinyLFU在面对突发性的稀疏流量(Sparse Bursts)时表现很差,因为TinyLFU算法在新的记录(New Items)还没来得及建立足够的频率之前就被删除了,这就使得命中率不高。通过设计名为Window Tiny LFU(W-TinyLFU)的策略解决了这个问题。如图1-69所示,Caffeine将整个内存空间划分为两个部分,即窗口缓存(window cache)和主缓存(main cache),窗口缓存的大小初始为总缓存大小的1%,主缓存的大小为总缓存大小的99%。窗口缓存采用LRU淘汰策略而没有任何接纳策略,主缓存使用SLRU淘汰策略和TinyLFU接纳策略,因此窗口缓存可以应对突发流量。

图1-69 Caffeine引入窗口缓存应对突发流量

5.Caffeine缓存驱逐与淘汰策略

缓存空间一般是有限的,无法将所有的缓存key都写入内存,在合适的时机选择合理的key进行淘汰是必要的,缓存的过期时间与淘汰策略又直接影响缓存访问的命中率,下面详细介绍Caffeine缓存key的过期时间和淘汰策略。

1)基于缓存空间大小驱逐缓存

窗口缓存占用总缓存大小的1%左右,主缓存占用99%。Caffeine可以根据工作负载特性动态调整窗口缓存和主缓存的比例。主缓存内部包含两个部分,一部分为Protected区域,存储热数据,占用主缓存80%空间;另一部分是Probation区域,存储冷数据,占用主缓存20%空间。缓存淘汰的过程:新添加的数据首先放入窗口缓存中,如果窗口缓存满了,则把窗口缓存淘汰的数据转移到主缓存Probation区域中。如果这时主缓存也满了,则从主缓存的Probation区域淘汰数据,把这条数据称为受害者,从窗口缓存淘汰的数据称为候选人。接下来候选人和受害者通过TinyLFU记录的访问频率来决定去留,如图1-70所示,具体过程如下:

(1)如果候选人的频率大于受害者的频率,则淘汰受害者。

(2)如果候选人的频率小于或等于5,则淘汰候选人。

(3)其余情况随机删除。

图1-70 窗口缓存淘汰的候选人和主缓存淘汰的受害者通过TinyLFU决定去留

2)指定时间后缓存过期

缓存key过期时间固定的场景:如图1-71所示的先进先出(FIFO)双端队列,由于所有key过期时间是固定的常数,因此最先放进队列的数据最先过期,处理过期数据时,只需要从队列头部依次检查缓存是否过期即可。

图1-71 利用FIFO双端队列实现缓存key固定时间过期

缓存key任意时间过期的场景:每个缓存项过期时间不一致,采用经典时间轮(TimingWheel)的方式实现缓存key任意时间过期,在消息队列、RPC框架等中间件中都存在它的应用。以单层时间轮为例,假设时间轮中有16个槽位,每个槽位代表1s,假设现在有3个任务,分别是任务A(1s后运行)、B(5s之后运行)、C(17s之后运行),则这3个任务在时间轮所处的槽位如图1-72所示,可以看到任务A被放到了槽位3,任务B被放到了槽位7,任务C被放到了槽位3对应的链表。当时间轮转动到对应的槽时,就会从槽中取出任务并判断是否需要执行。同时可以发现有一个剩余周期的概念,这是因为任务C的执行时间为17s后,超过了时间轮的周期16秒,所以可以标记它的剩余周期为1。当时间轮第一次转动到它的位置时,发现它的剩余周期为1。表示还没有到要处理的时间,将剩余周期减1,时间轮继续转动;当下一次转动到C任务位置时,发现剩余周期为0,表示到了需要处理该定时任务的时间。

图1-72 时间轮实现任意时间过期的缓存key

一般来说,时间轮底层是一个环形数组,而数组中每个元素都存放了一条链表,链表中封装了很多延时任务。当然时间轮可以设计多层结构。时间轮的优点是可以使用一个线程驱动指针旋转,监控很多的定时任务是否到期;缺点是时间精度由最小时间间隔确定,图1-72举例是1秒。

留一个小问题:具体任务能否使用驱动指针移动的线程来执行?

3)弱引用淘汰策略

垃圾回收器一旦发现了只具备弱引用的对象,无论当前内存空间是否足够,都会回收它的内存。弱引用能够和一个引用队列联合使用,若是弱引用所引用的对象被垃圾回收,则Java虚拟机就会把这个弱引用加入与之关联的引用队列中,Caffeine异步线程清理引用队列,代码段如图1-73所示。

图1-73 Caffeine异步线程清理引用队列

6.Caffine读写速度为什么这么快

Caffeine分别使用RingBuffer和MPSC来记录读、写事件,然后异步处理缓存维护性的工作,实现高性能,如图1-74所示。

● 利用写日志先行(WAL)的思想,异步处理缓存读或写操作后繁复的维护工作,比如统计频次、缓存key的过期、调整窗口缓存的大小等。

● Caffeine作者认为缓存读多写少,且写事件比读更重要,因此使用RingBuffer数组来记录缓存读事件,使用单个MPSC队列记录缓存写事件,并且写入Ringbuffer的数据允许丢失,但MPSC队列满了以后同步阻塞,执行写缓存后的维护性任务。MPSC和RingBuffer均基于数组,利用CAS避免锁竞争,利用缓存行的填充避免伪共享,从而保障了整体的高性能。

图1-74 Caffeine异步处理缓存读写后的维护工作

1.1.28 请自我介绍一下

面试官常要求面试人员做自我介绍,因此,面试人员在面试之前一定要做好准备。建议在面试前打好腹稿,做到言简意赅、语速和缓、思路清晰。自我介绍一般包含以下信息:

(1)姓名与学校,很有可能面试官是你的校友。

(2)专业与学术成果:论文、专利或者专著,在核心期刊上发表论文还能够加分。

(3)实习/工作经历,大厂履历会让面试官将对你的认识和评价放到一个新的标准里。

(4)自己的项目,一般按照以下步骤展开,后续有专门章节详细展开说明。

● 背景

● 项目领域划分与自己的位置

● 挑战

● 取得的可以量化的结果

● 不足之处与待改进的地方

仅凭面试很难完全准确地判断求职者的能力与价值,面试官往往还会参考一些硬核指标,比如学历背景、大厂履历、高优绩效、核心期刊。这些指标在你的自我介绍中应该突出展现出来,想象一下,求职者是南京大学的研究生,在校期间发表过一篇KDD·CUP(国际知识发现和数据挖掘竞赛)论文,在亚马逊实习过一段时间,那么这位求职者接下来的面试过程可能就不那么重要了。