饿了么 (电话⼀⾯)
饿了么 Java ⾯试
阿⾥巴巴 集团有很多,⽐如阿⾥云、淘宝、饿了么 、灵犀互娱等等 ,现在⾯试都是每个集团各⾃
负责 ,都有各⾃的招聘官⽅招聘⽹。因此,每个集团的校招开奖时间也不 同。
⽬前看到饿了么 率先开奖了,饿了么 25 届校招 Java 岗位的薪资开奖情况:

普通 offer :24k x 16 =38w
sp offer :26k x 16 + 2w 签字费(部分同学有) = 41w+这薪资待遇其实已经很不错了,妥妥 的是⼤⼚⽔平了,不过也有⼀些同学反馈不及预期。
我觉得主要是因为美团和京东今 年校招薪资⽐去年多了不 少,对⽐下来的话,可能就相对不够看
了。
实在太卷了,各⼤⼚相互卷校招薪资,都在⽤极具有竞争⼒的待遇来招优秀的⼈才,当然受益的
就是 25 届同学啦,妥妥 羡慕了。
这次跟⼤家分享⼀位同学今年秋招饿了么 的Java 后端⾯经,这是⼀⾯的⾯就,是电话⾯试的⽅
式,主要是考察技术⼋股为主 ,涉及到的范围是Java 、MySQL 、Redis 的知识。

饿了么 (电话⼀⾯)
ArrayList 和LinkedList 区别?
ArrayList 和LinkedList 都是Java 中常⻅的集合类,它们都实现了List 接⼝。
底层数据结构不同:ArrayList 使⽤数组实现,通过索引进⾏快速访问元素。LinkedList 使⽤链表
实现,通过节点之间的指针进⾏元素的访问和操作。
插⼊和删除操作的效率不同:ArrayList 在尾部的插⼊和删除操作效率较⾼,但在中间或开头的
插⼊和删除操作效率较低,需要移动元素。LinkedList 在任意位置的插⼊和删除操作效率都⽐较
⾼,因为只需要调整节点之间的指针。
随机访问的效率不同:ArrayList ⽀持通过索引进⾏快速随机访问,时间复杂度为O(1) 。
LinkedList 需要从头或尾开始遍历链表,时间复杂度为O(n) 。空间占⽤:ArrayList 在创建时需要分配⼀段连续的内存空间,因此会占⽤较⼤的空间。
LinkedList 每个节点只需要存储元素和指针,因此相对较⼩。
使⽤场景:ArrayList 适⽤于频繁随机访问和尾部的插⼊删除操作,⽽LinkedList 适⽤于频繁的中
间插⼊删除操作和不需要随机访问的场景。
线程安全:这两个 集合都不是线程安全的,Vector 是线程安全的
讲下HashMap ?
在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap 通过哈希算法将元素的键

(Key )映射到数组中的槽位(Bucket )。如果多个键映射到同⼀个槽位,它们会以 链表的形式 存储在同⼀个槽位上,因为链表的查询时间是O(n) ,所以冲突很严重,⼀个索引上的链表⾮常⻓,效率就很低了。所以在 JDK 1.8 版本的时候做 了优化,当⼀个链表的⻓度超过8的时候就转换数据结构,不再使⽤
链表存储,⽽是使⽤红⿊树,查找时使⽤红⿊树,时间复杂度O(log n ),可以提⾼查询性能,但
是在数量较少时,即数量⼩于6时,会将红⿊树转换回链表。

讲下ConcurrentHashMap ?
JDK 1.7 ConcurrentHashMap
在 JDK 1.7 中它使⽤的是数组加链表的形式 实现的,⽽数组⼜分为:⼤数组 Segment 和⼩数组
HashEntry 。 Segment 是⼀种可重⼊锁(ReentrantLock ),在 ConcurrentHashMap ⾥扮演锁的⻆⾊;HashEntry 则⽤于存储键值对数据。⼀个 ConcurrentHashMap ⾥包含⼀个 Segment 数组,⼀个 Segment ⾥包含⼀个 HashEntry 数组,每个 HashEntry 是⼀个链表结构的元素。

JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成⼀段⼀段的存储,然后给每⼀段数据配⼀把
锁,当⼀个线程占⽤锁访问其中⼀个段数据的时候,其他段的数据也能被其他线程访问,能够实
现真正的并发访问。
JDK 1.8 ConcurrentHashMap
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据⽐较多的情况下访问是很慢的,因为要遍历整个链表,⽽ JDK 1.8 则使⽤了数组 +
链表/红⿊树的⽅式优化了 ConcurrentHashMap 的实现,具体实现结构如下:

JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。
添加元素时⾸先会判断容器是否为空:
如果为空则使⽤ volatile 加 CAS 来初始化
如果容器不为 空,则根据存储的元素计算该位置是否为空。
如果根据存储的元素计算结果为空,则利 ⽤ CAS 设置该节点;
如果根据存储的元素计算结果不为 空,则使⽤ synchronized ,然后,遍历桶中的数据,并替
换或新增节点到桶中,最后再判断是否需要转为红⿊树,这样就能保证并发访问时的线程安
全了。
如果把上⾯的执⾏⽤⼀句话归纳的话,就相当于是ConcurrentHashMap 通过对头结点加锁来保证
线程安全的,锁的粒度相⽐ Segment 来说更⼩了,发⽣冲突和加锁的频率降低了,并发操作的性
能就提⾼了。
⽽且 JDK 1.8 使⽤的是红⿊树优化了之 前的固定链表,那么当数据量⽐较⼤的时候,查询性能也得
到了很⼤的提升,从之 前的 O(n) 优化到了 O(logn) 的时间复杂度。讲下阻塞队列?
阻塞队列是⼀个⽀持两个 附加操作的队列。这两个 附加操作是:
在队列为空时,获取元素的线程会等待队列变为⾮空。
当队列满时,存储元素的线程会等待队列可⽤。 阻塞队列常⽤于⽣产者-消费者模式中,⽣产者
线程⽣成数据并将其放⼊队列,⽽消费者线程则从队列中取出数据进⾏处理。阻塞队列使得⽣
产者和消费者之间⽆需显式地进⾏同步,因为队列会⾃动处理等待和通知逻辑。
以下是Java 中 java.util.concurrent 包中⼀些常⽤的阻塞队列实现:
ArrayBlockingQueue : 基于数组的阻塞队列,其⼤⼩在创建时被固定。
LinkedBlockingQueue : 基于链表的阻塞队列,其⼤⼩默认为 Integer.MAX_VALUE ,也可以在创建时指定。
PriorityBlockingQueue : ⼀个⽆界阻塞队列,它使⽤与类 java.util.PriorityQueue 相同的优先
级队列算法,并且提供了阻塞队列的所有操作。
DelayQueue : ⼀个⽆界阻塞队列,只有在延迟期满时才能从中 获取元素。
SynchronousQueue : ⼀个不 存储元素的阻塞队列,每个插⼊操作必须等待另⼀个线程的对应移
除操作。
讲下线程安全的List ?
常⻅的线程安全的List 实现包括 Collections.synchronizedList 和 CopyOnWriteArrayList 。
Collections.synchronizedList ⽅法可以将任何 普通List 转换为线程安全的List 。它通过在访问
⽅法时加锁来保证线程安全。这意味着所有对这个列表的操作都是原⼦性的,但使 ⽤起来需要
注意:如果需要频繁的读写操作且希望保持简单,可以使 ⽤ Collections.synchronizedList 。
CopyOnWriteArrayList 是⼀个⽀持线程安全操作的动态数组实现。它在修改操作(如添加、删
除)时会创建⼀个新的数组副本,这样可以确保读取操作不受影响。如果读操作远多于写操
作,可以选择 CopyOnWriteArrayList 。
讲下JVM 内存区域?
根据 JVM8 规范,JVM 运⾏时内存共分为虚拟机栈、堆、元空间、程序计数器、本地⽅法栈五个
部分。还有⼀部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。

