第一轮面试 面试官:先问些基础的,Java 中 ArrayList 和 HashMap 的底层数据结构分别是什么? 王铁牛:ArrayList 底层是数组,HashMap 底层是数组加链表,JDK8 之后还有红黑树。 面试官:不错,回答得很准确。那 ArrayList 在扩容时具体是怎么操作的? 王铁牛:嗯……好像是当元素个数达到容量的一定比例,就会扩容,新容量好像是原来的 1.5 倍,然后把旧数组的数据复制到新数组。 面试官:回答得可以。HashMap 在 JDK8 中引入红黑树的阈值是多少? 王铁牛:是 8,当链表长度达到 8 时,会转化为红黑树。
第二轮面试 面试官:接下来聊聊多线程和线程池。创建线程有几种方式? 王铁牛:有三种,继承 Thread 类,实现 Runnable 接口,还有实现 Callable 接口。 面试官:很好。那线程池的核心参数有哪些,分别有什么作用? 王铁牛:呃……有核心线程数、最大线程数,核心线程数就是一开始创建的线程数量,最大线程数就是最多能创建的线程数量,还有个队列,用来存放任务。 面试官:回答得不太完整。那线程池中的拒绝策略有哪些? 王铁牛:好像有 AbortPolicy,直接抛出异常,还有 DiscardPolicy,丢弃任务,其他的不太清楚了。
第三轮面试 面试官:再谈谈框架相关的。Spring 中 Bean 的作用域有哪些? 王铁牛:有 singleton,单例模式,一个 Spring 容器中只有一个实例;还有 prototype,每次请求都会创建一个新的实例。 面试官:还行。Spring Boot 自动配置的原理是什么? 王铁牛:就是 Spring Boot 会根据依赖和配置,自动帮我们配置一些 Bean,具体怎么实现的不太清楚。 面试官:那 MyBatis 中 #{} 和 {} 的区别是什么? **王铁牛**:#{} 是预编译,能防止 SQL 注入,{} 是直接拼接 SQL,可能会有 SQL 注入风险。
面试总结:从这三轮面试来看,你对于一些基础的 Java 知识掌握得还可以,像 ArrayList、HashMap 的底层结构,创建线程的方式等都回答得不错。但对于一些稍微深入的问题,比如线程池核心参数的完整解释、Spring Boot 自动配置原理等,回答得不是很清晰全面。后续我们会综合评估所有面试者的情况,你先回家等通知吧,无论结果如何,我们都会在一周内给你回复。
问题答案:
- ArrayList 和 HashMap 的底层数据结构:
- ArrayList:底层是数组结构,它可以动态扩容。数组的特点是可以根据索引快速访问元素,但插入和删除元素在非尾部位置时效率较低,因为需要移动元素。
- HashMap:JDK7 及之前,底层是数组加链表结构。数组的每个位置是一个链表头节点。当发生哈希冲突(不同的键计算出相同的哈希值)时,会将新的键值对以链表的形式挂在对应数组位置的链表上。JDK8 之后,当链表长度达到 8 且数组容量大于等于 64 时,链表会转化为红黑树,以提高查找效率。红黑树是一种自平衡的二叉查找树,查找、插入和删除操作平均时间复杂度为 O(log n)。
- ArrayList 扩容操作:
- ArrayList 有一个容量(capacity)和实际元素个数(size)。当向 ArrayList 中添加元素时,如果 size 达到了 capacity,就会触发扩容。
- 扩容时,新的容量是原来容量的 1.5 倍(oldCapacity + (oldCapacity >> 1))。然后会创建一个新的更大的数组,将旧数组中的元素复制到新数组中。这个复制过程使用 System.arraycopy 方法,它是一个本地方法,效率较高。
- HashMap 在 JDK8 中引入红黑树的阈值:
- 阈值是 8。当链表长度达到 8 时,并且此时数组容量大于等于 64,链表会转化为红黑树。这是因为链表在长度较短时,查找效率虽然是 O(n),但由于数据量小,性能影响不大。而当链表过长时,查找效率会显著下降,转化为红黑树可以将查找效率提高到 O(log n)。如果数组容量小于 64,优先进行扩容而不是转化为红黑树,因为扩容相对简单且能在一定程度上减少哈希冲突。
- 创建线程的方式:
- 继承 Thread 类:创建一个类继承 Thread 类,重写 run 方法,在 run 方法中编写线程执行的逻辑。然后创建该类的实例并调用 start 方法启动线程。例如:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行中");
}
}
MyThread myThread = new MyThread();
myThread.start();
- 实现 Runnable 接口:创建一个类实现 Runnable 接口,实现 run 方法。然后创建该类的实例,将其作为参数传递给 Thread 类的构造函数,再调用 start 方法启动线程。例如:
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程执行中");
}
}
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
- 实现 Callable 接口:创建一个类实现 Callable 接口,实现 call 方法,call 方法可以有返回值并且可以抛出异常。通过 FutureTask 类来包装 Callable 实例,再将 FutureTask 作为参数传递给 Thread 类的构造函数启动线程。可以通过 FutureTask 的 get 方法获取 call 方法的返回值。例如:
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 100;
}
}
MyCallable myCallable = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(myCallable);
Thread thread = new Thread(futureTask);
thread.start();
try {
Integer result = futureTask.get();
System.out.println("返回值: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
- 线程池的核心参数:
- 核心线程数(corePoolSize):线程池中会一直存活的线程数量,即使这些线程处于空闲状态,也不会被销毁。当有新任务提交到线程池时,如果当前线程池中的线程数量小于 corePoolSize,会优先创建新的线程来处理任务。
- 最大线程数(maximumPoolSize):线程池中允许存在的最大线程数量。当任务队列已满,并且当前线程池中的线程数量小于 maximumPoolSize 时,会继续创建新的线程来处理任务。
- 队列(workQueue):用于存放暂时无法处理的任务。当线程池中的线程数量达到 corePoolSize 后,新提交的任务会被放入队列中等待处理。常见的队列类型有 ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列,默认容量为 Integer.MAX_VALUE)、SynchronousQueue(不存储任务,直接提交给线程处理,如果没有空闲线程则任务会被拒绝)等。
- 线程存活时间(keepAliveTime):当线程池中的线程数量超过 corePoolSize 时,多余的空闲线程在存活了 keepAliveTime 时间后会被销毁。
- 时间单位(unit):keepAliveTime 的时间单位,如 TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)等。
- 线程池中的拒绝策略:
- AbortPolicy:这是默认的拒绝策略,当任务无法提交到线程池(队列已满且线程数达到 maximumPoolSize)时,直接抛出 RejectedExecutionException 异常。
- DiscardPolicy:丢弃无法处理的任务,不抛出异常,也不会对任务进行任何处理。
- DiscardOldestPolicy:丢弃队列中最老的任务(队头的任务),然后尝试将新任务提交到队列中。
- CallerRunsPolicy:当任务无法提交到线程池时,由提交任务的线程(调用者线程)来执行该任务。这样可以降低新任务的提交速度,减轻线程池的压力。
- Spring 中 Bean 的作用域:
- singleton:单例模式,在一个 Spring 容器中,只会创建一个该 Bean 的实例。所有对该 Bean 的请求都会返回同一个实例。这是默认的作用域。例如,对于一些无状态的服务类,如 UserService,使用单例模式可以减少内存开销,因为不需要为每个请求创建新的实例。
- prototype:原型模式,每次请求获取该 Bean 时,都会创建一个新的实例。适用于有状态的 Bean,比如 Struts2 的 Action 类,每个请求可能需要不同的状态,所以使用 prototype 作用域。
- request:在一次 HTTP 请求中,只会创建一个该 Bean 的实例。当请求结束时,该实例会被销毁。适用于处理与 HTTP 请求相关的业务逻辑,如记录请求相关的日志等。
- session:在一个 HTTP Session 中,只会创建一个该 Bean 的实例。当 Session 过期或被销毁时,该实例也会被销毁。适用于需要在整个 Session 生命周期内保持状态的场景,如用户登录信息的管理。
- global - session:在一个全局的 HTTP Session 中(通常用于 Portlet 环境),只会创建一个该 Bean 的实例。
- Spring Boot 自动配置的原理:
- Spring Boot 基于 Spring 框架,它的自动配置是通过一系列的条件注解和配置类实现的。
- 条件注解:例如 @ConditionalOnClass、@ConditionalOnProperty 等。@ConditionalOnClass 表示当类路径下存在某个类时,才会进行相关的配置。比如,如果类路径下存在 Tomcat 相关的类,Spring Boot 会自动配置 Tomcat 作为 Web 服务器。@ConditionalOnProperty 表示当配置文件中存在某个属性时,才会进行相关配置。
- 配置类:Spring Boot 提供了大量的自动配置类,如 DataSourceAutoConfiguration 用于数据库连接池的自动配置,WebMvcAutoConfiguration 用于 Spring MVC 的自动配置等。这些配置类会根据条件注解来决定是否生效。
- Spring Factories 机制:Spring Boot 使用 Spring Factories 机制来加载自动配置类。在 META - INF/spring.factories 文件中,定义了各种自动配置类的全限定名。Spring Boot 在启动时会扫描这些文件,加载并根据条件应用相应的自动配置类。
- MyBatis 中 #{} 和 ${} 的区别:
- #{}:是预编译方式,MyBatis 在处理 #{} 时,会将 SQL 中的 #{} 替换为?,然后使用 PreparedStatement 来执行 SQL。这种方式可以有效防止 SQL 注入攻击,因为参数是作为占位符传递的,而不是直接拼接在 SQL 语句中。例如:
<select id="selectUserById" parameterType="int" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
- **{} 时,会直接将 ${} 中的内容替换到 SQL 语句中。如果参数值是用户输入的,并且没有进行严格的过滤,就可能导致 SQL 注入攻击。例如:
<select id="selectUserByUsername" parameterType="string" resultType="User">
SELECT * FROM user WHERE username = '${username}'
</select>
- 一般情况下,能用 #{} 的地方尽量使用 #{},只有在需要进行 SQL 片段拼接等特殊情况下才使用 {} 时要对参数进行严格的过滤和验证。