跳至主要內容

腾讯云后端总结的面试15问

xlc520JavaJava大约 33 分钟约 9815 字

腾讯云后端总结的面试 15 问

  1. 聊聊项目,好的设计,好的代码
  2. 谈谈什么是零拷贝?
  3. 一共有几种 IO 模型?NIO 和多路复用的区别?
  4. Future 实现阻塞等待获取结果的原理?
  5. ReentrantLock 和 Synchronized 的区别?Synchronized 的原理?
  6. 聊聊 AOS?ReentrantLock 的实现原理?
  7. 乐观锁和悲观锁, 让你来写你怎么实现?
  8. Paxos 协议了解?工作流程是怎么样的?
  9. B+树聊一下?B+树是不是有序?B+树和 B-树的主要区别?
  10. TCP 的拥塞机制
  11. 工作中有过 JVM 实践嘛
  12. 数据库分库分表的缺点是啥?
  13. 分布式事务如何解决?TCC 了解?
  14. RocketMQ 如何保证消息的准确性和安全性?
  15. 算法题:三个数求和

1.聊聊项目,好的设计,好的代码项目的话,你可以聊聊你平时做的项目,尤其有亮点的项目。如果没有什么特别亮点的项目,也可以说说一些好的设计,或者你优化了什么接口,性能提升了多少,优化了什么慢 SQL 都可以。甚至是一些好的代码写法都可以。**2. 谈谈什么是零拷贝?**零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。它是一种 I/O 操作优化技术。传统 IO 的执行流程传统的 IO 流程,包括 read 和 write 的过程。

  • read:把数据从磁盘读取到内核缓冲区,再拷贝到用户缓冲区
  • write:先把数据写入到 socket 缓冲区,最后写入网卡设备。
img
img
  1. 用户应用进程调用 read 函数,向操作系统发起 IO 调用,上下文从用户态转为内核态(切换 1)
  2. DMA 控制器把数据从磁盘中,读取到内核缓冲区。
  3. CPU 把内核缓冲区数据,拷贝到用户应用缓冲区,上下文从内核态转为用户态(切换 2),read 函数返回
  4. 用户应用进程通过 write 函数,发起 IO 调用,上下文从用户态转为内核态(切换 3)
  5. CPU 将用户缓冲区中的数据,拷贝到 socket 缓冲区
  6. DMA 控制器把数据从 socket 缓冲区,拷贝到网卡设备,上下文从内核态切换回用户态(切换 4),write 函数返回

传统 IO 的读写流程,包括了4 次上下文切换(4 次用户态和内核态的切换),4 次数据拷贝(两次 CPU 拷贝以及两次的 DMA 拷贝)。零拷贝实现方式: 零拷贝并不是没有拷贝数据,而是减少用户态/内核态的切换次数以及 CPU 拷贝的次数。零拷贝一般有这三种实现方式:

  • mmap+write
  • sendfile
  • 带有 DMA 收集拷贝功能的 sendfile

mmap+writemmap 就是用了虚拟内存这个特点,它将内核中的读缓冲区与用户空间的缓冲区进行映射,以减少数据拷贝次数!

img
img
  1. 用户进程通过 mmap 方法向操作系统内核发起 IO 调用,上下文从用户态切换为内核态。
  2. CPU 利用 DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. 上下文从内核态切换回用户态,mmap 方法返回。
  4. 用户进程通过 write 方法向操作系统内核发起 IO 调用,上下文从用户态切换为内核态。
  5. CPU 将内核缓冲区的数据拷贝到的 socket 缓冲区。
  6. CPU 利用 DMA 控制器,把数据从 socket 缓冲区拷贝到网卡,上下文从内核态切换回用户态,write 调用返回。

mmap+write 实现的零拷贝,I/O 发生了 4 次用户空间与内核空间的上下文切换,以及 3 次数据拷贝(包括了 2 次 DMA 拷贝和 1 次 CPU 拷贝)。sendfilesendfile 表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作

img
img
  1. 用户进程发起 sendfile 系统调用,上下文(切换 1)从用户态转向内核态
  2. DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. CPU 将读缓冲区中数据拷贝到 socket 缓冲区
  4. DMA 控制器,异步把数据从 socket 缓冲区拷贝到网卡,
  5. 上下文(切换 2)从内核态切换回用户态,sendfile 调用返回。

sendfile 实现的零拷贝,I/O 发生了 2 次用户空间与内核空间的上下文切换,以及 3 次数据拷贝。其中 3 次数据拷贝中,包括了 2 次 DMA 拷贝和 1 次 CPU 拷贝。带有 DMA 收集拷贝功能的 sendfilelinux 2.4 版本之后,对 sendfile 做了优化升级,引入 SG-DMA 技术,其实就是对 DMA 拷贝加入了 scatter/gather 操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点搞零拷贝,即还可以多省去一次 CPU 拷贝。

