字节面试体验很棒!
大家好,我是小林。
今天分享秋招的字节、快手 Java 后端面经,我筛选了Java+MySQL+Redis+MQ+网络+操作系统共性的面试题,排除了项目和实习经历的问题,同学反馈字节面试体验很好,遇到不会的,面试官会一步一步引导,还会详细解释下,返回环节还介绍了部门情况。
整体难度不算太难,还算比较基础,很多都是高频面试题,可以收藏起来,反复复习。
网络
从输入域名到浏览器看见页面经历了什么过程?
解析URL:分析 URL 所需要使用的传输协议和请求的资源路径。如果输入的 URL 中的协议或者主机名不合法,将会把地址栏中输入的内容传递给搜索引擎。如果没有问题,浏览器会检查 URL 中是否出现了非法字符,则对非法字符进行转义后在进行下一过程。
缓存判断:浏览器会判断所请求的资源是否在缓存里,如果请求的资源在缓存里且没有失效,那么就直接使用,否则向服务器发起新的请求。
DNS解析:如果资源不在本地缓存,首先需要进行DNS解析。浏览器会向本地DNS服务器发送域名解析请求,本地DNS服务器会逐级查询,最终找到对应的IP地址。
获取MAC地址:当浏览器得到 IP 地址后,数据传输还需要知道目的主机 MAC 地址,因为应用层下发数据给传输层,TCP 协议会指定源端口号和目的端口号,然后下发给网络层。网络层会将本机地址作为源地址,获取的 IP 地址作为目的地址。然后将下发给数据链路层,数据链路层的发送需要加入通信双方的 MAC 地址,本机的 MAC 地址作为源 MAC 地址,目的 MAC 地址需要分情况处理。通过将 IP 地址与本机的子网掩码相结合,可以判断是否与请求主机在同一个子网里,如果在同一个子网里,可以使用 APR 协议获取到目的主机的 MAC 地址,如果不在一个子网里,那么请求应该转发给网关,由它代为转发,此时同样可以通过 ARP 协议来获取网关的 MAC 地址,此时目的主机的 MAC 地址应该为网关的地址。
建立TCP连接:主机将使用目标 IP地址和目标MAC地址发送一个TCP SYN包,请求建立一个TCP连接,然后交给路由器转发,等路由器转到目标服务器后,服务器回复一个SYN-ACK包,确认连接请求。然后,主机发送一个ACK包,确认已收到服务器的确认,然后 TCP 连接建立完成。
HTTPS 的 TLS 四次握手:如果使用的是 HTTPS 协议,在通信前还存在 TLS 的四次握手。
发送HTTP请求:连接建立后,浏览器会向服务器发送HTTP请求。请求中包含了用户需要获取的资源的信息,例如网页的URL、请求方法(GET、POST等)等。
服务器处理请求并返回响应:服务器收到请求后,会根据请求的内容进行相应的处理。例如,如果是请求网页,服务器会读取相应的网页文件,并生成HTTP响应。
TCP的三次握手过程?三次握手的原因是什么?
第一次握手(SYN):客户端向服务器发送一个带有SYN标志的数据包,请求建立连接。客户端会选择一个随机的初始序列号(ISN)作为起始序号。 第二次握手(SYN+ACK):服务器收到客户端的请求后,会发送一个带有SYN和ACK(确认)标志的数据包作为响应。服务器也会选择一个随机的初始序列号,并将客户端的初始序列号加1作为确认号。同时,服务器也表示自己已经收到了客户端的请求。 第三次握手(ACK):客户端收到服务器的响应后,会发送一个带有ACK标志的数据包作为确认。客户端会将服务器的初始序列号加1作为确认号,并向服务器表示自己已经收到了服务器的响应。
完成了这三次握手后,TCP连接就建立起来了,双方可以开始进行数据的传输。
三次握手的目的是确保双方都能够收到对方的请求和确认,并且双方都同意建立连接。这样可以防止因为网络延迟或丢包等问题导致连接建立失败或不稳定。同时,三次握手也能够防止已经失效的连接请求报文段在网络中重新出现,避免了资源的浪费。
TCP为什么可靠?
序列号与确认机制:TCP将每个数据包分配一个唯一的序列号,并且接收方会发送确认消息来确认已经接收到的数据。发送方会根据接收到的确认消息判断是否需要重新发送丢失的数据包。
数据校验和:TCP使用校验和来验证数据在传输过程中是否发生了损坏。接收方会计算校验和并与发送方发送的校验和进行比较,如果不一致,则说明数据包发生了损坏,需要重新发送。
滑动窗口机制:TCP使用滑动窗口来控制发送方发送数据的速度和接收方接收数据的速度,以避免因发送速度过快或接收速度过慢而导致的数据丢失或堵塞。
重传机制:如果发送方没有收到接收方的确认消息,或者接收方收到的数据包校验和不一致,发送方会重新发送数据包,确保数据的可靠传输。
拥塞控制:TCP具有拥塞控制机制,可以根据网络的拥塞情况来调整发送数据的速率,避免网络拥塞导致的数据丢失和延迟。
HTTP状态码有哪些?
1xx:信息性状态码,表示服务器已接收了客户端请求,客户端可继续发送请求。
100 Continue 101 Switching Protocols 2xx:成功状态码,表示服务器已成功接收到请求并进行处理。
200 OK 表示客户端请求成功
204 No Content 成功,但不返回任何实体的主体部分 206 Partial Content 成功执行了一个范围(Range)请求
3xx:重定向状态码,表示服务器要求客户端重定向。
301 Moved Permanently 永久性重定向,响应报文的Location首部应该有该资源的新URL 302 Found 临时性重定向,响应报文的Location首部给出的URL用来临时定位资源 303 See Other 请求的资源存在着另一个URI,客户端应使用GET方法定向获取请求的资源 304 Not Modified 服务器内容没有更新,可以直接读取浏览器缓存 307 Temporary Redirect 临时重定向。与302 Found含义一样。302禁止POST变换为GET,但实际使用时并不一定,307则更多浏览器可能会遵循这一标准,但也依赖于浏览器具体实现
4xx:客户端错误状态码,表示客户端的请求有非法内容。
400 Bad Request 表示客户端请求有语法错误,不能被服务器所理解 401 Unauthonzed 表示请求未经授权,该状态代码必须与 WWW-Authenticate 报头域一起使用 403 Forbidden 表示服务器收到请求,但是拒绝提供服务,通常会在响应正文中给出不提供服务的原因 404 Not Found 请求的资源不存在,例如,输入了错误的URL
5xx:服务器错误状态码,表示服务器未能正常处理客户端的请求而出现意外错误。
500 Internel Server Error 表示服务器发生不可预期的错误,导致无法完成客户端的请求 503 Service Unavailable 表示服务器当前不能够处理客户端的请求,在一段时间之后,服务器可能会恢复正常
操作系统
进程间通信有哪些?
管道(Pipe):管道是一种半双工的通信方式,可以在具有亲缘关系的进程之间进行通信。它可以分为匿名管道和命名管道。匿名管道只能在具有共同祖先的进程之间使用,而命名管道可以在不具有亲缘关系的进程之间使用。
优点:简单易用,无需额外的系统调用和复杂的设置。 缺点:只能在具有亲缘关系的进程之间进行通信,且只能实现单向通信。 信号(Signal):信号是一种异步的通信方式,用于通知进程发生了某种事件。一个进程可以向另一个进程发送信号,接收信号的进程可以选择采取相应的行动。
优点:简单、快速,适用于简单的通信需求。 缺点:信号的发送和接收是异步的,无法传递大量数据,且不支持双向通信。 共享内存(Shared Memory):共享内存是一种高效的通信方式,多个进程可以将同一块内存空间映射到各自的地址空间中,从而实现共享数据。
优点:传输效率高,适用于大量数据的共享。 缺点:需要额外的同步机制来保证数据的一致性和互斥访问,容易造成数据竞争和死锁。 信号量(Semaphore):信号量是一种用于进程间同步的机制,可以用来保护共享资源的互斥访问。
优点:可以用于进程间的同步和互斥。 缺点:只提供了同步和互斥的功能,无法传递大量数据。 消息队列:消息队列是一种消息传递的机制,可以在不同进程之间传递特定格式的消息。
优点:支持多对多的进程通信,每个消息都有特定的格式。 缺点:消息的发送和接收是同步的,且不支持实时性要求较高的通信。 套接字(Socket):套接字是一种通用的进程间通信机制,可以在不同主机上的进程之间进行通信。
优点:支持网络通信,可以在不同主机上的进程之间进行通信。 缺点:相对于其他IPC方式,套接字的使用和编程复杂度较高。
电脑 4GB内存,我申请 5GB内存可以吗?
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
虚拟内存的最大值首先操作系统的位数,32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,如下所示:
32
位系统的内核空间占用1G
,位于最高处,剩下的3G
是用户空间,所以在 32 位操作系统场景下,执行malloc申请5G内存,会失败。64
位系统的内核空间和用户空间都是128T
,所以在 64 位操作系统场景下,即使物理内存只有 4G,但是还是可以申请5G虚拟内存,能申请成功。
申请成功之后,在使用这5G内存时候会有问题吗?
会有问题,会发生 OOM 的错误,内存溢出。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存:
如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。 如果没有空闲的物理内存,那么内核就会开始进行的工作,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM (Out of Memory)机制。
MySQL
索引的底层是怎么实现的?
MySQL 默认存储引擎是 InnoDB,InnoDB 默认是使用 B+树 作为索引的数据结构。
为什么用B+树呢?
B+Tree vs 二叉树:对于有 N 个叶子节点的 B+Tree,其搜索复杂度为 O(logdN)
,其中 d 表示节点允许的最大子节点个数为 d 个。在实际的应用当中, d 值是大于100的,这样就保证了,即使数据达到千万级别时,B+Tree 的高度依然维持在 3~4 层左右,也就是说一次数据查询操作只需要做 3~4 次的磁盘 I/O 操作就能查询到目标数据。而二叉树的每个父节点的儿子节点个数只能是 2 个,意味着其搜索复杂度为O(logN)
,这已经比 B+Tree 高出不少,因此二叉树检索到目标数据所经历的磁盘 I/O 次数要更多。B+Tree vs Hash:Hash 在做等值查询的时候效率贼快,搜索复杂度为 O(1)。但是 Hash 表不适合做范围查询,它更适合做等值的查询,这也是 B+Tree 索引要比 Hash 表索引有着更广泛的适用场景的原因。 B+Tree vs B Tree:B+Tree 只在叶子节点存储数据,而 B 树 的非叶子节点也要存储数据,所以 B+Tree 的单个节点的数据量更小,在相同的磁盘 I/O 次数下,就能查询更多的节点。另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的基于范围的顺序查找,而 B 树无法做到这一点。
你是如何选择什么字段来做索引的?
字段有唯一性限制的,比如商品编码; 经常用于 WHERE
查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引。经常用于 GROUP BY
和ORDER BY
的字段,这样在查询的时候就不需要再去做一次排序了,因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。
假如现在有三个普通索引a,b,c,sql查询where a = xx and b = xx and c == xx会怎么样?
会进行索引合并,对多个索引分别进行条件扫描,然后将它们各自的结果进行合并。
MySQL5.0之前,一个表一次只能使用一个索引,无法同时使用多个索引分别进行条件扫描。但是从5.1开始,引入了索引合并优化技术,对同一个表可以使用多个索引分别进行条件扫描。
那如果不想索引合并呢?怎么解决?
如果出现了索引合并,那么一般同时也意味着我们的索引建立得不太合理,因为索引合并 是可以通过建立联合索引进行更一步优化的,减少索引扫描的次数。
比如,建立联合索引(a, b, c),这条查询语句遵循最左匹配原则,所以三个字段都可以利用联合索引。
Redis
Redis在项目中的应用?
主要用做缓存,提升查询的性能,避免请求查询mysql。
Redis过期淘汰策略有哪些?
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
1、不进行数据淘汰的策略
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。
2、进行数据淘汰的策略
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
在设置了过期时间的数据中进行淘汰:
volatile-random:随机淘汰设置了过期时间的任意键值; volatile-ttl:优先淘汰更早过期的键值。 volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值; volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
在所有数据范围内进行淘汰:
allkeys-random:随机淘汰任意键值; allkeys-lru:淘汰整个键值中最久未使用的键值; allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
Java
HashMap底层原理
数据结构:在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了,所以在 JDK 1.8版本的时候做了优化,当一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。
插入键值对的put方法的区别:JDK 1.8中会将节点插入到链表尾部,而1.7中是采用头插; 哈希算法:JDK 1.7中的 hash()
扰动函数需要进行4次异或运算;而 JDK 1.8中则是将 (h = key.hashCode()) ^ (h >>> 16
) 的结果作为计算下标的hash值,只需要一次异或运算就可以让hashCode的高位和低位同时参与下标值的计算,更具有随机性,可以使元素分布更均匀;
// JDK 1.7 hash 方法源码.
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8 hash 方法源码.
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^:按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap的扩容过程,说一下
hashMap默认的负载因子是0.75,即如果hashmap中的元素个数超过了总容量75%,则会触发扩容,扩容分为两个步骤:
新的数组扩容 2 倍大小 计算每个元素新的位置,然后迁移元素
JDK 1.8 在扩容 2 倍容量后,在迁移数据时,不需要重新计算元素的hash进行元素迁移,而是用原先位置key的hash值与旧数组的长度(oldCap)进行"与"操作。
举一个例子,假设元素 e 存储在旧数据的桶位 i 中:
如果(e.hash & oldCap) == 0 ,那么当前元素的桶位置不变。 如果(e.hash & oldCap) == 1,那么桶的位置就是原位置+原数组长度(oldCap)
为什么用 e.hash & oldCap 计算新位置?
首先我们要明确三点:
HashMap的数组大小一定是2的N次幂 HashMap扩容时一般为增大一倍,即size = size * 2 HashMap的索引计算方式为 hash(key) & (size - 1),由1可知,size-1的二进制「低位都为1」
我们假设 oldCap = 16, 即 2^4,16 - 1 = 15, 二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111
,可见除了低4位, 其他位置都是0,则 (16-1) & hash
自然就是取hash值的低4位,我们假设它为 abcd
(abcd 各自的值可能是 0 或者 1)。
当我们将oldCap扩大两倍后, 新的index的位置就变成了 (32-1) & hash
, 其实就是取 hash值的低5位.。那么对于同一个Node, 低5位的值无外乎下面两种情况:
0abcd
1abcd
其中, 0abcd
与原来的index值一致, 而1abcd
= 0abcd + 10000
= 0abcd + oldCap
(这里的oldCap是 16,二进制数:10000 )
故虽然数组大小扩大了一倍,但是同一个key
在新旧table中对应的index却存在一定联系:要么一致,要么相差一个 oldCap
。
而新旧index是否一致就体现在hash值的第4位(我们把最低为称作第0位), 怎么拿到这一位的值呢, 只要:
hash & 0000 0000 0000 0000 0000 0000 0001 0000
上式就等效于
hash & oldCap
故得出结论:
如果(e.hash & oldCap) == 0 ,那么当前元素的桶位置不变。 如果(e.hash & oldCap) == 1,那么桶的位置就是原位置+原数组长度(oldCap)
ArrayList底层是怎么实现扩容的?
当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData:为1.5倍
如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容,则直接扩容elementData为1.5倍。
ArrayList和LinkedList的区别
是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全;底层数据结构:
Arraylist
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)插入和删除是否受元素位置的影响: ①
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。②LinkedList
采用链表存储,所以对于add(E e)
方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i
插入和删除元素的话((add(int index, E element)
) 时间复杂度近似为o(n))
因为需要先移动到指定位置再插入。是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。内存空间占用:
ArrayList
的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而LinkedList
的空间花费则体现在它的每一个元素都需要消耗比ArrayList
更多的空间(因为要存放直接后继和直接前驱以及数据)。
消息队列
MQ如何防止消息不丢失?
使用一个消息队列,其实就分为三大块:生产者、中间件、消费者,所以要保证消息就是保证三个环节都不能丢失数据。
消息生产阶段:生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 ( MQ 中间件) 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,如果返回异常则进行消息重发,那么这个阶段是不会出现消息丢失的。 消息存储阶段:RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。 消息消费阶段:消费者接收消息+消息处理之后,才回复 ack 的话,那么消息阶段的消息不会丢失。不能收到消息就回 ack,否则可能消息处理中途挂掉了,消息就丢失了。
MQ消息大量堆积怎么办?
从生产者端解决:一般我们的系统容量或者处理能力都是规划好的,出现消息堆积的情况,大部分是由于流量暴增引起,这个时候可以考虑控制生产者的速率,对前端机器流量进行限速限流。 从消费者端解决。消费者端解决的思路有两种: 假如消费者数还有增加的空间,那么我们加消费者解决。
假如没有拓展的可能,但吞吐量还没达到MQ的上限,只是消费者消费能力不足,比如消费者总体消费能力已经到达上线(数据库写入能力等),或者类似Kafka的消费者数量与partition数有关,如果前期设计没有做好水平拓展的设计,这个时候多少个partition就只能对应多少个消费者。这个时候我们可以先把一部分消息先打到另外一个MQ中或者先落到日志文件中,再拓展消费者进行消费,优先恢复上游业。
从整体系统上进行解决:第2点有提到就是有些MQ的设计限制,导致的消费者数是没法动态拓展的,这个时候可以考虑将原先队列进行拆分,比如新建一个topic 分担一部分消息,这个方式需要对系统的上下游都要进行调整,在实际操作难度可能比较高,处理起来可能也比较耗时,如果在事前有做好这个设计那事发后就能很好进行调整。