JVM 的内存结构主要分为以下⼏个部分:
元空间:元空间的本质和永久代类似,都是对JVM 规范中⽅法区的实现。不过元空间与永久代
之间最⼤的区别在于:元空间并不在虚拟机中,⽽是使⽤本地内存。
Java 虚拟机栈:每个线程有⼀个私有的栈,随着线程的创建⽽创建。栈⾥⾯存着的是⼀种叫“栈
帧”的东西,每个⽅法会创建⼀个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引
⽤)、操作数栈、⽅法出⼝等信息。栈的⼤⼩可以固定也可以动态扩展。
本地⽅法栈:与虚拟机栈类似,区别是虚拟机栈执⾏java ⽅法,本地⽅法站执⾏native ⽅法。在
虚拟机规范中对本地⽅法栈中⽅法使⽤的语⾔、使⽤⽅法与数据结构没有强制规定,因此虚拟
机可以⾃由实现它。
程序计数器:程序计数器可以看成是当前线程所执 ⾏的字节码的⾏号指⽰器。在任何 ⼀个确定
的时刻,⼀个处理器( 对于多内核来说是⼀个内核)都只会执⾏⼀条线程中的指令。因此,为了线程切换后能恢复到正确的执⾏位置,每条线程都需要⼀个独⽴的程序计数器, 我们称这类
内存区域为“线程私有”内存。
堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和
数组都在堆上进⾏分配。这部分空间可通过 GC 进⾏回收。当申请不到空间时会抛出
OutOfMemoryError 。堆是JVM 内存占⽤最⼤,管理最复杂的⼀个区域。其唯⼀的⽤途就是存放
对象实例:所有的对象实例及数组都在对上进⾏分配。jdk1.8 后,字符串常量池从永久代中剥离
出来,存放在队中。
直接内存:直接内存并不是虚拟机运⾏时数据区的⼀部分,也不 是Java 虚拟机规范中农定义的
内存区域。在JDK1.4 中新加⼊了NIO(New Input/Output) 类,引⼊了⼀种基于通道 (Channel) 与缓冲区(Buffer )的I/O ⽅式,它可以使 ⽤native 函数库直接分配堆外内存,然后通脱⼀个存储
在Java 堆中的DirectByteBuffer 对象作为这块内存的引⽤进⾏操作。这样能在⼀些场景中显著提
⾼性能,因为避免了在Java 堆和Native 堆中来回复制数据。
spring ⾥@Autowired 和 @Resource 注解有什么 区别?
在Spring 框架中, @Autowired 和 @Resource 都是⽤来实现依赖注⼊的注解,区别如下:
来源不同: @Autowired 是Spring 框架提供的注解。 @Resource 是Java EE 的JSR-250 规范的⼀部
分,由Java 本⾝提供。
注⼊⽅式: @Autowired 默认是通过类型(byType )进⾏注⼊。如果容器中存在多个相同类型
的实例,它还可以与 @Qualifier 注解⼀起使⽤,通过指定bean 的id 来注⼊特定的实例。
@Resource 默认是通过名称(byName )进⾏注⼊。如果未 指定名称,则会尝试通过类型进⾏匹配。
属性: @Autowired 可以不指定任何 属性,仅通过类型⾃动装配。 @Resource 可以指定⼀个名为
name 的属性,该属性表⽰要注⼊的bean 的名称。
依赖性:使⽤ @Autowired 时,通常需要依赖Spring 的框架。使⽤ @Resource 时,即使不在
Spring 框架下,也可以在任何 符合Java EE 规范的环境中⼯作。
使⽤场景:当你需要更细粒度的控制注⼊过程,或者你需要⽀持Spring 框架之外的Java EE 环境
时, @Resource 注解可能是⼀个更好的选择;如果你完全在Spring 的环境下⼯作,并且希望通
过类型⾃动装配, @Autowired 是更常⻅的选择。
介绍⼀下类加载器过程?
类从被加载到虚拟机内存开始,到卸载出内存为⽌,它的整个⽣命周 期包括以下 7 个阶段:

加载:通过类的全限定名(包名 + 类名),获取到该类的.class ⽂件的⼆进制字节流,将⼆进制字节流所代表的静态存储结构,转化为⽅法区运⾏时的数据结构,在内存中⽣成⼀个代表该类
的java.lang.Class 对象,作为⽅法区这个类的各种数据的访问⼊⼝
连接:验证、准备、解析 3 个阶段统称为连接。
验证:确保class ⽂件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class 类的正确性,不会危害到虚拟机的安全。验证阶段⼤致会完成以下四个阶段的检验动
作:⽂件格式校验、元数据验证、字节码验证、符号引⽤验证
准备:为类中的静态字段分配内存,并设置默认的初始值,⽐如int 类型初始值是0。被final
修饰的static 字段不会设置,因为final 在编译的时候就分配了
解析:解析阶段是虚拟机将常量池的「符号引⽤」直接替换为「直接引⽤」的过程。符号引
⽤是以⼀组符号来描述所引⽤的⽬标,符号可 以是任何 形式 的字⾯量,只要使⽤的时候可以
⽆歧义地定位到⽬标即可。直接引⽤可以是直接指 向⽬标的指针、相对偏移量或是⼀个能间
接定位到⽬标的句柄,直接引⽤是和虚拟机实现的内存布局相关的。如果有 了直接引⽤, 那
引⽤的⽬标必定已经存在在 内存中了 。
初始化:初始化是整个类加载过 程的最后⼀个阶段,初始化阶段简单来说就是执⾏类的构造器
⽅法,要注意的是这⾥的构造器⽅法() 并不是开发者写的,⽽是编译器⾃动⽣成的。使⽤:使⽤类或者创建对象
卸载:如果有 下⾯的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是java 堆中不存在该类的任何 实例。2. 加载该类的ClassLoader 已经被回收。 3. 类对应的java.lang.Class 对
象没有任何 地⽅被引⽤,⽆法在任何 地⽅通过反射访问该类的⽅法。
mysql 如何避免全 表扫描?
可以考虑建⽴索引,让 sql 查询的时候,能通过索引快 速查询到数据。
我们可以针对下⾯这些情况的字段增加索引:
字段有唯⼀性限制的,⽐如商品编码;
经常⽤于 WHERE 查询条件的字段,这样能够提⾼整个表的查询速度,如果查 询条件不是⼀个
字段,可以建⽴联合索引。
经常⽤于 GROUP BY 和 ORDER BY 的字段,这样在查询的时候就不需要再去做⼀次排序了,因
为我们都已经知道了建⽴索引之后在 B+Tree 中的记录都是排序好的。
mysql 如何实现如果不存在就插⼊如果存在就更新?
可以使 ⽤ INSERT ... ON DUPLICATE KEY UPDATE 语句来实现“如果不存在就插⼊,如果存在就更
新”的功能。这种语句⾸先尝试执⾏插⼊,如果因为主 键或唯⼀索引冲突⽽插⼊失败,则执⾏更新
操作。
这⾥是⼀个基本的例⼦,假设有⼀个表 users ,包含 id (主键)和 name 字段:
CREATE TABLE users (id INT PRIMARY KEY,
name VARCHAR(255)
);要插⼊⼀个⽤⼾,如果该⽤⼾已经存在(根据 id 主键),则更新其 name ,可以使 ⽤以下语
句:
INSERT INTO users (id, name) VALUES (1, 'Alice')
ON DUPLICATE KEY UPDATE name = VALUES(name);在这个例⼦中:
如果 id 为 1 的⽤⼾不存在,则新插⼊⼀条记录, name 为 'Alice' 。
如果 id 为 1 的⽤⼾已经存在,则更新其 name 为 'Alice' 。数据库访问量过⼤怎么办?
创建或优化索引:根据查询条件创建合适的索引,特别是经常⽤于WHERE ⼦句的字段、
Orderby 排序的字段、Join 连表查询的字典、 group by 的字段,并且如果查 询中经常涉及多个
字段,考虑创建联合索引,使⽤联合索引要符合最左匹配原则,不然会索引失效
查询优化:避免使⽤SELECT * ,只查询真正需要的列;使⽤覆盖索引,即索引包含所有查 询的
字段;联表查询最好要以⼩表驱动⼤表,并且被驱动表的字段要有索引,当然最好通过冗余字
段的设计 ,避免联表查询。
避免索引失效:⽐如不要⽤左模糊匹配、函数计算、表达式计算等等 。
** 读写分离:** 搭建主从 架构 , 利⽤数据库的读写分离,Web 服务器在写数据的时候,访问主
数据库(master ),主数据库通过主从 复制将数据更新同步到从数据库(slave ),这样当Web 服
务器读数据的时候,就可以通过从数据库获得数据。这⼀⽅案使得在⼤量读操作的Web 应⽤可
以轻松地读取数据,⽽主数据库也只会承受少量的写⼊操作,还可以实现数据热备份,可谓是
⼀举两 得。
优化数据库表:如果单表的数据超过了千万级别,考虑是否需要将⼤表拆分为⼩表,减轻单个
表的查询压⼒。也可以将字段多的表分解成多个表,有些字段使⽤频率⾼,有些低,数据量⼤
时,会由于使⽤频率低的存在⽽变慢,可以考虑分开。
使⽤缓存技术:引⼊缓存层,如Redis ,存储热点 数据和频繁查询的结果,但是要考虑缓存⼀致
性的问题,对于读请 求会选择旁路缓存策略,对于写请求会选择先更新 db ,再删除缓存的策
略。
redis hotkey ⽤什么 查,怎么解决hotkey ?
Redis 提供了 Monitor 监控命令,使⽤ Monitor 命令可以实时监控 Redis 数据库的所有命令操作,
包括对 Hotkey 的读取和写⼊操作,通过对返回的执⾏命令进⾏统计来分析 Hotkey 的分布。