img
img
  1. 用户进程发起 sendfile 系统调用,上下文(切换 1)从用户态转向内核态
  2. DMA 控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. CPU 把内核缓冲区中的文件描述符信息(包括内核缓冲区的内存地址和偏移量)发送到 socket 缓冲区
  4. DMA 控制器根据文件描述符信息,直接把数据从内核缓冲区拷贝到网卡
  5. 上下文(切换 2)从内核态切换回用户态,sendfile 调用返回。

可以发现,sendfile+DMA scatter/gather实现的零拷贝,I/O 发生了 2 次用户空间与内核空间的上下文切换,以及 2 次数据拷贝。其中 2 次数据拷贝都是包 DMA 拷贝。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

点击此处可免费领取更多 Java 面试真题open in new window

**3. 一共有几种 IO 模型?NIO 和多路复用的区别?**一共有五种 IO 模型

  • 阻塞 IO 模型
  • 非阻塞 IO 模型
  • IO 多路复用模型
  • IO 模型之信号驱动模型
  • IO 模型之异步 IO(AIO)

**NIO(非阻塞 IO 模型)**NIO,即 Non-Blocking IO,是非阻塞 IO 模型。非阻塞 IO 的流程如下:

img
img
  1. 应用进程向操作系统内核,发起 recvfrom 读取数据。
  2. 操作系统内核数据没有准备好,立即返回 EWOULDBLOCK 错误码。
  3. 应用程序进程轮询调用,继续向操作系统内核发起 recvfrom 读取数据。
  4. 操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间。
  5. 完成调用,返回成功提示。

NIO(非阻塞 IO 模型)存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的 CPU 资源。可以考虑 IO 复用模型去解决这个问题。 IO 多路复用模型IO 多路复用就是,等到内核数据准备好了,主动通知应用进程再去进行系统调用。 IO 复用模型核心思路:系统给我们提供一类函数(如我们耳濡目染的 select、poll、epoll 函数),它们可以同时监控多个 fd 的操作,任何一个返回内核数据就绪,应用进程再发起 recvfrom 系统调用。IO 多路复用之 select应用进程通过调用 select 函数,可以同时监控多个 fd,在 select 函数监控的 fd 中,只要有任何一个数据状态准备就绪了,select 函数就会返回可读状态,这时应用进程再发起 recvfrom 请求去读取数据。

img
img

非阻塞 IO 模型(NIO)中,需要 N(N>=1)次轮询系统调用,然而借助 select 的 IO 多路复用模型,只需要发起一次询问就够了,大大优化了性能。 但是呢,select 有几个缺点:

  • 监听的 IO 最大连接数有限,在 Linux 系统上一般为 1024。
  • select 函数返回后,是通过遍历 fdset,找到就绪的描述符 fd。(仅知道有 I/O 事件发生,却不知是哪几个流,所以遍历所有流)

因为存在连接数限制,所以后来又提出了 poll。与 select 相比,poll 解决了连接数限制问题。但是呢,select 和 poll 一样,还是需要通过遍历文件描述符来获取已经就绪的 socket。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。 IO 多路复用之 epoll为了解决 select/poll 存在的问题,多路复用模型 epoll 诞生,它采用事件驱动来实现,流程图如下:

img
img

epoll 先通过 epoll_ctl()来注册一个 fd(文件描述符),一旦基于某个 fd 就绪时,内核会采用回调机制,迅速激活这个 fd,当进程调用 epoll_wait()时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是 epoll 的亮点。

**4. Future 实现阻塞等待获取结果的原理?**Future.get()用于异步结果的获取。它是阻塞的,背后原理是什么呢? 我们可以看下 FutureTask 的类结构图:

img
img

FutureTask 实现了RunnableFuture接口,RunnableFuture继承了Runnable和Future这两个接口, 对于 Runnable,我们太熟悉了, 那么 Future 呢?

Future 表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。

public interface Future<V> {

