java面试黑马版

求其上,得其中;求其中,得其下,求其下,必败

——《孙子兵法》

一 、Redis

1. 我看你在项目中使用了Redis,你最近在哪些场景中使用了Redis呢?

我在项目中使用Redis缓存医生排班、药品库存等热点数据,通过Redisson分布式锁解决挂号并发问题,确保号源分配的原子性操作。

Redis的缓存在项目中的使用?
  1. 医生排班信息缓存

    将医生的排班表、可预约时间段、剩余号源数量等数据缓存到Redis中,设置合理的过期时间(如1小时)。当挂号员查询医生排班时,优先从Redis获取数据,避免频繁查询数据库,提升响应速度。

  2. 用户登录状态管理

    使用Redis存储用户的JWT Token和会话信息,设置Token过期时间与JWT保持一致。当用户访问需要认证的接口时,从Redis中验证Token有效性,实现快速的身份验证。

  3. 药品库存信息缓存

    将常用药品的库存数量、价格、规格等信息缓存到Redis,挂号员和医生查询药品信息时直接从缓存获取,减少数据库压力。

  4. 患者挂号记录缓存

    将当天的挂号记录、已预约号源等信息缓存到Redis,方便快速查询和更新挂号状态。

为什么使用Redisson分布式锁?
  1. 挂号并发问题

    当多个挂号员同时为同一医生同一时间段挂号时,可能出现超卖问题。比如某医生上午10点只有5个号源,但6个挂号员同时操作,可能导致6个患者都成功挂号,造成号源超卖。

  2. 分布式锁的必要性

    使用Redisson分布式锁,在挂号操作前先获取锁,确保同一时间只有一个挂号员能操作该医生的号源,操作完成后释放锁,保证号源分配的原子性。

2. 那么你是怎么解决缓存穿透的问题呢?

嗯,我想一下。缓存穿透是指查询一个一定不存在的数据,由于存储层查不到数据因此不写入缓存,这将导致这个不存在的数据每次请求都要到 数据库去查询,可能导致 数据库 挂掉。这种情况大概率是遭到了攻击。解决方案的话一般有两种方式方案一呢是采用缓存空数据的方式,方案二就是采用布隆过滤器,我们通常都会用布隆过滤器来解决它。

3. 你能介绍一下布隆过滤器吗?

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

4. 什么是缓存击穿?怎么解决

嗯!缓存击穿的意思是,对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这个时间点对这个Key有大量的并发请求过来。这些请求发现缓存过期,一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。

解决方案有两种方式:第一,可以使用互斥锁:当缓存失效时,不立即去load db,先使用如 Redis 的 SETNX 去设置一个互斥锁。当操作成功返回时,再进行 load db的操作并回设缓存,否则重试get缓存的方法。第二种方案是设置当前key逻辑过期,大概思路如下:

  1. 在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间;
  2. 当查询的时候,从redis取出数据后判断时间是否过期;
  3. 如果过期,则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据可能不是最新的。

两种方案各有利弊:

如果选择数据的强一致性,建议使用分布式锁的方案,但性能上可能没那么高,且有可能产生死锁的问题。

如果选择key的逻辑删除,则优先考虑高可用性,性能比较高,但数据同步这块做不到强一致。

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

嗯!缓存雪崩意思是,设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重而雪崩。与缓存击穿的区别是:雪崩是很多key,而击穿是某一个key缓存。解决方案主要是,可以将缓存失效时间分散开。比如,可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机。这样,每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

6. Redis作为缓存,Mysql与Redis数据如何进行同步呢(双写一致)

我们当时是将医生排班信息,用户的一些信息缓存到数据库,这些数据实时要求性并没有这么高,所以我们当时采用的是异步的方案同步数据
我们当时采用的阿里的canal组件实现数据同步:不需要更改业务代码,部署一个canal服务。canal服务把自己伪装成mysql的一个从节点,当mysql数据更新以后,canal会读取binlog数据,然后在通过canal的客户端获取到数据,更新缓存即可。

7. Redis作为缓存,数据持久化是怎么做的

在Redis中提供了两种数据持久化的方式:1) RDB;2) AOF。

8. 这两种持久化方式有什么区别呢?

RDB是一个快照文件。它是把redis内存存储的数据写到磁盘上。当redis实例宕机恢复数据的时候,可以从RDB的快照文件中恢复数据。AOF的含义是追加文件。当redis执行写命令的时候,都会存储到这个文件中。当redis实例宕机恢复数据的时候,会从这个文件中再次执行一遍命令来恢复数据。

9. 这两种方式,哪种恢复的比较快呢?

RDB因为是二进制文件,保存时体积也比较小,所以它恢复得比较快。但它有可能会丢数据。我们通常在项目中也会使用AOF来恢复数据。虽然AOF恢复的速度慢一些,但它丢数据的风险要小很多。在AOF文件中可以设置刷盘策略。我们当时设置的就是每秒批量写入一次命令

10. Redis的过期策略有哪些

嗯~,在redis中提供了两种数据过期删除策略。第一种是惰性删除。在设置该key过期时间后,我们不去管它。当需要该key时,我们检查其是否过期。如果过期,我们就删掉它;反之,返回该key。第二种是定期删除。就是说,每隔一段时间,我们就对一些key进行检查,并删除里面过期的key。定期清理的两种模式是:1) SLOW模式,是定时任务,执行频率默认为10hz,每次不超过25ms,可以通过修改配置文件redis.conf的hz选项来调整这个次数;2) FAST模式,执行频率不固定,每次事件循环会尝试执行,但两次间隔不低于2ms,每次耗时不超过1ms。Redis的过期删除策略是:惰性删除 + 定期删除两种策略配合使用。

11. 数据淘汰策略有哪些

嗯,这个在redis中提供了很多种,默认是noeviction,不删除任何数据,内部不足时直接报错。这个可以在redis的配置文件中进行设置。里面有两个非常重要的概念:一个是LRU,另外一个是LFU。LRU的意思就是最少最近使用。它会用当前时间减去最后一次访问时间。这个值越大,则淘汰优先级越高。LFU的意思是最少频率使用。它会统计每个key的访问频率。值越小,淘汰优先级越高。我们在项目中设置的是allkeys-lru,它会挑选最近最少使用的数据进行淘汰,把一些经常访问的key留在redis中。

12. 数据库有1000万数据,Redis只能缓存20w数据。如何保证Redis中的数据都是热点数据?

嗯,我想一下()。可以使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略。那留下来的都是经常访问的热点数据。

13. Redis的内存用完了会发生什么?

嗯~,这个要看redis的数据淘汰策略是什么。如果是默认的配置,redis内存用完以后则直接报错。我们当时设置的是allkeys-lru策略,把最近最常访问的数据留在缓存中。

14. Redis的分布式锁是如何实现的呢

我们当时使用的是Redisson来实现分布式锁。在挂号业务中,当多个挂号员同时为同一医生同一时间段挂号时,可能会出现号源超卖的问题。底层使用的是setnx和lua脚本来保证原子性

15. Redis实现分布式锁如何合理控制锁的时长

嗯,的确。redis的SETNX指令不好控制这个问题。我们当时采用的是redis的一个框架Redisson实现的。在Redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间。当锁住的一个业务还没有执行完成的时候,Redisson会引入一个看门狗机制。就是说,每隔一段时间就检查当前业务是否还持有锁。如果持有,就增加加锁的持有时间。当业务执行完成之后,需要使用释放锁就可以了。还有一个好处就是,在高并发下,一个业务有可能会执行很快。客户1持有锁的时候,客户2来了以后并不会马上被拒绝。它会自旋不断尝试获取锁。如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升。

16. Redisson实现分布式锁是可重用的吗

嗯,是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计数上减一。在存储数据的时候采用的hash结构,大key可以按照自己的业务进行定制,其中小key是当前线程的唯一标识,value是当前线程重入的次数。

17. Redisson可以解决主从一致的问题吗

这个是不能的。比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时如果当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。我们可以利用Redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个Redis实例上创建锁,应该是在多个Redis实例上创建锁,并且要求在大多数Redis节点上都成功创建锁,红锁中要求是Redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。

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

18. 如果业务非要保证数据的强一致性,这个该怎么解决呢?

嗯~,Redis本身就是支持高可用的,要做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用ZooKeeper实现的分布式锁,它是可以保证强一致性的。


19. Redis集群有哪些方案,知道吗?

嗯~~,在Redis中提供的集群方案总共有三种:主从复制、哨兵模式、Redis分片集群。

20. 那你来介绍一下主从同步

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

21. 能说一下,主从同步数据的流程吗?

嗯~~,好!主从同步分为了两个阶段,一个是全量同步,一个是增量同步。

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

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

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

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

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

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

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

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

23. 你们使用Redis是单点还是集群,哪种集群?

嗯!我们当时使用的是主从(1主1从)加哨兵。一般单节点不超过10G内存,如果Redis内存不足则可以给不同服务分配独立的Redis主从节点。尽量不做分片集群。因为集群维护起来比较麻烦,并且集群之间的心跳检测和数据通信会消耗大量的网络带宽,也没有办法使用Lua脚本和事务。

24. Redis集群脑裂,该怎么解决呢?

嗯!这个在项目中很少见,不过脑裂的问题是这样的,我们现在用的是Redis的哨兵模式集群的。

有的时候由于网络等原因可能会出现脑裂的情况,就是说,由于Redis master节点和Redis slave节点和Sentinel处于不同的网络分区,使得Sentinel没有能够心跳感知到master,所以通过选举的方式提升了一个slave为master,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,Sentinel会将old master降为slave,这时再从新master同步数据,这会导致old master中的大量数据丢失。

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

25. Redis的分片集群有什么作用

