《揭秘互联网大厂Java面试:从基础到进阶的灵魂拷问》

84 阅读11分钟

故事场景为互联网大厂Java求职者面试,面试官对求职者进行多轮技术提问。

面试官:第一轮提问开始。第一个问题,Java中ArrayList和HashMap的底层数据结构分别是什么? 王铁牛:ArrayList底层是数组,HashMap底层是数组加链表,后来JDK 1.8引入了红黑树。 面试官:回答不错。第二个问题,HashMap在什么情况下会发生扩容? 王铁牛:当HashMap中的元素个数达到负载因子(默认0.75)乘以当前容量的时候,就会进行扩容。 面试官:很好。第三个问题,ArrayList在添加元素时,如果容量不足会怎样? 王铁牛:会进行扩容,新的容量大概是原来容量的1.5倍。

面试官:第二轮提问。第一个问题,多线程中线程池的核心参数有哪些,分别代表什么含义? 王铁牛:呃……有核心线程数,就是一直保留的线程数量,还有最大线程数,就是最多能创建的线程数,其他的……我不太记得了。 面试官:好。第二个问题,JUC包下的CountDownLatch是用来做什么的? 王铁牛:好像是用来控制线程等待的,具体怎么用不太清楚。 面试官:第三个问题,在多线程环境下,HashMap为什么不安全,而ConcurrentHashMap是安全的? 王铁牛:因为HashMap在多线程下可能会出现数据覆盖啥的,ConcurrentHashMap好像是用了什么分段锁技术,具体不太明白。

面试官:第三轮提问。第一个问题,Spring中Bean的生命周期是怎样的? 王铁牛:呃,就是先实例化,然后初始化,最后销毁,具体细节不太清楚。 面试官:第二个问题,Spring Boot自动配置的原理是什么? 王铁牛:好像是通过一些注解和配置文件,具体不太说得清。 面试官:第三个问题,MyBatis中#{}和{}的区别是什么? **王铁牛**:#{}是预编译,{}是字符串替换,好像是这样。 面试官:第四个问题,Dubbo的服务暴露和引用过程是怎样的? 王铁牛:这个……不太熟悉,只知道大概是注册中心啥的,具体流程不清楚。 面试官:好,面试到这里。从整体表现来看,你对一些基础的知识点掌握得还可以,但对于一些稍微深入和复杂的问题,理解得不够透彻。回去等通知吧,我们会综合评估所有候选人后,再做决定。

答案:

  1. ArrayList和HashMap的底层数据结构
    • ArrayList:底层是数组结构,它允许以数组下标的方式快速访问元素。例如,ArrayList<Integer> list = new ArrayList<>(); list.add(10); int num = list.get(0);这里通过get(0)能快速获取到添加的第一个元素,因为它基于数组存储。
    • HashMap:JDK 1.8之前底层是数组加链表结构,数组的每个位置是一个链表头节点。当发生哈希冲突时(即不同的键计算出相同的哈希值),会将新的键值对以链表的形式挂在该位置。JDK 1.8引入了红黑树,当链表长度大于8且数组容量大于64时,链表会转换为红黑树,以提高查找效率。例如,HashMap<String, Integer> map = new HashMap<>(); map.put("key1", 1);通过哈希算法计算“key1”的哈希值,确定在数组中的位置,如果该位置已有元素(哈希冲突),则以链表或红黑树的形式存储。
  2. HashMap扩容机制
    • 当HashMap中的元素个数(size)达到负载因子(loadFactor,默认0.75)乘以当前容量(capacity)时,就会触发扩容。例如,初始容量为16,当元素个数达到16 * 0.75 = 12时,就会进行扩容。扩容后新的容量是原来容量的2倍。扩容过程中,会重新计算每个键值对在新数组中的位置,并重新分配。这是因为哈希值与数组长度进行与运算(hash & (capacity - 1))来确定元素在数组中的位置,容量变化后,位置可能改变。
  3. ArrayList添加元素时容量不足的处理
    • 当ArrayList添加元素时,如果当前容量不足,会进行扩容。新的容量是原来容量的1.5倍(oldCapacity + (oldCapacity >> 1))。例如,初始容量为10,当添加第11个元素时,容量不足,新容量变为10 + (10 >> 1) = 15。扩容时,会创建一个新的更大的数组,然后将原数组的元素复制到新数组中。
  4. 线程池核心参数
    • 核心线程数(corePoolSize):线程池中一直存活的线程数量,即使这些线程处于空闲状态,也不会被销毁。例如,设置核心线程数为5,那么线程池启动后,会先创建5个线程等待任务。
    • 最大线程数(maximumPoolSize):线程池中允许创建的最大线程数量。当任务队列已满,且核心线程数都在忙碌时,会继续创建新线程,直到达到最大线程数。例如,最大线程数设置为10,核心线程数为5,当任务队列满了,且5个核心线程都在处理任务,会继续创建线程,最多创建到10个线程。
    • 任务队列(workQueue):用于存放等待执行的任务。常见的任务队列有ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列)等。例如,使用new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(10));这里创建了一个容量为10的有界队列。
    • 线程存活时间(keepAliveTime):当线程数大于核心线程数时,多余的空闲线程等待新任务的最长时间,超过这个时间,多余的线程会被销毁。例如,设置存活时间为10秒,当有多余的线程空闲10秒后,会被销毁。
    • 时间单位(unit):与线程存活时间配合,指定存活时间的单位,如TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分钟)等。
  5. CountDownLatch的作用
    • CountDownLatch是JUC包下的一个同步工具类,它允许一个或多个线程等待其他一组线程完成操作后再继续执行。例如,在一个项目启动时,可能需要多个初始化任务完成后,主线程才能继续启动服务。可以创建一个CountDownLatch,初始化计数值为需要等待的任务数。每个任务完成后调用countDown()方法将计数值减1,主线程调用await()方法等待计数值变为0,即所有任务完成后再继续执行。示例代码如下:
import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) {
        int taskCount = 3;
        CountDownLatch latch = new CountDownLatch(taskCount);

        for (int i = 0; i < taskCount; i++) {
            new Thread(() -> {
                // 模拟任务执行
                System.out.println(Thread.currentThread().getName() + " 任务执行中");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 任务完成");
                latch.countDown();
            }).start();
        }

        try {
            latch.await();
            System.out.println("所有任务完成,主线程继续执行");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. HashMap在多线程下不安全及ConcurrentHashMap安全的原因
    • HashMap在多线程下不安全
      • 数据覆盖:在多线程环境下,当多个线程同时进行put操作时,如果发生哈希冲突,可能会导致数据覆盖。例如,线程A和线程B同时计算出相同的哈希值,都要往数组的同一个位置添加元素,由于没有同步机制,可能会出现后添加的元素覆盖先添加元素的情况。
      • 扩容死循环:在JDK 1.7及之前,当多个线程同时进行扩容操作时,可能会形成环形链表,导致死循环。因为扩容时会重新计算元素位置并移动元素,多线程操作可能会使链表的指针指向混乱,形成环形链表,在遍历链表时就会陷入死循环。
    • ConcurrentHashMap安全
      • JDK 1.7:采用分段锁技术,将数据分成多个段(Segment),每个段有自己的锁。不同线程可以同时访问不同段的数据,提高了并发性能。例如,默认有16个段,每个段类似一个小的HashMap,当线程对某个段进行写操作时,只需要获取该段的锁,不影响其他段的操作。
      • JDK 1.8:摒弃了分段锁,采用CAS(Compare - And - Swap)和synchronized关键字结合的方式。在链表头节点使用synchronized关键字进行同步控制,在更新操作时使用CAS操作保证原子性。同时,在扩容时采用了更优化的方式,避免了死循环问题。
  2. Spring中Bean的生命周期
    • 实例化(Instantiation):通过构造函数或工厂方法创建Bean的实例。例如,public class UserService { public UserService() { // 构造函数 } }这里通过构造函数实例化UserService
    • 属性赋值(Populate):将Bean定义中的属性值注入到实例中。例如,在Spring配置文件中<bean id="userService" class="com.example.UserService"> <property name="name" value="张三"/> </bean>,这里将name属性值“张三”注入到UserService实例中。
    • 初始化前(Post - Process Before Initialization):在Bean实例化和属性赋值完成后,调用BeanPostProcessorpostProcessBeforeInitialization方法,允许对Bean进行一些预处理操作。
    • 初始化(Initialization):调用Bean的初始化方法,如通过init - method指定的方法。例如,public class UserService { public void init() { // 初始化方法 } }在Spring配置文件中<bean id="userService" class="com.example.UserService" init - method="init"/>,会在属性赋值后调用init方法。
    • 初始化后(Post - Process After Initialization):调用BeanPostProcessorpostProcessAfterInitialization方法,允许对Bean进行一些后处理操作。
    • 使用(Usage):Bean可以被应用程序使用,例如在其他Bean中注入该Bean并调用其方法。
    • 销毁前(Pre - Destroy):在容器关闭时,调用DisposableBeandestroy方法或通过destroy - method指定的方法,进行一些资源清理等操作。例如,public class UserService implements DisposableBean { @Override public void destroy() { // 销毁方法 } }或者在Spring配置文件中<bean id="userService" class="com.example.UserService" destroy - method="customDestroy"/>,会在容器关闭时调用相应的销毁方法。
    • 销毁(Destruction):Bean被销毁,释放资源。
  3. Spring Boot自动配置原理
    • 条件注解:Spring Boot使用了大量的条件注解,如@ConditionalOnClass(当类路径下存在指定类时才生效)、@ConditionalOnProperty(当指定属性存在且满足条件时才生效)等。例如,@ConditionalOnClass(DataSource.class)表示当DataSource类在类路径下时,相关的配置才会生效。
    • 自动配置类:Spring Boot提供了许多自动配置类,如DataSourceAutoConfigurationWebMvcAutoConfiguration等。这些自动配置类通过条件注解来决定是否生效。例如,DataSourceAutoConfiguration会根据类路径下是否存在数据库连接相关的类以及配置文件中的数据库连接属性来决定是否自动配置数据源。
    • Spring Factories机制:在META - INF/spring.factories文件中,定义了自动配置类的全限定名。Spring Boot启动时,会扫描所有依赖的META - INF/spring.factories文件,加载其中定义的自动配置类。例如,在Spring Boot的核心依赖中,spring - boot - autoconfigure.jarMETA - INF/spring.factories文件中定义了许多自动配置类,Spring Boot启动时会加载这些配置类,根据条件注解决定是否应用这些配置。
  4. MyBatis中#{}和${}的区别
    • #{}:是预编译方式,MyBatis会将SQL中的#{}替换为?,并使用PreparedStatement设置参数值。这样可以有效防止SQL注入攻击。例如,select * from user where username = #{username},MyBatis会将其转换为select * from user where username =?,然后通过PreparedStatement.setString(1, usernameValue)设置参数值。
    • **:是字符串替换方式,MyBatis会直接将{}**:是字符串替换方式,MyBatis会直接将{}中的内容替换到SQL中。例如,select * from user where username = '${username}',如果username的值为“admin' or '1'='1”,那么最终的SQL会变为select * from user where username = 'admin' or '1'='1',这就导致了SQL注入漏洞。所以${}一般用于传入数据库对象,如表名、列名等,但使用时要特别小心,确保传入的值是安全的。
  5. Dubbo的服务暴露和引用过程
  • 服务暴露
    • 配置服务:在服务提供方,通过Dubbo的配置文件(如XML配置或注解配置)定义要暴露的服务接口和实现类。例如,使用XML配置<dubbo:service interface="com.example.UserService" ref="userServiceImpl"/>,这里指定了服务接口com.example.UserService和实现类userServiceImpl
    • 注册服务:服务提供方将服务注册到注册中心(如Zookeeper、Nacos等)。以Zookeeper为例,服务提供方会在Zookeeper的指定节点下创建临时节点,节点数据包含服务的元数据信息,如服务接口、版本、地址等。
    • 监听端口:服务提供方启动Netty等网络框架,监听指定端口,等待消费者调用。
  • 服务引用
    • 配置引用:在服务消费方,通过Dubbo的配置文件或注解配置引用服务。例如,<dubbo:reference id="userService" interface="com.example.UserService"/>,这里定义了一个名为userService的引用,指向com.example.UserService接口。
    • 从注册中心获取服务:服务消费方从注册中心获取服务的元数据信息,包括服务地址等。例如,从Zookeeper的相应节点获取服务提供方的地址。
    • 代理调用:Dubbo会为服务接口生成代理对象,消费者通过代理对象调用服务方法。代理对象会根据获取的服务地址,通过网络通信(如Netty)与服务提供方进行交互,将调用参数发送给服务提供方,并接收返回结果。