    boolean cancel(boolean mayInterruptIfRunning);
    //Future 是否被取消
    boolean isCancelled();
    //当前 Future 是否已结束
    boolean isDone();
    //或取Future的结果值。如果当前 Future 还没有结束,当前线程阻塞等待,
    V get() throws InterruptedException, ExecutionException;
    //获取 Future 的结果值。与 get()一样,不过多了超时时间设置
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

FutureTask 就是Runnable和Future的结合体,我们可以把Runnable看作生产者, Future 看作消费者。而 FutureTask 是被这两者共享的,生产者运行run方法计算结果,消费者通过get方法获取结果。

生产者消费者模式,如果生产者数据还没准备的时候,消费者会被阻塞。当生产者数据准备好了以后会唤醒消费者继续执行。我们来看下 FutureTask 内部是如何实现的。

FutureTask`内部维护了任务状态`state
//NEW 新建状态,表示FutureTask新建还没开始执行
private static final int NEW          = 0;
//完成状态,表示FutureTask
private static final int COMPLETING   = 1;
//任务正常完成,没有发生异常
private static final int NORMAL       = 2;
//发生异常
private static final int EXCEPTIONAL  = 3;
//取消任务
private static final int CANCELLED    = 4;
//发起中断请求
private static final int INTERRUPTING = 5;
//中断请求完成
private static final int INTERRUPTED  = 6;

生产者 run 方法:

 public void run() {
        // 如果状态state不是 NEW,或者设置 runner 值失败,直接返回
        if (state != NEW ||
            !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                         null, Thread.currentThread()))
            return;
        try {
            Callable<V> c = callable;
            if (c != null && state == NEW) {
                V result;
                boolean ran;
                try {
                    //调用callable的call方法,获取结果
                    result = c.call();
                    //运行成功
                    ran = true;
                } catch (Throwable ex) {
                    result = null;
                    //运行不成功
                    ran = false;
                    //设置异常
                    setException(ex);
                }
                //运行成功设置返回结果
                if (ran)
                    set(result);
            }
        } finally {
            runner = null;
            int s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }

消费者的get方法

 public V get() throws InterruptedException, ExecutionException {
     int s = state;
     //如果状态小于等于 COMPLETING,表示 FutureTask 任务还没有完成, 则调用awaitDone让当前线程等待。
     if (s <= COMPLETING)
         s = awaitDone(false, 0L);
     return report(s);
 }

awaitDone 做了什么事情呢?

 private int awaitDone(boolean timed, long nanos)
        throws InterruptedException {
        final long deadline = timed ? System.nanoTime() + nanos : 0L;
        WaitNode q = null;
        boolean queued = false;
        for (;;) {
            // 如果当前线程是中断标记,则  
            if (Thread.interrupted()) {
                //那么从列表中移除节点 q,并抛出 InterruptedException 异常
                removeWaiter(q);
                throw new InterruptedException();
            }

            int s = state;
            //如果状态已经完成,表示FutureTask任务已结束
            if (s > COMPLETING) {
                if (q != null)
                    q.thread = null;
                //返回
                return s;
            }
            // 表示还有一些后序操作没有完成,那么当前线程让出执行权
            else if (s == COMPLETING) // cannot time out yet
                Thread.yield();
            //将当前线程阻塞等待
            else if (q == null)
                q = new WaitNode();
            else if (!queued)
                queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
                                                     q.next = waiters, q);
            //timed 为 true 表示需要设置超时                                        
            else if (timed) {
                nanos = deadline - System.nanoTime();
                if (nanos <= 0L) {
                    removeWaiter(q);
                    return state;
                }
                //让当前线程等待 nanos 时间
                LockSupport.parkNanos(this, nanos);
            }
            else
                LockSupport.park(this);
        }
    }

当然,面试的时候,不一定要讲到源码这么细,只需要讲个大概思路就好啦。

点击此处可免费领取更多 Java 面试真题open in new window

**5. ReentrantLock 和 Synchronized 的区别?Synchronized 原理?**ReentrantLock 和 Synchronized 的区别?

  • Synchronized 是依赖于 JVM 实现的,而 ReenTrantLock 是 API 实现的。
  • 在 Synchronized 优化以前,synchronized 的性能是比 ReenTrantLock 差很多的,但是自从 Synchronized 引入了偏向锁,轻量级锁(自旋锁)后,两者性能就差不多了。
  • Synchronized 的使用比较方便简洁,它由编译器去保证锁的加锁和释放。而 ReenTrantLock 需要手工声明来加锁和释放锁,最好在 finally 中声明释放锁。
  • ReentrantLock 可以指定是公平锁还是⾮公平锁。⽽synchronized 只能是⾮公平锁。
  • ReentrantLock 可响应中断、可轮回,而 Synchronized 是不可以响应中断的,

**6. 聊聊 AOS?ReentrantLock 的实现原理?**AQS(抽象同步队列)的核心回答要点就是:

  • state 状态的维护。
  • CLH 队列
  • ConditionObject 通知
  • 模板方法设计模式
  • 独占与共享模式。
  • 自定义同步器。

大家综合 ReentrantLock 的功能,比如可重入,公平锁,非公平锁等,与 AQS 结合一起讲就好啦。**7. 乐观锁和悲观锁, 让你来写你怎么实现?**悲观锁: 悲观锁她专一且缺乏安全感了,她的心只属于当前线程,每时每刻都担心着它心爱的数据可能被别的线程修改。因此一个线程拥有(获得)悲观锁后,其他任何线程都不能对数据进行修改啦,只能等待锁被释放才可以执行。