分片集群主要解决的是海量数据存储的问题,集群中有多个master,每个master保存不同数据,并且还可以给每个master设置多个slave节点,就可以继续增大集群的高并发能力。同时每个master之间通过ping监测彼此健康状态,就类似于哨兵模式了。当客户端请求可以访问集群任意节点,最终都会被转发到正确节点。

26. Redis分片集群中数据是怎么存储和读取的?

嗯~,在Redis集群中是这样的:

Redis 集群引入了哈希槽的概念,有 16384 个哈希槽,集群中每个主节点绑定了一定范围的哈希槽范围,key通过CRC16校验后对16384取模来决定放置哪个槽,通过槽找到对应的节点进行存储。取值的逻辑是一样的。

27. Redis是单线程的,但是为什么还那么快?

嗯,这个有几个原因吧~~~

  1. 完全基于内存的,C语言编写。
  2. 采用单线程,避免不必要的上下文切换和竞争条件。
  3. 使用多路I/O复用模型,非阻塞IO。

例如:BGSAVEBGREWRITEAOF都是在后台执行操作,不影响主线程的正常使用,不会产生阻塞。

28. 能解释一下I/O多路复用模型?(简历还我)

嗯~~,I/O多路复用是指利用单个线程来同时监听多个Socket,并且在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。

其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;

在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程

二、MySQL

1. 在MySQL中,如何定位慢查询呢?

嗯,我们当时在做压力测试时发现有些接口响应时间非常慢,压测的结果大概是5秒钟,我们在MySQL中开启了,慢日志查询,设置的值就是2秒,一但SQL的执行时间超过2秒就会记录到日志中

2. 那这个SQL语句执行很慢,如何分析呢?

如果一条SQL执行很慢,我们通常会使用MySQL的EXPLAIN命令来分析这条SQL的执行情况。通过keykey_len可以检查是否命中了索引,如果已经添加了索引,也可以判断索引是否有效。通过type字段可以查看SQL是否有优化空间,比如是否存在全索引扫描或全表扫描。通过extra建议可以判断是否出现回表情况,如果出现,可以尝试添加索引或修改返回字段来优化。

3. 那你了解过索引吗?什么是索引

嗯,索引在项目中非常常见,它是一种帮助MySQL高效获取数据的数据结构,主要用来提高数据检索效率,降低数据库的I/O成本。同时,索引列可以对数据进行排序,降低数据排序的成本,也能减少CPU的消耗。

4. 索引的底层数据结构了解过吗?

MySQL的默认存储引擎InnoDB使用的是B+树作为索引的存储结构。选择B+树的原因包括:节点可以有更多子节点,路径更短;磁盘读写代价更低,非叶子节点只存储键值和指针,叶子节点存储数据;B+树适合范围查询和扫描,因为叶子节点形成了一个双向链表。

5. B+树与B树的区别是什么?
  1. B树的非叶子节点和叶子节点都存放数据,而B+树的所有数据只出现在叶子节点,这使得B+树在查询时效率更稳定。
  2. B+树在进行范围查询时效率更高,因为所有数据都在叶子节点,并且叶子节点之间形成了双向链表。
6. 什么是聚簇索引什么是非聚簇索引

好的~聚簇索引是指数据与索引放在一起,B+树的叶子节点保存了整行数据,通常只有一个聚簇索引,一般是由主键构成。

非聚簇索引则是数据与索引分开存储,B+树的叶子节点保存的是主键值,可以有多个非聚簇索引,通常我们自定义的索引都是非聚簇索引。

7. 什么是回表查询呢

嗯,其实跟刚才介绍的聚簇索引和非聚簇索引是有关系的,回表的意思就是通过二级索引找到对应的主键值,然后再通过主键值找到聚集索引中所对应的整行数据,这个过程就是回表
【备注:如果面试官直接问回表,则需要先介绍聚簇索引和非聚簇索引】

8. 知道什么是覆盖索引吗?

嗯~,清楚的
覆盖索引是指select查询语句使用了索引,在返回的列,必须在索引中全部能够找到,如果我们使用id查询,它会直接走聚集索引查询,一次索引扫描,直接返回数据,性能高。
如果按照二级索引查询数据的时候,返回的列中没有创建索引,有可能会触发回表查询,尽量避免使用select*,尽量在返回的列中都包含添加索引的字段

9. MySQL超大分页怎么处理

嗯,超大分页通常发生在数据量大的情况下,使用LIMIT分页查询且需要排序时效率较低。可以通过覆盖索引和子查询来解决。首先查询数据的ID字段进行分页,然后根据ID列表用子查询来过滤只查询这些ID的数据,因为查询ID时使用的是覆盖索引,所以效率可以提升。

10. 索引的创建原则有哪些

嗯,这个情况有很多,不过都有一个大前提,就是表中的数据要超过10万以上,我们才会创建索引,并且添加索引的字段是查询比较频繁的字段,一般也是像作为查询条件,排序字段或分组的字段这些。
还有就是,我们通常创建索引的时候都是使用复合索引来创建,一条SQL的返回值,尽量使用覆盖索引,如果字段的区分度不高的话,我们也会把它放在组合索引后面的字段。
如果某一个字段的内容较长,我们会考虑使用前缀索引来使用,当然并不是所有的字段都要添加索引,这个索引的数量也要控制,因为添加索引也会导致新增改的速度变慢。

11. 什么情况下索引会失效

嗯,这个情况比较多,我说一些自己的经验,以前遇到过的
比如,索引在使用的时候没有遵循最左匹配法则,第二个是,模糊查询,如果%号在前面也会导致索引失效。如果在添加索引的字段上进行了运算操作或者类型转换也都会导致索引失效。
我们之前还遇到过一个就是,如果使用了复合索引,中间使用了范围查询,右边的条件索引也会失效所以,通常情况下,想要判断出这条sql是否有索引失效的情况,可以使用explain执行计划来分析

12. 谈一谈你对SQL优化的经验?(面试常见)

候选人:嗯,这个在项目还是挺常见的,当然如果直说sql优化的话,我们会从这几方面考虑,比如建表的时候、使用索引、sql语句的编写、主从复制,读写分离,还有一个是如果量比较大的话,可以考虑分库分表

13. 创建表的时候,你们是如何优化的呢?

创建表时,我们主要参考阿里云的开发手册,选择字段类型时结合字段内容,比如如果是数值的话,像tinyint、int、bigint这些类型,要根据实际情况选择。如果是字符串类型,也是结合存储的内容来选择char和varchar或者text类型

14. 在使用索引的时候,是如何优化呢?

在使用索引时,我们遵循索引创建原则,确保索引字段是查询频繁的,使用复合索引覆盖SQL返回值,避免在索引字段上进行运算或类型转换,以及控制索引数量。

15. 你平时对SQL语句做了哪些优化呢?

嗯,这个也有很多,比如SELECT语句务必指明字段名称,不要直接使用select*,还有就是要注意SQL语句避免造成索引失效的写法;如果是聚合查询,尽量使用UNION ALL代替UNION,表关联时优先使用INNER JOIN,以及在必须使用LEFT JOINRIGHT JOIN时,确保小表作为驱动表。

16. 事务的特征是什么,可以详细说一下吗

事务的特性是ACID,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。例如,A向B转账500元,这个操作要么都成功,要么都失败,体现了原子性。转账过程中数据要保持一致,A扣除了500元,B必须增加500元。隔离性体现在A向B转账时,不受其他事务干扰。持久性体现在事务提交后,数据要被持久化存储。

17 . 并发事务带来哪些问题?

并发事务可能导致脏读、不可重复读和幻读。脏读是指一个事务读到了另一个事务未提交的“脏数据”。不可重复读是指在一个事务内多次读取同一数据,由于其他事务的修改导致数据不一致。幻读是指一个事务读取到了其他事务插入的“幻行”。

18. 怎么解决这些问题呢? MySQL的默认隔离级别是

MySQL,支持四种隔离级别,分别有:
第一个是,未提交读(read uncommitted)它解决不了刚才提出的所有问题,一般项目中也不用这个。第二个是读已提交(read committed)它能解决脏读的问题的,但是解决不了不可重复读和幻读。第三个是可重复读(repeatable read)它能解决脏读和不可重复读,但是解决不了幻读,这个也是mysql默认的隔离级别。第四个是串行化(serializable)它可以解决刚才提出来的所有问题,但是由于让是事务串行执行的,性能比较低。所以,我们一般使用的都是mysql默认的隔离级别:可重复读

19. undo log和redo log 的区别

好的,其中redo log日志记录的是数据页的物理变化,服务宕机可用来同步数据,而undo log不同,它主要记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据,比如我们删除一条数据的时候,就会在undo log日志文件中新增一条delete语句,如果发生回滚就执行逆操作;
redo log保证了事务的持久性,undo log保证了事务的原子性和一致性

20. 事务中的隔离性是如何保障的呢?(请你解释一下MVCC)[面试常考]

事务的隔离性是由锁和mvcc实现的。
其中mwcc的意思是多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突,它的底层实现主要是分为了三个部分,第一个是隐藏字段,第二个是undo log日志,第三个是readView读视图隐藏字段是指:在mysql中给每个表都设置了隐藏字段,有一个是trx_id(事务id),记录每一次操作的事务id,是自增的;另一个字段是roll_pointer(回滚指针),指向上一个版本的事务版本记录地址
undo log主要的作用是记录回滚日志,存储老版本数据,在内部会形成一个版本链,在多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表
readView解决的是一个事务查询选择版本的问题,在内部定义了一些匹配规则和当前的一些事务id判断该访问那个版本的数据,不同的隔离级别快照读是不一样的,最终的访问的结果不一样。如果是rc隔离级别,每一次执行快照读时生成ReadView,如果是rr隔离级别仅在事务中第一次执行快照读时生成ReadView,后续复用

简洁版:

