美团后端暑期实习一面经历:从挂掉到重生的面试旅程与技术问题解析

一位同学的美团后端暑期实习第一次面试经历,主要包含了一些常规的面试题,难度适中,但个别问题确实让人难以回答。该同学的表现不尽如人意,原本以为会收到感谢信,没想到几天后竟然收到了复活赛的邀请,最终成功晋级。

图片

1. 线程池的参数分析

/**  
 * 用给定的初始参数创建一个新的ThreadPoolExecutor。  
 */  
public ThreadPoolExecutor(int corePoolSize, // 线程池的核心线程数量  
                          int maximumPoolSize, // 线程池的最大线程数  
                          long keepAliveTime, // 当线程数大于核心线程数时,多余空闲线程存活的最长时间  
                          TimeUnit unit, // 时间单位  
                          BlockingQueue<Runnable> workQueue, // 任务队列,用来储存等待执行的任务  
                          ThreadFactory threadFactory, // 线程工厂,用于创建线程,一般使用默认设置即可  
                          RejectedExecutionHandler handler // 拒绝策略,当提交的任务过多而不能及时处理时,可以定制策略来处理任务  
                          ) {  
    if (corePoolSize < 0 ||  
        maximumPoolSize <= 0 ||  
        maximumPoolSize < corePoolSize ||  
        keepAliveTime < 0)  
        throw new IllegalArgumentException();  
    if (workQueue == null || threadFactory == null || handler == null)  
        throw new NullPointerException();  
    this.corePoolSize = corePoolSize;  
    this.maximumPoolSize = maximumPoolSize;  
    this.workQueue = workQueue;  
    this.keepAliveTime = unit.toNanos(keepAliveTime);  
    this.threadFactory = threadFactory;  
    this.handler = handler;  
}  

ThreadPoolExecutor 的三个最重要参数包括:

  • corePoolSize: 任务队列未达到容量之前,最大同时运行的线程数量。
  • maximumPoolSize: 任务队列达到容量时,最大可以同时运行的线程数量。
  • workQueue: 当新任务到来时,首先会判断当前运行的线程数量是否达到核心线程数,若已达到,新任务将被放入队列中排队。

其他常见参数:

  • keepAliveTime: 当线程池中线程数量超过 corePoolSize 时,空闲线程的最大存活时间,超过该时间后将被回收。
  • unit: 指定 keepAliveTime 的时间单位。
  • threadFactory: 创建新线程时用到的工厂。
  • handler: 定义饱和策略(后续将详细介绍)。

以下图示有助于理解线程池中各个参数之间的关系(图片来源:《Java 性能调优实战》):

