Contents

面试题整合

Contents

没看懂的(循环依赖,多线程的3,redis脑裂)

计算机网络

1.讲一下tcp与upd及其区别

区别:

1.TCP面向连接,即使用TCP通信双方传输前要三次握手来简历TCP连接;UDP是无连接的,即发送数据之前不需要建立连接,可以随时发送数据。

2.TCP仅支持单播(即一对一通信);UDP支持单播、多播和广播;

3.TCP提供可靠的服务(即不会出现无码、丢失等传输差错);UDP提供不可靠服务。因此,TCP适用于要求可靠传输且对实时性要求不高的应用,如文件传输和电子邮件;而UDP适合视频会议等实时应用。

4.TCP面向字节流,即把应用报文看成一连串无结构的字节流;UDP是面向报文的,即对应用保温既不合并也不拆分而是保留报文的边界。

5.TCP有拥塞控制;UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如实时视频会议等)。

6.TCP首部开销20字节;UDP的首部开销小,只有8个字节。

2.三次握手与四次挥手能不能改,为什么

之所以需要第三次握手,主要为了防止已失效的连接请求报文段突然又传输到了服务端,导致产生问题。

  • 比如客户端A发出连接请求,可能因为网络阻塞原因,A没有收到确认报文,于是A再重传一次连接请求。
  • 然后连接成功,等待数据传输完毕后,就释放了连接。
  • A发出的第一个连接请求等到连接释放以后的某个时间才到达服务端B,此时B误认为A又发出一次新的连接请求,于是就向A发出确认报文段。
  • 如果不采用三次握手,只要B发出确认,就建立新的连接了,此时A不会响应B的确认且A不发送数据,这时候B处于SYN-RECV状态一直等待A发送数据,浪费资源。

第四次挥手为什么客户端要等待2MSL才进入Closed状态?

保证A发送的最后一个ACK报文段能够到达B。这个ACK报文段有可能丢失,B收不到这个确认报文,就会超时重传连接释放报文段,然后A可以在2MSL时间内收到这个重传的连接释放报文段,接着A重传一次确认,重新启动2MSL计时器,最后A和B都进入到CLOSED状态,若A在TIME-WAIT状态不等待一段时间,而是发送完ACK报文段后直接进入Closed状态,则A无法收到B重传的连接释放报文段,然后B就会反复重传连接释放报文段而不会进入Closed状态。

为什么是四次挥手?

在关闭连接时,当Server端收到Client端发出的连接释放报文时,很可能并不会立即关闭SOCKET,即服务端的报文还没有发完,所以Server端先回复一个ACK确认报文,告诉Client端我收到你的连接释放报文了。只有等到Server端所有的报文都发送完了,这时Server端才能发送连接释放报文,之后两边才会真正的断开连接。故需要四次挥手。

3.TCP 协议是如何保证可靠传输的?

  1. 数据包校验:目的是检测数据在传输过程中的任何变化,若校验出包有错,则丢弃报文段并且不给出响应,这时 TCP 发送数据端超时后会重发数据;
  2. 对失序数据包重排序:既然 TCP 报文段作为 IP 数据报来传输,而 IP 数据报的到达可能会失序,因此 TCP 报文段的到达也可能会失序。TCP 将对失序数据进行重新排序,然后才交给应用层;
  3. 丢弃重复数据:对于重复数据,能够丢弃重复数据;
  4. 应答机制:当 TCP 收到发自 TCP 连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒;
  5. 超时重传:TCP在发送一个数据之后,就开启一个定时器,若是在这个时间内没有收到发送数据的ACK确认报文,则对该报文进行重传,在达到一定次数还没有成功时放弃并发送一个复位信号。
  6. 流量控制:TCP 连接的每一方都有固定大小的缓冲空间。TCP 的接收端只允许另一端发送接收端缓冲区所能接纳的数据,这可以防止较快主机致使较慢主机的缓冲区溢出,这就是流量控制。TCP 使用的流量控制协议是可变大小的滑动窗口协议。
  7. 拥塞控制 : 当网络拥塞时,减少数据的发送。TCP 在发送数据的时候,需要考虑两个因素:一是接收方的接收能力,二是网络的拥塞程度。接收方的接收能力由滑动窗口表示,表示接收方还有多少缓冲区可以用来接收数据。网络的拥塞程度由拥塞窗口表示,它是发送方根据网络状况自己维护的一个值,表示发送方认为可以在网络中传输的数据量。发送方发送数据的大小是滑动窗口和拥塞窗口的最小值,这样可以保证发送方既不会超过接收方的接收能力,也不会造成网络的过度拥塞。

4.讲一下https如何实现可靠传输,https需要几次握手

HTTPS是基于HTTP的上层添加了一个叫做TLS的安全层,对数据的加密等操作都是在这个安全层中进行处理的,其底层还是应用的HTTP。用对称加密来传送消息,但对称加密所使用的密钥是通过非对称加密的方式发送出去。

四次

1.客户端从服务端请求非对称加密的公钥

2.服务端发送公钥

3.客户端发送非对称加密的对称加密密钥

4.服务端用私钥解密后确认收到对称密钥

mysql

1.讲一下mysql引擎如何执行一条sql的

1.查询语句执行流程

大概有4步:

  • 先连接器检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,会先查询缓存,以这条 SQL 语句为 key 在内存中查询是否有结果,如果有直接返回缓存结果,如果没有,执行下一步。
  • 通过分析器进行词法分析,提取 SQL 语句的关键元素。然后判断这个 SQL 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。
  • 接下来就是优化器进行确定执行方案,优化器根据自己的优化算法进行选择执行效率最好的一个方案
  • 进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果。

2.更新语句执行流程

举个例子,更新语句是这样的:

update user set name = 'name' where id = 1;

1.先查询到 id 为1的记录,有缓存会使用缓存。

2.拿到查询结果,将 name 更新为张三,然后调用引擎接口,写入更新数据,innodb 引擎将数据保存在内存中,同时记录redo log,此时redo log进入 准备状态。

3.执行器收到通知后记录binlog,然后调用引擎接口,提交redo log为提交状态。

4.更新完成。

2.数据隔离级别

MySQL数据库为我们提供的四种隔离级别:

  • Serializable(串行化):这种隔离级别模拟了事务串行执行,就像一个接一个执行一样,。通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。但会显著降低并发处理能力
  • Repeatable read(可重复读):MySQL的默认事务隔离级别,确保如果在一个事务内多次读取同一行数据,结果总是一致。防止了脏读和不可重复读,但是幻读仍然可能发生。为了实现这个隔离级别,mysql使用了一种称为多版本并发控制(mvcc)的技术。
  • Read committed(读已提交):只有在事务提交后,其他事务才能看到其所做的修改,从而避免了脏读。然而,它仍然允许不可重复读(在一个事务中,两次相同的查询可能得到不同的结果)和幻读(在一个事务中执行查询,另一个事务新增了记录并提交,第一个事务再次查询时会发现有额外的记录)
  • Read uncommitted(读未提交):允许事务读取尚未提交的其他事务修改的数据,因此可能发生脏读、不可重复读和幻读。

3.MVCC

InnoDB 的MVCC是通过 read view 和版本链实现的,版本链保存有历史版本记录,通过****read view 判断当前版本的数据是否可见,如果不可见,再从版本链中找到上一个版本,继续进行判断,直到找到一个可见的版本。

nnoDB默认的隔离级别是RR(REPEATABLE READ),RR解决脏读、不可重复读、幻读等问题,使用的是MVCC。MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。它最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构

隐藏列

InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。

基于undo log的版本链

每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链

使用事务更新行记录的时候,就会生成版本链,执行过程如下:

  1. 用排他锁锁住该行;
  2. 将该行原本的值拷贝到undo log,作为旧版本用于回滚;
  3. 修改当前行的值,生成一个新版本,更新事务id,使回滚指针指向旧版本的记录,这样就形成一条版本链。

read view

通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见

4.讲一下binlog redolog undolog

0.它们是什么?