事务的隔离性通过锁和多版本并发控制(MVCC)来保证。MVCC通过维护数据的多个版本来避免读写冲突。底层实现包括隐藏字段、undo logread view。隐藏字段包括trx_idroll_pointerundo log记录了不同版本的数据,通过roll_pointer形成版本链。read view定义了不同隔离级别下的快照读,决定了事务访问哪个版本的数据。

21. 主从复制的原理是什么

MySQL主从复制的核心是二进制日志(Binlog)。步骤如下:

  1. 主库在事务提交时记录数据变更到Binlog。
  2. 从库读取主库的Binlog并写入中继日志(Relay Log)。
  3. 从库重做中继日志中的事件,反映到自己的数据中。
22. 你们项目用过MySQL的分库分表吗?

我们采用微服务架构,每个微服务对应一个数据库,是根据业务进行拆分的,这个其实就是垂直拆分。

23. 那你之前使用过水平分库吗?

使用过。当时业务发展迅速,某个表数据量超过1000万,单库优化后性能仍然很慢,因此采用了水平分库。我们首先部署了3台服务器和3个数据库,使用mycat进行数据分片。旧数据也按照ID取模规则迁移到了各个数据库中,这样各个数据库可以分摊存储和读取压力,解决了性能问题。

三、 框架篇

1. Spring框架中的单例bean是线程安全的吗?

不是线程安全的。当多用户同时请求一个服务时,容器会给每个请求分配一个线程,这些线程会并发执行业务逻辑。如果处理逻辑中包含对单例状态的修改,比如修改单例的成员属性,就必须考虑线程同步问题。Spring框架本身并不对单例bean进行线程安全封装,线程安全和并发问题需要开发者自行处理。

通常在项目中使用的Spring bean是不可变状态(如Service类和DAO类),因此在某种程度上可以说Spring的单例bean是线程安全的。如果bean有多种状态(如ViewModel对象),就需要自行保证线程安全。最简单的解决办法是将单例bean的作用域由“singleton”变更为“prototype”。

2. 什么是AOP?

AOP,即面向切面编程,在Spring中用于将那些与业务无关但对多个对象产生影响的公共行为和逻辑抽取出来,实现公共模块复用,降低耦合。常见的应用场景包括公共日志保存和事务处理。

3. 你们项目中有没有使用到AOP

我们之前在后台管理系统中使用AOP来记录系统操作日志。主要思路是使用AOP的环绕通知和切点表达式,找到需要记录日志的方法,然后通过环绕通知的参数获取请求方法的参数,例如类信息、方法信息、注解、请求方式等,并将这些参数保存到数据库。

4. Spring的事务是如何实现的

Spring实现事务的本质是利用AOP完成的。它对方法前后进行拦截,在执行方法前开启事务,在执行完目标方法后根据执行情况提交或回滚事务。

5. Spring的事务失效的场景有哪些

在项目中,我遇到过几种导致事务失效的场景:

  1. 有一次我们在方法内部捕获并处理了异常,没有将异常抛出,会导致事务失效。因此,处理异常后应该确保异常能够被抛出。
  2. 如果方法抛出检查型异常(checked exception),并且没有在@Transactional注解上配置rollbackFor属性为Exception,那么异常发生时事务可能不会回滚。
  3. 如果事务注解的方法不是公开(public)修饰的,也可能导致事务失效。
6. Spring的bean的生命周期(易考)

Spring中bean的生命周期包括以下步骤:

  1. 通过BeanDefinition获取bean的定义信息。
  2. 调用构造函数实例化bean。
  3. 进行bean的依赖注入,例如通过setter方法或@Autowired注解。
  4. 处理实现了Aware接口的bean。通过Aware接口可以获取到bean的一些基本信息,比如bean的名称 bean的工厂和ApplicationContext
  5. 执行BeanPostProcessor的前置处理器。
  6. 调用初始化方法,如实现了InitializingBean接口或自定义的init-method
  7. 执行BeanPostProcessor的后置处理器,可能在这里产生代理对象。
  8. 最后是销毁bean。
7. Spring中的循环引用问题

循环依赖发生在两个或两个以上的bean互相持有对方,形成闭环。Spring框架允许循环依赖存在,并通过三级缓存解决大部分循环依赖问题:

  1. 一级缓存:单例池,缓存已完成初始化的bean对象。
  2. 二级缓存:缓存尚未完成生命周期的早期bean对象。
  3. 三级缓存:缓存ObjectFactory,用于创建bean对象。
8. 你能说一说具体流程吗

解决循环依赖的流程如下:

  1. 实例化A对象,并创建ObjectFactory存入三级缓存。
  2. A在初始化时需要B对象,开始B的创建逻辑。
  3. B实例化完成,也创建ObjectFactory存入三级缓存。
  4. B需要注入A,通过三级缓存获取ObjectFactory生成A对象,存入二级缓存。
  5. B通过二级缓存获得A对象后,B创建成功,存入一级缓存。
  6. A对象初始化时,由于B已创建完成,可以直接注入B,A创建成功存入一级缓存。
  7. 清除二级缓存中的临时对象A。
9. 构造方法出现了循环依赖怎么解决

由于构造函数是bean生命周期中最先执行的,Spring框架无法解决构造方法的循环依赖问题。可以使用@Lazy懒加载注解,延迟bean的创建直到实际需要时。

10. SpringMVC的执行流程知道吗

SpringMVC的执行流程包括以下步骤:

  1. 用户发送请求到前端控制器DispatcherServlet
  2. DispatcherServlet调用HandlerMapping找到具体处理器。
  3. HandlerMapping返回处理器对象及拦截器(如果有)给DispatcherServlet
  4. DispatcherServlet调用HandlerAdapter(处理器适配器)。
  5. HandlerAdapter适配并调用具体处理器(Controller)。
  6. 在方法上添加了@ResponseBody注解
  7. 通过HttpMessageConverter来返回结果转换为JSON并且响应
11. SpringBoot的自动装配原理(易考)

Spring Boot的自动配置原理基于@SpringBootApplication注解,它封装了@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan@EnableAutoConfiguration是核心,它通过@Import导入配置选择器,读取META-INF/spring.factories文件中的类名,根据条件注解决定是否将配置类中的Bean导入到Spring容器中。

12. spring框架常见注解

Spring的常见注解包括:

  1. 声明Bean的注解:@Component@Service@Repository@Controller
  2. 依赖注入相关注解:@Autowired@``Qualifier@Resource
  3. 设置作用域的注解:@Scope
  4. 配置相关注解:@Configuration@ComponentScan@Bean
  5. AOP相关注解:@Aspect@Before@After@Around@Pointcut
13. SpringMVC的常见注解

SpringMVC的常见注解有:

  • @RequestMapping:映射请求路径。
  • @RequestBody:接收HTTP请求的JSON数据。
  • @RequestParam:指定请求参数名称。
  • @PathVariable:从请求路径中获取参数。
  • @ResponseBody:将Controller方法返回的对象转化为JSON。
  • @RequestHeader:获取请求头数据。
  • @PostMapping@GetMapping等。
14. Springboot的常见注解

Spring Boot的常见注解包括:

  • @SpringBootApplication:由@``SpringBootConfiguration@``EnableAutoConfiguration@``ComponentScan组成。
  • 其他注解如@RestController@GetMapping@PostMapping等,用于简化Spring MVC的配置。
15.Spring和SpringBoot之间有什么区别?

Spring 和 Spring Boot 的区别在于它们的目标和用途不同。Spring 是一个轻量级的开源框架,它提供了一种简单的方式来构建企业级应用程序。Spring Boot 则是 Spring 框架的延伸和扩展,它提供了一种快速构建应用程序的方式。开发人员可以通过使用 Spring Boot Starter 来快速集成常用的第三方库和框架,使得开发人员可以快速构建出一个可运行的应用程序。

16. 请你描述一下Spring的DI和IOC、

IoC 和 DI 都是 Spring 框架中的核心概念,它们的区别在于:

  • IoC(Inverse of Control,控制反转):它是一种思想,主要解决程序设计中的对象依赖关系管理问题。在 IoC 思想中,对象的创建权反转给第三方容器,由容器进行对象的创建及依赖关系的管理。
  • DI(Dependency Injection,依赖注入):它是 IoC 思想的具体实现方式之一,用于实现 IoC。在 Spring 中,依赖注入是指:在对象创建时,由容器自动将依赖对象注入到需要依赖的对象中。

简单来说,它们的关系是:

  • IoC 是一种思想、理念,定义了对象创建和依赖关系处理的方式。
  • DI 是 IoC 思想的具体实现方式之一,实际提供对象依赖关系的注入功能。

例如在 Spring 框架中:

  • IoC 体现为 Spring 容器承担了对象创建及依赖关系管理的控制权。
  • DI 体现为 Spring 容器通过构造方法注入、Setter 方法注入等方式,将依赖对象注入到需要依赖的对象中。
17.JDK动态代理和CGLIB动态代理有什么区别?

JDK 动态代理和 CGLIB 动态代理都是常见的动态代理实现技术,但它们有以下区别:

  • JDK 动态代理基于接口,要求目标对象实现接口;CGLIB 动态代理基于类,可以代理没有实现接口的目标对象。
  • JDK 动态代理使用 java.lang.reflect.Proxy 和 java.lang.reflect.InvocationHandler 来生成代理对象;CGLIB 动态代理使用 CGLIB 库来生成代理对象。
  • JDK 动态代理生成的代理对象是目标对象的接口实现;CGLIB 动态代理生成的代理对象是目标对象的子类。
  • JDK 动态代理性能相对较高,生成代理对象速度较快;CGLIB 动态代理性能相对较低,生成代理对象速度较慢。
  • CGLIB 动态代理无法代理 final 类和 final 方法;JDK 动态代理可以代理任意类
18.拦截器和过滤器有什么区别