优点:
可以清楚的知道 key 的操作⾏为(写⼊还是读取)。
准确定位客⼾端来源。
缺点:
Monitor 命令本⾝会影响 Redis 的性能,特别是在⾼负载环境中。它会占⽤部分 Redis 服务器的
CPU 资源和⽹络带宽,在 Redis 官⽅⽂档 中描述如下,运⾏单个 Monitor 客⼾端可能会使 吞吐量减少50% 以上:
从 Redis 4.0.3 版本开始,Redis 引⼊了 hotkeys 的命令来帮助定位 Hotkey 。该命令可⽤于识别
在 Redis 数据库中访问频率最⾼的键。
对性能要求不是太⾼的业务场景下,建议使⽤该进⾏ Hotkey 的定位与分析。使⽤前需要先配置
Redis 的内存淘汰策略。

优点:
易⽤性:内置命令直接调⽤即可。
实时性:该命令提供的信息是实时的,能够及时反映当前的热点 键。
缺点:
性能影响:由于它是⼀个全量的Hotkey 数据,特别是存在⼤量hotkey 的场景下会对性能产⽣较
⼤影响,因此不推荐在⽣产环境频繁执⾏;
局限性:该命令返回的结果是基于Redis ⾃⾝内部的采样与统计算法,根据机器资源的或预期场
景的不同,该结果可能并不是100% 符合预期的;
完整性:该命令只提供了热点 键的基本信息,⽆法知道更详细的统计和分析信息,需要向业务
侧确认;
通常以其接收到的Key 被请求频率来判定是否为 hotkey ,例如:
QPS 集中在特定的Key :Redis 实例的总QPS (每秒查询率)为10,000 ,⽽其中⼀个Key 的每秒访
问量达到了7,000 。
带宽使⽤率集中在特定的Key :对⼀个拥有上千个成员且总⼤⼩为1 MB 的HASH Key 每秒发送⼤
量的HGETALL 操作请求。
CPU 使⽤时间占⽐集中在特定的Key :对⼀个拥有数万个 成员的Key (ZSET 类型)每秒发送⼤量
的ZRANGE 操作请求。
解决 hotkey 的⽅式:
在Redis 集群架构 中对热Key 进⾏复制。在Redis 集群架构 中,由于热Key 的迁移粒度问题,⽆法
将请求分散⾄其他数据分⽚,导致单个数据分⽚的压⼒⽆法下降。此时,可以将对 应热Key 进⾏
复制并迁移⾄其他数据分⽚,例如将热Key foo 复制出 3个内容完 全⼀样的Key 并名为foo2 、
foo3 、foo4 ,将这三个 Key 迁移到其他数据分⽚来解决单个数据分⽚的热Key 压⼒。
使⽤读写分离架构 。如果热Key 的产⽣来⾃于读请 求,您可以将实例改造成读写分离架构来 降低
每个数据分⽚的读请 求压⼒,甚⾄可以不断地增加从节点。但是读写分离架构 在增加业务代码
复杂度的同时,也会增加Redis 集群架构 复杂度。不仅 要为多个从 节点提供转发层(如Proxy ,
LVS 等)来实现负载均衡,还要考虑从节点数量显著增加后带来故障率增加的问题。Redis 集群
架构 变更会为监控、运维、故障处理带来了更⼤的挑战。
讲⼀个中 间件吧,讲讲 他的作⽤?
我还熟悉消息队列,消息队列的三⼤作⽤是:
解耦:可以在多个系统之间进⾏解耦,将原本通过⽹络之间的调⽤的⽅式改为使⽤MQ 进⾏消息
的异步通讯,只要该操作不是需要同步的,就可以改为使⽤MQ 进⾏不同系统之间的联系,这样
项⽬之间不会存在耦合,系统之间不会产⽣太⼤的影响,就算⼀个系统挂了,也只是消息挤压
在MQ ⾥⾯没⼈进⾏消费⽽已,不会对其他的系统产⽣影响。
异步:加⼊⼀个操作设计 到好⼏个步骤,这些步骤之间不需要同步完成,⽐如客⼾去创建了⼀
个订单,还要去客⼾轨迹系统添加⼀条轨迹、去库存系统更新库存、去客⼾系统修改客⼾的状
态等等 。这样如果这个系统都直接进⾏调⽤,那么将会产⽣⼤量的时间,这样对于客⼾是⽆法
接收的;并且像添加客⼾轨迹这种操作是不需要去同步操作的,如果使⽤MQ 将客⼾创建订单
时,将后⾯的轨迹、库存、状态等信息的更新全都放到MQ ⾥⾯然后去异步操作,这样就可加快
系统的访问速度,提供更好的客⼾体验。
削峰:⼀个系统访问流量有⾼峰时期,也有低峰时期,⽐如说,中午整点有⼀个抢购活动等等。⽐如系统平时流量并不⾼,⼀秒钟只有100 多个并发请求,系统处理没有任何 压⼒,⼀切⻛
平浪静,到了某个抢购活动时间,系统并发访问了剧增,⽐如达到了每秒5000 个并发请求,⽽
我们的系统每秒只能处理2000 个请求,那么由于流量太⼤,我们的系统、数据库可能就会崩
溃。这时如果使⽤MQ 进⾏流量削峰,将⽤⼾的⼤量消息直接放到MQ ⾥⾯,然后我们的系统去
按⾃⼰的最⼤消费能⼒去消费这些消息,就可以保 证系统的稳定,只是可能要跟进业务逻辑,
给⽤⼾返回特定⻚⾯或者稍后通过其他⽅式通知其结果。