![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

在线程池中,当任务队列未满时,最多运行的线程数量即为核心线程数;一旦任务队列满,最多可运行的线程数则达到最大线程数。

2. 线程池的执行过程

![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

线程池的执行过程分为以下几个步骤:

  1. 如果当前运行的线程数小于核心线程数,则会创建新的线程来执行任务。
  2. 如果当前运行的线程数等于或超过核心线程数,但小于最大线程数,则该任务将被放入任务队列中等待执行。
  3. 如果任务队列投放失败(任务队列已满),但当前运行的线程数小于最大线程数,则会创建新的线程执行该任务。
  4. 如果当前运行的线程数已达到最大线程数,则新任务将被拒绝,并且会调用 RejectedExecutionHandler.rejectedExecution() 方法处理。

3. Executors 工具类提供的线程池类型及其问题

Executors 工具类提供了几种常见的线程池:

  • FixedThreadPool: 固定数量的线程池,线程数量始终不变,空闲线程会立即处理新的任务,若没有空闲线程,则新的任务会被暂存到任务队列中。
  • SingleThreadExecutor: 只有一个线程的线程池,若有多个任务被提交,将按顺序处理。
  • CachedThreadPool: 可根据需要调整线程数量的线程池,优先复用空闲线程,若没有空闲线程则创建新线程处理任务。
  • ScheduledThreadPool: 支持延迟执行或定期执行任务的线程池。

常见的问题包括:

  • FixedThreadPoolSingleThreadExecutor: 使用无界的 LinkedBlockingQueue,可能导致大量请求堆积,造成 OOM。
  • CachedThreadPool: 使用同步队列 SynchronousQueue,若任务数量过多可能导致大量线程创建,从而引起 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor: 采用无界延迟阻塞队列 DelayedWorkQueue,同样可能导致 OOM。

4. 如何保证缓存与数据库的一致性?

在引入缓存后,解决短期不一致性的问题并不需要使系统设计变得复杂。以下是对 Cache Aside Pattern(旁路缓存模式) 的探讨。

在此模式中,写请求的处理流程为:更新数据库后,直接删除缓存。

若更新数据库成功,但删除缓存失败,可采取以下两种解决方案:

  1. 缩短缓存失效时间(不推荐):减短缓存数据的过期时间,以便缓存可以及时从数据库中加载数据,但该办法并不适用于先操作缓存再操作数据库的场景。
  2. 增加缓存更新重试机制(常用):如果缓存服务不可用导致缓存删除失败,可以设定一定时间间隔进行重试,重试次数可自行决策。最佳实践是在消息队列中实现异步重试,通过消息队列将删除缓存的重试消息发送,交由专门的消费者进行重试,直到成功。虽然增加了消息队列,但整体收益显著。

7. 依赖注入的原理

IoC(控制反转) 是 Spring 中一个非常重要的概念,其目标是通过“第三方”(如 Spring IoC 容器)来实现具有关联的对象之间的解耦,降低代码之间的耦合度。

IoC 不是一种技术,而是一种设计思想,以下模式(但不限于)实现了 IoC 原则。

![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

在 Spring 中,IoC 容器如同一个工厂,当需要创建对象时,仅需配置好相关的配置文件或注解,无需考虑如何创建对象。IoC 容器负责创建对象、连接对象、配置对象,并处理对象的整个生命周期,直到其被销毁。

在实际项目中,若服务类依赖多个底层类的实例化,使用 IoC 减少了开发者的负担,同时提高了项目的可维护性。

控制反转的理解:比如对象 a 依赖于对象 b,通常需要自己创建对象 b。但是引入 IoC 容器后,当对象 a 需要对象 b 时,可以指定 IoC 容器去创建并注入对象 b,这样对象 a 与对象 b 之间的关系由主动变为被动,控制权顺势反转。

DI(依赖注入)是实现控制反转的一种设计模式,依赖注入将实例变量传入到对象中。

8. Spring Bean 的生命周期

建议面试时尽量少问这类问题!

  1. 创建 Bean 实例:Bean 容器会查找配置文件中的 Bean 定义,并利用反射 API 创建实例。

  2. 属性赋值/填充:为 Bean 设置相关属性和依赖,例如通过 @Autowired 注解注入的对象、@Value 注入的值、setter 方法或构造函数注入的依赖等。

  3. Bean 初始化

    • 如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName() 方法传入 Bean 名称。
    • 如果实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader() 方法传入 ClassLoader 实例。
    • 如果实现了 BeanFactoryAware 接口,调用 setBeanFactory() 方法传入 BeanFactory 实例。
    • 如果有与加载该 Bean 的 Spring 容器相关的 BeanPostProcessor,执行 postProcessBeforeInitialization() 方法。
    • 如果 Bean 实现了 InitializingBean 接口,执行 afterPropertiesSet() 方法。
    • 如果配置文件中定义了 init-method 属性,执行指定的方法。
    • 如果有与加载该 Bean 的 Spring 容器相关的 BeanPostProcessor,执行 postProcessAfterInitialization() 方法。
  4. 销毁 Bean:销毁并不意味着立即销毁 Bean,而是将销毁方法记录下来,未来需要销毁 Bean 或容器时调用这些方法以释放资源。

    • 如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。
    • 如果配置文件中定义了 destroy-method 属性,执行指定的 Bean 销毁方法,或通过 @PreDestroy 注解标记 Bean 销毁前执行的方法。

AbstractAutowireCapableBeanFactorydoCreateBean() 方法中依次执行了这四个阶段:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)  
    throws BeanCreationException {  

    // 1. 创建 Bean 实例  
    BeanWrapper instanceWrapper = null;  
    if (instanceWrapper == null) {  
        instanceWrapper = createBeanInstance(beanName, mbd, args);  
    }  

    Object exposedObject = bean;  
    try {  
        // 2. Bean 属性赋值/填充  
        populateBean(beanName, mbd, instanceWrapper);  
        // 3. Bean 初始化  
        exposedObject = initializeBean(beanName, exposedObject, mbd);  
    }  

    // 4. 销毁 Bean-注册回调接口  
    try {  
        registerDisposableBeanIfNecessary(beanName, bean, mbd);  
    }  

    return exposedObject;  
}  

Aware 接口允许 Bean 获取 Spring 容器资源。Spring 中的 Aware 接口主要有:

  1. BeanNameAware: 注入当前 bean 对应的 beanName;
  2. BeanClassLoaderAware: 注入加载当前 bean 的 ClassLoader;
  3. BeanFactoryAware: 注入当前 BeanFactory 容器的引用。

BeanPostProcessor 接口为修改 Bean 提供了强大的扩展点。

public interface BeanPostProcessor {  

    // 初始化前置处理  
    default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {  
        return bean;  
    }  

    // 初始化后置处理  
    default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {  
        return bean;  
    }  
}  
  • postProcessBeforeInitialization: Bean 实例化、属性注入完成后,自定义的 init-method 方法之前执行;
  • postProcessAfterInitialization: 类似于上述,在 InitializingBean#afterPropertiesSet 方法和自定义 init-method 之后执行。

InitializingBeaninit-method 是 Spring 为 Bean 初始化提供的扩展点。

public interface InitializingBean {  
    // 初始化逻辑  
    void afterPropertiesSet() throws Exception;  
}  

指定 init-method 方法:

<?xml version="1.0" encoding="UTF-8"?>  
<beans xmlns="http://www.springframework.org/schema/beans"  
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">  

    <bean id="demo" class="com.chaycao.Demo" init-method="init()"/>  

</beans>  

如何记忆 Bean 的生命周期?

  1. 整体可简单分为四个步骤:实例化 → 属性赋值 → 初始化 → 销毁。
  2. 初始化步骤较复杂,涉及到 Aware 接口的依赖注入、BeanPostProcessor 的初始化前后处理、InitializingBeaninit-method 的初始化操作。
  3. 销毁步骤则会注册相关的销毁回调接口,最后通过 DisposableBeandestroy-method 释放资源。

最后,分享一张清晰的图示(图源:如何记忆 Spring Bean 的生命周期[1])。

![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

9. Spring 循环依赖解读与解决方案

强烈建议面试时少问此类问题,难以理解且实际意义不大。

循环依赖是指 Bean 对象之间的彼此引用,例如 CircularDependencyA → CircularDependencyB → CircularDependencyA:

@Component  
public class CircularDependencyA {  
    @Autowired  
    private CircularDependencyB circB;  
}  

@Component  
public class CircularDependencyB {  
    @Autowired  
    private CircularDependencyA circA;  
}  

单个对象自我依赖的情况也可导致循环依赖,概率极低,属于编码错误。

@Component  
public class CircularDependencyA {  
    @Autowired  
    private CircularDependencyA circA;  
}  

Spring 框架通过三级缓存的机制解决此问题,确保即使在循环依赖的情况下也能成功创建 Bean。

// 一级缓存  
/** Cache of singleton objects: bean name to bean instance. */  
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);  

// 三级缓存  
/** Cache of singleton factories: bean name to ObjectFactory. */  
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);  

// 二级缓存  
/** Cache of early singleton objects: bean name to bean instance. */  
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);  

