Skip to content

涵盖了集合、并发、数据库和缓存等核心领域。


3. 1000个K-V放入HashMap,最终情况(桶2048个)

回答: 如果最终数组桶(table)的长度是2048,说明这个HashMap是经过了扩容或者初始化设置的。

  1. 初始化状态:HashMap的默认初始容量是16,负载因子(load factor)是0.75。如果我们不做任何设置直接new HashMap(),放入1000个数据会触发多次扩容。
  2. 当前场景分析:既然桶的数量是2048,这意味着要么我们在初始化时指定了容量(例如 new HashMap<>(1500),经过tableSizeFor计算后会调整为2048),要么是默认创建后经过多次put自动扩容到了这一步。
  3. 实际存储情况
    • 数组长度: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不是线程安全的。

  • 原因
    1. 数据覆盖:在多线程put操作且发生hash冲突时,可能导致一个线程的数据覆盖另一个线程的数据。
    2. 死循环(JDK 1.7):在扩容(resize)时,使用头插法转移链表,多线程环境下可能形成环形链表,导致get数据时死循环。
    3. 数据错乱(JDK 1.8):虽然JDK 1.8改为了尾插法,解决了死循环,但在扩容过程中多线程操作仍可能导致数据丢失或不一致。

线程安全的替代方案:

  1. ConcurrentHashMap强烈推荐。采用分段锁或CAS+synchronized,性能最好,是目前互联网公司的首选。
  2. Collections.synchronizedMap(map):使用synchronized修饰读写方法,性能较差,相当于给整个Map加锁。
  3. 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接口的子接口,核心区别在于存储特性底层实现

特性ListSet
有序性有序(按插入顺序排列)无序(不保证插入顺序,LinkedHashSet除外)
重复性允许存储重复元素不允许存储重复元素(唯一性)
Null值允许多个null值最多允许一个null值
底层结构数组(ArrayList) / 链表(LinkedList)哈希表(HashSet) / 红黑树(TreeSet)
常用实现ArrayList, LinkedListHashSet, TreeSet, LinkedHashSet
  • 实际场景
    • List:适合需要按顺序存储、允许重复的场景,比如“用户购买记录”。
    • Set:适合需要去重的场景,比如“爬虫去重URL”、“微信群成员列表”。

7. List的遍历方式

回答: 对于List,主要有以下三种遍历方式:

  1. 普通for循环(按下标遍历)
    • 通过list.get(i)获取元素。
    • 适用场景ArrayList(基于数组,随机访问快)。对于LinkedList,这种方式效率极低(每次get都要从头遍历)。
  2. 增强for循环(foreach语法糖)
    • for (Object obj : list) {}
    • 底层实际上是基于Iterator实现的。
    • 适用场景:通用,代码简洁,适合只读遍历。
  3. 迭代器(Iterator)遍历
    • Iterator it = list.iterator(); while(it.hasNext()) { it.next(); }
    • 适用场景:最底层的方式,也是唯一支持在遍历过程中安全删除元素的方式(使用it.remove())。

8. List遍历中删除元素怎么做?

回答: 这是一个经典的“ConcurrentModificationException”(并发修改异常)问题。

  • 错误做法:使用普通for循环或foreach循环,直接调用list.remove(obj)
    • 原因:这会修改modCount(修改次数),而迭代器预期的expectedModCount没变,导致下一次迭代时校验失败抛出异常。
  • 正确做法
    1. 使用Iterator:这是最标准的做法。在遍历时,调用iterator.remove()方法,它会同步更新expectedModCount。
    2. 使用removeIf()方法(JDK 1.8+)list.removeIf(obj -> obj.getProperty() == value); 内部也是基于迭代器实现的,代码更优雅。
    3. 使用临时集合:先遍历把要删除的元素放入一个临时List,遍历结束后再调用list.removeAll(tempList)(性能较差,不推荐大数据量)。

9. 线程池的几个参数?

