第一次面小米,被疯狂拷打!

沉默王二

共 15597字,需浏览 32分钟

 ·

2024-07-09 14:04

大家好,我是二哥呀。

好家伙,小米集团的 25 届提前批已经开了,只不过这次是硬件工程师,看来小米汽车的销量确实不错啊(😄),既然硬件已经来了,软件工程师还会远吗?

信息来源于新职课

我只能说,今年的秋招比去年来得更早一些,就连华夏银行、小度、深信服、诺瓦星云、Oppo、科大讯飞等等知名的公司都开始了。

希望参加今年秋招的小伙伴,要抓紧时间准备了,八股、项目、算法,都要按部就班地往前推进。时间不等人,星球里还遇到过秋招错过、春招也错过的球友,导致后续非常被动。

今天我们就以《Java 面试指南》中收录的《小米面经同学 F》 面试题为例,来看看小米面试官都喜欢问哪些问题,好做到知彼知己百战不殆。

让天下所有的面渣都能逆袭 😁

能看得出来,小米的面试题依然是围绕着二哥一直强调的 Java 后端四大件展开,覆盖面还是非常广的,大家要注意聚焦自己的注意力,别学太多没用的。

小米面经

HashMap的八股(底层,链表/红黑树转换原因)

JDK 8 中 HashMap 的数据结构是数组+链表+红黑树

三分恶面渣逆袭:JDK 8 HashMap 数据结构示意图

HashMap 的核心是一个动态数组(Node[] table),用于存储键值对。这个数组的每个元素称为一个“桶”(Bucket),每个桶的索引是通过对键的哈希值进行哈希函数处理得到的。

当多个键经哈希处理后得到相同的索引时,会发生哈希冲突。HashMap 通过链表来解决哈希冲突——即将具有相同索引的键值对通过链表连接起来。

不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。数组的查询效率是 O(1)。

HashTable和ConcurrentHashMap的底层实现

①、HashTable 是直接在方法上加 synchronized 关键字,比较粗暴。

二哥的 Java 进阶之路:HashTable

③、ConcurrentHashMap 在 JDK 7 中使用分段锁,在 JKD 8 中使用了 CAS(Compare-And-Swap)+ synchronized 关键字,性能得到进一步提升。

初念初恋:ConcurrentHashMap 8 中的实现

ArrayList和LinkedList的区别和使用场景

  • ArrayList 基于数组实现
  • LinkedList 基于链表实现
三分恶面渣逆袭:ArrayList和LinkedList的数据结构

使用场景有什么不同?

ArrayList 适用于:

  • 随机访问频繁:需要频繁通过索引访问元素的场景。
  • 读取操作远多于写入操作:如存储不经常改变的列表。
  • 末尾添加元素:需要频繁在列表末尾添加元素的场景。

LinkedList 适用于:

  • 频繁插入和删除:在列表中间频繁插入和删除元素的场景。
  • 不需要快速随机访问:顺序访问多于随机访问的场景。
  • 队列和栈:由于其双向链表的特性,LinkedList 可以高效地实现队列(FIFO)和栈(LIFO)。

线程池的参数及创建线程的方式

线程池有 7 个参数,需要重点关注corePoolSizemaximumPoolSizeworkQueuehandler 这四个。

三分恶面渣逆袭:线程池参数

我一一说一下:

①、corePoolSize

定义了线程池中的核心线程数量。即使这些线程处于空闲状态,它们也不会被回收。这是线程池保持在等待状态下的线程数。

②、maximumPoolSize

线程池允许的最大线程数量。当工作队列满了之后,线程池会创建新线程来处理任务,直到线程数达到这个最大值。

⑤、workQueue

用于存放待处理任务的阻塞队列。当所有核心线程都忙时,新任务会被放在这个队列里等待执行。

⑦、handler

拒绝策略 RejectedExecutionHandler,定义了当线程池和工作队列都满了之后对新提交的任务的处理策略。常见的拒绝策略包括抛出异常、直接丢弃、丢弃队列中最老的任务、由提交任务的线程来直接执行任务等。

说说线程有几种创建方式?

Java 中创建线程主要有三种方式,分别为继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。

二哥的 Java 进阶之路

volatile保证了什么(问了具体的内存屏障),volatile加在基本类型和对象上的区别

volatile 关键字主要有两个作用,一个是保证变量的内存可见性,一个是禁止指令重排序。