img
img
  • SQL 语句 select ...for update 就是悲观锁的一种实现
  • 还有 Java 的 synchronized 关键字也是悲观锁的一种体现

乐观锁:乐观锁的很乐观,它认为数据的变动不会太频繁,操作时一般都不会产生并发问题。因此,它不会上锁,只是在更新数据时,再去判断其他线程在这之前有没有对数据进行过修改。 实现方式:乐观锁一般会使用版本号机制或 CAS 算法实现。

img
img

**8. Paxos 协议了解?工作流程是怎么样的?****8.1 为什么需要 Paxos 算法?**当前我们应用都是集群部署的,要求所有机器状态一致。假设当前有两台机器 A 和 B,A 要把状态修改为 a,B 要把状态修改为 b,那么应该听谁的呢?这时候可以像 2PC 一样,引入一个协调者,谁最先到就听谁的。

img
img

这里有个问题,就是协调者是单节点,如果它挂了呢。因为可以引入多个协调者

img
img

但是这么多协调者,应该听谁的呢? 引入 Paxos 算法解决这个问题,Paxos 算法是一种基于消息传递的分布式一致性算法。8.2 Paxos 的角色Paxos 涉及三种角色,分别是 Proposer、Accecptor 、Learners。

  • Proposer:它可以提出提案 (Proposal),提案信息包括提案编号和提案值
  • Acceptor:接受接受(accept)提案。一旦接受提案,提案里面的提案值(可以用 V 表示)就被选定了。
  • Learner: 哪个提案被选定了, Learner 就学习这个被选择的提案

一个进程可能是 Proposer,也可能是 Acceptor,也可能是 Learner。8.2 Paxos 算法推导过程一致性算法需要前置条件

  • 在这些被提出的提案中,只有一个会被选定
  • 如果没有提案被提出,就不应该有被选定的提案 -当提案被选定后,learner 可以学习被选中的提案

假设只有一个 Acceptor,只要 Acceptor 接受它收到的第一个提案,就可以保证只有一个 value 会被选定。但是这个 Acceptor 宕机,会导致整个系统不可用。

img
img

如果是是多个 Proposer 和多个 Acceptor,如何选定一个提案呢?

img
img

我们可以加个约定条件,假设就叫约束 P1一个 Acceptor 必须接受它收到的第一个提案。但是这样还是可能会有问题,如果每个 Proposer 分别提出不同的 value(如下图 V1,V2,V3),发给了不同的 Acceptor,最后会导致不同的 value 被选择。

img
img

我们可以给多一个额外的约定P1a:一个提案被选定,需要被半数以上 Acceptor 接受。这跟P1有点矛盾啦,我们可以使用一个全局的编号来标识每一个 Acceptor 批准的提案,当一个具有某 value 值的提案被半数以上的 Acceptor批准后,我们就认为该 value 被选定了。即提案 P= 提案参数 + 提案值,可以记为【M,V】。 现在可以允许多个提案被选定,但必须保证所有被选定的提案都具有相同的 value 值。要不然又会出现不一致啦。因此可以再加个约束 P2: 如果提案 P[M1,V1] 被选定了,那么所有比 M1 编号更高的被选定提案 P,其 value 的值也必须是 V1。 一个提案要被选定,至少要被一个 Acceptor 接受,因此我们可以把 P2 约束改成对 Acceptor 接受的约束 P2a: 如果提案 P[M1,V1] 被接受了,那么所有比 M1 编号更高的,且被 Acceptor 接受的 P,其值也是 V1。 多提案被选择的问题解决了,但是如果是网络不稳定或者宕机的原因,还是会有问题。

img
img

假设有 5 个 Acceptor。Proposer2 提出 [M1,V1]的提案,Acceptor2~5(半数以上)均接受了该提案,于是对于 Acceptor2~5 和 Proposer2 来讲,它们都认为 V1 被选定。Acceptor1 刚刚从 宕机状态 恢复过来(之前 Acceptor1 没有收到过任何提案),此时 Proposer1 向 Acceptor1 发送了 [M2,V2] 的提案 (V2≠V1 且 M2>M1)。对于 Acceptor1 来讲,这是它收到的 第一个提案。根据 P1(一个 Acceptor 必须接受它收到的 第一个提案),Acceptor1 必须接受该提案。同时 Acceptor1 认为 V2 被选定。 这就出现了两个问题:

  • Acceptor1 认为 V2 被选定,Acceptor2~5 和 Proposer2 认为 V1 被选定。出现了不一致。
  • V1 被选定了,但是编号更高的被 Acceptor1 接受的提案[M2,V2]的 value 为 V2,且 V2≠V1。这就跟 P2a(如果提案 P[M1,V1] 被接受了,那么所有比 M1 编号更高的,且被 Acceptor 接受的 P,其值也是 V1。)矛盾了。