bin log(二进制日志)和 redo log(重做日志)和 undo log(回滚日志)。

bin log二进制日志

Bin Log是MySQL的二进制日志,记录了所有更改数据或可能更改数据的SQL语句,并以二进制格式保存在磁盘上

binlog主要用于数据备份和主从同步。

redo log重做日志

redo log是重做日志,记录的是数据修改之后的值,不管事务是否提交都会记录下来。它的主要作用是保证服务崩溃后,仍能把事务中变更的数据持久化到磁盘上。

redo log保证了事务的持久性,比如断电了,InnoDB存储引擎会使用redo log恢复到断电前的时刻,以此来保证数据的完整性。

undo log回滚日志

Undo Log是MySQL的回滚日志,它用于在事务回滚或者数据库崩溃时撤销已提交的事务对数据库的修改。Undo Log记录了记录了事务对数据库所做的更改的相反操作。例如,如果事务执行了一条INSERT语句,那么Undo Log会记录一个DELETE操作;若执行UPDATE,则记录一个相反的UPDATE操作。,以便在需要回滚时可以恢复数据。

undo log则负责事务的回滚和实现MVCC,保证了事务的原子性和一致性

1.undo log 与redo log的区别

Undo Log和Redo Log是InnoDB存储引擎中实现事务持久性和原子性的关键机制,但它们的功能和应用场景不同。下面将从多个维度详细对比这两种日志:

  1. 基本概念 Undo Log:记录事务对数据所做的修改前的状态,用于事务回滚和MVCC(多版本并发控制)。Redo Log:记录数据页的物理修改操作,主要用于系统崩溃后的恢复和保证事务的持久性。
  2. 应用场景 Undo Log:用于事务回滚时恢复数据到原始状态,支持MVCC以提供一致性读视图,避免锁竞争。Redo Log:在数据库崩溃后通过重放日志保证已提交事务的修改不会丢失,采用WAL策略提升写入性能。
  3. 存储内容 Undo Log:记录数据的逻辑变化,如INSERT操作会记录相反的DELETE操作信息。Redo Log:记录物理数据页面的修改信息,如数据页的变更。

2.bin log和redo log有什么区别?

下面将从多个维度详细对比Bin Log和Redo Log:

  1. 基本概念 Bin Log:记录了对MySQL数据库执行更改的所有操作,包括DDL和DML语句(不包括查询语句),以事件形式记录。Redo Log:记录数据页的物理修改操作,主要用于系统崩溃后的恢复。
  2. 应用场景 Bin Log:用于数据恢复、主从复制以及数据备份。Redo Log:保证事务的持久性,防止系统崩溃导致的数据丢失,提升写入性能。
  3. 记录内容 Bin Log:记录逻辑操作,如SQL语句及其增删改操作的反向信息。Redo Log:记录物理数据页面的修改信息。

3.redo log 的执行流程?

Redo Log(重做日志)是MySQL数据库中的一种日志文件,用于记录数据库中每个事务的修改操作。它的执行流程如下:

  1. 当一个事务开始时,InnoDB存储引擎会为这个事务分配一个连续的、且自增的log sequence number(LSN,日志序列号)。
  2. 在事务执行过程中,每当有数据被修改,InnoDB存储引擎就会将这个修改作为一个“redo log record”写入到Redo Log Buffer(重做日志缓冲区)中。这个记录包含了修改的类型(插入、更新或删除)、修改的表空间ID、修改的数据页号、修改的行信息等。
  3. 当事务提交时,InnoDB存储引擎会将Redo Log Buffer中的redo log record写入到Redo Log文件中。这个过程是异步进行的,也就是说,即使redo log record还没有被写入到磁盘上的Redo Log文件中,事务也可以正常提交。
  4. 当系统发生故障时,如服务器宕机或者数据库进程异常退出,InnoDB存储引擎可以通过Redo Log文件中的redo log record来恢复未完成的事务。具体来说,它会扫描所有的Redo Log文件,找到未完成的事务,然后根据redo log record重新执行这些事务的修改操作,从而保证数据的一致性。
  5. Redo Log文件的大小是有限的,当Redo Log Buffer中的redo log record被写入到Redo Log文件后,这些redo log record就会被标记为“已提交”。同时,InnoDB存储引擎会定期地将Redo Log文件中的“已提交”的redo log record进行清理,以释放磁盘空间。这个过程被称为“redo log compaction”。

总之,Redo Log的主要作用是记录数据库中每个事务的修改操作,以便在系统发生故障时能够恢复未完成的事务,保证数据的一致性。

4.为什么 redo log 具有 crash-safe 的能力,是 binlog 无法替代的?

第一点:redo log 可确保 innoDB 判断哪些数据已经刷盘,哪些数据还没有

  • redo log 和 binlog 有一个很大的区别就是,一个是循环写,一个是追加写。也就是说 redo log 只会记录未刷盘的日志,已经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是追加日志,保存的是全量的日志。
  • 当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志,但没有一个标志让 innoDB 判断哪些数据已经刷盘,哪些数据还没有。
  • 但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,因为是循环写!数据库重启后,直接把 redo log 中的数据都恢复至内存就可以了。

第二点:如果 redo log 写入失败,说明此次操作失败,事务也不可能提交

  • redo log 每次更新操作完成后,就一定会写入日志,如果写入失败,说明此次操作失败,事务也不可能提交。
  • redo log 内部结构是基于页的,记录了这个页的字段值变化,只要crash后读取redo log进行重放,就可以恢复数据。
  • 这就是为什么 redo log 具有 crash-safe 的能力,而 binlog 不具备。

5.讲一下一个事务执行流程开始到最后这几个日志需要进行什么操作

[事务开始] │ ├─ 数据修改 │ ├─ 记录undo log(用于回滚) │ └─ 记录redo log(buffer) │ [提交事务] │ ├─ 阶段1:redo log prepare │ └─ 刷redo log到磁盘 │ ├─ 阶段2:写binlog │ └─ 刷binlog到磁盘 │ └─ 阶段3:redo log commit └─ 标记事务提交完成

6.讲一下慢sql优化,b+树太高了如何解决

1.查看慢查询日志记录,分析慢SQL

通过慢查询日志slow log,定位那些执行效率较低的SQL语句,重点关注分析

2.explain查看分析SQL的执行计划

当定位出查询效率低的SQL后,可以使用explain查看SQL的执行计划。

比如在这里面可以通过key和key_len检查是否命中了索引,如果本身已经添加了索引,也可以判断索引是否有失效的情况,

第二个,可以通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描,

第三个可以通过extra建议来判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复

3.profile 分析执行耗时

explain只是看到SQL的预估执行计划,如果要了解SQL真正的执行线程状态及消耗的时间,需要使用profiling。开启profiling参数后,后续执行的SQL语句都会记录其资源开销,包括IO,上下文切换,CPU,内存等等,我们可以根据这些开销进一步分析当前慢SQL的瓶颈再进一步进行优化。

4.Optimizer Trace分析详情

profile只能查看到SQL的执行耗时,但是无法看到SQL真正执行的过程信息,即不知道MySQL优化器是如何选择执行计划。这时候,我们可以使用Optimizer Trace,它可以跟踪执行语句的解析优化执行的全过程。

大家可以查看分析其执行树,会包括三个阶段:

  • join_preparation:准备阶段
  • join_optimization:分析阶段
  • join_execution:执行阶段

5.确定问题并采用相应的措施

最后确认问题,就采取对应的措施。

  • 多数慢SQL都跟索引有关,比如不加索引,索引不生效、不合理等,这时候,我们可以优化索引
  • 我们还可以优化SQL语句,比如一些in元素过多问题(分批),深分页问题(基于上一次数据过滤等),进行时间分段查询
  • SQl没办法很好优化,可以改用ES的方式,或者数仓。
  • 如果单表数据量过大导致慢查询,则可以考虑分库分表
  • 如果数据库在刷脏页导致慢查询,考虑是否可以优化一些参数,跟DBA讨论优化方案
  • 如果存量数据量太大,考虑是否可以让部分数据归档