volatile 怎么保证可见性的呢?

当一个变量被声明为 volatile 时,Java 内存模型会确保所有线程看到该变量时的值是一致的。

深入浅出 Java 多线程:Java内存模型

也就是说,当线程对 volatile 变量进行写操作时,JMM 会在写入这个变量之后插入一个 Store-Barrier(写屏障)指令,这个指令会强制将本地内存中的变量值刷新到主内存中。

三分恶面渣逆袭:volatile写插入内存屏障后生成的指令序列示意图

当线程对 volatile 变量进行读操作时,JMM 会插入一个 Load-Barrier(读屏障)指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。

三分恶面渣逆袭:volatile写插入内存屏障后生成的指令序列示意图

volatile加在基本类型和对象上的区别?

volatile 用于基本数据类型时,能确保该变量的读写操作是直接从主内存中读取或写入的。

private volatile int count = 0;

volatile 用于引用类型时,它确保引用本身的可见性,即确保引用指向的对象地址是最新的。

但是,volatile 并不能保证引用对象内部状态的线程安全性。

private volatile SomeObject obj = new SomeObject();

虽然 volatile 确保了 obj 引用的可见性,但对 obj 引用的具体对象的操作并不受 volatile 保护。如果需要保证引用对象内部状态的线程安全,需要使用其他同步机制(如 synchronizedReentrantLock)。

synchronized和ReentrantLock区别和场景

synchronized 是一个关键字,而 Lock 属于一个接口,其实现类主要有 ReentrantLock、ReentrantReadWriteLock。

三分恶面渣逆袭:synchronized和ReentrantLock的区别

synchronized 可以直接在方法上加锁,也可以在代码块上加锁(无需手动释放锁,锁会自动释放),而 ReentrantLock 必须手动声明来加锁和释放锁。

// synchronized 修饰方法
public synchronized void method() {
    // 业务代码
}

// synchronized 修饰代码块
synchronized (this) {
    // 业务代码
}

// ReentrantLock 加锁
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 业务代码
finally {
    lock.unlock();
}

随着 JDK 版本的升级,synchronized 的性能已经可以媲美 ReentrantLock 了,加入了偏向锁、轻量级锁和重量级锁的自适应优化等,所以可以大胆地用。

如果需要更细粒度的控制(如可中断的锁操作、尝试非阻塞获取锁、超时获取锁或者使用公平锁等),可以使用 Lock。

  • ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly()来实现这个机制。
  • ReentrantLock 可以指定是公平锁还是非公平锁。
  • ReentrantReadWriteLock 读写锁,读锁是共享锁,写锁是独占锁,读锁可以同时被多个线程持有,写锁只能被一个线程持有。这种锁的设计可以提高性能,特别是在读操作的数量远远超过写操作的情况下。

Lock 还提供了newCondition()方法来创建等待通知条件Condition,比 synchronized 与 wait()notify()/notifyAll()方法的组合更强大。

ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

垃圾回收的算法及详细介绍

垃圾收集算法主要有三种,分别是标记-清除算法、标记-复制算法和标记-整理算法。

说说标记-清除算法?

标记-清除(Mark-Sweep)算法分为两个阶段:

  • 标记:标记所有需要回收的对象
  • 清除:回收所有被标记的对象
三分恶面渣逆袭:标记-清除算法

优点是实现简单,缺点是回收过程中会产生内存碎片。

说说标记-复制算法?

标记-复制(Mark-Copy)算法可以解决标记-清除算法的内存碎片问题,因为它将内存空间划分为两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后清理掉这一块。

三分恶面渣逆袭:标记-复制算法

缺点是浪费了一半的内存空间。

说说标记-整理算法?

标记-整理(Mark-Compact)算法是标记-清除复制算法的升级版,它不再划分内存空间,而是将存活的对象向内存的一端移动,然后清理边界以外的内存。

标记-整理算法

缺点是移动对象的成本比较高。

反射的介绍与使用场景

创建一个对象是通过 new 关键字来实现的,比如:

Person person = new Person();

Person 类的信息在编译时就确定了,那假如在编译期无法确定类的信息,但又想在运行时获取类的信息、创建类的实例、调用类的方法,这时候就要用到反射。

反射功能主要通过 java.lang.Class 类及 java.lang.reflect 包中的类如 Method, Field, Constructor 等来实现。