简单来说,Spring 的三级缓存包括:

  1. 一级缓存(singletonObjects):存放最终形态的 Bean(已实例化、属性填充、初始化),通常获取 Bean 时从这里提取。
  2. 二级缓存(earlySingletonObjects):存放过渡 Bean(半成品,尚未属性填充),结合三级缓存使用,防止在 AOP 的情况下生成多个代理对象。
  3. 三级缓存(singletonFactories):存放 ObjectFactory,其 getObject() 方法生成原始 Bean 或代理对象(当 Bean 被 AOP 切面代理时)。

只用两级缓存是否足够? 在无 AOP 的情况下,确实可以只使用一级和三级缓存来解决循环依赖。但涉及 AOP 时,二级缓存显得尤为重要,因为它确保对早期引用的请求总是返回同一代理对象,避免了多个代理对象的问题。

这种机制也有不足之处,比如增加内存开销(需要维护三级缓存),降低性能(需要进行多次检查和转换)。此外,某些情况不支持循环依赖,例如非单例 Bean 和加了 @Async 注解的 Bean。

在 SpringBoot 2.6.x 之前,默认允许循环依赖。自 2.6.x 之后,官方不再推荐编写存在循环依赖的代码,建议开发者减少不必要的互相依赖。这是因为循环依赖本质上是一种设计缺陷,过度依赖框架可能会降低编码规范和质量,未来某个版本也可能彻底禁止此类代码。