B+树高度增加通常由以下原因导致:

  1. 数据量过大而索引设置不合理
  2. 主键或索引设计不当
  3. 频繁的页分裂导致树不平衡
  4. 索引列选择不合适

7.b+树与hash索引有什么区别,各自优缺点

哈希索引的key是经过hash运算得出的,即跟实际数据的值没有关系,因此哈希索引不适用于范围查询和排序操作

容易导致全表扫描,因为可能存在不同的key经过hash运算后值相同

索引列上的值相同的话,易造成hash冲突,效率低下

8.讲一下分库分表,你们怎么做的。

业务分库:按照业务模块将数据分散到不同的数据库服务器

水平分表和垂直分表:

./面试题笔记.assets/136bc2f01919edcb8271df6f7e71af40.jpg

9.深分页

mysql查询中 limit 1000,10 会比 limit 10 更慢。原因是 limit 1000,10 会取出1000+10条数据,并抛弃前1000条,这部分耗时更大。

(什么是深度分页问题?)

select * from xxx order by id limit 500000, 10;

当offset非常大时,server层会从引擎层获取到很多无用的数据,而当select后面是*号时,就需要拷贝完整的行信息,拷贝完整数据相比只拷贝行数据里的其中一两个列字段更耗费时间。这就是深度分页问题。

优化方法:

  • 子查询优化

我们可以采用覆盖索引和子查询来解决

先分页查询数据的id字段,确定了id之后,再用子查询来过滤,只查询这个id列表中的数据就可以了

因为查询id的时候,走的覆盖索引,所以效率可以提升很多

select * from xxx  where id >=(select id from xxx order by id limit 500000, 1) order by id limit 10;
  • 延迟关联尽可能地使用索引覆盖扫描,而不是查询所有的列,然后根据需要做一次关联操作再返回所需的列。

10.Mysql优化和索引失效

  • mysql慢查询日志 开启慢查询日志功能:慢查询日志是MySQL内置的一项功能,可以记录执行超过指定时间的SQL语句。在MySQL配置文件中设置slow_query_log=1以启用该功能,并设定long_query_time=2来规定只有执行时间超过2秒的SQL才会被记录。分析慢查询日志文件:通过查看日志文件中记录的SQL语句和相应的执行时间、扫描行数等指标,可以直观地识别出慢查询。一般情况下,慢查询日志会记录查询执行的时间、返回的行数以及检查的行数等重要信息。

  • 创建适当的索引:可根据EXPLAIN来查看是否用了索引还是全表扫描 索引类型选择:根据查询的需要创建有针对性的索引,比如在WHERE和ORDER BY命令上涉及的列建立索引。避免索引过多:索引并不是越多越好,需要根据实际查询有针对性地创建,同时删除不必要的索引,避免维护索引的额外开销。合理设置索引顺序:使用多列索引时,注意索引的顺序与查询条件保持一致,可以提高索引的使用效率。避免索引失效

  • **优化查询语句:**避免全表扫描:应尽量减少在查询语句中使用全表扫描的操作,比如避免使用 SELECT ,尽量在 WHERE 子句中使用已有索引的字段。优化子查询和连接:尽量减少子查询的使用,将其转化为连接(JOIN)方式;如果必须使用子查询,尽量使其返回更少的记录数。同样地,避免多个表的连接,特别是大数据表之间的连接。使用具体的列名:在SELECT语句中,指定所需的具体列名而不是使用 * ,可以避免回表查询

  • 利用缓存:利用Redis等缓存热点数据,提高查询效率

  • 提升硬件配置性能更高的硬件设备:采用更快的存储介质(如固态硬盘)和更大的内存容量,以提升查询的IO性能。均衡系统资源:合理分配系统资源,确保MySQL的服务模型能够充分利用服务器的CPU资源。

导致索引失效的情况:

  • 以%开头的like查询如%abc,无法使用索引
  • 查询条件使用or连接,也会导致索引失效:例如,select * from student_info where student_name='Helen' or student_age > 15,这种情况下索引不会起作用。原因是数据库无法从OR条件的一个范围查询中获益,只能通过扫描表的方式来满足查询需求。
  • 在进行范围查询(如 BETWEEN、>、<、>=、<=)时会失效
  • 索引在使用的时候没有遵循最左匹配法则(联合索引的匹配从where的最左边的字段开始,只有匹配成功才能继续匹配到右边的下一个字段。)
  • 在添加索引的字段上进行了运算操作:例如,若索引列是年龄(age),正常的查询select * from student_info where student_age = 21会利用索引,但若进行计算select * from student_info where student_age + 1 = 21,则索引失效。这是因为数据库无法直接通过计算后的值去匹配索引。
  • 当操作符左右两边的数据类型不一致时,会发生隐式转换让索引失效。
  • 对索引列进行函数操作:如果有一个基于enrollment_date的索引,正常的日期等值查询可以利用索引,而一旦查询中使用了日期函数,如YEAR(enrollment_date) = 2022,索引就会失效。这是因为函数操作改变了索引列的原值,数据库无法直接利用索引进行快速检索。

Redis

1.介绍一下redis常规穿透击穿雪崩及其解决方案

1.什么是缓存穿透 (数据库缓存都没有)? 怎么解决 ?

缓存穿透:请求根本不存在的资源(DB本身就不存在,Redis更是不存在),这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。

有2个解决方案:

方案一:使用布隆过滤器

使用BitMap作为布隆过滤器,将目前所有可以访问到的资源通过简单的映射关系放入到布隆过滤器中(哈希计算),当一个请求来临的时候先进行布隆过滤器的判断,如果有那么才进行放行,否则就直接拦截

布隆过滤器

什么是布隆过滤器?

布隆过滤器的核心思想是使用多个哈希函数来将元素映射到位数组中的多个位置上。当一个元素被加入到布隆过滤器中时,它会被多次哈希,并将对应的位数组位置设置为1。当需要判断一个元素是否在布隆过滤器中时,我们只需将该元素进行多次哈希,并检查对应的位数组位置是否都为1,如果其中有任意一位为0,则说明该元素不在集合中;如果所有位都为1,则说明该元素可能在集合中(因为有可能存在哈希冲突),需要进一步检查。

布隆过滤器主要是用于检索一个元素是否在一个集合中。我们当时使用的是redisson实现的布隆过滤器它的底层主要是先去初始化一个比较大数组,里面存放的二进制0或1。在一开始都是0,当一个key来了之后经过3次hash计算,模于数组长度找到数据的下标然后把数组中原来的0改为1,这样的话,三个数组的位置就能标明一个key的存在。查找的过程也是一样的。但由于哈希冲突,存在一定的误判率,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,其实已经算是很划分了,5%以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。

方案二:对空值进行缓存

如果查询缓存和数据库的数据没有找到,则直接设置一个默认值(可以是空值)存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会继续访问存数据库。

2.什么是缓存击穿(缓存没有数据库有) ? 怎么解决 ?

缓存击穿的意思是:redis某个热点key过期或者刚开始,但是此时有大量的用户访问该过期key(或者大并发场景下刚开始这个数据只在数据库里不在缓存里),这个时候大并发的请求可能会瞬间把 DB 压垮。

解决方案有两种方式:

第一可以使用互斥锁:只有一个请求可以获取到互斥锁,然后到DB中将数据查询并加入到缓存,之后所有请求就可以从缓存中得到响应

第二监控数据,实时调整:监控哪些数据是热门数据,实时的调整key的过期时长。

3.什么是缓存雪崩 ? 怎么解决 ?

缓存雪崩是指:redis中大量的key集体过期,请求全部转发到DB,DB 瞬时压力过重雪崩。

与缓存击穿的区别:雪崩是很多key,击穿是某一个key缓存。

解决方式:将缓存失效时间分散开,比如可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件

2.只用redis如何实现分布式🔒

1.为什么需要分布式锁?

在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。