三分恶面渣逆袭:Java反射相关类

比如说我们可以装来动态加载类并创建对象:

String className = "java.util.Date";
Class<?> cls = Class.forName(className);
Object obj = cls.newInstance();
System.out.println(obj.getClass().getName());

Spring 框架就大量使用了反射来动态加载和管理 Bean。

Class<?> clazz = Class.forName("com.example.MyClass");
Object instance = clazz.newInstance();

两种动态代理的区别

①、JDK 动态代理是基于接口的代理,只能代理实现了接口的类。使用 JDK 动态代理时,Spring AOP 会创建一个代理对象,该代理对象实现了目标对象所实现的接口,并在方法调用前后插入横切逻辑。

优点:只需依赖 JDK 自带的 java.lang.reflect.Proxy 类,不需要额外的库;缺点:只能代理接口,不能代理类本身。

②、CGLIB 动态代理是基于继承的代理,可以代理没有实现接口的类。使用 CGLIB 动态代理时,Spring AOP 会生成目标类的子类,并在方法调用前后插入横切逻辑。

图片来源于网络

优点:可以代理没有实现接口的类,灵活性更高;缺点:需要依赖 CGLIB 库,创建代理对象的开销相对较大。

SpringBoot和Spring的区别,自动装配的原理

Spring Boot 是 Spring Framework 的一个扩展,提供了一套快速配置和开发的框架,可以帮助我们快速搭建 Spring 项目骨架,极大地提高了我们的生产效率。

特性 Spring Framework Spring Boot
目的 提供全面的企业级开发工具和库 简化 Spring 应用的开发、配置和部署
配置方式 主要通过 XML 和注解配置 主要通过注解和外部配置文件
启动和运行 需要手动配置和部署到服务器 支持嵌入式服务器,打包成 JAR 文件直接运行
自动配置 手动配置各种组件和依赖 提供开箱即用的自动配置
依赖管理 手动添加和管理依赖 使用 spring-boot-starter 简化依赖管理
模块化 高度模块化,可以选择使用不同的模块 集成多个常用模块,提供统一的启动入口
生产准备功能 需要手动集成和配置 内置监控、健康检查等生产准备功能

项目用到的redis数据结构和场景

在 Spring 中,自动装配是指容器利用反射技术,根据 Bean 的类型、名称等自动注入所需的依赖。

三分恶面渣逆袭:SpringBoot自动配置原理

在 Spring Boot 中,开启自动装配的注解是@EnableAutoConfiguration

二哥的 Java 进阶之路:@EnableAutoConfiguration 源码

Spring Boot 为了进一步简化,直接通过 @SpringBootApplication 注解一步搞定,这个注解包含了 @EnableAutoConfiguration 注解。

二哥的 Java 进阶之路:@SpringBootApplication源码

①、@EnableAutoConfiguration 只是一个简单的注解,但是它的背后却是一个非常复杂的自动装配机制,核心是AutoConfigurationImportSelector 类。

@AutoConfigurationPackage //将main同级的包下的所有组件注册到容器中
@Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration
public @interface EnableAutoConfiguration 
{
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

②、AutoConfigurationImportSelector实现了ImportSelector接口,这个接口的作用就是收集需要导入的配置类,配合@Import()就将相应的类导入到 Spring 容器中。

二哥的 Java 进阶之路:AutoConfigurationImportSelector源码

③、获取注入类的方法是 selectImports(),它实际调用的是getAutoConfigurationEntry(),这个方法是获取自动装配类的关键。

protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
    // 检查自动配置是否启用。如果@ConditionalOnClass等条件注解使得自动配置不适用于当前环境,则返回一个空的配置条目。
    if (!isEnabled(annotationMetadata)) {
        return EMPTY_ENTRY;
    }

    // 获取启动类上的@EnableAutoConfiguration注解的属性,这可能包括对特定自动配置类的排除。
    AnnotationAttributes attributes = getAttributes(annotationMetadata);