拦截器和过滤器的区别主要体现在以下 5 点:

  1. 出身不同:过滤器来自于 Servlet,而拦截器来自于 Spring 框架;
  2. 触发时机不同:请求的执行顺序是:请求进入容器 > 进入过滤器 > 进入 Servlet > 进入拦截器 > 执行控制器(Controller),所以过滤器和拦截器的执行时机,是过滤器会先执行,然后才会执行拦截器,最后才会进入真正的要调用的方法;
  3. 底层实现不同:过滤器是基于方法回调实现的,拦截器是基于动态代理(底层是反射)实现的;
  4. 支持的项目类型不同:过滤器是 Servlet 规范中定义的,所以过滤器要依赖 Servlet 容器,它只能用在 Web 项目中;而拦截器是 Spring 中的一个组件,因此拦截器既可以用在 Web 项目中,同时还可以用在 Application 或 Swing 程序中;
  5. 使用的场景不同:因为拦截器更接近业务系统,所以拦截器主要用来实现项目中的业务判断的,比如:登录判断、权限判断、日志记录等业务;而过滤器通常是用来实现通用功能过滤的,比如:敏感词过滤、字符集编码设置、响应数据压缩等功能。
19. @Autowired 和 @Resource 有什么不同

@Autowired 和 @Resource 都是用来实现依赖注入的注解(在 Spring/Spring Boot 项目中),但二者却有着 5 点不同:

  1. 来源不同:@Autowired 来自 Spring 框架,而 @Resource 来自于(Java)JSR-250;
  2. 依赖查找的顺序不同:@Autowired 先根据类型再根据名称查询,而 @Resource 先根据名称再根据类型查询;
  3. 支持的参数不同:@Autowired 只支持设置 1 个参数,而 @Resource 支持设置 7 个参数;
  4. 依赖注入的用法支持不同:@Autowired 既支持构造方法注入,又支持属性注入和 Setter 注入,而 @Resource 只支持属性注入和 Setter 注入;
  5. 编译器 IDEA 的提示不同:当注入 Mapper 对象时,使用 @Autowired 注解编译器会提示错误,而使用 @Resource 注解则不会提示错误
20. MyBatis的执行流程是什么

MyBatis的执行流程如下:

  1. 读取MyBatis配置文件mybatis-config.xml
  2. 构造会话工厂SqlSessionFactory
  3. 会话工厂创建SqlSession对象。
  4. 操作数据库的接口,Executor执行器。
  5. Executor执行方法中的MappedStatement参数。
  6. 输入参数映射。
  7. 输出结果映射。
21. Mybatis是否支持延迟加载?

MyBatis支持延迟加载,即在需要用到数据时才加载。可以通过配置文件中的lazyLoadingEnabled配置启用或禁用延迟加载。

22. 延迟加载的底层原理知道吗?

延迟加载的底层原理主要使用CGLIB动态代理实现:

  1. 使用CGLIB创建目标对象的代理对象。
  2. 调用目标方法时,如果发现是null值,则执行SQL查询。
  3. 获取数据后,设置属性值并继续查询目标方法。
23. Mybatis的一级、二级缓存用过吗?

MyBatis的一级缓存是基于Perpetual``Cache的HashMap本地缓存,作用域为Session,默认开启。二级缓存需要单独开启,作用域为Namespace或mapper,默认也是采用PerpetualCache,HashMap存储。

四、微服务

1. SpringCloud的五大组件是什么

在早期,Spring Cloud的五大组件通常指的是:

  • Eureka:服务注册中心。
  • Ribbon:客户端负载均衡器。
  • Feign:声明式的服务调用。
  • Hystrix:服务熔断器。
  • Zuul/Gateway:API网关。

随着Spring Cloud Alibaba的兴起,我们项目中也融入了一些阿里巴巴的技术组件:

  • 服务注册与配置中心:Nacos。
  • 负载均衡:Ribbon。
  • 服务调用:Feign。
  • 服务保护:Sentinel。
  • API网关:Gateway。
2. 服务注册和发现是什么意思?Spring Cloud 如何实现服务注册发现?

服务注册与发现主要包含三个核心功能:服务注册、服务发现和服务状态监控。

我们项目中采用了 Nacos 作为服务注册中心,它是一个集服务发现、配置管理于一体的中间件。

  • 服务注册:服务提供者在启动时,会将自己的信息(如服务名称、IP、端口、权重等)注册到 Nacos 服务器。
  • 服务发现:服务消费者通过 Nacos 客户端从注册中心获取目标服务的可用实例列表,并基于内置的负载均衡策略选择合适的服务实例进行调用。
  • 服务监控:服务提供者会定期向 Nacos 发送心跳以维持服务实例的健康状态;Nacos 通过心跳检测和健康检查机制,实时感知服务实例的存活状态,当实例异常时会及时将其从服务列表中移除,确保服务调用的准确性。
3. 我看你之前也用过nacos,你能说下nacos与eureka的区别?

在使用Nacos作为注册中心的项目中,我注意到Nacos与Eureka的共同点和区别:

  • 共同点:两者都支持服务注册与发现,以及心跳检测作为健康检查机制。
  • 区别
    • Nacos支持服务端主动检测服务提供者状态,而Eureka依赖客户端心跳。
    • Nacos区分临时实例和非临时实例,采用不同的健康检查策略。
    • Nacos支持服务列表变更的消息推送,使服务更新更及时。
    • Nacos集群默认采用AP模式,但在存在非临时实例时,会采用CP模式;而Eureka始终采用AP模式。
4. 你们项目负载均衡如何实现的?
  1. 服务注册与元数据管理:所有服务实例启动时会自动注册到 Nacos 注册中心,同时上报自身的元数据(包括 IP、端口、权重、健康状态等信息)。
  2. 负载均衡组件集成:服务消费者端集成了 Spring Cloud LoadBalancer 作为负载均衡客户端,它会通过 Nacos 客户端实时获取目标服务的可用实例列表。
  3. 策略选择与调用:默认采用轮询策略分发请求,确保各实例负载相对均衡。针对不同业务场景,我们还做了策略扩展:
    • 对性能差异较大的实例,通过 Nacos 控制台配置权重策略,让高性能实例承担更多流量
    • 对有会话保持需求的模块(如用户中心),采用 IP 哈希策略确保会话一致性
    • 核心业务服务启用了最少并发策略,优先调用负载较低的实例
  4. 动态调整机制:当服务实例发生扩缩容、权重变更或健康状态变化时,Nacos 会实时推送最新实例列表给客户端,负载均衡组件会自动感知并调整路由决策,无需人工干预。
6. nacos/Spring Cloud LoadBalancer 的负载均衡策略
  1. 轮询策略(默认):按照服务实例的顺序依次进行调用,每个实例被调用的机会均等,适用于各实例性能相近的场景。
  2. 权重策略:可以为不同的服务实例设置不同的权重值,权重越高的实例被选中的概率越大。通过 Nacos 控制台可动态调整权重,便于在实例性能有差异时进行流量分配(如给性能好的实例分配更高权重)。
  3. 随机策略:从可用的服务实例中随机选择一个进行调用,适用于对调用顺序无要求的场景。
  4. 最少并发策略:优先选择当前并发量最低的服务实例,可避免请求集中到负载较高的实例上,提高系统整体吞吐量。
  5. IP 哈希策略:根据请求来源的 IP 地址进行哈希计算,将同一 IP 的请求固定路由到同一个服务实例,适用于需要会话保持的场景。
7. 如果想自定义负载均衡的策略如何实现呢
  1. 全局策略:实现 ReactorServiceInstanceLoadBalancer 接口,在逻辑中通过 Nacos 客户端获取服务实例列表及元数据,自定义选择规则后,通过配置类将其注册为默认 Bean。
  2. 特定服务策略:通过 @LoadBalancerClient 注解指定服务名和对应的配置类,在配置类中定义该服务专属的负载均衡器 Bean,同样可以结合 Nacos 的实例元数据(如在 Nacos 控制台配置的权重、扩展信息)来实现策略,这样只会对该服务生效。
8. 什么是服务雪崩,怎么解决这个问题

服务雪崩是指一个服务的失败导致整个链路的服务相继失败。我们通常通过服务降级和服务熔断来解决这个问题:

服务降级︰服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与feign接口整合,编写降级逻辑
服务熔断∶默认关闭,需要手动打开,如果检测到10秒内请求的失败率超过50%,就触发熔断机制。之后每隔5秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求

9. 你们的微服务是怎么监控的呢

我们项目中采用了SkyWalking进行微服务监控:

  1. SkyWalking能够监控接口、服务和物理实例的状态,帮助我们识别和优化慢服务。
  2. 我们还设置了告警规则,一旦检测到异常,系统会通过短信或邮件通知相关负责人。
10. 你们项目中有没有做过限流?怎么做的?

在我们的项目中,由于面临可能的突发流量,我们采用了网关限流策略:

RequestRateLimiter过滤器进行限流,采用令牌桶算法,可以基于IP或路径进行限流。