举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 1个。如果不对共享资源进行互斥访问,就可能出现以下情况:

  • 线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。
  • 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
  • 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。
  • 此时就发生了超卖问题,导致商品被多卖了一份。

为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。

如何才能实现共享资源的互斥访问呢? 锁是一个比较通用的解决方案,更准确点来说是悲观锁。

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。

分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。

举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。

2.redis的分布式锁redission的工作原理

redission底层是redis的setnx命令和lua脚本。

他的工作过程如下:

一个线程加锁成功后,会另开一个线程(称为看门狗Watch dog)进行监控,不断监听持有锁的线程,给线程增加持有锁的时间,也就是“续期”,规则是每隔releaseTime(锁的过期时间)/3的时间做一次续期(就是重新设置锁过期时间为releaseTime),手动释放锁,此时通知对应线程的Watch dog不需要再监听了。

另外一个线程也想加锁,whike循环不断尝试获取锁,到了一定时间与之后就会放弃。

redis本身怎么实现锁?

  • SETEX key seconds value 设置指定key的值,并将 key 的过期时间设为 seconds 秒
  • SETNX key value 只有在 key 不存在时设置 key 的值

3.redission实现的分布式锁可重入吗

可重入就是说某个线程已经获得某个锁,该线程可以再次获取锁而不会出现死锁。

可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。 说白了就是同一个线程再次进入同样代码时,可以再次拿到该锁。 它的作用是:防止在同一线程中多次获取锁而导致死锁发生。

redisson实现的分布式锁是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计算上减一。在存储数据的时候采用的hash结构,其中key是当前线程的唯一标识,value是当前线程重入的次数。

4.redisson实现的分布式锁能解决主从一致性的问题吗

redisson实现的分布式锁不能解决主从一致性的问题,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。

我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。

但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁

如果业务非要保证数据的强一致性,建议使用zookeeper实现的分布式锁,它是可以保证强一致性的。

3.AOF有哪些机制,AOF数据恢复流程

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。

1.AOF 工作基本流程是怎样的?

AOF 持久化功能的实现可以简单分为 5 步:

  1. 命令追加(append):所有的写命令会追加到 AOF 缓冲区中:所有写入命令会先追加到缓冲区(aof_buf)中,而不是直接写入文件。这是为了减少磁盘IO操作,提升性能。Redis使用单线程响应命令,如果每次写操作都直接追加到硬盘,性能会受到严重影响。因此,先将命令写入缓冲区是一种折中方式。
  2. 文件同步(fsync):AOF 缓冲区根据对应的持久化方式( fsync 策略)向AOF文件做同步操作。这一步需要调用 fsync 函数(系统调用), fsync 针对单个文件操作,对其进行强制硬盘同步,fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。(在AOF文件中,每一个写命令都会被记录下来,其中包括对键值的修改、添加和删除操作。然而,某些键可能会被多次修改或删除,这就造成了数据的冗余。在AOF重写过程中,只记录最终的结果,从而大大减少了文件大小。)
  3. 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。随着不断写入AOF文件,其体积会不断增大。为了减小文件体积并提高恢复速度,Redis提供了AOF文件重写机制。Redis通过fork创建一个子进程来进行重写,子进程根据RDB内存快照将AOF文件数据写入新的AOF文件,父进程继续接收并缓存新的写命令,同时将这些新的命令通过增量写入新的AOF文件。完成后,新AOF文件替换旧文件。
  4. 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。

2.AOF 持久化方式有哪些?

fsync是一个在Linux操作系统中非常重要的函数,用于将缓冲区中的数据立即写入磁盘,从而确保数据的持久性和安全性

策略有三种:always、everysec和no。always表示每次写操作都会立即同步写入AOF文件,everysec表示每秒执行一次同步操作,no表示由操作系统决定何时进行同步。

在always策略下,命令写入缓冲区后,直接调用系统的fsync操作同步到AOF文件。在everysec策略下,命令写入缓冲区后,每秒执行一次fsync操作。而在no策略下,仅依靠操作系统的写入操作,不进行fsync同步。

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:

  1. appendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。
  2. appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsyncfsync间隔为 1 秒)
  3. appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsyncfsync 的时机由操作系统决定)。

可以看出:这 3 种持久化方式的主要区别在于 fsync 同步 AOF 文件的时机(刷盘)

3.AOF 为什么是在执行完命令之后记录日志?

关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。

为什么是在执行完命令之后记录日志呢?

  • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
  • 在命令执行完之后再记录,不会阻塞当前的命令执行。

这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):

  • 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
  • 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。

4.Redis数据类型、原理

1 五种常用数据类型介绍

Redis存储的是key-value结构的数据,其中key是字符串类型,value有5种常用的数据类型:

  • 字符串(string):普通字符串,Redis中最简单的数据类型,string的内部结构实现上类似Java的ArrayList
  • 哈希(hash):也叫散列,类似于Java中的HashMap结构
  • 列表(list):按照插入顺序排序,可以有重复元素,类似于Java中的LinkedList,底层是双向链表
  • 集合(set):无序集合,没有重复元素,类似于Java中的HashSet
  • 有序集合(sorted set/zset):集合中每个元素关联一个分数(score),根据分数升序排序,没有重复元素

1.zset的底层原理

Redis的有序集合(Zset)底层采用两种数据结构,分别是压缩列表(ziplist)和跳跃表(skiplist)

  • 当Zset的元素个数小于128个且每个元素的长度小于64字节时,采用ziplist编码。在ziplist中,所有的key和value都按顺序存储,构成一个有序列表。这种实现方式主要是为了节省内存,因为压缩列表是一块连续的内存区域,通过紧凑的存储可以有效地利用空间。虽然压缩列表可以有效减少内存占用,但在需要修改数据时,可能需要对整个列表进行重写,性能较低。
  • 跳表是一种多层次的链表结构,通过多级索引提升查找效率。在不满足使用压缩列表的条件下,Redis会采用跳表作为Zset的底层数据结构。跳表能够提供平均O(logN)的时间复杂度进行元素查找,最坏情况下为O(N)。跳表中的每一层都是一个有序链表,并且层级越高,链表中的节点数就越少,从而允许在高层快速跳过一些元素,达到快速定位的目的。

综上所述,Redis的Zset通过灵活地使用压缩列表和跳跃表作为底层数据结构,在不同的场景下平衡了内存使用效率和数据操作性能。这两种数据结构各有优劣,压缩列表适用于数据量小、内存受限的场景,而跳跃表适合于数据量大、需要高效操作的环境。

什么是跳表?

跳表(Skip List)是一种基于有序链表的数据结构,通过多级索引的方式实现高效的查找、插入和删除操作

跳表以空间换时间的方式优化了传统单链表的效率。在单链表中,即使数据是有序的,查找一个元素也需要从头到尾遍历整个链表,时间复杂度为O(n)。而在跳表中,通过建立多层索引来实现快速查找。顶层索引链表的节点数量远少于底层原始链表,并且层级越高,节点越少。

跳表中的每一层都是一个有序链表,并且每个节点都包含指向同层级下一个节点的指针以及指向下一层对应节点的down指针。例如,当查找一个元素时,首先在顶层索引进行查找,如果当前节点的值大于要查找的值,则继续在同一层级向右移动;如果小于要查找的值,则通过down指针下沉到下一层继续查找。每下降一层,搜索范围就缩小一半,最终在底层链表中找到目标元素或者确认元素不存在。

跳表的插入和删除操作同样高效,其时间复杂度也是O(logn)。向跳表中插入新元素时,首先要找到合适的插入位置,保持链表的有序性。然后通过随机函数决定新节点应该出现在哪些层级的索引中:随机结果高于某个固定概率p,就在该层级插入新节点。删除操作类似,先找到要删除的节点,然后在所有包含该节点的层级中移除它。

2.hash的底层原理

Redis的Hash数据结构底层原理主要基于两种数据结构:ziplist和hashtable

