面试准备

一、 自我介绍

各位面试官好,我是 李阳,目前是 武汉纺织大学外经贸学院软件工程专业的一名应届本科生,虽然暂未拥有正式职场的 Java 开发经验,但通过 4 年专业学习和多段实践项目,已构建起相对完整的 Java 技术体系和开发思维,今天特别希望能向各位展示我的学习成果与潜力。

我比较擅长 Java,熟悉 Spring Boot、Spring MVC、SpringCloud,MyBatis 等主流框架,能独立搭建后端项目架构;熟悉 MySQL 数据库,熟悉索引优化、SQL 性能分析(Explain);了解 Redis 缓存相关知识点

我希望能够加入贵公司,在java领域发挥我的技术能力,同时学习更先进的技术和业务知识,实现个人和公司的共同发展。

二、介绍一下你的项目

我在校内是主要参与和主导了一个一套功能完备的医院信息管理系统。这个系统有管理员,挂号员,医生,检察员等角色。实现从患者挂号、医生诊疗、检查申请、结果回传到费用结算的完整业务流程。具备实际医院管理系统的功能完整性。

我们项目团队由4名成员组成,我担任项目组长,负责整体架构设计、技术选型、核心功能开发、团队协调等工作。其他成员分别负责前端开发、数据库设计和测试工作

这个项目开发过程中也遇到了一些挑战,比如需要处理复杂的医院业务流程,涉及多角色权限管理、大量并发挂号操作、AI智能辅助诊断等技术创新点。同时需要保证系统的稳定性、安全性和用户体验。

我在项目中设计并实现前后端分离架构,集成阿里云百炼大模型实现AI辅助诊断,使用Redis缓存优化系统性能,实现分布式锁解决挂号并发问题。主要解决挂号并发导致的号源超卖问题,优化药品分页查询性能,实现AI智能病历分析,确保多角色权限控制的安全性,提升系统整体性能和用户体验。

我们项目采用Spring Boot + Vue3前后端分离架构,使用MyBatis Plus进行数据持久化,集成阿里云OSS存储检查影像,使用Redis缓存热点数据,通过Redisson分布式锁解决并发问题,集成阿里云百炼大模型实现AI辅助诊断。

当然我们在开发中也遇到一些问题比如说药品分页查询性能问题通过基于主键的分页优化和复合索引解决,挂号并发问题通过Redisson分布式锁解决。我们也克服了这些问题,药品分页查询响应时间从几秒钟优化到几十毫秒,改好也不会出现超卖的问题了,用户体验得到显著改善。

通过项目实践,我深入掌握了Spring Boot、Vue3、Redis、锁、AI大模型集成等核心技术,提升了系统架构设计能力、问题解决能力和团队协作能力。

三、八股部分
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. 那么你是怎么解决缓存穿透的问题呢?

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

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文件中可以设置刷盘策略。我们当时设置的就是每秒批量写入一次命令

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节点上的问题了。

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

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

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

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

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

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

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

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

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

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

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

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

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

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+树适合范围查询和扫描,因为叶子节点形成了一个双向链表。

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

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

9. MySQL超大分页怎么处理

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

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

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

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转账时,不受其他事务干扰。持久性体现在事务提交后,数据要被持久化存储。

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

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

2. 什么是AOP?

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

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

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

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

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

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。
11. SpringBoot的自动装配原理(易考)

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

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 方法注入等方式,将依赖对象注入到需要依赖的对象中。
18.拦截器和过滤器有什么区别

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

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

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

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最后完成

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。

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,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

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种,当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。

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

嗯,是这样的~~

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

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

嗯,知道一些~

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

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

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

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

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

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

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

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

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

9. 栈和堆的区别呢

嗯,好的,有这几个区别

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

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

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

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

嗯,是这样~~

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

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

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

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

思考一会~~

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

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

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

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默认)

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分钟。

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