第一次面小米,被疯狂拷打!
共 15597字,需浏览 32分钟
·
2024-07-09 14:04
大家好,我是二哥呀。
好家伙,小米集团的 25 届提前批已经开了,只不过这次是硬件工程师,看来小米汽车的销量确实不错啊(😄),既然硬件已经来了,软件工程师还会远吗?
我只能说,今年的秋招比去年来得更早一些,就连华夏银行、小度、深信服、诺瓦星云、Oppo、科大讯飞等等知名的公司都开始了。
希望参加今年秋招的小伙伴,要抓紧时间准备了,八股、项目、算法,都要按部就班地往前推进。时间不等人,星球里还遇到过秋招错过、春招也错过的球友,导致后续非常被动。
今天我们就以《Java 面试指南》中收录的《小米面经同学 F》 面试题为例,来看看小米面试官都喜欢问哪些问题,好做到知彼知己百战不殆。
能看得出来,小米的面试题依然是围绕着二哥一直强调的 Java 后端四大件展开,覆盖面还是非常广的,大家要注意聚焦自己的注意力,别学太多没用的。
1、二哥的 Linux 速查备忘手册.pdf 下载 2、三分恶面渣逆袭在线版:https://javabetter.cn/sidebar/sanfene/nixi.html
小米面经
HashMap的八股(底层,链表/红黑树转换原因)
JDK 8 中 HashMap 的数据结构是数组
+链表
+红黑树
。
HashMap 的核心是一个动态数组(Node[] table
),用于存储键值对。这个数组的每个元素称为一个“桶”(Bucket),每个桶的索引是通过对键的哈希值进行哈希函数处理得到的。
当多个键经哈希处理后得到相同的索引时,会发生哈希冲突。HashMap 通过链表来解决哈希冲突——即将具有相同索引的键值对通过链表连接起来。
不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。数组的查询效率是 O(1)。
HashTable和ConcurrentHashMap的底层实现
①、HashTable 是直接在方法上加 synchronized 关键字,比较粗暴。
③、ConcurrentHashMap 在 JDK 7 中使用分段锁,在 JKD 8 中使用了 CAS(Compare-And-Swap)+ synchronized 关键字,性能得到进一步提升。
ArrayList和LinkedList的区别和使用场景
-
ArrayList 基于数组实现 -
LinkedList 基于链表实现
使用场景有什么不同?
ArrayList 适用于:
-
随机访问频繁:需要频繁通过索引访问元素的场景。 -
读取操作远多于写入操作:如存储不经常改变的列表。 -
末尾添加元素:需要频繁在列表末尾添加元素的场景。
LinkedList 适用于:
-
频繁插入和删除:在列表中间频繁插入和删除元素的场景。 -
不需要快速随机访问:顺序访问多于随机访问的场景。 -
队列和栈:由于其双向链表的特性,LinkedList 可以高效地实现队列(FIFO)和栈(LIFO)。
线程池的参数及创建线程的方式
线程池有 7 个参数,需要重点关注corePoolSize
、maximumPoolSize
、workQueue
、handler
这四个。
我一一说一下:
①、corePoolSize
定义了线程池中的核心线程数量。即使这些线程处于空闲状态,它们也不会被回收。这是线程池保持在等待状态下的线程数。
②、maximumPoolSize
线程池允许的最大线程数量。当工作队列满了之后,线程池会创建新线程来处理任务,直到线程数达到这个最大值。
⑤、workQueue
用于存放待处理任务的阻塞队列。当所有核心线程都忙时,新任务会被放在这个队列里等待执行。
⑦、handler
拒绝策略 RejectedExecutionHandler,定义了当线程池和工作队列都满了之后对新提交的任务的处理策略。常见的拒绝策略包括抛出异常、直接丢弃、丢弃队列中最老的任务、由提交任务的线程来直接执行任务等。
说说线程有几种创建方式?
Java 中创建线程主要有三种方式,分别为继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。
volatile保证了什么(问了具体的内存屏障),volatile加在基本类型和对象上的区别
volatile 关键字主要有两个作用,一个是保证变量的内存可见性,一个是禁止指令重排序。
volatile 怎么保证可见性的呢?
当一个变量被声明为 volatile 时,Java 内存模型会确保所有线程看到该变量时的值是一致的。
也就是说,当线程对 volatile 变量进行写操作时,JMM 会在写入这个变量之后插入一个 Store-Barrier(写屏障)指令,这个指令会强制将本地内存中的变量值刷新到主内存中。
当线程对 volatile 变量进行读操作时,JMM 会插入一个 Load-Barrier(读屏障)指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。
volatile加在基本类型和对象上的区别?
当 volatile
用于基本数据类型时,能确保该变量的读写操作是直接从主内存中读取或写入的。
private volatile int count = 0;
当 volatile
用于引用类型时,它确保引用本身的可见性,即确保引用指向的对象地址是最新的。
但是,volatile
并不能保证引用对象内部状态的线程安全性。
private volatile SomeObject obj = new SomeObject();
虽然 volatile
确保了 obj
引用的可见性,但对 obj
引用的具体对象的操作并不受 volatile
保护。如果需要保证引用对象内部状态的线程安全,需要使用其他同步机制(如 synchronized
或 ReentrantLock
)。
synchronized和ReentrantLock区别和场景
synchronized 是一个关键字,而 Lock 属于一个接口,其实现类主要有 ReentrantLock、ReentrantReadWriteLock。
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 等来实现。
比如说我们可以装来动态加载类并创建对象:
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 的类型、名称等自动注入所需的依赖。
在 Spring Boot 中,开启自动装配的注解是@EnableAutoConfiguration
。
Spring Boot 为了进一步简化,直接通过 @SpringBootApplication
注解一步搞定,这个注解包含了 @EnableAutoConfiguration
注解。
①、@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 容器中。
③、获取注入类的方法是 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 提供了多种高效的数据结构,如字符串(String)、列表(List)、集合(Set)、有序集合(Sorted Set)等,这些数据结构经过了高度优化,能够支持快速的数据操作。
缓存常见问题和解决方案(引申到多级缓存),多级缓存(redis,nginx,本地缓存)的实现思路
缓存穿透、缓存击穿和缓存雪崩是指在使用 Redis 做为缓存时可能遇到的三种问题。
什么是缓存击穿?
缓存击穿是指某一个或少数几个数据被高频访问,当这些数据在缓存中过期的那一刻,大量请求就会直接到达数据库,导致数据库瞬间压力过大。
解决⽅案:
①、加锁更新,⽐如请求查询 A,发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。
②、将过期时间组合写在 value 中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。
什么是缓存穿透?
缓存穿透是指查询不存在的数据,由于缓存没有命中(因为数据根本就不存在),请求每次都会穿过缓存去查询数据库。如果这种查询非常频繁,就会给数据库造成很大的压力。
缓存穿透意味着缓存失去了减轻数据压力的意义。缓存穿透可能有两种原因:
-
自身业务代码问题 -
恶意攻击,爬虫造成空命中
它主要有两种解决办法:
①、缓存空值/默认值
在数据库无法命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。
②、布隆过滤器
除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。
布隆过滤器里会保存数据是否存在,如果判断数据不存在,就不会访问存储。
什么是缓存雪崩?
缓存雪崩是指在某一个时间点,由于大量的缓存数据同时过期或缓存服务器突然宕机了,导致所有的请求都落到了数据库上(比如 MySQL),从而对数据库造成巨大压力,甚至导致数据库崩溃的现象。
总之就是,崩了,崩的非常严重,就叫雪崩了(电影电视里应该看到过,非常夸张)。
如何解决缓存雪崩呢?
01、集群部署:采用分布式缓存而不是单一缓存服务器,可以降低单点故障的风险。即使某个缓存节点发生故障,其他节点仍然可以提供服务,从而避免对数据库的大量直接访问。
可以利用 Redis Cluster。
或者第三方集群方案 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 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 源码:
其中 hincrby 命令用于对哈希表中的字段值执行自增操作,pexpire 命令用于设置键的过期时间。比 SETNX 更优雅。
redis的主从架构和主从哨兵区别
主从复制(Master-Slave Replication)是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。
前者称为主节点(master),后者称为从节点(slave)。且数据的复制是单向的,只能由主节点到从节点。
通常会使用 Sentinel 哨兵来实现自动故障转移,当主节点挂掉时,Sentinel 会自动将一个从节点升级为主节点,保证系统的可用性。
内容来源
-
星球嘉宾三分恶的面渣逆袭:https://javabetter.cn/sidebar/sanfene/nixi.html -
二哥的 Java 进阶之路(GitHub 已有 12000+star):https://javabetter.cn
ending
一个人可以走得很快,但一群人才能走得更远。二哥的编程星球已经有 5600 多名球友加入了,如果你也需要一个良好的学习环境,戳链接 🔗 加入我们吧。这是一个编程学习指南 + Java 项目实战 + LeetCode 刷题的私密圈子,你可以阅读星球专栏、向二哥提问、帮你制定学习计划、和球友一起打卡成长。
两个置顶帖「球友必看」和「知识图谱」里已经沉淀了非常多优质的学习资源,相信能帮助你走的更快、更稳、更远。
欢迎点击左下角阅读原文了解二哥的编程星球,这可能是你学习求职路上最有含金量的一次点击。
最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。