具体来说,这两种数据结构的应用如下:

  • ziplist:当满足特定条件时(键和值的字符串长度都小于64字节,且键值对数量少于512),Hash数据结构会采用ziplist作为其底层实现。在ziplist中,所有的key和value都按顺序存储,构成一个有序列表。这种实现方式主要是为了节省内存,因为压缩列表是一块连续的内存区域,通过紧凑的存储可以有效地利用空间。
  • hashtable:当不满足ziplist的条件时,Hash数据结构会使用hashtable作为底层实现。在hashtable中,每个键值对都以字典的形式保存,其中字典的键为字符串对象,保存了原键值对的键;字典的值为另一个字符串对象,保存了原键值对的值。这样的结构允许快速的查找、插入和删除操作。

此外,在Hash数据结构中,如果ziplist编码所需的两个条件中的任意一个不再满足时,会发生编码转换,即原本保存在ziplist中的所有键值对会被转移到字典中,对象的编码也会从ziplist变为hashtable。这通常发生在键的长度过大、值的长度过大或者键值对的数量过多的情况下。

综上所述,Redis的Hash数据结构根据数据的规模和访问模式灵活地在ziplist和hashtable之间切换,以达到既节省内存又保证访问效率的目的。

2.3种redis特殊数据类型

Bitmap (位图)

Bitmap 存储的是连续的二进制数字(0 和 1),本来int数字占4字节32位,但通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态(比如:01表示1,001表示2) 。,所以 Bitmap 本身会极大的节省储存空间。

# 将名为myBitmap的bitmap的第5位设置为1
SETBIT myBitmap 5 1  //SETBIT key offset value

你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。

HyperLogLog(基数统计)

HyperLogLog 是一种有名的基数计数概率算法 ,基于 LogLog Counting(LLC)优化改进得来,并不是 Redis 特有的,Redis 只是实现了这个算法并提供了一些开箱即用的 API。

Redis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:

  • 稀疏矩阵:计数较少的时候,占用空间很小。
  • 稠密矩阵:计数达到某个阈值的时候,占用 12k 的空间

Geospatial (地理位置)

Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。

通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。

数值范围0-40亿的数如何排序(bitmap)

使用Bitmap进行排序是一种特殊的方法,适用于处理大量数据的排序问题,尤其是在内存有限的情况下。以下是使用Bitmap排序的步骤:

  1. 初始化Bitmap:根据数值范围创建一个足够大的Bitmap。由于数值范围是0-40亿,Bitmap的大小需要能够覆盖这个范围,即至少需要40亿位。
  2. 标记数值:遍历待排序的数值列表,将每个数值在Bitmap中对应的位置标记为1。例如,如果数值是5,则在Bitmap的第6位(从0开始计数)标记为1。
  3. 按位输出:按照Bitmap的顺序,输出所有标记为1的位置对应的数值,即可得到排序后的结果。

5.redis过期策略,与mysql的数据一致性如何保证

双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致

**Cache Aside Pattern (旁路缓存模式)**中遇到写请求是这样的:更新 DB,然后直接删除 cache 。

如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:

  1. 缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
  2. 增加 cache 更新重试机制(常用):如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可。

怎么解决读写

1.若一致性要求高:强一致方案

采用redisson实现的读写锁。

在读的时候添加共享锁,可以保证读读不互斥,读写互斥(其他线程可以一起读,但是不能写)。当我们更新数据的时候,添加排他锁,它是读写,读读都互斥(其他线程不能读也不能写),这样就能保证在写数据的同时是不会让其他线程读数据的,避免了脏数据

那这个排他锁是如何保证读写、读读互斥的呢?

其实排他锁底层使用也是setnx,保证了同时只能有一个线程操作锁住的方法

2.延时双删

  • 策略原理:延时双删策略的核心是在写库操作的前后分别进行删除缓存操作,并设定合理的超时时间来确保读请求结束,写请求可以删除可能产生的缓存脏数据。
  • 具体步骤:先删除缓存,再写数据库,然后线程休眠一段时间(比如500毫秒),最后再次删除缓存。休眠时间的确定需要评估项目读数据业务逻辑的耗时,并考虑Redis和数据库主从同步的耗时。
  • 优缺点:这种策略能在一定程度上解决数据不一致的问题,但增加了写请求的耗时,并且在最差的超时时间内,数据仍可能存在不一致性。

3.先写db再删除缓存策略

  • 推荐理由:这是实时性最好的方案,适用于对并发和实时一致性要求较高的场景。如果删除Redis失败,则可以通过重试机制来解决。
  • 执行流程:先执行数据库的更新操作,成功后立即删除Redis中的对应缓存。
  • 优缺点:虽然在极少数情况下会出现数据不一致的情况,但其条件相对苛刻,是实时性要求下的最佳选择。

6.Redis部署,redis集群和哨兵机制,主节点选取机制

主从复制(解决高并发问题)及其原理/流程

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,可以搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中

1.主从复制作用

1.数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。

2.故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。

3.负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量

4.高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。

2.主从同步流程

主从同步分为了两个阶段,一个是全量同步,一个是增量同步

全量同步是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:

第一:从节点请求主节点同步数据,其中从节点会携带自己的replication id和offset偏移量。

第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致。

第三:在同时主节点会执行bgsave,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致

当然,如果在rdb生成执行期间,依然有请求到了主节点,而主节点会以命令的方式(AOF方式)记录到一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步

增量同步指的是,当从节点服务重启之后,数据就不一致了,所以这个时候,从节点会请求主节点同步数据,主节点还是判断不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从AOF命令日志中获取offset值之后的数据,发送给从节点进行数据同步

3.哨兵模式(解决高可用问题)

主从复制存在不能自动故障转移、达不到高可用的问题。哨兵模式解决了这些问题。通过哨兵机制可以自动切换主从节点。

客户端连接Redis的时候,先连接哨兵,哨兵会告诉客户端Redis主节点的地址,然后客户端连接上Redis并进行后续的操作。当主节点宕机的时候,哨兵监测到主节点宕机,会重新推选出某个表现良好的从节点成为新的主节点,然后通过发布订阅模式通知其他的从服务器,让它们切换主机。

1.哨兵的作用和工作原理(心跳机制,选主规则)

./面试题笔记.assets/D2B5CA33BD970F64A6301FA75AE2EB22.png

./面试题笔记.assets/D2B5CA33BD970F64A6301FA75AE2EB22-1749546100747-1.png

./面试题笔记.assets/D2B5CA33BD970F64A6301FA75AE2EB22-1749546100747-2.png

2.怎么保证Redis的高并发高可用

首先可以搭建主从集群,再加上使用redis中的哨兵模式,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知;如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主;同时Sentinel也充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端,所以一般项目都会采用哨兵的模式来保证redis的高并发高可用

3.redis集群脑裂问题

一个Redis集群因为网络故障被分割成两部分,其中一部分包含主节点(Master),另一部分则由从节点(Slave)组成。如果此时主节点仍然能够接受客户端的写请求,而从节点因为与主节点失联而被哨兵系统提升为新的主节点,那么同一个数据就可能在两个“主节点”上被不同客户端写入,导致数据严重不一致。就像大脑分裂了一样,这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将old master降为salve,这时再从新master同步数据,这会导致old master中的大量数据丢失。

关于解决的话,我记得在redis的配置中可以设置:第一可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据,第二个可以设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失

ES

倒排索引,分词器(分词流程),分片机制(分布式集群),分段机制

多线程

1.线程间的通信方式

1、使用 Object 类的 wait()/notify()。Object 类提供了线程间通信的方法:wait()notify()notifyAll(),它们是多线程通信的基础。其中,wait/notify 必须配合 synchronized 使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify(),notify并不释放锁,只是告诉调用过wait()的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait() 的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。

2、使用 volatile 关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。

3、使用JUC工具类 CountDownLatch。jdk1.5 之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。

4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字

2.讲一下线程池,介绍下线程池的工作流程

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。

通过ThreadPoolExecutor手动创建(推荐)。

怎么创建线程池?