回答: 核心参数共有7个,通常在创建ThreadPoolExecutor时传入:

  1. corePoolSize:核心线程数。线程池中常驻的线程数量。
  2. maximumPoolSize:最大线程数。线程池能创建的最大线程数量。
  3. keepAliveTime:非核心线程的空闲存活时间。
  4. unit:存活时间的单位(如TimeUnit.SECONDS)。
  5. workQueue:任务队列。用于存放待执行任务的阻塞队列(如ArrayBlockingQueue, LinkedBlockingQueue)。
  6. threadFactory:线程工厂。用于创建新线程,建议自定义以便给线程起有意义的名字。
  7. handler:拒绝策略。当线程池饱和(队列满且线程数达到最大)时如何处理新任务。

10. 线程池的拒绝策略?默认的是哪种?

回答: 当线程池的阻塞队列已满且线程数达到maximumPoolSize时,会触发拒绝策略。

四种内置策略:

  1. AbortPolicy(默认策略)
    • 直接抛出RejectedExecutionException异常。这是默认行为
  2. CallerRunsPolicy
    • 由提交任务的线程(调用者)自己来执行这个任务。这可以减缓新任务的提交速度,起到“限流”作用。
  3. DiscardOldestPolicy
    • 丢弃队列中最老的一个任务,然后尝试重新提交当前任务。
  4. 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

回答: 这道题考察的是线程间的协作(通信)。我有以下几种实现思路:

  1. synchronized + wait/notifyAll
    • 使用一个共享状态变量(如int state = 0)。
    • 线程A打印完A后,将state设为1并notifyAll;线程B检查state是否为1,是则打印B并设为2;以此类推。注意要用while循环判断,防止虚假唤醒。
  2. ReentrantLock + Condition(推荐)
    • 定义三个Condition:conditionA, conditionB, conditionC
    • 线程A等待conditionA,打印后唤醒conditionB;线程B等待conditionB,打印后唤醒conditionC;线程C打印后唤醒conditionA。这种方式唤醒更精准,避免了notifyAll的“惊群效应”。
  3. Semaphore(信号量)
    • 初始化三个信号量:A初始为1,B和C初始为0。
    • 线程A获取semaphoreA后打印,然后释放semaphoreB;线程B获取semaphoreB后打印,然后释放semaphoreC;线程C获取semaphoreC后打印,然后释放semaphoreA。
  4. BlockingQueue
    • 使用SynchronousQueue或者容量为1的ArrayBlockingQueue。线程A放入"A",线程B取出并打印,再放入"B"给线程C,以此类推。

13. MySQL分库分表方式

回答: 分库分表是解决MySQL单机存储和性能瓶颈的终极手段。

  • 分片方式

    1. 垂直分片:按业务或字段拆分。垂直分库(订单库、用户库分开);垂直分表(将大字段拆到单独的表中)。
    2. 水平分片:按数据行拆分。将一张大表的数据按某种规则分散到多个库或多个表中。
  • 如何分库?

    • 通常先分库,再分表。例如:分为4个库,每个库里面有16张表(总共64张表)。
    • 路由规则:通常是 hash(userId) % (dbNum * tableNum),或者先取模确定库,再取模确定表。
  • 主键设置(分布式ID)

    • 不能使用数据库自增ID,因为会重复。
    • 解决方案:雪花算法(Snowflake)、UUID(性能较差)、美团的Leaf、数据库号段模式。
  • 路由ID

    • 路由的依据通常是一个分片键(Sharding Key),比如user_idorder_id
    • 中间件(如ShardingSphere)会根据SQL中的分片键值计算出应该路由到哪个具体的库和表。

14. MySQL集群搭建方式