如不想重构循环依赖的代码,可采取以下方法:

  • 在全局配置中允许循环依赖:spring.main.allow-circular-references=true(不太推荐)。
  • 在导致循环依赖的 Bean 上添加 @Lazy 注解,推荐的做法,延迟加载可以应用在类、方法、构造器、方法参数和成员变量上。

更多关于 Spring 循环依赖的内容可参阅以下几篇文章:

10. 数据库隔离级别及其解决的问题

SQL 标准定义了四个隔离级别:

  • READ-UNCOMMITTED(读取未提交):最低的隔离级别,允许读取尚未提交的数据变更,可能导致脏读、幻读或不可重复读。
  • READ-COMMITTED(读取已提交):允许读取并发事务已提交的数据,可阻止脏读,但幻读或不可重复读仍可能发生。
  • REPEATABLE-READ(可重复读):对同一字段的多次读取结果一致,除非该数据被本事务自身修改,可阻止脏读和不可重复读,但幻读仍可能发生。
  • SERIALIZABLE(可串行化):最高隔离级别,完全遵循 ACID 的隔离要求,所有事务依次执行,因此事务间不可能产生干扰,阻止脏读、不可重复读和幻读。
隔离级别脏读不可重复读幻读
READ-UNCOMMITTED
READ-COMMITTED×
REPEATABLE-READ××
SERIALIZABLE×××

MySQL InnoDB 存储引擎默认支持的隔离级别为 REPEATABLE-READ(可重读)。可通过以下命令查看:SELECT @@tx_isolation;(在 MySQL 8.0 中,改为 SELECT @@transaction_isolation;)。

MySQL> SELECT @@tx_isolation;  
+-----------------+  
| @@tx_isolation  |  
+-----------------+  
| REPEATABLE-READ |  
+-----------------+  

根据 SQL 标准,REPEATABLE-READ 隔离级别并不能防止幻读。然而,InnoDB 实现的 REPEATABLE-READ 隔离级别能够解决幻读问题,主要依赖以下两种方式:

  • 快照读:使用 MVCC 机制避免幻读。
  • 当前读:通过 Next-Key Lock 进行加锁,Next-Key Lock 结合行锁(Record Lock)和间隙锁(Gap Lock)。

由于较低的隔离级别请求的锁较少,因此大多数数据库系统默认使用 READ-COMMITTED。但 InnoDB 存储引擎默认使用 REPEATABLE-READ 不会带来性能损失。

在分布式事务的情况下,InnoDB 存储引擎通常会采用 SERIALIZABLE 隔离级别。

《MySQL 技术内幕:InnoDB 存储引擎(第 2 版)》第 7.7 章提到:

InnoDB 存储引擎提供对 XA 事务的支持,通过 XA 事务实现分布式事务。分布式事务允许多个独立的事务资源参与到全局事务中,确保所有参与事务要么都提交,要么都回滚,进一步提高了事务的 ACID 要求。

11. 索引叶子节点存储的内容

索引的叶子节点存储的内容根据其是主键索引还是非主键索引而有所不同。在 MySQL 的 InnoDB 存储引擎中,利用 B+ 树作为索引结构:

  • 主键索引的叶子节点存储整行数据。
  • 非主键索引(即二级索引)的叶子节点则存储主键的值。

在基于非主键的索引查询时,数据库首先在非主键索引的 B+ 树中查找对应的主键值,再通过主键索引的 B+ 树获取最终数据,该过程称为回表。

[1] 如何记忆 Spring Bean 的生命周期: https://chaycao.github.io/2020/02/15/如何记忆Spring-Bean的生命周期.html

[2] 浅谈 Spring 如何解决 Bean 的循环依赖问题: https://juejin.cn/post/7218080360403615804

[3] 聊透 Spring 循环依赖: https://juejin.cn/post/7146458376505917447