1.说一下线程池的核心参数

  • corePoolSize 核心线程数目
  • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
  • keepAliveTime 当线程数大于核心线程数时,多余的空闲线程存活的最长时间
  • unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  • blockingQueue 阻塞队列- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  • threadFactory 线程工厂 - 每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  • handler 拒绝策略 - 当所有线程都在繁忙,blockingQueue 也放满时,会触发拒绝策略。

2.线程池处理任务的流程

工作流程

1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满这时对于一个新提交的任务,线程池会创建一个线程去处理任务。(当线程池里面存活的线程数小于等于核心线程数corePoolSize时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。)

2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列,等待后续线程来执行提交地任务

3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务

4,如果当前的线程数达到了最大线程数目,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个RejectedExecutionException异常。

3.线程池拒绝策略

1.AbortPolicy:直接抛出异常,默认策略;

2.CallerRunsPolicy:用调用者所在的线程来执行任务;

3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

4.DiscardPolicy:直接丢弃任务;

4.线程池中有哪些常见的阻塞队列

workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

线程池中常见的阻塞队列有几种,包括但不限于:

  1. ArrayBlockingQueue:基于数组实现的有界阻塞队列。它按照先进先出(FIFO)的原则对元素进行排序,当队列满时,尝试添加元素的线程将会被阻塞,直到队列中有空闲位置。
  2. LinkedBlockingQueue:通常容量设为Integer.MAX_VALUE,可以视为无界队列。适用于那些任务数量可能会非常多的场景,例如在FixedThreadPool和SingleThreadExecutor线程池中使用,这两种线程池的核心线程数和最大线程数是一致的,当任务多的时候,处理不过来的任务就会放到这个队列中等待处理。
  3. SynchronousQueue:不存储元素的阻塞队列。每个插入操作必须等待另一个线程进行相应的取出操作,反之亦然。它通常用在CachedThreadPool线程池中,这种线程池的特点是可以无限创建新线程,因此需要一个能够严格控制任务数量的队列来防止资源耗尽。
  4. PriorityBlockingQueue:支持优先级的无界阻塞队列。采用了与堆数据结构类似的方法,使得队列中的元素可以按照自然顺序或者自定义的Comparator排序。

注意:

如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

3.CompletableFuture原理(复杂,暂时没看)

CompletableFuture是由Java 8引入的,在Java8之前我们一般通过Future实现异步。

  • Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,Java 8之前若要设置回调一般会使用guava的ListenableFuture,回调的引入又会导致臭名昭著的回调地狱(下面的例子会通过ListenableFuture的使用来具体进行展示)。
  • CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题。

下面将举例来说明,我们通过ListenableFuture、CompletableFuture来实现异步的差异。假设有三个操作step1、step2、step3存在依赖关系,其中step3的执行依赖step1和step2的结果。

CompletableFuture原理与实践-外卖商家端API的异步化 - 美团技术团队

JVM

1.讲一下垃圾回收器,你们用的什么,详细介绍下g1

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

堆:**此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。**下图是堆的结构,第一层是新生代,第二次层是老年代,第三层元空间在直接内存里。

./面试题笔记.assets/image-20250606172705406.png

方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

方法区和永久代以及元空间是什么关系呢? 方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
  • 并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
  • 最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
  • 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。

./面试题笔记.assets/g1-garbage-collector.png

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。

2.堆分区后小块区域都存储些什么

./面试题笔记.assets/D2B5CA33BD970F64A6301FA75AE2EB22.png

  • 新生代主要用来存放新生的对象。新生代又被进一步划分为 Eden区 和 Survivor区,Survivor 区由 From Survivor 和 To Survivor 组成。新生代通常采用年轻代垃圾回收算法,如复制算法,能够高效地回收生命周期短的对象这里主要存放新创建的对象,以及那些经过几次垃圾回收后仍然存活的对象。年轻代的垃圾回收频率较高,因为大部分对象在这里很快就被回收。新生代内又分三个区:一个Eden区,两个Survivor区(S0、S1,又称From Survivor、To Survivor),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足晋升到老年代条件的对象将被复制到另外一个Survivor区。对象每经历一次复制,年龄加1,达到晋升年龄阈值后,转移到老年代
  • 老年代主要存放应用中生命周期长的内存对象。老年代通常采用标记-清除或标记-整理算法,适合回收生命周期较长的对象。经过多次年轻代垃圾回收后仍然存活的对象会被提升到老年代。老年代的垃圾回收频率较低,但每次回收可能需要更长时间。
  • 永久代指的是永久保存区域。主要存放类的元数据,包括类的定义信息、运行时常量池、字段和方法的数据等。。在Java8中,永久代已经被移除,取而代之的是一个称之为“元数据区”(元空间)的区域。元空间和永久代类似,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存的限制。

3.g1优点与缺点,相对cms他的核心优势是什么

G1的主要目标是在满足极低的垃圾回收暂停时间(GC pause time)的同时,保持良好的应用程序吞吐量

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

用标记整理(Mark-Compact)算法