我们要对 P2a 约束强化一下得到约束 P2b, 如果 P[M1,V1] 被选定后,任何 Proposer 产生的 P,其值也是 V1。 对于 P2b 中的描述,如何保证任何 Proposer 产生的 P,其值也是 V1 ?只要满足 P2c 即可: 对于任意的 M 和 V,如果提案[M,V]被提出,那么肯定存在一个由半数以上的 Acceptor 组成的集合 S,满足以下两个条件 中的任意一个:

  • 要么 S 中每个 Acceptor 都没有接受过编号小于 M 的提案。
  • 要么 S 中所有 Acceptor 批准的所有编号小于 Mn 的提案中,编号最大的那个提案的 value 值为 Vn

8.3 算法流程****8.3.1. Proposer 生成提案

  • Prepare 请求
  • Accept 请求

P2c约束基础上,如何生成提案呢? Proposer 选择一个新的提案编号 N,向 Acceptor 集合 S(数目在半数以上)发送请求,要求 S 中的每一个 Acceptor 做出如下响应:

  • 如果 Acceptor 没有接受过提案,则向 Proposer 保证 不再接受编号小于 N 的提案。
  • 如果 Acceptor 接受过请求,则向 Proposer 返回 已经接受过的编号小于 N 的编号最大的提案。

我们将这个请求称为编号为 N 的Prepare 请求

  • 如果 Proposer 收到半数以上的 Acceptor 响应,则生成编号为 N,value 为 V 的提案 [N,V],V 为所有响应中编号最大的提案的 value。
  • 如果 Proposer 收到的响应中没有提案,那么 value 由 Proposer 自己生成,生成后将此提案发给 S,并期望 Acceptor 能接受此提案。

我们称这个请求为Accept 请求****8.3.2 Acceptor 接受提案一个 Acceptor 可能会受到来自 Proposer 的两种请求:Prepare请求和 Accept 请求。Acceptor 什么时候可以响应一个请求呢,它也有个约束:P1b: 一个 Acceptor 只要尚未响应过任何编号大于 N 的 Prepare 请求,那么他就可以接受这个编号为 N 的提案。 Acceptor 收到编号为 N 的 Prepare 请求,如果在此之前它已经响应过编号大于 N 的 Prepare 请求。由约束 P1b,该 Acceptor 不会接受这个编号为 N 的提案。因此,Acceptor 会忽略这个请求。 一个 Acceptor 只需记住两点:已接受的编号最大的提案和已响应的请求的最大编号。8.3.3 Paxos 算法描述阶段一:

  • Proposer 选择一个提案编号 N,然后向半数以上的 Acceptor 发送编号为 N 的 Prepare 请求。
  • 如果一个 Acceptor 收到一个编号为 N 的 Prepare 请求,且 N 大于该 Acceptor 已经响应过的所有 Prepare 请求的编 号,那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给 Proposer,同时该 Acceptor 承诺不再接受任何编号小于 N 的提案。

阶段二:

  • 如果 Proposer 收到半数以上 Acceptor 对其发出的编号为 N 的 Prepare 请求的响应,那么它就会发送一个针对 [N,V]提案的 Accept 请求给半数以上的 Acceptor。注意:V就是收到的响应中编号最大的提案的 value,如果响应 中不包含任何提案,那么 V 就由 Proposer 自己决定。
  • 如果 Acceptor 收到一个针对编号为 N 的提案的 Accept 请求,只要该 Acceptor 没有对编号大于 N 的 Prepare 请求 做出过响应,它就接受该提案。点击此处可免费领取更多 Java 面试真题open in new window

8.3.4 Learner 学习被选定的 value

img
img

**9. B+树是不是有序?B+树和 B-树的主要区别?B+树索引,一次查找过程?**B+树是有序的。B+树和 B-树的主要区别?

  • B-树内部节点是保存数据的;而 B+树内部节点是不保存数据的,只作索引作用,它的叶子节点才保存数据。
  • B+树相邻的叶子节点之间是通过链表指针连起来的,B-树却不是。
  • 查找过程中,B-树在找到具体的数值以后就结束,而 B+树则需要通过索引找到叶子结点中的数据才结束
  • B-树中任何一个关键字出现且只出现在一个结点中,而 B+树可以出现多次。

假设有这么一个 SQL: select * from Temployee where age=32; age 加个一个索引,这条 SQL 是如何在索引上执行的?大家可以举例子画个示意图哈,比如二级索引树,

img
img

再画出 id 主键索引,我们先画出聚族索引结构图,如下:

img
img

因此,这条 SQL 查询语句执行大概流程就是酱紫:

  • 搜索 idx_age 索引树,将磁盘块 1 加载到内存,由于 32<37,搜索左路分支,到磁盘寻址磁盘块 2。
  • 将磁盘块 2 加载到内存中,在内存继续遍历,找到 age=32 的记录,取得 id = 400.
  • 拿到 id=400 后,回到 id 主键索引树。
  • 搜索 id 主键索引树,将磁盘块 1 加载内存,在内存遍历,找到了 400,但是 B+树索引非叶子节点是不保存数据的。索引会继续搜索 400 的右分支,到磁盘寻址磁盘块 3.
  • 将磁盘块 3 加载内存,在内存遍历,找到 id=400 的记录,拿到 R4 这一行的数据,好的,大功告成。

**10. TCP 怎么实现拥塞控制?**拥塞控制是作用于网络的,防止过多的数据包注入到网络中,避免出现网络负载过大的情况。它的目标主要是最大化利用网络上瓶颈链路的带宽。 实际上,拥塞控制主要有这几种常用算法

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

慢启动算法慢启动算法,表面意思就是,别急慢慢来。它表示 TCP 建立连接完成后,一开始不要发送大量的数据,而是先探测一下网络的拥塞程度。由小到大逐渐增加拥塞窗口的大小,如果没有出现丢包,* *每收到一个 ACK,就将拥塞窗口 cwnd 大小就加 1(单位是 MSS)**。每轮次发送窗口增加一倍,呈指数增长,如果出现丢包,拥塞窗口就减半,进入拥塞避免阶段。

  • TCP 连接完成,初始化 cwnd = 1,表明可以传一个 MSS 单位大小的数据。
  • 每当收到一个 ACK,cwnd 就加一;
  • 每当过了一个 RTT,cwnd 就增加一倍; 呈指数让升
img
img

为了防止 cwnd 增长过大引起网络拥塞,还需设置一个慢启动阀值 ssthresh(slow start threshold)状态变量。当 cwnd 到达该阀值后,就好像水管被关小了水龙头一样,减少拥塞状态。即当cwnd >ssthresh时,进入了拥塞避免算法。拥塞避免算法 一般来说,慢启动阀值 ssthresh 是 65535 字节,cwnd 到达慢启动阀值

  • 每收到一个 ACK 时,cwnd = cwnd + 1/cwnd
  • 当每过一个 RTT 时,cwnd = cwnd + 1

显然这是一个线性上升的算法,避免过快导致网络拥塞问题。点击此处可免费领取更多 Java 面试真题open in new window

img
img

拥塞发生当网络拥塞发生丢包时,会有两种情况:

  • RTO 超时重传
  • 快速重传

如果是发生了RTO 超时重传,就会使用拥塞发生算法

  • 慢启动阀值 sshthresh = cwnd /2
  • cwnd 重置为 1
  • 进入新的慢启动过程
img
img

这真的是辛辛苦苦几十年,一朝回到解放前。其实还有更好的处理方式,就是快速重传。发送方收到 3 个连续重复的 ACK 时,就会快速地重传,不必等待RTO 超时再重传。

img
img

慢启动阀值 ssthresh 和 cwnd 变化如下:

  • 拥塞窗口大小 cwnd = cwnd/2
  • 慢启动阀值 ssthresh = cwnd
  • 进入快速恢复算法

快速恢复快速重传和快速恢复算法一般同时使用。快速恢复算法认为,还有 3 个重复 ACK 收到,说明网络也没那么糟糕,所以没有必要像 RTO 超时那么强烈。 正如前面所说,进入快速恢复之前,cwnd 和 sshthresh 已被更新: - cwnd = cwnd /2 - sshthresh = cwnd 然后,真正的快速算法如下:

  • cwnd = sshthresh + 3
  • 重传重复的那几个 ACK(即丢失的那几个数据包)
  • 如果再收到重复的 ACK,那么 cwnd = cwnd +1
  • 如果收到新数据的 ACK 后, cwnd = sshthresh。因为收到新数据的 ACK,表明恢复过程已经结束,可以再次进入了拥塞避免的算法了。
img
img

11. JVM 调优****11.1 一般什么时候考虑 JVM 调优呢?

  • Heap 内存(老年代)持续上涨达到设置的最大内存值;
  • Full GC 次数频繁;
  • GC 停顿时间过长(超过 1 秒);
  • 应用出现 OutOfMemory 等内存异常;
  • 应用中有使用本地缓存且占用大量内存空间;
  • 系统吞吐量与响应性能不高或下降。