回答: 目前主流的高可用集群方案是主从复制 + 高可用管理

  1. 主从复制(Replication)原理

    • 主库(Master):记录binlog(二进制日志),记录所有数据变更。
    • 从库(Slave):启动一个I/O线程,连接主库,拉取binlog并写入本地的relay log(中继日志)。
    • SQL线程:读取relay log,重放SQL,实现数据同步。
  2. 高可用与故障转移

    • MHA(Master High Availability):比较成熟的方案,监控主库,挂了之后自动提升一个从库为新主库。
    • Orchestrator:更现代的MySQL复制拓扑管理工具。
    • 哨兵模式(Sentinel):这是Redis的术语。在MySQL领域,类似的概念由MHA或Orchestrator实现。它们负责监控、选主和配置更新。
  3. InnoDB Cluster / Group Replication

    • MySQL官方提供的基于Paxos协议的组复制技术,数据强一致性更好,自动故障转移。

15. Redis主库挂了,写操作如何保证?

回答: 这是一个关于CAP理论(一致性、可用性、分区容忍性)的权衡问题。

当主库(Master)挂掉且未完成故障转移(Failover)时,客户端的写操作会失败。我们主要从以下几点来保证有效性和一致性:

  1. 哨兵(Sentinel)或集群自动故障转移

    • 哨兵会监控主库,一旦发现主库宕机,会从从库(Slave)中选举一个新的主库。
    • 一致性风险:如果旧主库挂掉前,有部分数据还没同步给从库(异步复制的延迟),这部分数据会丢失。
    • 解决方案:在业务允许的情况下,写入时使用WAIT命令,强制要求至少同步到N个从库才算成功,但这会降低性能。
  2. 客户端容错(降级与熔断)

    • 使用Hystrix或Sentinel组件,当检测到Redis写入失败率达到阈值时,触发熔断。
    • 降级策略:写操作暂时落盘到本地文件、消息队列(MQ)或数据库,待Redis恢复后进行补偿重放。
  3. 双写或多活架构(高级方案)

    • 在极端要求不丢数据的场景,可以采用多活架构(如Codis或某些云厂商的全球多活方案),写入多个数据中心,但代价是极高的复杂度和延迟。

16. & 17. 索引字段选择与联合索引范围查询

回答:

16. 字段选择与联合索引失效:

  • 字段选择:选择区分度高(重复度小)、经常用于查询条件不为null的字段。
  • 联合索引最左前缀原则:如果有一个联合索引 (a, b, c),查询条件必须包含 a 才能命中索引。如果只查 bc,索引失效。
  • 部分失效场景
    • where a = 1 and b > 2 and c = 3:这里索引会用到 ab,但 c 无法使用索引进行查找(因为b是范围查询),需要回表后再过滤。

17. 组合索引与时间范围查询:

  • 场景:联合索引 (a, time, b),其中 time 是范围查询。
  • 结论time之后的字段无法使用索引
  • 解释:B+树是按照索引顺序排序的。当 a 是等值查询时,time 是有序的;但一旦 time 进行了范围查询,b 的顺序就无法保证了。因此,索引只能用到 atimeb 字段需要回表后进行过滤。

18. 数据加载到Redis的策略设计

回答: 如果让我设计一个从持久层(DB)加载数据到Redis的策略,我会考虑以下几个维度:

  1. 全量加载(系统启动时)

    • 分页扫描:为了避免一次性加载过多数据导致DB或Redis雪崩,采用分页(如LIMIT offset, size)或游标(Redis的SCAN,DB的WHERE id > last_id)的方式分批加载。
    • 多线程并行:开启多个线程并行读取DB和写入Redis,提高加载速度。
  2. 增量加载(运行时)

    • 监听Binlog(推荐):使用Canal或Debezium监听MySQL的binlog,一旦数据有变更(增删改),就同步更新Redis。这是目前互联网最主流的缓存同步方案。
    • 双写一致性:在业务代码中,先更新DB,再删除(或更新)Redis。为了保证一致性,通常采用“先更新数据库,再删除缓存”(Cache Aside Pattern)。
  3. 缓存预热

    • 在系统上线或大促前,提前将热点数据(如爆款商品)加载到Redis中,避免冷启动时大量请求打到数据库。
  4. 容错与降级

    • 失败重试:加载失败时,记录日志并放入重试队列。
    • 熔断机制:如果Redis不可用,直接走数据库,保证服务可用性。