G1的核心特点:

  1. 并行与并发:G1利用多核处理器的能力,在垃圾回收的不同阶段使用并行和并发的技术,减少对应用程序线程的影响。
  2. 分区的堆管理:G1将堆空间分割成许多相同大小的区域(Region),每个区域可以作为Eden、Survivor或Old区使用,这种布局有助于更灵活地管理内存。
  3. 垃圾优先策略:G1采用“垃圾优先”(即那些包含最少存活数据的区域(垃圾最多的区域)的策略,即根据预期的垃圾回收收益来决定哪些区域优先进行回收,从而在给定的暂停时间内获得最大的清理效果。
  4. 可预测的暂停时间:开发者可以设置期望的GC暂停时间目标,G1会尝试在不超过这个时间限制的情况下进行垃圾回收,这对于交互式应用和服务端应用非常重要。
  5. 混合回收:G1可以同时进行年轻代和老年代的垃圾回收,这有助于避免长时间的Full GC暂停。

G1的工作流程可以大致分为以下阶段:

  • 初始标记(Initial Marking):这是一个短暂的Stop-The-World(STW)阶段,用于标记从根节点直接可达的对象。
  • 并发标记(Concurrent Marking):与应用程序线程并发执行,标记所有可达的对象。
  • 最终标记(Final Marking):另一个短暂的STW阶段,用于修正并发标记期间的引用变化。
  • 筛选回收(Live Data Counting and Evacuation):根据垃圾优先策略(即那些包含最少存活数据的区域(垃圾最多的区域)),选择部分区域进行回收,对选定的区域执行复制算法,将存活对象移动到另一个空闲区域,同时清理原区域。

G1和CMS有什么不同?

G1垃圾回收器和CMS垃圾回收器是Java HotSpot虚拟机中的两种不同的垃圾回收策略,它们在设计目标、工作机制和使用范围等方面存在差异。

  1. 工作机制G1在进行垃圾回收时会不区分新生代老年代,将堆内存分割成多个Region,每次回收可以只选择部分Region进行,从而减少停顿时间。G1还引入了Remembered Set来处理Region之间的依赖关系,以提高效率。而CMS使用“标记-清除”算法进行垃圾回收,并且在清理老年代对象时,可以与用户线程并发执行,减少对应用的影响。
  2. 回收对象:G1收集器可以同时作为新生代和老年代的收集器,而CMS收集器通常只能作为老年代的收集器,并且需要配合新生代的Serial或ParNew收集器使用。
  3. 碎片问题:由于CMS使用的是“标记-清除”算法,不会产生压缩效果,因此可能会引起内存碎片问题。而G1在回收过程中会对Region进行整理,减少了内存碎片的问题。
  4. STW时间G1:提供了可预测的停顿时间,允许用户指定垃圾回收的最大停顿时长。CMS:目标是最小化停顿时间,但不能像G1那样预测停顿时间
  5. 回收算法G1:使用标记整理(Mark-Compact)算法,这有助于避免内存碎片的产生。CMS:采用标记清除(Mark-Sweep)算法,容易产生内存碎片。
  6. 内存碎片G1:不会产生内存碎片,有效避免了因碎片导致的性能问题。CMS:会产生内存碎片,且在回收过程中可能会因为碎片过多导致Full GC。

综上所述,G1和CMS在设计目标、工作机制、使用范围以及碎片问题上都有所不同。G1提供了更加可控的停顿时间,并且能够同时管理新生代和老年代,而CMS则专注于减少老年代的停顿时间。在选择垃圾回收器时,需要根据具体的应用场景和性能要求来决定使用哪一种。

4.g1调优参数

  1. -XX:+UseG1GC 启用CMS 垃圾收集器 -XX:MaxGCPauseMillis 设置最大GC 暂停时间的目标(以毫秒为单位)。这是一个软目标,并且JVM 将尽最大的努力(G1 会尝试调整Young 区的块数来)来实现它。默认情况下,没有最大暂停时间值。 -XX:GCPauseIntervalMillis GC 的间隔时间 -XX:+G1HeapRegionSize 分区大小,建议逐渐增大该值,1 2 4 8 16 32。随着size 增加,垃圾的存活时间更长,GC 间隔更长,但每次GC 的时间也会更长 -XX:G1NewSizePercent 新生代最小比例,默认为5% -XX:G1MaxNewSizePercent 新生代最大比例,默认为60% -XX:GCTimeRatioGC 时间建议比例,G1 会根据这个值调整堆空间 -XX:ConcGCThreads 线程数量 -XX:InitiatingHeapOccupancyPercent 启动G1 的堆空间占用比例,根据整个堆的占用而触发并发GC 周期

5.有没有解决过oom问题,介绍一下

问题描述:公司的后台系统,偶发性的引发 OOM 异常,堆内存溢出。

1、因为是偶发性的,所以第一次简单的认为就是堆内存不足导致,所以单方面的加大了堆内存从 4G 调整到 8G。

2、但是问题依然没有解决,只能从堆内存信息下手,通过开启了 -XX:+HeapDumpOnOutOfMemoryError 参数 获得堆内存的 dump 文件。

3、VisualVM 对堆 dump 文件进行分析,通过 VisualVM 查看到占用内存最大的对象是 String 对象,本来想跟踪着 String 对象找到其引用的地方,但 dump 文件太大,跟踪进去的时候总是卡死,而 String 对象占用比较多也比较正常,最开始也没有认定就是这里的问题,于是就从线程信息里面找突破点。

4、通过线程进行分析,先找到了几个正在运行的业务线程,然后逐一跟进业务线程看了下代码,发现有个引起我注意的方法,导出订单信息。

5、因为订单信息导出这个方法可能会有几万的数据量,首先要从数据库里面查询出来订单信息,然后把订单信息生成 excel,这个过程会产生大量的 String 对象。

6、为了验证自己的猜想,于是准备登录后台去测试下,结果在测试的过程中发现到处订单的按钮前端居然没有做点击后按钮置灰交互事件,结果按钮可以一直点,因为导出订单数据本来就非常慢,使用的人员可能发现点击后很久后页面都没反应,结果就一直点,结果就大量的请求进入到后台,堆内存产生了大量的订单对象和 EXCEL 对象,而且方法执行非常慢,导致这一段时间内这些对象都无法被回收,所以最终导致内存溢出。

7、知道了问题就容易解决了,最终没有调整任何 JVM 参数,只是在前端的导出订单按钮上加上了置灰状态,等后端响应之后按钮才可以进行点击,然后减少了查询订单信息的非必要字段来减少生成对象的体积,然后问题就解决了。

附加内容

jstack 是 JDK 自带的 Java 堆栈跟踪工具,用于生成 Java 虚拟机(JVM)当前时刻的线程快照(thread dump)。它是诊断 Java 应用性能问题和死锁情况的重要工具。

Spring框架

1.讲一下spring事务原理

  • 声明式事务:在 XML 配置文件中配置或者直接基于注解(推荐使用) : 实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)

原子性Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;

一致性Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;

隔离性Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;

持久性Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

2.事务的传播机制

事务传播行为是为了解决业务层方法之间互相调用的事务问题

当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。

例如:methodA方法调用methodB方法时,methodB是继续在调用者methodA的事务中运行呢,还是为自己开启一个新事务运行,这就是由methodB的事务传播行为决定的。

在TransactionDefinition接口中定义了七个事务传播行为,下面两个是重点

PROPAGATION_NESTED 与PROPAGATION_REQUIRES_NEW的区别:

使用PROPAGATION_REQUIRES_NEW时,内层事务与外层事务是两个独立的事务。一旦内层事务进行了提交后,外层事务不能对其进行回滚。两个事务互不影响。

使用PROPAGATION_NESTED时,外层事务的回滚可以引起内层事务的回滚。而内层事务的异常并不会导致外层事务的回滚,它是一个真正的嵌套事务。

3.AOP

1.什么是AOP

AOP是面向切面编程,将公共逻辑(事务管理、日志、缓存等)封装成切面,跟业务代码进行分离,抽取公共模块复用,降低耦合。切面就是那些与业务无关,但所有业务模块都会调用的公共逻辑。

具体组成:

1.连接点(JoinPoint):程序执行过程中的任意位置,粒度为执行方法、抛出异常、设置变量等

  • 在SpringAOP中,理解为方法的执行

2.切入点(Pointcut):匹配连接点的式子

  • 在SpringAOP中,一个切入点可以描述一个具体方法,也可也匹配多个方法
  • 连接点范围要比切入点范围大,是切入点的方法也一定是连接点,但是是连接点的方法就不一定要被增强,所以可能不是切入点。
  • 切入点定义依托一个不具有实际意义的方法进行,即无参数、无返回值、方法体无实际逻辑,然后在上面加个@PointCut注解。

3.通知(Advice):在切入点处执行的操作,也就是共性功能,是通知类的一个函数

4.通知类:定义通知的类

5.切面(Aspect):描述通知与切入点的对应关系。

2.AOP怎么实现

AOP有两种实现方式:静态代理和动态代理。

静态代理

静态代理:代理类在编译阶段生成,在编译阶段将通知织入Java字节码中,也称编译时增强。AspectJ使用的是静态代理。

缺点:代理对象需要与目标对象实现一样的接口,并且实现接口的方法,会有冗余代码。同时,一旦接口增加方法,目标对象与代理对象都要维护。

动态代理

动态代理:代理类在程序运行时创建,AOP框架不会去修改字节码,而是在内存中临时生成一个代理对象,在运行期间对业务方法进行增强,不会生成新类。

3.Spring的AOP是怎么实现的

SpringAOP是通过动态代理实现的。而SpringAOP使用了两种动态代理,分别是JDK的动态代理,以及CGLib的动态代理

如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK 动态代理,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib动态代理 生成一个被代理对象的子类来作为代理。

4.JDK动态代理和CGLIB动态代理的区别

JDK动态代理

如果目标类实现了接口,Spring AOP会选择使用JDK动态代理目标类。代理类根据目标类实现的接口动态生成,不需要自己编写,生成的动态代理类和目标类都实现相同的接口。JDK动态代理的核心是InvocationHandler接口和Proxy类。

缺点:目标类必须有实现的接口。如果某个类没有实现接口,那么这个类就不能用JDK动态代理。

CGLIB动态代理

通过继承实现。如果目标类没有实现接口,那么Spring AOP会选择使用CGLIB来动态代理目标类。CGLIB(Code Generation Library)可以在运行时动态生成类的字节码,动态创建目标类的子类对象,在子类对象中增强目标类。

优点:目标类不需要实现特定的接口,更加灵活。

缺点:CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它是无法使用CGLIB做动态代理的。

