小米最近也开奖了!不得不说,软件开发岗位的薪资“性价比”确实拉满了,在北京这边,白菜价只有18k * 15,再高一点有(20~24)k * 15的,普遍开的不高。
不过,小米今年在自动驾驶领域算是下了血本啊,开的薪资有点离谱,能开到100w~160w。不过,要求也是真的高,通常需要 Top 院校的博士学历加上顶级个人实力。
小米是六险一金,12%全额公积金。工作强度话,研发整体是 9:30~(21:30~22:00),两个小时午休,某些部门可能会更晚下班。整体也比较卷,但相比较于某些互联网公司要稍微好一点点。
小米 Java 岗位的面试难度并不大,问题也较为常规,主要以技术八股和项目拷打为主,手撕算法题基本来自 Leetcode 上的常见题目(难度适中)。
下面,分享一篇武汉小米 Java 岗位的校招面经(一二面核心问题整理,附带详细参考答案),大家可以感受一下具体的面试难度。
谈谈对反射的理解
如果说大家研究过框架的底层原理或者咱们自己写过框架的话,一定对反射这个概念不陌生。
反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力。
通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。
但是,这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。
这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。
比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类Method来调用指定的方法。
public class DebugInvocationHandler implements InvocationHandler { /** * 代理类中的真实对象 */ privatefinal Object target; public DebugInvocationHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException { System.out.println("before method " + method.getName()); Object result = method.invoke(target, args); System.out.println("after method " + method.getName()); return result; } }另外,像 Java 中的一大利器注解的实现也用到了反射。
为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个@Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?
这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
如何实现动态代理?
动态代理是一种非常强大的设计模式,它允许我们在不修改源代码的情况下,对一个类或对象的方法进行功能增强(Enhancement)。
在 Java 中,实现动态代理最主流的方式有两种:JDK 动态代理和CGLIB 动态代理。
第一种:JDK 动态代理
Java 官方提供的,其核心要求是目标类必须实现一个或多个接口。JDK 动态代理在运行时,会利用Proxy.newProxyInstance()方法,动态地创建一个实现了这些接口的代理类的实例。这个代理类在内存中生成,你看不到它的.java或.class文件。
当你调用代理对象的任何一个方法时,这个调用都会被转发到我们提供的一个InvocationHandler接口的invoke方法中。在invoke方法里,我们就可以在调用原始方法(目标方法)之前或之后,加入我们自己的增强逻辑。
第二种:CGLIB 动态代理
CGLIB 是一个第三方的代码生成库。它的原理与 JDK 完全不同,它不要求被代理的类实现接口。它在运行时,动态生成目标类的子类作为代理类(通过 ASM 字节码操作技术)。然后,它会重写父类(也就是被代理类)中所有非final、private和static的方法。
当你调用代理对象的任何一个方法时,这个调用会被 CGLIB 的MethodInterceptor接口的intercept方法拦截。和InvocationHandler的invoke方法一样,我们可以在intercept方法里,在调用原始的父类方法之前或之后,加入我们的增强逻辑。
静态代理和动态代理有什么区别?
静态代理和动态代理的核心差异在于代理关系的确定时机、实现灵活性及维护成本。
对比维度 | 静态代理 (Static Proxy) | 动态代理 (Dynamic Proxy) |
|---|---|---|
代理关系确定时机 | 编译期(编译后生成固定的 | 运行时(动态生成代理类字节码并加载到 JVM) |
实现方式 | 手动编写代理类,需与目标类实现同一接口,一对一绑定 | 无需手动编写代理类,通过 |
接口依赖 | 必须实现接口(代理类与目标类遵循同一接口规范) | 支持代理接口或直接代理实现类 |
代码量与维护性 | 代码量大(目标类越多,代理类越多),维护成本高;接口新增方法时,目标类与代理类需同步修改 | 代码量极少(通用增强逻辑可复用),维护性好;与接口解耦,接口变更不影响代理逻辑 |
核心优势 | 实现简单、逻辑直观,无额外框架依赖 | 灵活性强、复用性高,降低重复编码,适配复杂场景 |
典型应用场景 | 简单的装饰器模式、少量固定类的增强需求 | Spring AOP、RPC 框架(如 Dubbo)、ORM 框架 |
JDK 动态代理和 CGLIB 动态代理有什么区别?
JDK 动态代理是官方的,它要求被代理的类必须实现接口。它的原理是动态生成一个接口的实现类来作为代理。CGLIB 是第三方的,它不需要接口。它的原理是动态生成一个被代理类的子类来作为代理。但也正因为是继承,所以它不能代理
final的类,被代理的方法也不能是final或private。就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
推荐顺带着看看笔者写的 Java 代理模式详解[1]这篇文章,总结的比较全面。
介绍一下动态代理在框架中的实际应用场景
动态代理最典型的应用场景就是Spring AOP。
AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。
Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用Cglib生成一个被代理对象的子类来作为代理,如下图所示:
SpringAOPProcess
SPI 是什么?和 API 有什么区别?
SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
那 SPI 和 API 有啥区别?
说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
SPI VS API
一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。
当接口存在于调用方这边时,这就是SPI。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
Java 同步锁的实现
Java 同步锁实现方式主要有下面几类:
synchronized关键字:synchronized是 Java 内置的同步机制,依赖于 JVM 实现。在 Java 早期版本中,synchronized属于重量级锁,效率低下。在 Java 6 之后,synchronized引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让synchronized锁的效率提升了很多。因此,synchronized还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了synchronized。Lock和ReadWriteLock接口实现类:基于 Java 代码实现,常见的实现类有:ReentrantLock:一个可重入且独占式的锁,和synchronized关键字类似。不过,ReentrantLock更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。ReentrantReadWriteLock:ReentrantReadWriteLock实现了ReadWriteLock,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
StampedLock: JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量Condition。不同于一般的Lock类,StampedLock并不是直接实现Lock或ReadWriteLock接口,而是基于 CLH 锁独立实现的(AQS 也是基于这玩意)。
synchronized 代码块或方法的代码如果抛出异常,锁会释放吗?
synchronized代码块或方法的代码如果抛出异常,锁会自动释放。
这是因为 Java 的synchronized关键字是基于 JVM 的监视器锁(Monitor Lock)机制实现的。当一个线程进入synchronized代码块或方法时,它会获取该对象的 monitor 锁。当线程离开synchronized代码块或方法时,它会释放 monitor 锁。JVM 会确保在synchronized代码块或方法执行结束后(无论是正常结束还是异常结束),锁都会被正确释放。这种机制避免了因异常导致的死锁问题,确保了锁的可靠释放。
下面通过代码来实际演示一下:
private staticfinal Object lock = new Object(); privatestaticint counter = 0; public static void main(String[] args) { Thread thread1 = new Thread(() -> { try { incrementAndThrow(); } catch (RuntimeException e) { System.out.println(Thread.currentThread().getName() + " 捕获到异常: " + e.getMessage()); } }, "Thread 1"); Thread thread2 = new Thread(() -> { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " 获取到锁,计数器值: " + counter); } }, "Thread 2"); thread1.start(); // 稍微延迟一下,确保 thread1 先执行 try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } thread2.start(); } private static void incrementAndThrow() { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " 获取到锁,增加计数器"); counter++; thrownew RuntimeException("故意抛出异常"); } }输出:
Thread 1 获取到锁,增加计数器 Thread 1 捕获到异常: 故意抛出异常 Thread 2 获取到锁,计数器值: 1从输出结果可以看出,即使thread1在持有锁的情况下抛出了异常,thread2仍然能够获取到锁,并访问counter变量。这证明了synchronized代码块在抛出异常时会释放锁。counter的值为 1 也证明了thread1在抛出异常之前成功执行了counter++操作。
项目为什么要用 Redis?
1、访问速度更快
传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。
2、高并发
一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。
QPS(Query Per Second):服务器每秒可以执行的查询次数;
由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。
3、功能全面
Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!
为什么用 Redis 而不用本地缓存呢?
特性 | 本地缓存 | Redis |
|---|---|---|
数据一致性 | 多服务器部署时存在数据不一致问题 | 数据一致 |
内存限制 | 受限于单台服务器内存 | 独立部署,内存空间更大 |
数据丢失风险 | 服务器宕机数据丢失 | 可持久化,数据不易丢失 |
管理维护 | 分散,管理不便 | 集中管理,提供丰富的管理工具 |
功能丰富性 | 功能有限,通常只提供简单的键值对存储 | 功能丰富,支持多种数据结构和功能 |
本地缓存和 Redis 能搭配使用吗?
当然可以!本地缓存和分布式缓存虽然都属于缓存,但本地缓存的访问速度要远大于分布式缓存,这是因为访问本地缓存不存在额外的网络开销,我们在上面也提到了。
不过,一般情况下,我们也是不建议使用多级缓存的,这会增加维护负担(比如你需要保证一级缓存和二级缓存的数据一致性)。而且,其实际带来的提升效果对于绝大部分业务场景来说其实并不是很大。
这里简单总结一下适合多级缓存的两种业务场景:
缓存的数据不会频繁修改,比较稳定;
数据访问量特别大比如秒杀场景。
多级缓存方案中,第一级缓存(L1)使用本地内存(比如 Caffeine)),第二级缓存(L2)使用分布式缓存(比如 Redis)。
多级缓存
读取缓存数据的时候,我们先从 L1 中读取,读取不到的时候再去 L2 读取。这样可以降低 L2 的压力,减少 L2 的读次数。如果 L2 也没有此数据的话,再去数据库查询,数据查询成功后再将数据写入到 L1 和 L2 中。
多级缓存开源实现推荐:
J2Cache[2]:基于本地内存和 Redis 的两级 Java 缓存框架。
JetCache[3]:阿里开源的缓存框架,支持多级缓存、分布式缓存自动刷新、 TTL 等功能。
除了缓存,Redis 还能用来做什么?
分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:分布式锁详解[4] 。
限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的
RRateLimiter来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
分布式 Session:利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜、通过 HyperLogLog 统计网站 UV 和 PV。
……
Redis 分布式锁实现
一般建议使用 Redisson 内置的 Redis 分布式锁实现,自带自动续期机制,使用起来非常简单。
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
Redisson 看门狗自动续期
看门狗名字的由来于getLockWatchdogTimeout()方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30 秒(redisson-3.17.6[5])。
//默认 30秒,支持修改 private long lockWatchdogTimeout = 30 * 1000; public Config setLockWatchdogTimeout(long lockWatchdogTimeout) { this.lockWatchdogTimeout = lockWatchdogTimeout; return this; } public long getLockWatchdogTimeout() { return lockWatchdogTimeout; }renewExpiration()方法包含了看门狗的主要逻辑:
private void renewExpiration() { //...... Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //...... // 异步续期,基于 Lua 脚本 CompletionStage<Boolean> future = renewExpirationAsync(threadId); future.whenComplete((res, e) -> { if (e != null) { // 无法续期 log.error("Can't update lock " + getRawName() + " expiration", e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } if (res) { // 递归调用实现续期 renewExpiration(); } else { // 取消续期 cancelExpirationRenewal(null); } }); } // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); }默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
Watch Dog 通过调用renewExpirationAsync()方法实现锁的异步续期:
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认) "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return 1; " + "end; " + "return 0;", Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); }可以看出,renewExpirationAsync方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。
我这里以 Redisson 的分布式可重入锁RLock为例来说明如何使用 Redisson 实现分布式锁:
// 1.获取指定的分布式锁对象 RLock lock = redisson.getLock("lock"); // 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制 lock.lock(); // 3.执行业务 ... // 4.释放锁 lock.unlock();只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制 lock.lock(10, TimeUnit.SECONDS);Redis 实现分布式锁更详细的介绍,可以参考我写的这篇文章:分布式锁常见实现方案总结[6] 。
你项目中怎么向前端传数据的?
在我们开发的项目里,后端给前端传数据主要有这么几种方式:
RESTful API (最常用):
这是目前最主流、最常用的方式。简单来说,就是前端通过 HTTP 请求(比如 GET 获取数据、POST 提交数据、PUT 更新数据、DELETE 删除数据)来访问后端定义好的接口 (API)。
后端收到请求后,处理相应的业务逻辑(比如查询数据库),然后把结果(通常是 JSON 格式的数据)打包在 HTTP 响应里返回给前端。
这种方式是无状态的,每次请求都是独立的,非常适合绝大部分的网页数据展示、表单提交等场景。
WebSocket:
这个技术能在后端和前端之间建立一个持久的、双向的连接。一旦连接建立,双方可以随时互相发送消息,实现实时通信。
特别适合需要即时互动或数据更新的场景,比如:在线聊天室、多人协作编辑、实时显示股票价格或订单状态、游戏状态同步等。
Server-Sent Events (SSE):
这个主要是服务器单向地向前端推送信息。前端发起一次连接请求后,服务器可以持续地把更新的数据流式地发送给前端。
相比 WebSocket,它更轻量一些,实现也相对简单,因为它只是单向推送。
适用于只需要服务器主动通知前端更新的情况,比如:站内信通知、新闻 Feed 实时推送、监控仪表盘的数据更新等。
为什么选择 JWT 做身份验证?
相比于 Session 认证的方式来说,使用 JWT 进行身份认证主要有下面 4 个优势:
无状态:JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控!
有效避免了 CSRF 攻击:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击。
适合移动端应用:使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存
SessionId),所以不适合移动端。但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。单点登录友好:使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 JWT 进行认证的话, JWT 被保存在客户端,不会存在这些问题。
但 JWT 并不是银弹,依然存在很多问题需要解决,例如:
注销登录等场景下 JWT 还有效:这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。我们也说过了,JWT 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。
续签问题:JWT 通常有一个有效期(
exp字段),当令牌过期时,用户需要重新登录或获取一个新的令牌,这就是所谓的续签(refresh)问题。JWT 体积太大:JWT 结构复杂(Header、Payload 和 Signature),包含了更多额外的信息,还需要进行 Base64Url 编码,这会使得 JWT 体积较大,增加了网络传输的开销。
项目怎么保存用户密码的?
对于密码,绝对不能直接明文存储。一般情况下,我们都是通过哈希算法来加密密码并保存。也就是说,保存密码到数据库时使用哈希算法进行加密,可以通过比较用户输入密码的哈希值和数据库保存的哈希值是否一致,来判断密码是否正确。
哈希算法分为两类:
加密哈希算法:安全性较高的哈希算法,它可以提供一定的数据完整性保护和数据防篡改能力,能够抵御一定的攻击手段,安全性相对较高,适用于对安全性要求较高的场景。例如,SHA-256、SHA-512、SM3、Bcrypt、SCrypt 等等。
非加密哈希算法:安全性相对较低的哈希算法,易受到暴力破解、冲突攻击等攻击手段的影响,但性能较高,适用于对安全性没有要求的业务场景。例如,CRC32、MurMurHash3 等等。
加密密码比较常用的是加密哈希算法 Bcrypt 和 SCrypt。这俩也属于是慢哈希算法,所谓慢哈希,其实就是指执行这个哈希函数非常慢,这样暴力破解需要枚举遍历所有可能结果时,就需要花上非常非常长的时间。
为了进一步保证安全性,还可以进一步加盐(Salt)。Salt 的存在主要是为了让破解密码的成本增加。建议每个用户的 Salt 值不同(最好对不同用户的密码随机生成不同的 salt,salt 库和密码库分离开),这样就没办法用彩虹表进行批量破解。再加上加密哈希算法,暴力破解的成本也呈指数级增加。
相关阅读:https://t.zsxq.com/YNt5i 。
手撕算法
Leetcode.217.存在重复元素[7]:给你一个整数数组
nums。如果任一值在数组中出现至少两次,返回true;如果数组中每个元素互不相同,返回false。Leetcode.61.旋转链表[8]:给你一个链表的头节点
head,旋转链表,将链表每个节点向右移动k个位置。