涵盖了集合、并发、数据库和缓存等核心领域。
3. 1000个K-V放入HashMap,最终情况(桶2048个)
回答: 如果最终数组桶(table)的长度是2048,说明这个HashMap是经过了扩容或者初始化设置的。
- 初始化状态:HashMap的默认初始容量是16,负载因子(load factor)是0.75。如果我们不做任何设置直接
new HashMap(),放入1000个数据会触发多次扩容。 - 当前场景分析:既然桶的数量是2048,这意味着要么我们在初始化时指定了容量(例如
new HashMap<>(1500),经过tableSizeFor计算后会调整为2048),要么是默认创建后经过多次put自动扩容到了这一步。 - 实际存储情况:
- 数组长度:2048。
- 阈值(threshold):
2048 * 0.75 = 1536。 - 数据分布:1000个数据会根据Key的hash值分散在2048个桶中。
- 链表/红黑树:在理想情况下,数据分布均匀,大部分桶里是空的,部分桶里有1个Node,极少数桶里可能会因为hash冲突形成链表。
- 是否会树化:通常不会树化。因为1000个数据对于2048的桶来说,负载因子约为0.49,远低于0.75的扩容阈值,且单个桶内冲突链表长度很难达到8(树化的阈值)。
实际开发经验:如果预估数据量为1000,为了防止扩容带来的性能损耗,建议初始化时设置容量为
1000 / 0.75 + 1,这样可以一步到位,避免rehash。
4. HashMap线程安全吗?线程安全的Hash结构?
回答:HashMap不是线程安全的。
- 原因:
- 数据覆盖:在多线程
put操作且发生hash冲突时,可能导致一个线程的数据覆盖另一个线程的数据。 - 死循环(JDK 1.7):在扩容(resize)时,使用头插法转移链表,多线程环境下可能形成环形链表,导致
get数据时死循环。 - 数据错乱(JDK 1.8):虽然JDK 1.8改为了尾插法,解决了死循环,但在扩容过程中多线程操作仍可能导致数据丢失或不一致。
- 数据覆盖:在多线程
线程安全的替代方案:
ConcurrentHashMap:强烈推荐。采用分段锁或CAS+synchronized,性能最好,是目前互联网公司的首选。Collections.synchronizedMap(map):使用synchronized修饰读写方法,性能较差,相当于给整个Map加锁。Hashtable:老古董,方法使用synchronized修饰,全表锁,性能极差,已不推荐使用。
5. ConcurrentHashMap线程安全实现原理(1.8之前/之后)
回答: ConcurrentHashMap的设计目标就是在保证线程安全的前提下,最大化并发度。
JDK 1.8 之前(分段锁 Segment):
- 数据结构:
Segment数组 + HashEntry数组 + 链表。 - 原理:将数据分成一段一段的存储,
Segment继承自ReentrantLock。 - 并发度:默认有16个Segment(可以通过
concurrencyLevel参数调整),不同Segment之间互不干扰,可以并发写。理论上并发度就是Segment的数量。 - 缺点:结构复杂,且Segment数量一旦初始化无法扩容,可能会导致某些Segment过热。
- 数据结构:
JDK 1.8 及之后(CAS + synchronized):
- 数据结构:
Node数组 + 链表 + 红黑树(与HashMap 1.8一致)。 - 原理:
- CAS:在初始化数组或插入第一个节点时,使用CAS操作保证原子性。
- synchronized:当发生hash冲突需要插入链表或红黑树时,使用
synchronized锁住当前链表头节点或红黑树根节点。
- 优势:
- 锁的粒度更细(从“Segment锁”变成了“桶锁”),并发性能更高。
- 充分利用了synchronized在JDK 1.6之后的优化(偏向锁、轻量级锁),性能不输ReentrantLock。
- 数据结构:
6. List和Set集合的区别
回答: 这二者都是Collection接口的子接口,核心区别在于存储特性和底层实现。
| 特性 | List | Set |
|---|---|---|
| 有序性 | 有序(按插入顺序排列) | 无序(不保证插入顺序,LinkedHashSet除外) |
| 重复性 | 允许存储重复元素 | 不允许存储重复元素(唯一性) |
| Null值 | 允许多个null值 | 最多允许一个null值 |
| 底层结构 | 数组(ArrayList) / 链表(LinkedList) | 哈希表(HashSet) / 红黑树(TreeSet) |
| 常用实现 | ArrayList, LinkedList | HashSet, TreeSet, LinkedHashSet |
- 实际场景:
List:适合需要按顺序存储、允许重复的场景,比如“用户购买记录”。Set:适合需要去重的场景,比如“爬虫去重URL”、“微信群成员列表”。
7. List的遍历方式
回答: 对于List,主要有以下三种遍历方式:
- 普通for循环(按下标遍历):
- 通过
list.get(i)获取元素。 - 适用场景:ArrayList(基于数组,随机访问快)。对于LinkedList,这种方式效率极低(每次get都要从头遍历)。
- 通过
- 增强for循环(foreach语法糖):
for (Object obj : list) {}- 底层实际上是基于Iterator实现的。
- 适用场景:通用,代码简洁,适合只读遍历。
- 迭代器(Iterator)遍历:
Iterator it = list.iterator(); while(it.hasNext()) { it.next(); }- 适用场景:最底层的方式,也是唯一支持在遍历过程中安全删除元素的方式(使用
it.remove())。
8. List遍历中删除元素怎么做?
回答: 这是一个经典的“ConcurrentModificationException”(并发修改异常)问题。
- 错误做法:使用普通for循环或foreach循环,直接调用
list.remove(obj)。- 原因:这会修改modCount(修改次数),而迭代器预期的expectedModCount没变,导致下一次迭代时校验失败抛出异常。
- 正确做法:
- 使用Iterator:这是最标准的做法。在遍历时,调用
iterator.remove()方法,它会同步更新expectedModCount。 - 使用removeIf()方法(JDK 1.8+):
list.removeIf(obj -> obj.getProperty() == value);内部也是基于迭代器实现的,代码更优雅。 - 使用临时集合:先遍历把要删除的元素放入一个临时List,遍历结束后再调用
list.removeAll(tempList)(性能较差,不推荐大数据量)。
- 使用Iterator:这是最标准的做法。在遍历时,调用
9. 线程池的几个参数?
回答: 核心参数共有7个,通常在创建ThreadPoolExecutor时传入:
corePoolSize:核心线程数。线程池中常驻的线程数量。maximumPoolSize:最大线程数。线程池能创建的最大线程数量。keepAliveTime:非核心线程的空闲存活时间。unit:存活时间的单位(如TimeUnit.SECONDS)。workQueue:任务队列。用于存放待执行任务的阻塞队列(如ArrayBlockingQueue, LinkedBlockingQueue)。threadFactory:线程工厂。用于创建新线程,建议自定义以便给线程起有意义的名字。handler:拒绝策略。当线程池饱和(队列满且线程数达到最大)时如何处理新任务。
10. 线程池的拒绝策略?默认的是哪种?
回答: 当线程池的阻塞队列已满且线程数达到maximumPoolSize时,会触发拒绝策略。
四种内置策略:
AbortPolicy(默认策略):- 直接抛出
RejectedExecutionException异常。这是默认行为。
- 直接抛出
CallerRunsPolicy:- 由提交任务的线程(调用者)自己来执行这个任务。这可以减缓新任务的提交速度,起到“限流”作用。
DiscardOldestPolicy:- 丢弃队列中最老的一个任务,然后尝试重新提交当前任务。
DiscardPolicy:- 直接丢弃当前任务,不抛异常,也不执行。
最佳实践:在生产环境中,建议根据业务场景自定义拒绝策略(如记录日志、写入MQ异步处理等),而不是直接抛异常。
11. 线程工厂有哪几种?默认的是哪种?
回答: JDK并没有提供多种“内置”的线程工厂类,通常我们说的是Executors.defaultThreadFactory()。
- 默认线程工厂:
Executors.defaultThreadFactory()- 它创建的线程都是同一个线程组,优先级默认,且都是非守护线程。
- 缺点:创建的线程名字是自动生成的(如
pool-1-thread-1),在排查线上问题(dump线程栈)时很难区分是哪个业务的线程。
- 实际开发建议(自定义线程工厂):
- 我们通常会实现
ThreadFactory接口,使用ThreadFactoryBuilder(Google Guava提供)或自己封装。 - 目的:给线程设置有意义的名字(如
order-service-pool-%d),方便监控和排查问题。
- 我们通常会实现
12. 三个线程顺序打印A, B, C
回答: 这道题考察的是线程间的协作(通信)。我有以下几种实现思路:
synchronized + wait/notifyAll:- 使用一个共享状态变量(如
int state = 0)。 - 线程A打印完A后,将state设为1并notifyAll;线程B检查state是否为1,是则打印B并设为2;以此类推。注意要用while循环判断,防止虚假唤醒。
- 使用一个共享状态变量(如
ReentrantLock + Condition(推荐):- 定义三个Condition:
conditionA,conditionB,conditionC。 - 线程A等待
conditionA,打印后唤醒conditionB;线程B等待conditionB,打印后唤醒conditionC;线程C打印后唤醒conditionA。这种方式唤醒更精准,避免了notifyAll的“惊群效应”。
- 定义三个Condition:
Semaphore(信号量):- 初始化三个信号量:A初始为1,B和C初始为0。
- 线程A获取semaphoreA后打印,然后释放semaphoreB;线程B获取semaphoreB后打印,然后释放semaphoreC;线程C获取semaphoreC后打印,然后释放semaphoreA。
BlockingQueue:- 使用SynchronousQueue或者容量为1的ArrayBlockingQueue。线程A放入"A",线程B取出并打印,再放入"B"给线程C,以此类推。
13. MySQL分库分表方式
回答: 分库分表是解决MySQL单机存储和性能瓶颈的终极手段。
分片方式:
- 垂直分片:按业务或字段拆分。垂直分库(订单库、用户库分开);垂直分表(将大字段拆到单独的表中)。
- 水平分片:按数据行拆分。将一张大表的数据按某种规则分散到多个库或多个表中。
如何分库?
- 通常先分库,再分表。例如:分为4个库,每个库里面有16张表(总共64张表)。
- 路由规则:通常是
hash(userId) % (dbNum * tableNum),或者先取模确定库,再取模确定表。
主键设置(分布式ID):
- 不能使用数据库自增ID,因为会重复。
- 解决方案:雪花算法(Snowflake)、UUID(性能较差)、美团的Leaf、数据库号段模式。
路由ID:
- 路由的依据通常是一个分片键(Sharding Key),比如
user_id、order_id。 - 中间件(如ShardingSphere)会根据SQL中的分片键值计算出应该路由到哪个具体的库和表。
- 路由的依据通常是一个分片键(Sharding Key),比如
14. MySQL集群搭建方式
回答: 目前主流的高可用集群方案是主从复制 + 高可用管理。
主从复制(Replication)原理:
- 主库(Master):记录
binlog(二进制日志),记录所有数据变更。 - 从库(Slave):启动一个I/O线程,连接主库,拉取binlog并写入本地的
relay log(中继日志)。 - SQL线程:读取relay log,重放SQL,实现数据同步。
- 主库(Master):记录
高可用与故障转移:
- MHA(Master High Availability):比较成熟的方案,监控主库,挂了之后自动提升一个从库为新主库。
- Orchestrator:更现代的MySQL复制拓扑管理工具。
- 哨兵模式(Sentinel):这是Redis的术语。在MySQL领域,类似的概念由MHA或Orchestrator实现。它们负责监控、选主和配置更新。
InnoDB Cluster / Group Replication:
- MySQL官方提供的基于Paxos协议的组复制技术,数据强一致性更好,自动故障转移。
15. Redis主库挂了,写操作如何保证?
回答: 这是一个关于CAP理论(一致性、可用性、分区容忍性)的权衡问题。
当主库(Master)挂掉且未完成故障转移(Failover)时,客户端的写操作会失败。我们主要从以下几点来保证有效性和一致性:
哨兵(Sentinel)或集群自动故障转移:
- 哨兵会监控主库,一旦发现主库宕机,会从从库(Slave)中选举一个新的主库。
- 一致性风险:如果旧主库挂掉前,有部分数据还没同步给从库(异步复制的延迟),这部分数据会丢失。
- 解决方案:在业务允许的情况下,写入时使用
WAIT命令,强制要求至少同步到N个从库才算成功,但这会降低性能。
客户端容错(降级与熔断):
- 使用Hystrix或Sentinel组件,当检测到Redis写入失败率达到阈值时,触发熔断。
- 降级策略:写操作暂时落盘到本地文件、消息队列(MQ)或数据库,待Redis恢复后进行补偿重放。
双写或多活架构(高级方案):
- 在极端要求不丢数据的场景,可以采用多活架构(如Codis或某些云厂商的全球多活方案),写入多个数据中心,但代价是极高的复杂度和延迟。
16. & 17. 索引字段选择与联合索引范围查询
回答:
16. 字段选择与联合索引失效:
- 字段选择:选择区分度高(重复度小)、经常用于查询条件、不为null的字段。
- 联合索引最左前缀原则:如果有一个联合索引
(a, b, c),查询条件必须包含a才能命中索引。如果只查b或c,索引失效。 - 部分失效场景:
where a = 1 and b > 2 and c = 3:这里索引会用到a和b,但c无法使用索引进行查找(因为b是范围查询),需要回表后再过滤。
17. 组合索引与时间范围查询:
- 场景:联合索引
(a, time, b),其中time是范围查询。 - 结论:
time之后的字段无法使用索引。 - 解释:B+树是按照索引顺序排序的。当
a是等值查询时,time是有序的;但一旦time进行了范围查询,b的顺序就无法保证了。因此,索引只能用到a和time,b字段需要回表后进行过滤。
18. 数据加载到Redis的策略设计
回答: 如果让我设计一个从持久层(DB)加载数据到Redis的策略,我会考虑以下几个维度:
全量加载(系统启动时):
- 分页扫描:为了避免一次性加载过多数据导致DB或Redis雪崩,采用分页(如
LIMIT offset, size)或游标(Redis的SCAN,DB的WHERE id > last_id)的方式分批加载。 - 多线程并行:开启多个线程并行读取DB和写入Redis,提高加载速度。
- 分页扫描:为了避免一次性加载过多数据导致DB或Redis雪崩,采用分页(如
增量加载(运行时):
- 监听Binlog(推荐):使用Canal或Debezium监听MySQL的binlog,一旦数据有变更(增删改),就同步更新Redis。这是目前互联网最主流的缓存同步方案。
- 双写一致性:在业务代码中,先更新DB,再删除(或更新)Redis。为了保证一致性,通常采用“先更新数据库,再删除缓存”(Cache Aside Pattern)。
缓存预热:
- 在系统上线或大促前,提前将热点数据(如爆款商品)加载到Redis中,避免冷启动时大量请求打到数据库。
容错与降级:
- 失败重试:加载失败时,记录日志并放入重试队列。
- 熔断机制:如果Redis不可用,直接走数据库,保证服务可用性。
19. Redis分布式锁的实现
回答: 分布式锁的核心是保证在分布式环境下,同一时刻只有一个线程能执行某段代码。
基础实现(SETNX + EXPIRE):
- 使用
SETNX key value(如果key不存在则设置成功,返回1)。 - 设置成功后,必须紧接着设置过期时间
EXPIRE key seconds,防止死锁。 - 问题:SETNX和EXPIRE不是原子操作,如果在设置过期时间前机器宕机,锁将永远无法释放。
- 使用
原子性实现(SET命令):
- 使用
SET key value NX EX seconds。 NX表示只有key不存在时才设置,EX设置过期时间。这两个操作在Redis中是原子的。
- 使用
高级特性(Redlock / Redisson):
- 可重入性:同一个线程可以多次获取锁。Redisson通过hash结构存储
threadId:count来实现。 - 锁续期(Watchdog):Redisson提供了看门狗机制,如果持有锁的线程未执行完,后台线程会自动给Redis中的锁续期(延长过期时间),防止锁过期被别人获取。
- 防止误删:解锁时,必须校验value是否是自己的(通过Lua脚本保证校验和删除的原子性),防止把别人的锁删了。
- 可重入性:同一个线程可以多次获取锁。Redisson通过hash结构存储
面试官,您好。接下来看第20题,关于 OLAP 和 OLTP,以及后续的事务相关问题,我结合在阿里和Google接触过的大型系统架构经验,为您详细拆解。
20. OLAP 和 OLTP 了解过吗?
回答:
是的,这两个是数据库领域的核心基石。简单来说,OLTP 负责“干活”,OLAP 负责“看数”。
在实际业务中,我们通常不会用同一个数据库既做交易又做分析,因为它们的底层存储结构和优化方向是完全相反的。
1. 核心区别对比
| 维度 | OLTP (联机事务处理) | OLAP (联机分析处理) |
|---|---|---|
| 核心目标 | 实时交易,保证业务流畅、数据准确 | 数据分析,挖掘历史数据价值,辅助决策 |
| 典型场景 | 用户下单、支付、转账、库存扣减 | 双11大屏、用户画像、销售趋势报表 |
| 数据模型 | ER模型,高度规范化(3NF),减少冗余 | 星型/雪花模型,反规范化,允许冗余,便于关联 |
| 存储方式 | 行式存储 (Row-based) | 列式存储 (Column-based) |
| 读写特点 | 短事务,读写少量行(如 SELECT * FROM order WHERE id=?) | 复杂查询,扫描百万行数据做聚合(如 SUM(sales) GROUP BY region) |
| 响应时间 | 毫秒级,要求极低延迟 | 秒级到分钟级,容忍较长的查询时间 |
| 代表数据库 | MySQL, PostgreSQL, Oracle | ClickHouse, Hive, Redshift, Doris |
2. 深入底层原理(行存 vs 列存)
- OLTP(行式存储):
- 原理:数据按行连续存储。比如一条订单记录(ID, 用户ID, 商品, 金额, 时间)作为一个整体存入磁盘块。
- 优势:适合
INSERT/UPDATE操作。当我要查询或修改某一个具体的订单详情时,一次磁盘IO就能把整行数据读出来。
- OLAP(列式存储):
- 原理:数据按列存储。磁盘上先存所有的“金额”列,再存所有的“地区”列。
- 优势:适合聚合分析。当我只需要计算“所有订单的总金额”时,数据库只需要读取“金额”这一列的数据,极大地减少了磁盘IO和网络传输量。同时,同一列的数据类型相同,压缩比极高(通常能达到10:1)。
3. 实际开发中的痛点与解法
在之前的项目中,我们曾遇到过业务直接在MySQL(OLTP)上跑复杂的报表查询,导致数据库CPU飙升,进而影响了正常的下单交易。
- 解决方案:引入 HTAP 架构(混合事务/分析处理)或者通过 ETL 将数据同步到专门的数仓(OLAP)中进行分析,从而隔离分析流量对交易流量的影响。
21. 事务了解过吗?分布式事务了解过吗?
回答:
事务(Transaction) 是数据库操作的逻辑单元,要么全部成功,要么全部失败。
- ACID特性:
- 原子性(Atomicity):通过 undo log 实现,记录数据修改前的镜像,出错时回滚。
- 一致性(Consistency):事务的最终目的,保证数据符合业务规则。
- 隔离性(Isolation):通过锁和 MVCC(多版本并发控制)实现。
- 持久性(Durability):通过 redo log 实现,确保数据刷盘。
分布式事务:
在微服务架构下,一个业务操作可能涉及多个服务(多个数据库),这就需要保证跨服务的数据一致性。
- 常见解决方案:
- Seata (AT模式):阿里开源的框架,通过全局锁和自动生成回滚SQL来实现,对业务侵入较小。
- TCC (Try-Confirm-Cancel):两阶段补偿型事务。先Try预留资源,Confirm真正提交,Cancel回滚资源。性能好,但开发成本高。
- 基于MQ的最终一致性:利用MQ的事务消息机制,通过消息投递来触发下游服务的更新,保证最终数据一致。这是互联网高并发场景下最常用的方案。
22. 事务如何开启?
回答:
在Spring框架中(这是我们最常用的),通常通过 @Transactional 注解来开启事务。
- 声明式事务(推荐):在Service层的方法或类上添加
@Transactional注解。 - 编程式事务:通过
TransactionTemplate手动控制,适用于逻辑非常复杂的场景。
底层原理:
Spring利用AOP(动态代理)在方法执行前通过 DataSource 获取连接,并设置 autoCommit=false;方法执行成功后提交,出现异常则回滚。
23. A方法调用B方法,B方法上有@Transactional注解,B的事务会生效吗?
回答:
不一定生效。这是一个非常经典的坑,取决于A方法和B方法是否在同一个对象中。
- 情况1:不生效(默认情况)
- 如果A和B都在同一个Service类中,A直接调用B(
this.b()),事务不会生效。 - 原因:Spring的事务是基于代理(Proxy)实现的。A调用B实际上是内部方法调用,绕过了代理对象,因此注解的切面逻辑不会被触发。
- 如果A和B都在同一个Service类中,A直接调用B(
- 情况2:生效
- 如果B方法在另一个Service中(通过@Autowired注入),A通过代理对象调用B,事务生效。
- 或者在同一个类中,通过
ApplicationContext获取当前Bean的代理对象,再通过代理对象调用B。
解决方案:
- 将B方法拆分到另一个Service中。
- 使用
AopContext.currentProxy()强制获取代理对象(需要在配置中开启expose-proxy=true)。
24. 介绍一下单例模式
回答:
单例模式确保一个类只有一个实例,并提供一个全局访问点。
- Spring中的单例:
- Spring容器中的Bean默认就是单例(Singleton)的。容器启动时创建,容器关闭时销毁。
- 注意:Spring的单例是基于容器和Bean Name的,它是线程不安全的(如果Bean中有成员变量)。通常我们把Service设计成无状态的(不保存成员变量),来规避线程安全问题。
- 非单例(Prototype):
- 每次从容器中获取Bean都会创建一个新的实例。
手写单例(双重检查锁 DCL):
public class Singleton {
// volatile 关键字防止指令重排序
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}25. Spring中用到的设计模式?
回答:
Spring框架是设计模式的集大成者,我列举几个核心的:
工厂模式 (Factory Pattern)
- 体现:
BeanFactory和ApplicationContext。 - 作用:解耦对象的创建和使用,我们不需要
new对象,而是从Spring容器中获取。
- 体现:
单例模式 (Singleton Pattern)
- 体现:Spring Bean默认作用域。
- 作用:节省内存,提高性能。
代理模式 (Proxy Pattern)
- 体现:AOP(面向切面编程)和事务管理。
- 实现:JDK动态代理(基于接口)和 CGLIB(基于子类)。
模板方法模式 (Template Method Pattern)
- 体现:
JdbcTemplate,RestTemplate。 - 作用:封装了固定的流程(如获取连接、释放连接),将变化的部分(SQL执行)交给回调函数处理。
- 体现:
观察者模式 (Observer Pattern)
- 体现:Spring的事件监听机制(
ApplicationEvent和ApplicationListener)。 - 作用:实现组件间的解耦,比如发布“用户注册成功”事件,由监听器去发送邮件。
- 体现:Spring的事件监听机制(
适配器模式 (Adapter Pattern)
- 体现:
HandlerAdapter(在SpringMVC中)。 - 作用:允许不同的Controller(如实现了Controller接口的,或者加了@RequestMapping注解的)都能被统一的DispatcherServlet调用。
- 体现: