大家好, 最近一位朋友(6年工作经验)面了腾讯云,以下是面试题和答案。加油,一起卷。
项目的话,你可以聊聊你平时做的项目,尤其有亮点的项目。如果没有什么特别亮点的项目,也可以说说一些好的设计,或者你优化了什么接口,性能提升了多少,优化了什么慢SQL都可以。甚至是一些好的代码写法都可以。
如果是代码优化细节,可以看我这篇:
[工作四年,分享50个让你代码更好的小建议]
如果是慢SQL优化,可以看下我之前MySQL专栏系列文章哈:
[看一遍就理解:order by详解] [实战!聊聊如何解决MySQL深分页问题] [后端程序员必备:书写高质量SQL的30条建议] [阿里一面,给了几条SQL,问需要执行几次树搜索操作?]
零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及CPU的拷贝时间。它是一种I/O操作优化技术。
传统 IO 的执行流程
传统的IO流程,包括read和write的过程。
传统IO的读写流程,包括了4次上下文切换(4次用户态和内核态的切换),4次数据拷贝(两次CPU拷贝以及两次的DMA拷贝)。
零拷贝实现方式:
零拷贝并不是没有拷贝数据,而是减少用户态/内核态的切换次数以及CPU拷贝的次数。零拷贝一般有这三种实现方式:
mmap+write
mmap就是用了虚拟内存这个特点,它将内核中的读缓冲区与用户空间的缓冲区进行映射,以减少数据拷贝次数!
mmap+write实现的零拷贝,I/O发生了4次用户空间与内核空间的上下文切换,以及3次数据拷贝(包括了2次DMA拷贝和1次CPU拷贝)。
sendfile
sendfile表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作
sendfile实现的零拷贝,I/O发生了2次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中,包括了2次DMA拷贝和1次CPU拷贝。
带有DMA收集拷贝功能的sendfile
linux 2.4版本之后,对sendfile做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点搞零拷贝,即还可以多省去一次CPU拷贝。
可以发现,sendfile+DMA scatter/gather实现的零拷贝,I/O发生了2次用户空间与内核空间的上下文切换,以及2次数据拷贝。其中2次数据拷贝都是包DMA拷贝。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。
[看一遍就理解:零拷贝详解]
一共有五种IO模型
NIO(非阻塞IO模型)
NIO,即Non-Blocking IO,是非阻塞IO模型。非阻塞IO的流程如下:
NIO(非阻塞IO模型)存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的CPU资源。可以考虑IO复用模型去解决这个问题。
IO多路复用模型
IO多路复用就是,等到内核数据准备好了,主动通知应用进程再去进行系统调用。
IO复用模型核心思路:系统给我们提供一类函数(如我们耳濡目染的select、poll、epoll函数),它们可以同时监控多个fd的操作,任何一个返回内核数据就绪,应用进程再发起recvfrom系统调用。
IO多路复用之select
应用进程通过调用select函数,可以同时监控多个fd,在select函数监控的fd中,只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时应用进程再发起recvfrom请求去读取数据。
非阻塞IO模型(NIO)中,需要N(N>=1)次轮询系统调用,然而借助select的IO多路复用模型,只需要发起一次询问就够了,大大优化了性能。
但是呢,select有几个缺点:
因为存在连接数限制,所以后来又提出了poll。与select相比,poll解决了连接数限制问题。但是呢,select和poll一样,还是需要通过遍历文件描述符来获取已经就绪的socket。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。
IO多路复用之epoll
为了解决select/poll存在的问题,多路复用模型epoll诞生,它采用事件驱动来实现,流程图如下:
epoll先通过epoll_ctl()来注册一个fd(文件描述符),一旦基于某个fd就绪时,内核会采用回调机制,迅速激活这个fd,当进程调用epoll_wait()时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是epoll的亮点。
Future.get()
用于异步结果的获取。它是阻塞的,背后原理是什么呢?
我们可以看下FutureTask
的类结构图:
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);
}
}
当然,面试的时候,不一定要讲到源码这么细,只需要讲个大概思路就好啦。
ReentrantLock和 Synchronized 的区别?
至于Synchronized的原理,大家可以看我这篇文章哈
[Synchronized解析——如果你愿意一层一层剥开我的心]
AQS(抽象同步队列)的核心回答要点就是:
大家可以看下我之前这篇文章哈:[AQS解析与实战]
大家综合ReentrantLock的功能,比如可重入,公平锁,非公平锁等,与AQS结合一起讲就好啦。
悲观锁:
悲观锁她专一且缺乏安全感了,她的心只属于当前线程,每时每刻都担心着它心爱的数据可能被别的线程修改。因此一个线程拥有(获得)悲观锁后,其他任何线程都不能对数据进行修改啦,只能等待锁被释放才可以执行。
select ...for update
就是悲观锁的一种实现乐观锁:
乐观锁的很乐观,它认为数据的变动不会太频繁,操作时一般都不会产生并发问题。因此,它不会上锁,只是在更新数据时,再去判断其他线程在这之前有没有对数据进行过修改。实现方式:乐观锁一般会使用版本号机制或CAS算法实现。
之前业务上使用过CAS解决并发问题,大家有兴趣可以看一下哈:
当前我们应用都是集群部署的,要求所有机器状态一致。假设当前有两台机器A和B,A要把状态修改为a,B要把状态修改为b,那么应该听谁的呢?这时候可以像2PC一样,引入一个协调者,谁最先到就听谁的。
这里有个问题,就是协调者是单节点,如果它挂了呢。因为可以引入多个协调者
但是这么多协调者,应该听谁的呢?
引入Paxos算法解决这个问题,Paxos算法是一种基于消息传递的分布式一致性算法。
Paxos涉及三种角色,分别是Proposer、Accecptor 、Learners。
一个进程可能是Proposer,也可能是Acceptor,也可能是Learner。
一致性算法需要前置条件
- 在这些被提出的提案中,只有一个会被选定
- 如果没有提案被提出,就不应该有被选定的提案 -当提案被选定后,learner可以学习被选中的提案
假设只有一个Acceptor,只要Acceptor接受它收到的第一个提案,就可以保证只有一个value会被选定。但是这个 Acceptor 宕机,会导致整个系统不可用。
如果是是多个Proposer和多个Acceptor,如何选定一个提案呢?
我们可以加个约定条件,假设就叫约束P1:一个 Acceptor 必须接受它收到的第一个提案。但是这样还是可能会有问题,如果每个Proposer分别提出不同的value(如下图V1,V2,V3),发给了不同的Acceptor,最后会导致不同的value被选择。
我们可以给多一个额外的约定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。
多提案被选择的问题解决了,但是如果是网络不稳定或者宕机的原因,还是会有问题。
假设有 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
在 P2c约束基础上,如何生成提案呢?
Proposer选择一个新的提案编号N,向 Acceptor 集合 S(数目在半数以上)发送请求,要求 S 中的每一个 Acceptor 做出如下响应:
我们将这个请求称为编号为N的Prepare请求。
N
,value 为 V 的提案 [N,V],V 为所有响应中编号最大的提案的value。我们称这个请求为Accept请求
一个Acceptor可能会受到来自Proposer的两种请求:Prepare请求和Accept请求。Acceptor 什么时候可以响应一个请求呢,它也有个约束:P1b:
一个Acceptor只要尚未响应过任何编号大于N的Prepare请求,那么他就可以接受这个编号为N的提案。
Acceptor收到编号为 N的Prepare 请求,如果在此之前它已经响应过编号大于N的Prepare请求。由约束P1b,该Acceptor不会接受这个编号为N的提案。因此,Acceptor会忽略这个请求。
一个 Acceptor 只需记住两点:已接受的编号最大的提案和已响应的请求的最大编号。
阶段一:
阶段二:
B+树是有序的。
B+树和B-树的主要区别?
假设有这么一个SQL:
select * from Temployee where age=32;
age加个一个索引,这条SQL是如何在索引上执行的?大家可以举例子画个示意图哈,比如二级索引树,
再画出id主键索引,我们先画出聚族索引结构图,如下:
因此,这条 SQL 查询语句执行大概流程就是酱紫:
拥塞控制是作用于网络的,防止过多的数据包注入到网络中,避免出现网络负载过大的情况。它的目标主要是最大化利用网络上瓶颈链路的带宽。
实际上,拥塞控制主要有这几种常用算法
慢启动算法,表面意思就是,别急慢慢来。它表示TCP建立连接完成后,一开始不要发送大量的数据,而是先探测一下网络的拥塞程度。由小到大逐渐增加拥塞窗口的大小,如果没有出现丢包,每收到一个ACK,就将拥塞窗口cwnd大小就加1(单位是MSS)。每轮次发送窗口增加一倍,呈指数增长,如果出现丢包,拥塞窗口就减半,进入拥塞避免阶段。
为了防止cwnd增长过大引起网络拥塞,还需设置一个慢启动阀值ssthresh(slow start threshold)状态变量。当cwnd
到达该阀值后,就好像水管被关小了水龙头一样,减少拥塞状态。即当cwnd >ssthresh时,进入了拥塞避免算法。
一般来说,慢启动阀值ssthresh是65535字节,cwnd
到达慢启动阀值后
显然这是一个线性上升的算法,避免过快导致网络拥塞问题。
当网络拥塞发生丢包时,会有两种情况:
如果是发生了RTO超时重传,就会使用拥塞发生算法
这真的是辛辛苦苦几十年,一朝回到解放前。其实还有更好的处理方式,就是快速重传。发送方收到3个连续重复的ACK时,就会快速地重传,不必等待RTO超时再重传。
image.png
慢启动阀值ssthresh 和 cwnd 变化如下:
快速重传和快速恢复算法一般同时使用。快速恢复算法认为,还有3个重复ACK收到,说明网络也没那么糟糕,所以没有必要像RTO超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd 和 sshthresh已被更新:
- cwnd = cwnd /2
- sshthresh = cwnd
然后,真正的快速算法如下:
堆栈配置相关
-Xmx3550m -Xms3550m -Xmn2g -Xss128k
-XX:MaxPermSize=16m -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxTenuringThreshold=0
垃圾收集器相关
-XX:+UseParallelGC
-XX:ParallelGCThreads=20
-XX:+UseConcMarkSweepGC
-XX:CMSFullGCsBeforeCompaction=5
-XX:+UseCMSCompactAtFullCollection:
-XX:+UseConcMarkSweepGC
辅助信息
-XX:+PrintGC
-XX:+PrintGCDetails
分布式事务:
就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单来说,分布式事务指的就是分布式系统中的事务,它的存在就是为了保证不同数据库节点的数据一致性。
聊到分布式事务,需要知道这两个基本理论哈。
CAP 理论
BASE 理论
它是对CAP中AP的一个扩展,对于我们的业务系统,我们考虑牺牲一致性来换取系统的可用性和分区容错性。BASE是Basically Available,Soft state,和 Eventually consistent
三个短语的缩写。
分布式事务的几种解决方案:
TCC(补偿机制)
TCC 采用了补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。TCC(Try-Confirm-Cancel)包括三段流程:
下面再拿用户下单购买礼物作为例子来模拟TCC实现分布式事务的过程:
假设用户A余额为100金币,拥有的礼物为5朵。A花了10个金币,下订单,购买10朵玫瑰。余额、订单、礼物都在不同数据库。
TCC的Try阶段:
TCC的Confirm阶段:
TCC的Cancel阶段:
大家有兴趣可以看下我之前这篇文章哈:
[后端程序员必备:分布式事务基础篇]
我个人理解的话,这道题换汤不换药,就是为如何保证RocketMQ 不丢消息,保证不重复消费,消息有序性,消息堆积的处理。
消息不丢失的话,即从生产者、存储端、消费端去考虑
大家可以看下我之前这篇文章哈:
[消息队列经典十连问]
给你一个包含 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;
}
}
[1]Callable/Future 使用及原理分析: https://cloud.tencent.com/developer/article/1692202
[2]【JVM进阶之路】十:JVM调优总结: https://zhuanlan.zhihu.com/p/363961261
[3]分布式理论(五) - 一致性算法Paxos: https://juejin.cn/post/6844903621499289613#heading-19
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8