《互联网大厂 Java 面试:从基础到进阶的核心技术大考验》

62 阅读4分钟

第一轮面试 面试官:先问些基础的,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 的服务暴露过程。 王铁牛:呃,就是……好像是通过一些协议把服务暴露出去,具体步骤我不太确定。

面试结束,面试官表情严肃地说:“今天的面试就到这里,你回去等通知吧。我们会综合评估所有候选人,之后会给你反馈。感谢你今天来参加面试。”

问题答案

  1. 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 的哈希值,确定在数组中的位置,若有冲突则按链表或红黑树规则处理。
  2. ArrayList 在扩容时具体是怎么操作的?
    • 当 ArrayList 中元素个数达到容量阈值(默认为数组容量的 0.75 倍)时,会进行扩容。
    • 扩容时,会创建一个新的数组,新数组容量是原数组容量的 1.5 倍(原容量右移一位再加原容量)。例如原容量为 10,新容量就是 10 + (10 >> 1) = 15
    • 然后通过 System.arraycopy() 方法将原数组的元素复制到新数组中。这样就完成了扩容操作,使得 ArrayList 可以继续添加新元素。
  3. HashMap 在 JDK1.8 中,链表转红黑树的条件是什么?
    • 链表长度大于等于 8。这是为了在链表过长时,将其转换为红黑树以提高查找效率。因为链表的查找时间复杂度为 O(n),而红黑树的查找时间复杂度为 O(logn)。
    • 同时数组容量大于等于 64。如果数组容量过小,即使链表长度达到 8,也不会转成红黑树,而是继续以链表形式存在,这是为了避免在数组容量较小时频繁进行树化和反树化操作,影响性能。
  4. 创建线程有几种方式?
    • 继承 Thread 类:创建一个类继承 Thread 类,重写 run() 方法,在 run() 方法中编写线程执行的逻辑。例如:
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();
}
  1. 线程池的核心参数有哪些,分别有什么作用?
    • 核心线程数(corePoolSize):线程池初始化时创建的线程数。即使这些线程处于空闲状态,也不会被销毁(除非设置了 allowCoreThreadTimeOut 为 true)。例如,一个 Web 服务器的线程池,核心线程数可以设置为能处理日常请求量的线程数量,保证服务器能快速响应常规请求。
    • 最大线程数(maximumPoolSize):线程池能容纳的最大线程数。当任务队列已满且核心线程都在忙碌时,线程池会创建新的线程,直到线程数达到最大线程数。但如果此时任务队列和线程池都已满,新任务会根据拒绝策略处理。例如,在高并发的电商抢购场景,可能需要设置较大的最大线程数来处理瞬间大量的请求。
    • 存活时间(keepAliveTime):当线程数大于核心线程数时,多余的空闲线程在没有任务执行的情况下,最多存活的时间。超过这个时间,这些线程会被销毁。例如,在一些批处理任务场景,任务执行完后,多余的线程在存活时间过后会被回收,避免资源浪费。
    • 时间单位(unit):存活时间的单位,如 TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分钟)等,用来明确存活时间的度量单位。
    • 任务队列(workQueue):用来存放提交但未执行的任务。常见的任务队列有 ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列)等。例如,使用 ArrayBlockingQueue 可以限制任务队列的大小,避免任务堆积过多导致内存溢出;而 LinkedBlockingQueue 若不指定容量则为无界队列,可能会在高并发时消耗大量内存。
  2. 线程池中的拒绝策略有哪些?
    • AbortPolicy:直接抛出 RejectedExecutionException 异常,阻止系统正常运行。例如,当线程池和任务队列都已满,新任务提交时,采用此策略会立即抛出异常,提醒调用者任务无法处理。
    • DiscardPolicy:丢弃任务,不抛出异常。这种策略比较“安静”,适用于对任务处理结果不太关心,只希望系统能继续运行的场景,例如一些日志记录任务,偶尔丢失一两条日志记录对系统整体影响不大。
    • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(如果线程池未关闭)。例如,当任务队列已满且有新任务提交时,会丢弃最早进入队列的任务,然后尝试将新任务放入队列,适用于希望优先处理新任务的场景。
    • CallerRunsPolicy:由调用线程处理该任务。当线程池和任务队列都已满时,提交任务的线程会自己执行该任务,这样可以降低新任务的提交速度,减轻线程池的压力,适用于对响应时间要求不高,但希望系统能尽量处理所有任务的场景。
  3. 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 实例,使得单元测试更加简单和独立。
  4. 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 类会根据相关条件生效,自动配置数据源。
  5. MyBatis 中 #{} 和 ${} 的区别是什么?
    • #{}:是预编译处理,MyBatis 在处理 #{} 时,会将 SQL 中的 #{} 替换为 ?,然后使用 PreparedStatement 的 set 方法来设置参数值。这样可以有效防止 SQL 注入攻击。例如:
<select id="selectUser" parameterType="int" resultType="User">
    SELECT * FROM users WHERE id = #{id}
</select>

实际执行的 SQL 类似 SELECT * FROM users WHERE id =?,然后通过 PreparedStatement 设置 ? 的值。

  • **:是字符串替换,MyBatis在处理{}**:是字符串替换,MyBatis 在处理 `{}时,会直接将${}` 替换为变量的值。这种方式可能会导致 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 等)。注册中心起到服务发现和路由的作用,消费者可以从注册中心获取到服务提供者的地址列表。
  • 服务监听:服务提供者启动一个网络服务,监听指定的端口,等待消费者的调用请求。当消费者发起调用时,服务提供者接收到请求,通过之前生成的代理类调用实际的服务实现方法,处理请求并返回结果。