11.2 JVM 调优的目标

  • 延迟:GC 低停顿和 GC 低频率;
  • 低内存占用;
  • 高吞吐量;

11.3 JVM 调优量化目标

  • Heap 内存使用率 <= 70%;
  • Old generation 内存使用率<= 70%;
  • avgpause <= 1 秒;
  • Full gc 次数 0 或 avg pause interval >= 24 小时 ;

11.4 JVM 调优的步骤

  • 分析 GC 日志及 dump 文件,判断是否需要优化,确定瓶颈问题点;
  • 确定 JVM 调优量化目标;
  • 确定 JVM 调优参数(根据历史 JVM 参数来调整);
  • 依次调优内存、延迟、吞吐量等指标;
  • 对比观察调优前后的差异;
  • 不断的分析和调整,直到找到合适的 JVM 参数配置;
  • 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。

11.5 常见的 JVM 参数****堆栈配置相关-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:MaxPermSize=16m -XX:NewRatio=4 -XX: SurvivorRatio=4 -XX:MaxTenuringThreshold=0

  • -Xmx3550m:最大堆大小为 3550m。
  • -Xms3550m:设置初始堆大小为 3550m。
  • -Xmn2g:设置年轻代大小为 2g。
  • -Xss128k:每个线程的堆栈大小为 128k。
  • -XX:MaxPermSize: 设置持久代大小为 16m
  • -XX:NewRatio=4: 设置年轻代(包括 Eden 和两个 Survivor 区)与年老代的比值(除去持久代)。
  • -XX:SurvivorRatio=4:设置年轻代中 Eden 区与 Survivor 区的大小比值。设置为 4,则两个 Survivor 区与一个 Eden 区的比值为 2: 4,一个 Survivor 区占整个年轻代的 1/6
  • -XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。

垃圾收集器相关-XX:+UseParallelGC -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=5 -XX:+UseCMSCompactAtFullCollection: -XX:+UseConcMarkSweepGC

  • -XX:+UseParallelGC:选择垃圾收集器为并行收集器。
  • -XX:ParallelGCThreads=20:配置并行收集器的线程数
  • -XX:+UseConcMarkSweepGC:设置年老代为并发收集。
  • -XX:CMSFullGCsBeforeCompaction:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次 GC 以后对内存空间进行压缩、整理。
  • -XX:+UseCMSCompactAtFullCollection:打开对年老代的压缩。可能会影响性能,但是可以消除碎片
  • -XX:+UseConcMarkSweepGC 使用 CMS 垃圾收集器

辅助信息-XX:+PrintGC -XX: +PrintGCDetails点击此处可免费领取更多 Java 面试真题open in new window

11.6 常用调优策略

  • 选择合适的垃圾回收器
  • 调整内存大小(垃圾收集频率非常频繁,如果是内存太小,可适当调整内存大小)
  • 调整内存区域大小比率(某一个区域的 GC 频繁,其他都正常。)
  • 调整对象升老年代的年龄(老年代频繁 GC,每次回收的对象很多。)
  • 调整大对象的标准(老年代频繁 GC,每次回收的对象很多,而且单个对象的体积都比较大。)
  • 调整 GC 的触发时机(CMS,G1 经常 Full GC,程序卡顿严重。)
  • 调整 JVM 本地内存大小(GC 的次数、时间和回收的对象都正常,堆内存空间充足,但是报 OOM)

12. 数据库分库分表的缺点是啥?

  1. 事务问题,已经不可以用本地事务了,需要用分布式事务。
  2. 跨节点 Join 的问题:解决这一问题可以分两次查询实现
  3. 跨节点的 count,order by,group by 以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并。
  4. ID 问题:数据库被切分后,不能再依赖数据库自身的主键生成机制啦,最简单可以考虑 UUID
  5. 跨分片的排序分页问题(后台加大 pagesize 处理?)

**13. 分布式事务如何解决?TCC 了解?**分布式事务: 就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单来说,分布式事务指的就是分布式系统中的事务,它的存在就是为了保证不同数据库节点的数据一致性。 聊到分布式事务,需要知道这两个基本理论哈。

  • CAP 理论
  • BASE 理论

CAP 理论

  • 一致性(C:Consistency):一致性是指数据在多个副本之间能否保持一致的特性。例如一个数据在某个分区节点更新之后,在其他分区节点读出来的数据也是更新之后的数据。
  • 可用性(A:Availability) :可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。这里的重点是" 有限时间内"和"返回结果"。
  • 分区容错性(P:Partition tolerance):分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务。

