查看原文
其他

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

沉默王二 沉默王二
2024-09-05

大家好,我是二哥呀。

好家伙,小米集团的 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 FrameworkSpring 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 刷题的私密圈子,你可以阅读星球专栏、向二哥提问、帮你制定学习计划、和球友一起打卡成长。

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

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

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

个人观点,仅供参考
继续滑动看下一个
沉默王二
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存