    // 从spring.factories中获取所有候选的自动配置类。这是通过加载META-INF/spring.factories文件中对应的条目来实现的。
    List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);

    // 移除配置列表中的重复项,确保每个自动配置类只被考虑一次。
    configurations = removeDuplicates(configurations);

    // 根据注解属性解析出需要排除的自动配置类。
    Set<String> exclusions = getExclusions(annotationMetadata, attributes);

    // 检查排除的类是否存在于候选配置中,如果存在,则抛出异常。
    checkExcludedClasses(configurations, exclusions);

    // 从候选配置中移除排除的类。
    configurations.removeAll(exclusions);

    // 应用过滤器进一步筛选自动配置类。过滤器可能基于条件注解如@ConditionalOnBean等来排除特定的配置类。
    configurations = getConfigurationClassFilter().filter(configurations);

    // 触发自动配置导入事件,允许监听器对自动配置过程进行干预。
    fireAutoConfigurationImportEvents(configurations, exclusions);

    // 创建并返回一个包含最终确定的自动配置类和排除的配置类的AutoConfigurationEntry对象。
    return new AutoConfigurationEntry(configurations, exclusions);
}

Spring Boot 的自动装配原理依赖于 Spring 框架的依赖注入和条件注册,通过这种方式,Spring Boot 能够智能地配置 bean,并且只有当这些 bean 实际需要时才会被创建和配置。

redis快的原因

Redis 的速度⾮常快,单机的 Redis 就可以⽀撑每秒十几万的并发,性能是 MySQL 的⼏⼗倍。速度快的原因主要有⼏点:

①、基于内存的数据存储,Redis 将数据存储在内存当中,使得数据的读写操作避开了磁盘 I/O。而内存的访问速度远超硬盘,这是 Redis 读写速度快的根本原因。

②、单线程模型,Redis 使用单线程模型来处理客户端的请求,这意味着在任何时刻只有一个命令在执行。这样就避免了线程切换和锁竞争带来的消耗。

③、IO 多路复⽤,基于 Linux 的 select/epoll 机制。该机制允许内核中同时存在多个监听套接字和已连接套接字,内核会一直监听这些套接字上的连接请求或者数据请求,一旦有请求到达,就会交给 Redis 处理,就实现了所谓的 Redis 单个线程处理多个 IO 读写的请求。

三分恶面渣逆袭:Redis使用IO多路复用和自身事件模型

④、高效的数据结构,Redis 提供了多种高效的数据结构,如字符串(String)、列表(List)、集合(Set)、有序集合(Sorted Set)等,这些数据结构经过了高度优化,能够支持快速的数据操作。

缓存常见问题和解决方案(引申到多级缓存),多级缓存(redis,nginx,本地缓存)的实现思路

缓存穿透、缓存击穿和缓存雪崩是指在使用 Redis 做为缓存时可能遇到的三种问题。

什么是缓存击穿?

缓存击穿是指某一个或少数几个数据被高频访问,当这些数据在缓存中过期的那一刻,大量请求就会直接到达数据库,导致数据库瞬间压力过大。

三分恶面渣逆袭:缓存击穿

解决⽅案:

①、加锁更新,⽐如请求查询 A,发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。

三分恶面渣逆袭:加锁更新

②、将过期时间组合写在 value 中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。

什么是缓存穿透?

缓存穿透是指查询不存在的数据,由于缓存没有命中(因为数据根本就不存在),请求每次都会穿过缓存去查询数据库。如果这种查询非常频繁,就会给数据库造成很大的压力。

三分恶面渣逆袭:缓存穿透

缓存穿透意味着缓存失去了减轻数据压力的意义。缓存穿透可能有两种原因:

  1. 自身业务代码问题
  2. 恶意攻击,爬虫造成空命中

它主要有两种解决办法:

①、缓存空值/默认值

在数据库无法命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。

三分恶面渣逆袭:缓存空值/默认值

②、布隆过滤器

除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。

布隆过滤器里会保存数据是否存在,如果判断数据不存在,就不会访问存储。

三分恶面渣逆袭:布隆过滤器

什么是缓存雪崩?

缓存雪崩是指在某一个时间点,由于大量的缓存数据同时过期或缓存服务器突然宕机了,导致所有的请求都落到了数据库上(比如 MySQL),从而对数据库造成巨大压力,甚至导致数据库崩溃的现象。

总之就是,崩了,崩的非常严重,就叫雪崩了(电影电视里应该看到过,非常夸张)。

三分恶面渣逆袭:缓存雪崩

如何解决缓存雪崩呢?

01、集群部署:采用分布式缓存而不是单一缓存服务器,可以降低单点故障的风险。即使某个缓存节点发生故障,其他节点仍然可以提供服务,从而避免对数据库的大量直接访问。