5.Spring AOP的相关术语

  • 连接点(JoinPoint):程序执行过程中的任意位置,粒度为执行方法、抛出异常、设置变量等
    • 在SpringAOP中,理解为方法的执行
  • 切入点(Pointcut):匹配连接点的式子
    • 在SpringAOP中,一个切入点可以描述一个具体方法,也可也匹配多个方法
      • 一个具体的方法:如com.itheima.dao包下的BookDao接口中的无形参无返回值的save方法
      • 匹配多个方法:所有的save方法,所有的get开头的方法,所有以Dao结尾的接口中的任意方法,所有带有一个参数的方法
    • 连接点范围要比切入点范围大,是切入点的方法也一定是连接点,但是是连接点的方法就不一定要被增强,所以可能不是切入点。
  • 通知(Advice):在切入点处执行的操作,也就是共性功能
    • 在SpringAOP中,功能最终以方法的形式呈现
  • 通知类:定义通知的类
  • 切面(Aspect):描述通知与切入点的对应关系。

./面试题笔记.assets/1d68d70357b84d3b91bd3e44914d6e9ftplv-k3u1fbpfcp-jj-mark0000q75.png

Spring通知类型

Spring切面可以应用5种类型的通知:

  1. 前置通知(Before):在目标方法被调用之前调用通知功能;
  2. 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么;
  3. 返回通知(After-returning ):在目标方法成功执行之后调用通知;
  4. 异常通知(After-throwing):在目标方法抛出异常后调用通知;
  5. 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的逻辑。

6.Spring一般是怎么使用AOP(公共日志保存/事务处理)

1.怎么用AOP实现公共日志保存

使用aop中的环绕通知+切点表达式,这个表达式就是要找到要记录日志的方法,然后通过环绕通知的参数获取请求方法的参数,比如类信息、方法信息、注解、请求方式等,获取到这些参数以后,保存到ES数据库

2.怎么用AOP实现事务处理

在方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

7.多个切面的执行顺序如何控制?

可以使用@Order 注解直接定义切面顺序

// 值越小优先级越高
@Order
@Component
@Aspect
public class LoggingAspect implements Ordered {

4.Spring循环依赖问题(三级缓存)

循环依赖其实就是循环引用,也就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A。

首先,有两种Bean注入的方式。

构造器注入和属性注入。

就有了2种循环依赖。

对于构造器注入的循环依赖,Spring处理不了,会直接抛出异常。我们可以使用@Lazy懒加载,什么时候需要对象再进行bean对象的创建

对于属性注入的循环依赖,是通过三级缓存处理来循环依赖的。

1.singletonObjects:一级缓存,里面放置的是已经完成所有创建动作的单例对象,也就是说这里存放的bean已经完成了所有创建的生命周期过程,在项目运行阶段当执行多次getBean()时就是从一级缓存中获取的。 2.earlySingletonObjects:二级缓存,里面存放的只是进行了实例化的bean,还没有进行属性设置和初始化操作,也就是bean的创建还没有完成,还在进行中,这里是为了方便被别的bean引用 3.singletonFactories:三级缓存,Spring中的每个bean创建都有自己专属的ObjectFactory工厂类,三级缓存缓存的就是对应的bean的工厂实例,可以通过该工厂实例的getObject()方法获取该bean的实例。

下面我来讲一下通过三级缓存处理来循环依赖的过程吧。

第一,先实例A对象,同时会创建ObjectFactory对象存入三级缓存singletonFactories

第二,A在初始化的时候需要B对象,这个走B的创建的逻辑

第三,B实例化完成,也会创建ObjectFactory对象存入三级缓存singletonFactories

第四,B需要注入A,通过三级缓存中获取ObjectFactory来生成一个A的对象同时存入二级缓存,这个是有两种情况,一个是可能是A的普通对象,另外一个是A的代理对象,都可以让ObjectFactory来生产对应的对象,这也是三级缓存的关键

第五,B通过从通过二级缓存earlySingletonObjects 获得到A的对象后可以正常注入,B创建成功,存入一级缓存singletonObjects

第六,回到A对象初始化,因为B对象已经创建完成,则可以直接注入B,A创建成功存入一次缓存singletonObjects

第七,二级缓存中的临时对象A清除

为什么要有第三级缓存?

如果没有AOP的话确实可以两级缓存就可以解决循环依赖的问题,如果加上AOP,两级缓存是无法解决的,不可能每次执行singleFactory.getObject()方法都给我产生一个新的代理对象,所以还要借助另外一个缓存来保存产生的代理对象

5.讲一下spring,有哪些优点

Spring 是一款开源的轻量级的控制反转(IoC)和面向切面(AOP) 开发框架,可以提高开发人员的开发效率以及系统的可维护性。

1.通过Ioc控制反转和DI依赖注入实现松耦合,能够降低组件之间的耦合度,使得代码更加灵活、易于维护和扩展(Spring就是一个大工厂,可以将所有对象的创建和依赖关系维护都交给spring管理)

2.支持AOP面向切面的编程,把日志、事务管理等从业务逻辑中分离出来,提高了代码的复用性和可维护性。

3.支持声明式事务,可以通过声明式事务来管理数据库操作的事务

4.Spring是非侵入式的。使用Spring的框架不用继承Spring的类和接口,

5.Spring框架拥有庞大的社区支持,提供了丰富的文档、教程等资料,用的人多大家交流问题也更加方便

DI依赖注入

(1)什么是依赖注入呢?

在Spring创建对象的过程中,把对象依赖的属性注入到对象中。spring就是通过反射来实现注入的。依赖注入主要有三种方式:注解注入(@Autowired),构造器注入和属性注入

IOC和DI的作用

最终目标就是:充分解耦,具体实现靠:

  • 使用IOC容器管理bean(IOC)
  • 在IOC容器内将有依赖关系的bean进行关系绑定(DI)
  • 最终:使用对象时不仅可以直接从IOC容器中获取,并且获取到的bean已经绑定了所有的依赖关系.

6.Spring事务失效场景

1.访问权限问题:如果事务方法的访问权限不是定义成public,这样会导致事务失效,因为spring要求被代理方法必须是public的。

2. 方法用final修饰:如果事务方法用final修饰,将会导致事务失效。因为spring事务底层使用了aop,也就是通过jdk动态代理或者cglib,帮我们生成了代理类,在代理类中实现的事务功能。但如果某个方法用final修饰了,那么在它的代理类中,就无法重写该方法添加事务功能。

3.对象没有被spring管理:使用spring事务的前提是:对象要被spring管理,需要创建bean实例。如果类没有加@Controller、@Service、@Component、@Repository等注解,即该类没有交给spring去管理,那么它的方法也不会生成事务。

4.在代码中捕获异常没有抛出:事务通知只有捉到了目标抛出的异常才能进行后续的回滚处理。如果我们自己捕获了异常处理掉了没有抛出,这种情况下spring事务不会回滚。

5.表不支持事务:如果MySQL使用的存储引擎是myisam,这样的话是不支持事务的。因为myisam存储引擎不支持事务。

7.为什么事务注解修饰的方法内使用多线程会失效,你有什么思考

事务注解(如Spring的@Transactional)修饰的方法在多线程环境下失效是一个常见问题,主要原因如下:

核心原因

  1. 线程隔离性
    • 事务管理通常基于ThreadLocal实现,事务上下文信息(如Connection)绑定在当前线程
    • 新创建的线程无法继承父线程的事务上下文
  2. 代理机制限制
    • Spring的事务管理基于AOP代理实现
    • 方法内调用(包括多线程调用)会绕过代理,导致事务失效

具体表现

  • 子线程中的数据库操作不会参与主线程的事务
  • 子线程抛出的异常不会触发主线程事务回滚
  • 各线程的操作实际上处于不同的事务中

解决方案

  1. 避免在事务方法内使用多线程处理事务性操作

    • 将多线程逻辑移到事务方法外部
  2. 使用编程式事务管理

    TransactionTemplate transactionTemplate = ...;
    new Thread(() -> {
        transactionTemplate.execute(status -> {
            // 事务性操作
            return null;
        });
    }).start();
    
  3. 传递事务上下文(复杂且不推荐)

    • 手动传递Connection等资源
    • 使用分布式事务解决方案
  4. 重新设计架构

    • 考虑消息队列等异步处理方案
    • 使用事件驱动架构

项目

苍穹外卖+黑马点评