11. 限流常见的算法有哪些?
  1. 漏桶算法

    漏桶算法的实现思路是,有一个固定容量的漏桶,水流(请求)可以按照任意速率先进入到漏桶里,但漏桶总是以固定的速率匀速流出,当流入量过大的时候(超过桶的容量),则多余水流(请求)直接溢出

  2. 令牌桶算法

    令牌按固定的速率被放入令牌桶中,桶中最多存放 N 个令牌(Token),当桶装满时,新添加的令牌被丢弃或拒绝。当请求到达时,将从桶中删除 1 个令牌。令牌桶中的令牌不仅可以被移除,还可以往里添加,所以为了保证接口随时有数据通过,必须不停地往桶里加令牌。由此可见,往桶里加令牌的速度就决定了数据通过接口的速度。我们通过控制往令牌桶里加令牌的速度从而控制接口的流量

  3. 区别

    漏桶算法是按照常量固定速率流出请求的,流入请求速率任意,当流入的请求数累积到漏桶容量时,新流入的请求被拒绝。令牌桶算法是按照固定速率往桶中添加令牌的,请求是否被处理需要看桶中的令牌是否足够,当令牌数减为零时,拒绝新的请求。令牌桶算法允许突发请求,只要有令牌就可以处理,允许一定程度的突发流量。漏桶算法限制的是常量流出速率,从而使突发流入速率平滑。 比如服务器空闲时,理论上使用漏桶算法服务器可以直接处理一次洪峰(一次洪水过程的最大流量),但是漏桶算法处理请求的速率是恒定的,因此,前期服务器资源只能根据恒定的漏水速度逐步处理请求,无法直接处理这次洪峰。而使用令牌桶算法就不存在这个问题,因为它可以先把令牌桶一次性装满,处理一次洪峰之后再走限流

12. 什么是CAP理论?

CAP理论是分布式系统设计的基础理论,包含一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)。在网络分区发生时,系统只能在一致性和可用性之间选择其一。

13. 什么是BASE理论

BASE理论是分布式系统设计中对CAP理论中AP方案的延伸,强调通过基本可用、软状态和最终一致性来实现系统设计。

14. 为什么分布式系统中无法同时保证一致性和可用性?

在分布式系统中,为了保证分区容错性,我们通常需要在一致性和可用性之间做出选择。如果系统优先保证一致性,可能需要牺牲可用性,反之亦然。

15. 你们采用哪种分布式事务解决方案?

我们项目中使用了Seata的AT模式来解决分布式事务问题。AT模式通过记录业务数据的变更日志来保证事务的最终一致性。

16. 分布式服务的接口幂等性如何设计?

我们通过Token和Redis来实现接口幂等性。用户操作时,系统生成一个Token并存储在Redis中,当用户提交操作时,系统会验证Token的存在性,并在验证通过后删除Token,确保每个Token只被处理一次。

17. xxl-job路由策略有哪些?

xxl-job支持多种路由策略,包括轮询、故障转移和分片广播等。

18. xxl-job任务执行失败怎么解决?

面对任务执行失败,我们可以:

  1. 选择故障转移路由策略,优先使用健康的实例执行任务。
  2. 设置任务重试次数。
  3. 通过日志记录和邮件告警通知相关负责人。
19. 如果有大数据量的任务同时都需要执行,怎么解决?

我们可以通过部署多个实例并使用分片广播路由策略来分散任务负载。在任务执行代码中,根据分片信息和总数对任务进行分配。

五、 消息中间件

1. RabbitMQ如何保证消息不丢失

我们使用RabbitMQ来确保MySQL和Redis间数据双写的一致性,这要求我们实现消息的高可用性,具体措施包括:

  1. 开启生产者确认机制,确保消息能被送达队列,如有错误则记录日志并修复数据。
  2. 启用持久化功能,保证消息在未消费前不会在队列中丢失,需要对交换机、队列和消息本身都进行持久化。
  3. 对消费者开启自动确认机制,由spring确认消息处理成功后完成ack,并设置重试次数。例如,我们设置了3次重试,若失败则将消息发送至异常交换机,由人工处理。
2. RabbitMQ消息的重复消费问题如何解决?

嗯,这个我们还真遇到过,是这样的,我们当时消费者是设置了自动确认机制,当服务还没来得及给MQ确认的时候,服务宕机了,导致服务重启之后,又消费了一次消息。这样就重复消费了
因为我们当时处理的支付(订单|业务唯一标识),它有一个业务的唯一标识,我们再处理消息时,先到数据库查询一下,这个数据是否存在,如果不存在,说明没有处理过,这个时候就可以正常处理这个消息了。如果已经存在这个数据了,就说明消息重复消费了,我们就不需要再消费了

3. 那你还知道其他的解决方案吗?

是的,这属于幂等性问题,可以通过以下方法解决:

使用Redis分布式锁或数据库锁来确保操作的幂等性

4. RabbitMQ中死信交换机了解吗?(RabbitMQ延迟队列有了解过吗?)

嗯!了解过!
我们当时的xx项目有一个xx业务,需要用到延迟队列,其中就是使用RabbitMQ来实现的。延迟队列就是用到了死信交换机和TTL(消息存活时间)实现的。
如果消息超时未消费就会变成死信,在RabbitMQ中如果消息成为死信,队列可以绑定一个死信交换机,在死信交换机上可以绑定其他队列,在我们发消息的时候可以按照需求指定TTL的时间,这样就实现了延迟队列的功能了。入
我记得RabbitMQ还有一种方式可以实现延迟队列,在RabbitMO中安装一个死信插件,这样更方便一些,我们只需要在声明交互机的时候,指定这个就是死信交换机,然后在发送消息的时候直接指定超时时间就行了,相对于死信交换机+TTL要省略了一些步骤

5. 如果有100万消息堆积在MQ,如何解决?(消息堆积该如何解决)

我在实际的开发中,没遇到过这种情况,不过,如果发生了堆积的问题,解决方案也所有很多的

第一: 提高消费者的消费能力,可以使用多线程消费任务
第二: 增加更多消费者,提高消费速度

​ 使用工作队列模式,设置多个消费者消费消费同一个队列中的消息

第三:扩大队列容积,提高堆积上限

可以使用RabbitMQ惰性队列,惰性队列的好处主要是
①接收到消息后直接存入磁盘而非内存

②消费者要消费消息时才会从磁盘中读取并加载到内存

③支持数百万条的消息存储

6. RabbitMQ的高可用机制了解吗?

嗯,熟悉的~
我们当时项目在生产环境下,使用的集群,当时搭建是镜像模式集群,使用了3台机器。
镜像队列结构是一主多从,所有操作都是主节点完成,然后同步给镜像节点,如果主节点宕机后,镜像节点会替代成新的主节点,不过在主从同步完成前,主节点就已经宕机,可能出现数据丢失

7. 如果出现数据丢失怎么解决呢

我们可以采用仲裁队列,与镜像队列一样,都是主从模式,支持主从数据同步,主从同步基于Raft协议,强一致。
并且使用起来也非常简单,不需要额外的配置,在声明队列的时候只要指定这个是仲裁队列即可

8. Kafka怎么保证消息不丢失的呢

嗯,这个保证机制很多,在发送消息到消费者接收消息,在每个阶段都有可能会丢失消息,所以我们解决的话也是从多个方面考虑
第一个是生产者发送消息的时候,可以使用异步回调发送,如果消息发送失败,我们可以通过回调获取失败后的消息信息,可以考虑重试或记录日志,后边再做补偿都是可以的。同时在生产者这边还可以设置消息重试,有的时候是由于网络抖动的原因导致发送不成功,就可以使用重试机制来解决第二个在broker中消息有可能会丢失,我们可以通过kafka的复制机制来确保消息不丢失,在生产者发送消息的时候,可以设置一个acks,就是确认机制。我们可以设置参数为all,这样的话,当生产者发送消息到了分区之后,不仅仅只在leader分区保存确认,在follwer分区也会保存确认,只有当所有的副本都保存确认以后才算是成功发送了消息,所以,这样设置就很大程度了保证了消息不会在broker丢失
第三个有可能是在消费者端丢失消息,kafka消费消息都是按照offset进行标记消费的,消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,如果出现重平衡的情况,可能会重复消费或丢失数据。我们一般都会禁用掉自动提价偏移量,改为手动提交,当消费成功以后再报告给broker消费的位置,这样就可以避免消息丢失和重复消费了

9. Kafka中消息的重复消费问题如何解决?

kafka消费消息都是按照offset进行标记消费的,消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次,如果出现重平衡的情况,可能会重复消费或丢失数据。我们一般都会禁用掉自动提价偏移量,改为手动提交,当消费成功以后再报告给broker消费的位置,这样就可以避免消息丢失和重复消费了

10. Kafka是如何保证消费的顺序性的呢

kafka默认存储和消费消息,是不能保证顺序性的,因为一个topic数据可能存储在不同的分区中,每个分区都有一个按照顺序的存储的偏移量,如果消费者关联了多个分区不能保证顺序性
如果有这样的需求的话,我们是可以解决的,把消息都存储同一个分区下就行了,有两种方式都可以进行设置,第一个是发送消息时指定分区号,第二个是发送消息时按照相同的业务设置相同的key,因为默认情况下分区也是通过key的hashcode值来选择分区的,hash值如果一样的话,分区肯定也是一样的

11. Kafka的高可用机制了解吗?

嗯,主要是有两个层面,第一个是集群,第二个是提供了复制机制

kafka集群指的是由多个broker实例组成,即使某一台宕机,也不耽误其他broker继续对外提供服务复制机制是可以保证kafka的高可用的,一个topic有多个分区,每个分区有多个副本,有一个leader,其余的是follower,副本存储在不同的broker中;所有的分区副本的内容是都是相同的,如果leader发生故障时,会自动将其中一个follower提升为leader,保证了系统的容错性、高可用性

12. 解释一下复制机制中的ISR?

ISR的意思是in-sync replica,就是需要同步复制保存的follower
其中分区副本有很多的follower,分为了两类,一个是lSR,与leader副本同步保存数据,另外一个普通的副本,是异步同步数据,当leader挂掉之后,会优先从ISR副本列表中选取一个作为leader,因为ISR是同步保存数据,数据更加的完整一些,所以优先选择ISR副本列表

13. Kafka的数据清理机制了解吗

嗯,了解过~~
Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment
每个分段都在磁盘上以索引(xxx.index)和日志文件(xxxx.log)的形式存储,这样分段的好处是,第一能够减少单个文件内容的大小,查找数据方便,第二方便kafka进行日志清理。