19. Redis分布式锁的实现

回答: 分布式锁的核心是保证在分布式环境下,同一时刻只有一个线程能执行某段代码。

  1. 基础实现(SETNX + EXPIRE)

    • 使用 SETNX key value(如果key不存在则设置成功,返回1)。
    • 设置成功后,必须紧接着设置过期时间 EXPIRE key seconds,防止死锁。
    • 问题:SETNX和EXPIRE不是原子操作,如果在设置过期时间前机器宕机,锁将永远无法释放。
  2. 原子性实现(SET命令)

    • 使用 SET key value NX EX seconds
    • NX 表示只有key不存在时才设置,EX 设置过期时间。这两个操作在Redis中是原子的。
  3. 高级特性(Redlock / Redisson)

    • 可重入性:同一个线程可以多次获取锁。Redisson通过hash结构存储threadId:count来实现。
    • 锁续期(Watchdog):Redisson提供了看门狗机制,如果持有锁的线程未执行完,后台线程会自动给Redis中的锁续期(延长过期时间),防止锁过期被别人获取。
    • 防止误删:解锁时,必须校验value是否是自己的(通过Lua脚本保证校验和删除的原子性),防止把别人的锁删了。

面试官,您好。接下来看第20题,关于 OLAPOLTP,以及后续的事务相关问题,我结合在阿里和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, OracleClickHouse, 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实际上是内部方法调用,绕过了代理对象,因此注解的切面逻辑不会被触发。
  • 情况2:生效
    • 如果B方法在另一个Service中(通过@Autowired注入),A通过代理对象调用B,事务生效。
    • 或者在同一个类中,通过 ApplicationContext 获取当前Bean的代理对象,再通过代理对象调用B。

解决方案

  1. 将B方法拆分到另一个Service中。
  2. 使用 AopContext.currentProxy() 强制获取代理对象(需要在配置中开启 expose-proxy=true)。

24. 介绍一下单例模式

回答:
单例模式确保一个类只有一个实例,并提供一个全局访问点。

  • Spring中的单例
    • Spring容器中的Bean默认就是单例(Singleton)的。容器启动时创建,容器关闭时销毁。
    • 注意:Spring的单例是基于容器Bean Name的,它是线程不安全的(如果Bean中有成员变量)。通常我们把Service设计成无状态的(不保存成员变量),来规避线程安全问题。
  • 非单例(Prototype)
    • 每次从容器中获取Bean都会创建一个新的实例。

手写单例(双重检查锁 DCL)

java
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框架是设计模式的集大成者,我列举几个核心的:

  1. 工厂模式 (Factory Pattern)

    • 体现BeanFactoryApplicationContext
    • 作用:解耦对象的创建和使用,我们不需要 new 对象,而是从Spring容器中获取。
  2. 单例模式 (Singleton Pattern)

    • 体现:Spring Bean默认作用域。
    • 作用:节省内存,提高性能。
  3. 代理模式 (Proxy Pattern)

    • 体现:AOP(面向切面编程)和事务管理。
    • 实现:JDK动态代理(基于接口)和 CGLIB(基于子类)。
  4. 模板方法模式 (Template Method Pattern)

    • 体现JdbcTemplate, RestTemplate
    • 作用:封装了固定的流程(如获取连接、释放连接),将变化的部分(SQL执行)交给回调函数处理。
  5. 观察者模式 (Observer Pattern)

    • 体现:Spring的事件监听机制(ApplicationEventApplicationListener)。
    • 作用:实现组件间的解耦,比如发布“用户注册成功”事件,由监听器去发送邮件。
  6. 适配器模式 (Adapter Pattern)

    • 体现HandlerAdapter(在SpringMVC中)。
    • 作用:允许不同的Controller(如实现了Controller接口的,或者加了@RequestMapping注解的)都能被统一的DispatcherServlet调用。