第一轮面试 面试官:先问些基础的,Java 中 ArrayList 和 HashMap 的底层数据结构分别是什么? 王铁牛:ArrayList 底层是数组,HashMap 底层是数组加链表,JDK1.8 之后引入了红黑树。 面试官:不错,回答得很准确。那 ArrayList 在扩容时具体是怎么操作的? 王铁牛:当 ArrayList 元素个数达到容量阈值时,会创建一个新的数组,新数组容量是原数组的 1.5 倍,然后把原数组的元素复制到新数组。 面试官:很好。HashMap 在 JDK1.8 中,链表转红黑树的条件是什么? 王铁牛:当链表长度大于等于 8 且数组容量大于等于 64 时,链表会转成红黑树。
第二轮面试 面试官:接下来聊聊多线程和线程池。创建线程有几种方式? 王铁牛:有三种,继承 Thread 类、实现 Runnable 接口、实现 Callable 接口并配合 FutureTask 使用。 面试官:那线程池的核心参数有哪些,分别有什么作用? 王铁牛:有核心线程数、最大线程数、存活时间、时间单位、任务队列。核心线程数是线程池初始化时创建的线程数,最大线程数是线程池能容纳的最大线程数,存活时间是线程在没有任务时最多存活的时间,时间单位就是存活时间的单位,任务队列用来存放提交但未执行的任务。 面试官:线程池中的拒绝策略有哪些? 王铁牛:呃……有 AbortPolicy,直接抛出异常;还有 DiscardPolicy,丢弃任务不抛出异常,其他的……我有点记不太清了。
第三轮面试 面试官:谈谈 Spring 和 Spring Boot。Spring 中的 IOC 是什么,有什么作用? 王铁牛:IOC 就是控制反转,把对象的创建和管理交给 Spring 容器,这样可以降低代码的耦合度。 面试官:Spring Boot 自动配置的原理是什么? 王铁牛:嗯……好像是通过一些配置类和条件注解,根据项目依赖来自动配置一些 Bean。具体细节我不太能说清楚。 面试官:MyBatis 中 #{} 和 {} 的区别是什么? **王铁牛**:#{} 是预编译处理,{} 是字符串替换。#{} 可以防止 SQL 注入,${} 可能会有 SQL 注入风险。 面试官:Dubbo、RabbitMq、xxl - job、Redis 这些技术,简单说下 Dubbo 的服务暴露过程。 王铁牛:呃,就是……好像是通过一些协议把服务暴露出去,具体步骤我不太确定。
面试结束,面试官表情严肃地说:“今天的面试就到这里,你回去等通知吧。我们会综合评估所有候选人,之后会给你反馈。感谢你今天来参加面试。”
问题答案:
- Java 中 ArrayList 和 HashMap 的底层数据结构分别是什么?
- ArrayList:底层是数组结构。它允许以数组下标的方式快速访问元素,适合随机访问场景。例如,
ArrayList<Integer> list = new ArrayList<>(); list.add(10); int num = list.get(0);这里通过get(0)能快速获取到第一个元素,时间复杂度为 O(1)。 - HashMap:JDK1.7 及之前,底层是数组加链表。数组的每个位置是一个链表头节点,当发生哈希冲突时,新的元素会以头插法插入到链表中。JDK1.8 之后,底层是数组加链表加红黑树。当链表长度大于等于 8 且数组容量大于等于 64 时,链表会转成红黑树,以提高查找效率。例如,
HashMap<String, Integer> map = new HashMap<>(); map.put("key", 10);通过哈希算法计算key的哈希值,确定在数组中的位置,若有冲突则按链表或红黑树规则处理。
- ArrayList:底层是数组结构。它允许以数组下标的方式快速访问元素,适合随机访问场景。例如,
- ArrayList 在扩容时具体是怎么操作的?
- 当 ArrayList 中元素个数达到容量阈值(默认为数组容量的 0.75 倍)时,会进行扩容。
- 扩容时,会创建一个新的数组,新数组容量是原数组容量的 1.5 倍(原容量右移一位再加原容量)。例如原容量为 10,新容量就是
10 + (10 >> 1) = 15。 - 然后通过
System.arraycopy()方法将原数组的元素复制到新数组中。这样就完成了扩容操作,使得 ArrayList 可以继续添加新元素。
- HashMap 在 JDK1.8 中,链表转红黑树的条件是什么?
- 链表长度大于等于 8。这是为了在链表过长时,将其转换为红黑树以提高查找效率。因为链表的查找时间复杂度为 O(n),而红黑树的查找时间复杂度为 O(logn)。
- 同时数组容量大于等于 64。如果数组容量过小,即使链表长度达到 8,也不会转成红黑树,而是继续以链表形式存在,这是为了避免在数组容量较小时频繁进行树化和反树化操作,影响性能。
- 创建线程有几种方式?
- 继承 Thread 类:创建一个类继承 Thread 类,重写
run()方法,在run()方法中编写线程执行的逻辑。例如:
- 继承 Thread 类:创建一个类继承 Thread 类,重写
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行中");
}
}
MyThread thread = new MyThread();
thread.start();
- 实现 Runnable 接口:创建一个类实现 Runnable 接口,实现
run()方法,然后将该类的实例作为参数传递给 Thread 类的构造函数来创建线程。例如:
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程执行中");
}
}
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
- 实现 Callable 接口并配合 FutureTask 使用:创建一个类实现 Callable 接口,实现
call()方法,call()方法可以有返回值。通过 FutureTask 类包装 Callable 接口的实现类实例,再将 FutureTask 作为参数传递给 Thread 类的构造函数来创建线程。可以通过 FutureTask 的get()方法获取线程执行的返回值。例如:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 10;
}
}
MyCallable callable = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
try {
Integer result = futureTask.get();
System.out.println("线程返回值: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
- 线程池的核心参数有哪些,分别有什么作用?
- 核心线程数(corePoolSize):线程池初始化时创建的线程数。即使这些线程处于空闲状态,也不会被销毁(除非设置了 allowCoreThreadTimeOut 为 true)。例如,一个 Web 服务器的线程池,核心线程数可以设置为能处理日常请求量的线程数量,保证服务器能快速响应常规请求。
- 最大线程数(maximumPoolSize):线程池能容纳的最大线程数。当任务队列已满且核心线程都在忙碌时,线程池会创建新的线程,直到线程数达到最大线程数。但如果此时任务队列和线程池都已满,新任务会根据拒绝策略处理。例如,在高并发的电商抢购场景,可能需要设置较大的最大线程数来处理瞬间大量的请求。
- 存活时间(keepAliveTime):当线程数大于核心线程数时,多余的空闲线程在没有任务执行的情况下,最多存活的时间。超过这个时间,这些线程会被销毁。例如,在一些批处理任务场景,任务执行完后,多余的线程在存活时间过后会被回收,避免资源浪费。
- 时间单位(unit):存活时间的单位,如 TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分钟)等,用来明确存活时间的度量单位。
- 任务队列(workQueue):用来存放提交但未执行的任务。常见的任务队列有 ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列)等。例如,使用 ArrayBlockingQueue 可以限制任务队列的大小,避免任务堆积过多导致内存溢出;而 LinkedBlockingQueue 若不指定容量则为无界队列,可能会在高并发时消耗大量内存。
- 线程池中的拒绝策略有哪些?
- AbortPolicy:直接抛出 RejectedExecutionException 异常,阻止系统正常运行。例如,当线程池和任务队列都已满,新任务提交时,采用此策略会立即抛出异常,提醒调用者任务无法处理。
- DiscardPolicy:丢弃任务,不抛出异常。这种策略比较“安静”,适用于对任务处理结果不太关心,只希望系统能继续运行的场景,例如一些日志记录任务,偶尔丢失一两条日志记录对系统整体影响不大。
- DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(如果线程池未关闭)。例如,当任务队列已满且有新任务提交时,会丢弃最早进入队列的任务,然后尝试将新任务放入队列,适用于希望优先处理新任务的场景。
- CallerRunsPolicy:由调用线程处理该任务。当线程池和任务队列都已满时,提交任务的线程会自己执行该任务,这样可以降低新任务的提交速度,减轻线程池的压力,适用于对响应时间要求不高,但希望系统能尽量处理所有任务的场景。
- Spring 中的 IOC 是什么,有什么作用?
- IOC(Inversion of Control)即控制反转:它是 Spring 框架的核心概念之一。传统编程中,对象的创建和依赖关系的管理由程序自身负责,而在 Spring 的 IOC 模式下,对象的创建、初始化、销毁等工作由 Spring 容器来管理,程序只需要使用对象即可,这就是“控制反转”,控制权从程序代码转移到了 Spring 容器。
- 作用:
- 降低耦合度:例如,一个 Service 类可能依赖多个 Dao 类,如果在 Service 类中自己创建 Dao 类的实例,那么 Service 类与 Dao 类的耦合度就很高。使用 IOC 后,Spring 容器负责创建和注入 Dao 实例,Service 类只需要声明对 Dao 的依赖,这样当 Dao 类的实现发生变化时,Service 类不需要修改代码,只需要修改 Spring 的配置文件即可,提高了代码的可维护性和可扩展性。
- 提高代码的可测试性:在测试 Service 类时,通过 IOC 可以方便地注入模拟的 Dao 实例,而不需要创建真实的 Dao 实例,使得单元测试更加简单和独立。
- Spring Boot 自动配置的原理是什么?
- Spring Boot 自动配置主要依赖于以下几个关键机制:
- @Configuration 注解和配置类:Spring Boot 通过定义大量的配置类,这些配置类使用
@Configuration注解标记,表明它们是配置类。例如DataSourceAutoConfiguration类,用于自动配置数据源相关的 Bean。 - @Conditional 系列注解:配置类中的配置方法使用
@Conditional系列注解(如@ConditionalOnClass、@ConditionalOnProperty等)。@ConditionalOnClass表示当类路径下存在某个类时才进行配置,比如@ConditionalOnClass(DataSource.class)表示当DataSource类在类路径下时,才会配置数据源相关的 Bean。@ConditionalOnProperty表示当配置文件中某个属性满足条件时才进行配置,例如@ConditionalOnProperty(name = "spring.datasource.url")表示当配置文件中有spring.datasource.url属性时才进行相关配置。 - Spring FactoriesLoader:Spring Boot 在
META - INF/spring.factories文件中定义了一系列自动配置类。当 Spring Boot 应用启动时,SpringFactoriesLoader会加载这些文件,根据类路径下的依赖和条件注解来决定哪些自动配置类生效,从而实现自动配置。例如,当项目引入了spring - jdbc依赖时,DataSourceAutoConfiguration类会根据相关条件生效,自动配置数据源。
- @Configuration 注解和配置类:Spring Boot 通过定义大量的配置类,这些配置类使用
- Spring Boot 自动配置主要依赖于以下几个关键机制:
- MyBatis 中 #{} 和 ${} 的区别是什么?
- #{}:是预编译处理,MyBatis 在处理
#{}时,会将 SQL 中的#{}替换为?,然后使用 PreparedStatement 的 set 方法来设置参数值。这样可以有效防止 SQL 注入攻击。例如:
- #{}:是预编译处理,MyBatis 在处理
<select id="selectUser" parameterType="int" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
实际执行的 SQL 类似 SELECT * FROM users WHERE id =?,然后通过 PreparedStatement 设置 ? 的值。
- **{}
时,会直接将${}` 替换为变量的值。这种方式可能会导致 SQL 注入风险。例如:
<select id="selectUser" parameterType="string" resultType="User">
SELECT * FROM users WHERE username = '${username}'
</select>
如果传入的 username 为 '; DROP TABLE users; --,则实际执行的 SQL 为 SELECT * FROM users WHERE username = ''; DROP TABLE users; --',会导致数据库表被删除。所以在使用 ${} 时要特别小心,一般用于传入数据库对象名(如表名、列名)等,且要确保传入的值是可信的。
10. Dubbo 的服务暴露过程:
- 配置解析:Dubbo 首先会读取配置文件或注解中的配置信息,包括服务接口、实现类、协议、端口等。例如,通过 XML 配置:
<dubbo:service interface="com.example.UserService" ref="userServiceImpl" protocol="dubbo" port="20880"/>
- Proxy 生成:Dubbo 使用动态代理技术(如 Javassist 或 JDK 动态代理)为服务接口生成代理类。代理类负责将调用请求封装成 Dubbo 协议能够识别的消息格式。
- 协议适配:根据配置的协议(如 Dubbo 协议、HTTP 协议等),Dubbo 将服务接口和实现类适配成相应协议的格式。以 Dubbo 协议为例,它会将服务信息封装成 Dubbo 特有的数据包结构,包括请求头和请求体,请求头包含一些元数据信息,如版本号、序列化方式等,请求体包含具体的方法名、参数等。
- 注册中心注册:Dubbo 会将服务的元数据信息(如服务接口、地址、端口等)注册到注册中心(如 Zookeeper、Nacos 等)。注册中心起到服务发现和路由的作用,消费者可以从注册中心获取到服务提供者的地址列表。
- 服务监听:服务提供者启动一个网络服务,监听指定的端口,等待消费者的调用请求。当消费者发起调用时,服务提供者接收到请求,通过之前生成的代理类调用实际的服务实现方法,处理请求并返回结果。