在kafka中提供了两个日志的清理策略:
第一,根据消息的保留时间,当消息保存的时间超过了指定的时间,就会触发清理,默认是168小时(7天)
第二是根据topic存储的数据大小,当topic所占的日志文件大小大于一定的阈值,则开始删除最久的消息。这个默认是关闭的
这两个策略都可以通过kafka的broker中的配置文件进行设置

14. Kafka中实现高性能的设计有了解过吗?

Kafka高性能,是多方面协同的结果,包括宏观架构、分布式存储、ISR数据同步、以及高效的利用磁盘、操作系统特性等。主要体现有这么几点:
消息分区:不受单台服务器的限制,可以不受限的处理更多的数据

顺序读写:磁盘顺序读写,提升读写效率

页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问

零拷贝:减少上下文切换及数据拷贝

消息压缩:减少磁盘IO和网络IO

分批发送:将消息打包批量发送,减少网络开销

六、集合篇

1. ArrayList的底层原理是什么
  • ArrayList底层是用动态的数组实现的

  • ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10

  • ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组

  • ArrayList在添加数据的时候

    • 确保数组已使用长度(size)加1之后足够存下下一个数据

    • 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)

    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。

    • 返回添加成功布尔值。

2. 面试题-ArrayList list=new ArrayList(10)中的list扩容几次(少考)

该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容

3. 如何实现数组和List之间的转换(少考)
  • 数组转List ,使用JDK中java.util.Arrays工具类的asList方法
  • List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
4. 用Arrays.asList转List后,如果修改了数组内容,list受影响吗

Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个数组进行了包装而已,最终指向的都是同一个内存地址

5. List用toArray转数组后,如果修改了List内容,数组受影响吗

list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响

6. 面试题-ArrayList和LinkedList的区别是什么?(常考)

底层数据结构

  • ArrayList 是动态数组的数据结构实现
  • LinkedList 是双向链表的数据结构实现

操作数据效率

  • ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】, LinkedList不支持下标查询
  • 查找(未知索引): ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)
  • 新增和删除
    • ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
    • LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)

内存空间占用

  • ArrayList底层是数组,内存连续,节省内存
  • LinkedList 是双向链表需要存储数据,和两个指针,更占用内存

线程安全

  • ArrayList和LinkedList都不是线程安全的
  • 如果需要保证线程安全,有两种方案:
    • 在方法内使用,局部变量则是线程安全的
    • 使用线程安全的ArrayList和LinkedList
嗯,好的,刚才你说了ArrayList 和 LinkedList 不是线程安全的,你们在项目中是如何解决这个的线程安全问题的?

嗯,是这样的,主要有两种解决方案:

第一:我们使用这个集合,优先在方法内使用,定义为局部变量,这样的话,就不会出现线程安全问题。

第二:如果非要在成员变量中使用的话,可以使用线程安全的集合来替代

ArrayList可以通过Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。

LinkedList 换成ConcurrentLinkedQueue来使用

7. 说一下HashMap的实现原理(易考)

HashMap的数据结构: 底层使用hash表数据结构,即数组和链表或红黑树

  1. 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标

  2. 存储时,如果出现hash值相同的key,此时有两种情况。

    a. 如果key相同,则覆盖原始值;

    b. 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中

  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。

8. 面试题-HashMap的put方法的具体流程
  1. 判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)

  2. 根据键值key计算hash值得到数组索引

  3. 判断table[i]==null,条件成立,直接新建节点添加

  4. 如果table[i]==null ,不成立

    4.1判断table[i]的首个元素是否和key一样,如果相同直接覆盖value

    4.2判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对

    4.3遍历tablei],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在直接覆盖value

  5. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容.

9. 讲一讲HashMap的扩容机制

在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)

每次扩容的时候,都是扩容之前容量的2倍;

扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中

  • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
  • 如果是红黑树,走红黑树的添加
  • 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
10. HashMap的寻址方法
  1. 计算对象的 hashCode()
  2. 再进行调用hash()方法进行二次哈希, hashcode值右移16位再异或运算,让哈希分布更为均匀
  3. 最后(capacity - 1)& hash 得到索引
11. 为什么HashMap的数组长度一定是2的次幂

嗯,好的。hashmap这么设计主要有两个原因:

第一:

计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模

第二:

扩容时重新计算索引效率更高:在进行扩容时会进行判断 hash值按位与运算旧数组长租是否 == 0

如果等于0,则把元素留在原来位置 ,否则新位置是等于旧位置的下标+旧数组长度

12. hashmap在1.7情况下的多线程死循环问题

在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环

比如说,现在有两个线程

线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入

线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。

线程一:继续执行的时候就会出现死循环的问题。

线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,

所以B->A->B,形成循环。

当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。

13. HashSet与HashMap的区别

(1)HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.

(2)HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值,(利用hashMap的key键进行存储), 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.

15. 说一说Java提供的常见集合?

在java中提供了两大类的集合框架,主要分为两类:

第一个是Collection 属于单列集合,第二个是Map 属于双列集合

  • 在Collection中有两个子接口List和Set。在我们平常开发的过程中用的比较多像list接口中的实现类ArrarList和LinkedList。 在Set接口中有实现类HashSet和TreeSet。
  • 在map接口中有很多的实现类,平时比较常见的是HashMap、TreeMap,还有一个线程安全的map:ConcurrentHashMap
16. ArrayList底层是如何实现的?

嗯~,我阅读过arraylist的源码,我主要说一下add方法吧

第一:确保数组已使用长度(size)加1之后足够存下下一个数据

第二:计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)

第三:确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。

第四:返回添加成功布尔值。

17. HashTable与HashMap的区别

嗯,他们的主要区别是有几个吧

第一,数据结构不一样,hashtable是数组+链表,hashmap在1.8之后改为了数组+链表+红黑树

第二,hashtable存储数据的时候都不能为null,而hashmap是可以的

第三,hash算法不同,hashtable是用本地修饰的hashcode值,而hashmap经常了二次hash

第四,扩容方式不同,hashtable是当前容量翻倍+1,hashmap是当前容量翻倍

第五,hashtable是线程安全的,操作数据的时候加了锁synchronized,hashmap不是线程安全的,效率更高一些

在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类

七、多线程篇(易考)

1. 进程和线程区别

进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务

不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间

线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

2. 并行和并发有什么区别?

现在都是多核CPU,在多核CPU下

并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程

3. 创建线程的方式有哪些(易考)

在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。

4. runnable 和 callable 有什么区别
  1. Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
  2. Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
  3. Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
5. 线程的 run()和 start()有什么区别?

start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,可以被调用多次。

6. 线程包括了哪些状态,状态之间是如何变化的

在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。

关于线程的状态切换情况比较多。我分别介绍一下

当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态

还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

7. 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])

可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

比如说:

使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成

8. notify()和 notifyAll()有什么区别?

notifyAll:唤醒所有wait的线程

notify:只随机唤醒一个 wait 线程

9. 在 java 中 wait 和 sleep 方法的不同?

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同
    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同
    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)
    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
10. 如何停止一个正在运行的线程

有三种方式可以停止线程

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程
11. 讲一下synchronized关键字的底层原理?

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程
  • EntryList:保存处于Blocked状态的线程
  • Owner:持有锁的线程

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

12. synchronized关键字的底层原理-进阶

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

13. 你谈谈 JMM(Java 内存模型)

Java内存模型是Java虚拟机规范中定义的一种非常重要的内存模型。它的主要作用是描述Java程序中线程共享变量的访问规则,以及这些变量在JVM中是如何被存储和读取的,涉及到一些底层的细节。

这个模型有几个核心的特点。首先,所有的共享变量,包括实例变量和类变量,都被存储在主内存中,也就是计算机的RAM。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。

其次,每个线程都有自己的工作内存,这里保留了线程所使用的变量的工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。

最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。

14. 谈谈你对CAS的理解

CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。

  • CAS使用到的地方很多:AQS框架、AtomicXXX类
  • 在操作共享变量的时候使用的自旋锁,效率上更高一些
  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
15. 谈谈你对volatile的理解

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

16. 什么是AQS

AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素
  • head 指向队列中最久的一个元素

其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。

17. ReentrantLock的实现原理

ReentrantLock是一个可重入锁:,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。

ReentrantLock是属于juc报下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。

它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

18. synchronized和Lock有什么区别 ? (易考)

第一,语法层面

  • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
  • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

统合来看,需要根据不同的场景来选择不同的锁的使用。

19. 死锁产生的条件是什么?

嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:

t1 线程获得A对象锁,接下来想获取B对象的锁

t2 线程获得B对象锁,接下来想获取A对象的锁

这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁

20. 如何进行死锁诊断?

我们只需要通过jdk自动的工具就能搞定

我们可以先通过jps来查看当前java程序运行的进程id

然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

21. 聊一下ConcurrentHashMap (易考)

ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。

  • JDK1.7的底层采用是分段的数组+链表 实现
  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修 改时,必须首先获得对应的 Segment的锁。

Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁

在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保 证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲 突,就不会产生并发 , 效率得到提升

22. 导致并发程序出现问题的根本原因是什么

Java并发编程有三大核心特性,分别是原子性、可见性和有序性。

首先,原子性指的是一个线程在CPU中的操作是不可暂停也不可中断的,要么执行完成,要么不执行。比如,一些简单的操作如赋值可能是原子的,但复合操作如自增就不是原子的。为了保证原子性,我们可以使用synchronized关键字或JUC里面的Lock来进行加锁。

其次,可见性是指让一个线程对共享变量的修改对另一个线程可见。由于线程可能在自己的工作内存中缓存共享变量的副本,因此一个线程对共享变量的修改可能不会立即反映在其他线程的工作内存中。为了解决这个问题,我们可以使用synchronized关键字、volatile关键字或Lock来确保可见性。