可以利用 Redis Cluster。

Rajat Pachauri:Redis Cluster

或者第三方集群方案 Codis。

极客时间:Codis

02、备份缓存:对于关键数据,除了在主缓存中存储,还可以在备用缓存中保存一份。当主缓存不可用时,可以快速切换到备用缓存,确保系统的稳定性和可用性。

在技术派实战项目中,我们采用了多级缓存的策略,其中就包括使用本地缓存 Guava Cache 和 Caffeine 来作为二级缓存,在 Redis 出现问题时,系统会自动切换到本地缓存。

这个过程称为“降级”,意味着系统在失去优先级高的资源时仍能继续提供服务。

技术派教程

当从 Redis 获取数据失败时,尝试从本地缓存读取数据。

LoadingCache<String, UserPermissions> permissionsCache = Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(this::loadPermissionsFromRedis);

public UserPermissions loadPermissionsFromRedis(String userId) {
    try {
        return redisClient.getPermissions(userId);
    } catch (Exception ex) {
        // Redis 异常处理,尝试从本地缓存获取
        return permissionsCache.getIfPresent(userId);
    }
}

自己实现redis分布式锁的坑(主动提了Redission)

Redis 实现分布式锁的本质,就是在 Redis 里面占一个“茅坑”,当别的客户端也来占坑时,发现已经有客户端蹲在那里了,就只好放弃或者稍后再试。

可以使用 Redis 的 SET 命令实现分布式锁。SET 命令支持设置键值对的同时添加过期时间,这样可以防止死锁的发生。

三分恶面渣逆袭:set原子命令
SET key value NX PX 30000
  • key 是锁名。
  • value 是锁的持有者标识,可以使用 UUID 作为 value。
  • NX 只在键不存在时设置。
  • PX 30000:设置键的过期时间为 30 秒(防止死锁)。

上面这段命令其实是 setnx 和 expire 组合在一起的原子命令,算是比较完善的一个分布式锁了。

当然,实际的开发中,没人会去自己写分布式锁的命令,因为有专业的轮子——Redisson。(戳链接跳转至悟空聊架构:分布式锁中的王者方案 - Redisson)

Redisson 了解吗?

图片来源于网络

Redisson 是一个基于 Redis 的 Java 驻内存数据网格(In-Memory Data Grid),提供了一系列 API 用来操作 Redis,其中最常用的功能就是分布式锁。

RLock lock = redisson.getLock("lock");
lock.lock();
try {
    // do something
finally {
    lock.unlock();
}

普通锁的实现源码是在 RedissonLock 类中,也是通过 Lua 脚本封装一些 Redis 命令来实现的的,比如说 tryLockInnerAsync 源码:

二哥的 Java 进阶之路:RedissonLock

其中 hincrby 命令用于对哈希表中的字段值执行自增操作,pexpire 命令用于设置键的过期时间。比 SETNX 更优雅。

redis的主从架构和主从哨兵区别

主从复制(Master-Slave Replication)是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。

前者称为主节点(master),后者称为从节点(slave)。且数据的复制是单向的,只能由主节点到从节点。

三分恶面渣逆袭:Redis主从复制简图

通常会使用 Sentinel 哨兵来实现自动故障转移,当主节点挂掉时,Sentinel 会自动将一个从节点升级为主节点,保证系统的可用性。

内容来源

  • 星球嘉宾三分恶的面渣逆袭:https://javabetter.cn/sidebar/sanfene/nixi.html
  • 二哥的 Java 进阶之路(GitHub 已有 12000+star):https://javabetter.cn

ending

一个人可以走得很快,但一群人才能走得更远。二哥的编程星球已经有 5600 多名球友加入了,如果你也需要一个良好的学习环境,戳链接 🔗 加入我们吧。这是一个编程学习指南 + Java 项目实战 + LeetCode 刷题的私密圈子,你可以阅读星球专栏、向二哥提问、帮你制定学习计划、和球友一起打卡成长。

两个置顶帖「球友必看」和「知识图谱」里已经沉淀了非常多优质的学习资源,相信能帮助你走的更快、更稳、更远

欢迎点击左下角阅读原文了解二哥的编程星球,这可能是你学习求职路上最有含金量的一次点击。

最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。

浏览 752
2点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
2点赞
评论
收藏
分享

手机扫一扫分享

举报