BASE 理论它是对 CAP 中 AP 的一个扩展,对于我们的业务系统,我们考虑牺牲一致性来换取系统的可用性和分区容错性。BASE 是 Basically Available,Soft state,和 Eventually consistent 三个短语的缩写。

  • Basically Available(基本可用):通过支持局部故障而不是系统全局故障来实现的。如将用户分区在 5 个数据库服务器上,一个用户数据库的故障只影响这台特定主机那 20% 的用户,其他用户不受影响。
  • Soft State(软状态):状态可以有一段时间不同步
  • Eventually Consistent(最终一致):最终数据是一致的就可以了,而不是时时保持强一致。

分布式事务的几种解决方案:

  • 2PC(二阶段提交)方案、3PC
  • TCC(Try、Confirm、Cancel)
  • 本地消息表
  • 最大努力通知
  • seata 事务

**TCC(补偿机制)**TCC 采用了补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。TCC(Try-Confirm-Cancel)包括三段流程:

  • try 阶段:尝试去执行,完成所有业务的一致性检查,预留必须的业务资源。
  • Confirm 阶段:该阶段对业务进行确认提交,不做任何检查,因为 try 阶段已经检查过了,默认 Confirm 阶段是不会出错的。
  • Cancel 阶段:若业务执行失败,则进入该阶段,它会释放 try 阶段占用的所有业务资源,并回滚 Confirm 阶段执行的所有操作。

下面再拿用户下单购买礼物作为例子来模拟 TCC 实现分布式事务的过程: 假设用户 A 余额为 100 金币,拥有的礼物为 5 朵。A 花了 10 个金币,下订单,购买 10 朵玫瑰。余额、订单、礼物都在不同数据库。TCC 的 Try 阶段:

  • 生成一条订单记录,订单状态为待确认。
  • 将用户 A 的账户金币中余额更新为 90,冻结金币为 10(预留业务资源)
  • 将用户的礼物数量为 5,预增加数量为 10。
  • Try 成功之后,便进入 Confirm 阶段
  • Try 过程发生任何异常,均进入 Cancel 阶段
img
img

TCC 的 Confirm 阶段:

  • 订单状态更新为已支付
  • 更新用户余额为 90,可冻结为 0
  • 用户礼物数量更新为 15,预增加为 0
  • Confirm 过程发生任何异常,均进入 Cancel 阶段
  • Confirm 过程执行成功,则该事务结束
img
img

TCC 的 Cancel 阶段:

  • 修改订单状态为已取消
  • 更新用户余额回 100
  • 更新用户礼物数量为 5
img
img
  • TCC 的优点是可以自定义数据库操作的粒度,降低了锁冲突,可以提升性能
  • TCC 的缺点是应用侵入性强,需要根据网络、系统故障等不同失败原因实现不同的回滚策略,实现难度大,一般借助 TCC 开源框架,ByteTCC,TCC-transaction,Himly。

14, RocketMQ 如何保证消息的准确性和安全性?

我个人理解的话,这道题换汤不换药,就是为如何保证 RocketMQ 不丢消息,保证不重复消费,消息有序性,消息堆积的处理。

消息不丢失的话,即从生产者、存储端、消费端去考虑

点击此处可免费领取更多 Java 面试真题open in new window

15. 三个数求和

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。

注意:答案中不可以包含重复的三元组

实例 1:

输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]

实例 2:

输入:nums = [0]
输出:[]

思路:

这道题可以先给数组排序,接着用左右双指针。

完整代码如下:

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {

        List<List<Integer>> result = new LinkedList<>();
        if(nums==null||nums.length<3){ //为空或者元素个数小于3,直接返回
            return result;

        }

        Arrays.sort(nums); //排序

        for(int i=0;i<nums.length-2;i++){ //遍历到倒数第三个,因为是三个数总和
            if(nums[i]>0){ //大于0可以直接跳出循环了
                break;
            }

            if(i>0&&nums[i]==nums[i-1]){ //过滤重复
                continue;
            }

            int left = i+1;  //左指针
            int right = nums.length-1; //右指针
            int target = - nums[i];  //目标总和,是第i个的取反,也就是a+b+c=0,则b+c=-a即可

            while(left<right){
                if(nums[left]+ nums[right]==target){ //b+c=-a,满足a+b+c=0
                   result.add(Arrays.asList(nums[i],nums[left],nums[right]));
                   left++;  //左指针右移
                   right--;  //右指针左移
                   while(left<right&&nums[left]==nums[left-1]) left++; //继续左边过滤重复
                   while(left<right&&nums[right]==nums[right+1]) right--; //继续右边过滤重复
                }else if(nums[left]+ nums[right]<target){
                   left++; //小于目标值,需要右移,因为排好序是从小到大的
                }else{
                  right--;  
                }

            }
        }
            return result;
        }
}