最后,有序性是指处理器为了提高程序运行效率,可能会对输入代码进行优化,导致程序中各个语句的执行先后顺序与代码中的顺序不一致。虽然处理器会保证程序最终执行结果与代码顺序执行的结果一致,但在某些情况下我们可能需要确保特定的执行顺序。为了解决这个问题,我们可以使用volatile关键字来禁止指令重排。

23. 项目中有没有用到线程池,以及线程数是怎么设置的和拒绝策略是怎么设置的

线程池的使用场景:

我们当时在项目中确实使用了线程池,主要用于异步处理AI大模型的请求。因为AI接口的响应时间比较长,如果使用同步调用会阻塞主线程,所以我们使用线程池来处理这些异步任务。

线程池配置:

我们创建了一个自定义的线程池,核心线程数设置为5,最大线程数设置为20,队列容量设置为100。这样配置是因为AI请求不是特别频繁,但单个请求耗时较长,所以需要足够的线程来处理并发请求。

线程数设置原则:

我们按照CPU密集型任务来设置线程数,因为AI请求主要是网络IO和计算密集型操作。考虑到我们的服务器是4核CPU,我们设置核心线程数为5,最大线程数为20,这样可以充分利用CPU资源,同时避免创建过多线程导致上下文切换开销。

拒绝策略设置:

我们使用了CallerRunsPolicy作为拒绝策略,当线程池和队列都满了时,新任务会由调用线程来执行。这样虽然会阻塞调用线程,但能保证任务不会丢失,对于AI请求这种重要业务来说是比较合适的选择。

实际效果:

使用线程池后,AI接口的响应时间从原来的3-5秒降低到1-2秒,用户体验得到了显著提升。同时系统的并发处理能力也得到了增强,能够支持更多的用户同时使用AI功能。

24. 说一下线程池的核心参数(线程池的执行原理知道嘛)(易考)

在线程池中一共有7个核心参数:

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数
  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

拒绝策略有4种,当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。

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

Jdk中提供了很多阻塞队列,开发中常见的有两个:ArrayBlockingQueueLinkedBlockingQueue

ArrayBlockingQueueLinkedBlockingQueue是Java中两种常见的阻塞队列,它们在实现和使用上有一些关键的区别。

首先,ArrayBlockingQueue是一个有界队列,它在创建时必须指定容量,并且这个容量不能改变。而LinkedBlockingQueue默认是无界的,但也可以在创建时指定最大容量,使其变为有界队列。

其次,它们在内部数据结构上也有所不同。ArrayBlockingQueue是基于数组实现的,而LinkedBlockingQueue则是基于链表实现的。这意味着ArrayBlockingQueue在访问元素时可能会更快,因为它可以直接通过索引访问数组中的元素。而LinkedBlockingQueue则在添加和删除元素时可能更快,因为它不需要移动其他元素来填充空间。

另外,它们在加锁机制上也有所不同。ArrayBlockingQueue使用一把锁来控制对队列的访问,这意味着读写操作都是互斥的。而LinkedBlockingQueue则使用两把锁,一把用于控制读操作,另一把用于控制写操作,这样可以提高并发性能。

26. 如何确定核心线程数

① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换

② 并发不高、任务执行时间长

  • IO密集型的任务 --> (CPU核数 * 2 + 1)
  • 计算密集型任务 --> ( CPU核数+1 )

③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,参考CPU核数*2+1

27. 线程池的种类有哪些

在jdk中默认提供了4中方式创建线程池

第一个是:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。

第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。

第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

28. 为什么不建议用Executors创建线程池

其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了

主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。

所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

29. 线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)

我们当时在项目中主要在两个地方使用了多线程:一个是AI大模型请求的异步处理,另一个是批量数据导入的并行处理。

我们使用CompletableFuture来处理AI请求的异步结果。比如医生提交病历分析请求后,我们返回一个Future对象,前端可以轮询这个Future来获取处理结果。当AI分析完成后,我们会将结果存储到缓存中,前端通过轮询获取到最终结果。

当管理员需要导入大量患者数据时,我们会将数据分批处理,每批数据使用一个线程来处理,最后使用Future.allOf()等待所有批次处理完成。这样既提升了处理速度,又保证了数据的一致性。

我们创建了一个异步服务类,使用@Async注解标记异步方法,返回CompletableFuture类型。在调用时,我们可以通过Future.get()方法获取结果,或者使用回调函数处理结果。大大改善了用户体验感

30. 如何控制某个方法允许并发访问线程的数量?

嗯~~,我想一下

在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)

它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是0,就代表已经用完了信号量,其他线程需要阻塞了

第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1

31.谈谈你对ThreadLocal的理解(易考)

嗯,是这样的~~

ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享

32. 好的,那你知道ThreadLocal的底层原理实现吗?

嗯,知道一些~

在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

33. 好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?

嗯,我之前看过源码,我想一下~~

是因为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。

在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。

34. 我看你的项目中有使用ThreadLocal,谈谈你对他的理解

ThreadLocal是Java提供的一个线程本地变量,每个线程都有自己独立的变量副本,线程之间互不干扰。它通过ThreadLocalMap来实现线程隔离,每个线程都有自己的ThreadLocalMap来存储变量。

我们当时在项目中主要用ThreadLocal来存储当前登录用户的信息。当用户登录后,我们将用户信息存储到ThreadLocal中,这样在整个请求处理过程中,任何地方都可以通过ThreadLocal获取到当前用户信息,而不需要每次都传递用户参数。

使用ThreadLocal后,我们不需要在每个方法中传递用户参数,代码更加简洁。同时,由于每个线程都有独立的变量副本,避免了线程安全问题,不需要使用synchronized等同步机制。

在使用ThreadLocal时,我们需要注意内存泄漏问题。如果ThreadLocal变量没有及时清理,可能会导致内存泄漏。所以我们会在请求处理完成后主动清理ThreadLocal,确保资源的正确释放。

八、JVM

1. JVM由那些部分组成,运行流程是什么?(易考)

嗯,好的~~

在JVM中共有四大部分,分别是ClassLoader(类加载器)、Runtime Data Area(运行时数据区,内存分区)、Execution Engine(执行引擎)、Native Method Library(本地库接口)

它们的运行流程是:

第一,类加载器(ClassLoader)把Java代码转换为字节码

第二,运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行

第三,执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能。

2. 什么是程序计数器?(易考)

嗯,是这样~~

java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。这时候程序计数器就起到了关键作用,程序计数器在来回切换的线程中记录他上一次执行的行号,然后接着继续向下执行。

3. 介绍一下Java的堆

好的~

Java中的堆术语线程共享的区域。主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

​ 在JAVA8中堆内会存在年轻代、老年代

​ 1)Young区被划分为三部分,Eden区和两个大小严格相同的Survivor区,其中,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用。在Eden区变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到Tenured区间。

​ 2)Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区。

4. 什么是虚拟机栈

虚拟机栈是描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢。保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈【相当于清空了数据】,所以这块区域不需要进行 GC

5. 垃圾回收是否涉及栈内存?

垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放

6. 栈内存分配越大越好吗?

未必,默认的栈内存通常为1024k

栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半

7. 方法内的局部变量是否线程安全?

如果方法内局部变量没有逃离方法的作用范围,它是线程安全的

如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

8. 栈内存溢出情况

栈帧过多导致栈内存溢出,典型问题:递归调用

栈帧过大导致栈内存溢出

9. 栈和堆的区别呢

嗯,好的,有这几个区别

第一,栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。

第二、栈内存是线程私有的,而堆内存是线程共有的。

第三、两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。

栈空间不足:java.lang.StackOverFlowError。

堆空间不足:java.lang.OutOfMemoryError。

10. 能不能解释一下方法区

好的~

与虚拟机栈类似。本地方法栈是为虚拟机执行本地方法时提供服务的。不需要进行GC。本地方法一般是由其他语言编写。

11. 解释一下运行常量池

常量池:可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
当类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

12. 说一下 JVM 运行时数据区

嗯,好~

运行时数据区包含了堆、方法区、栈、本地方法栈、程序计数器这几部分,每个功能作用不一样。

  • 堆解决的是对象实例存储的问题,垃圾回收器管理的主要区域。
  • 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。
  • 栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。
  • 本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口。
  • 程序计数器(PC寄存器)程序计数器中存放的是当前线程所执行的字节码的行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。
13. 你听过直接内存吗?

嗯~~

它又叫做堆外内存线程共享的区域,在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果大量动态生成类(将类信息放入永久代),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。

​ 所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能。

14. 什么是类加载器,类加载器有哪些?

嗯,是这样的

JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

常见的类加载器有4个

第一个是启动类加载器(BootStrap ClassLoader):其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。

第二个是扩展类加载器(ExtClassLoader):该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。

第三个是应用类加载器(AppClassLoader):该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。

第四个是自定义类加载器:开发者自定义类继承ClassLoader,实现自定义类加载规则。

15. 什么是双亲委派模型(易考)

嗯,它是是这样的。

如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传说到顶层的启动类加载器中,只有当父类加载器返回自己无法完成这个加载请求(它的搜索返回中没有找到所需的类)时,子类加载器才会尝试自己去加载

16. JVM为什么采用双亲委派机制

主要有两个原因。

第一、通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。

第二、为了安全,保证类库API不会被修改

17. 说一下类装载的执行过程

面试官您好,类装载的执行过程主要分为以下几个步骤:

首先是加载阶段,这一步会去查找并导入class文件,把字节码数据加载到 JVM 的方法区中,同时在堆区创建对应的Class对象,作为程序访问方法区中类数据的入口。

接着是验证阶段,目的是保证加载进来的类的准确性,会对字节码的格式、语义等进行校验,防止恶意或者错误的类文件影响 JVM 的正常运行。

然后是准备阶段,要为类变量(也就是静态变量)分配内存,并且设置类变量的初始值,不过这里要注意,初始值通常是该类型的默认值,比如int类型默认初始值是 0,而不是代码中显式指定的初始值。

之后是解析阶段,会把类中的符号引用转换为直接引用,符号引用就像我们代码里用名字来指代其他类、方法等,直接引用则是实际能定位到目标的内存地址等具体引用方式。

再然后是初始化阶段,这一步会对类的静态变量、静态代码块执行初始化操作,按照代码中定义的顺序去赋值或者执行代码逻辑,这时候才会把准备阶段的默认初始值替换成我们期望的初始值。

当完成初始化后,就到了使用阶段,JVM 会从入口方法开始,执行用户的程序代码。

最后是卸载阶段,当用户程序代码执行完毕,JVM 就会开始销毁创建的Class对象,释放相关的资源。

18. 简述Java垃圾回收机制?(GC是什么?为什么要GC)

嗯,是这样~~

为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Collection)。

有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。

在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机

19. 对象什么时候可以被垃圾器回收(易考)

思考一会~~

如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。

如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法

通常都使用可达性分析算法来确定是不是垃圾

20. JVM的垃圾清除算法有哪些(易考)

我记得一共有四种,分别是标记清除算法、复制算法、标记整理算法、分代回收

21. 说一下JVM的分代回收

关于分代回收是这样的

在java8时,堆被分为了两份:新生代和老年代,它们默认空间占用比例是1:2

对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区默认空间占用比例是8:1:1

具体的工作机制是有些情况:

1)当创建一个对象的时候,那么这个对象会被分配在新生代的Eden区。当Eden区要满了时候,触发YoungGC。

2)当进行YoungGC后,此时在Eden区存活的对象被移动到S0区,并且当前对象的年龄会加1,清空Eden区。

3)当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S0中的对象,移动到S1区中,这些对象的年龄会加1,清空Eden区和S0区。

4)当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S1中的对象,移动到S0区中,这些对象的年龄会加1,清空Eden区和S1区。

5)对象的年龄达到了某一个限定的值(默认15岁 ),那么这个对象就会进入到老年代中。

当然也有特殊情况,如果进入Eden区的是一个大对象,在触发YoungGC的时候,会直接存放到老年代

当老年代满了之后,触发FullGCFullGC同时回收新生代和老年代,当前只会存在一个FullGC的线程进行执行,其他的线程全部会被挂起。 我们在程序中要尽量避免FullGC的出现。

22. 说一说讲一下新生代、老年代、永久代 区别相关

嗯!是这样的,简单说就是

新生代主要用来存放新生的对象。

老年代主要存放应用中生命周期长的内存对象。

永久代指的是永久保存区域。主要存放Class和Meta(元数据)的信息。在Java8中,永久代已经被移除,取而代之的是一个称之为“元数据区”(元空间)的区域。元空间和永久代类似,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存的限制。

23. 说一下JVM有哪些垃圾回收器(易考)

在jvm中,实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器(JDK8默认)、CMS(并发)垃圾收集器、G1垃圾收集器(JDK9默认)

24. 详细说一下什么是G1垃圾回收器

面试官您好,G1 垃圾回收器是 JVM 里很重要的一款回收器,我来详细和您聊聊。

首先,它能同时处理新生代和老年代,而且从 JDK9 之后,G1 就成为默认的垃圾回收器了。

G1 把堆内存划分成多个区域,每个区域可以灵活充当 eden 区、survivor 区、old 区,还有专门为大对象准备的 humongous 区。

在回收算法上,G1 采用的是复制算法。它在性能方面做得不错,能兼顾响应时间和吞吐量,这对于很多应用场景来说是很友好的。

G1 的工作主要分三个阶段:第一阶段是新生代回收,这个阶段会触发 STW(暂停所有应用线程);第二阶段是并发标记,不过其中重新标记的环节还是会 STW;第三阶段是混合收集。另外,如果出现并发失败的情况,也就是垃圾回收的速度跟不上新对象创建的速度,就会触发 Full GC。

25. 强引用,软引用,弱引用,虚引用的区别

嗯,其实它们指的是不同代之间的垃圾回收

Minor GC 发生在新生代的垃圾回收,暂停时间短

Major GC 老年代区域的垃圾回收,老年代空间不足时,会先尝试触发Minor GC。Minor GC之后空间还不足,则会触发Major GC,Major GC速度比较慢,暂停时间长

Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免

26. JVM的调优参数可以在哪里设置

我们当时的项目是springboot项目,可以在项目启动的时候,java -jar中加入参数就行了

27. JVM的调优参数都有哪些(少考)

嗯,这些参数是比较多的

我记得当时我们设置过堆的大小,像-Xms和-Xmx

还有就是可以设置年轻代中Eden区和两个Survivor区的大小比例

还有就是可以设置使用哪种垃圾回收器等等。具体的指令还真记不太清楚。

28. 说一下JVM的调优工具(少考)

嗯,我们一般都是使用jdk自带的一些工具,比如

jps 输出JVM中运行的进程状态信息

jstack查看java进程内线程的堆栈信息。

jmap 用于生成堆转存快照

jstat用于JVM统计监测工具

还有一些可视化工具,像jconsole和VisualVM等

29. java内存泄漏的排查思路(易考)

嗯,这个我在之前项目排查过

第一呢可以通过jmap指定打印他的内存快照 dump文件,不过有的情况打印不了,我们会设置vm参数让程序自动生成dump文件

第二,可以通过工具去分析 dump文件,jdk自带的VisualVM就可以分析

第三,通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题

第四,找到对应的代码,通过阅读上下文的情况,进行修复即可

30. CPU飙高的排查方案与思路(易考)

嗯,我思考一下~~

可以这么做~~

第一可以使用使用top命令查看占用cpu的情况

第二通过top命令查看后,可以查看是哪一个进程占用cpu较高,记录这个进程id

第三可以通过ps 查看当前进程中的线程信息,看看哪个线程的cpu占用较高

第四可以jstack命令打印进行的id,找到这个线程,就可以进一步定位问题代码的行号

九、常见技术场景

1. 你的单点登录模块怎么实现的呢(什么是SSO)

我们当时在项目中实现了单点登录功能,主要解决了用户在不同系统间切换时需要重复登录的问题。我们采用了基于JWT Token的方案,结合Redis缓存来实现单点登录。

当用户首次登录时,系统会生成一个JWT Token,并将Token存储到Redis中,同时设置合理的过期时间。用户访问其他系统时,会携带这个Token,系统通过验证Token的有效性来判断用户是否已登录。如果token无效会返回401错误,跳转到登录页面。

不同系统通过统一的认证中心来验证Token的有效性。认证中心会检查Token的签名、过期时间等信息,同时查询Redis确认Token是否仍然有效。

2. 你的项目中权限认证如何实现的

我们当时在项目中采用了基于RBAC(基于角色的访问控制)的权限模型。我们设计了用户、角色、权限三个核心实体,用户通过角色来获得权限,角色通过权限来控制系统功能。比如管理员角色拥有所有权限,挂号员角色只能进行挂号相关操作,医生角色只能进行诊疗相关操作。

我们使用Spring Security作为安全框架,通过配置SecurityConfig类来实现权限控制。我们自定义了UserDetailsService来加载用户信息,实现了基于数据库的用户认证。同时,我们配置了不同的URL路径对应不同的权限要求,比如/admin/路径需要管理员权限,/doctor/路径需要医生权限。

我们创建了自定义的权限验证拦截器,在用户访问受保护的资源时,会检查用户是否具有相应的权限。我们使用@PreAuthorize注解在方法级别进行权限控制,来确保只有相对应的权限才能执行挂号操作。

3. 你负责的项目中遇到哪些棘手的问题?怎么解决的

我们当时在项目中遇到了药品分页查询性能问题。药品表有300多万条数据,当医生查询药品信息时,使用传统的LIMIT分页查询,当查询第1000页以后的数据时,查询时间从原来的几百毫秒飙升到几秒钟,严重影响用户体验。

通过EXPLAIN分析发现,当使用LIMIT 1000, 20查询时,MySQL需要先扫描前1000条记录,然后返回第1001-1020条记录,随着页码增加,扫描的数据量呈线性增长,导致查询性能急剧下降。

我们采用了基于主键的分页查询优化方案。首先,我们为药品表添加了合适的索引,包括药品名称、分类、价格等常用查询字段的复合索引。然后,我们修改了分页查询逻辑,使用主键ID作为游标进行分页,比如查询ID大于某个值的记录,这样避免了大量数据的扫描。

我们将传统的LIMIT分页改为基于ID的分页,前端传递上一页最后一条记录的ID,后端查询ID大于该值的记录。同时,我们实现了查询条件的优化,将常用的查询条件组合成复合索引,比如(药品名称, 分类, 价格)的复合索引。

我们使用Redis缓存了热门药品信息,对于访问频率高的药品,直接从缓存获取,减少数据库查询压力。同时,我们实现了查询结果的缓存,相同查询条件的结果会缓存5分钟。

经过优化后,药品分页查询的响应时间从原来的几秒钟降低到几十毫秒,用户体验得到了显著提升。同时,通过缓存机制,我们进一步减少了数据库的查询压力,提升了系统的整体性能。

4. 你们项目中的日志是怎么采集的

我们当时在项目中采用了ELK(Elasticsearch + Logstash + Kibana)日志采集方案。通过Logstash收集应用日志,存储到Elasticsearch中,最后通过Kibana进行日志分析和可视化展示。

我们使用Logback作为日志框架,配置了不同的Appender来输出不同类型的日志。系统日志输出到系统日志文件,业务日志输出到业务日志文件,错误日志输出到错误日志文件。Logstash通过文件监控的方式采集这些日志文件。