招银网络科技后端开发一面面经(10.8)
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 请做一下自我介绍,并介绍一下你的实习项目。
- JVM 的垃圾回收机制是怎么工作的?
- 在程序中怎么设计年轻代和老年代的大小?你自己有没有手动设置过?
- 什么情况下会发生堆溢出、什么情况下会发生栈溢出?
- 描述一下 Spring Boot 依赖注入的原理。
- 高并发场景中如何保证线程安全?
- 在一个大文件中存在重复的整数,如何把重复的整数找出来?(原帖表述为「11 个重复整数」)
- 编程题:对输入的字符串输出所有可能的不重复排列。例如输入
aab,输出[aab, aba, baa]。
《参考解析》
GC 机制要按「怎么判定垃圾 → 怎么回收 → 在哪个区域回收」三段来讲。 判定阶段主流是可达性分析:从 GC Roots(栈上的局部变量、静态变量、常量引用、JNI 引用等)出发做图遍历,走不到的对象判定为可回收;引用计数法因为无法处理循环引用,只作为补充手段存在。回收阶段有标记清除、标记复制、标记整理三种基本算法:标记清除简单但会产生内存碎片;标记复制把存活对象复制到另一块空间、没有碎片但需要预留一半空间,适合存活率低的区域;标记整理在原地压缩、没有碎片但移动成本高,适合存活率高的区域。分代是这三者的组合应用:堆分新生代和老年代,新生代再分为 Eden 和两个 Survivor(默认 Eden : S0 : S1 = 8 : 1 : 1),新对象先分配在 Eden,Eden 满触发 Minor GC,存活对象复制到 Survivor 并记录年龄,年龄到阈值(默认 15,动态年龄判定会提前)晋升老年代;大对象可直接进老年代;老年代空间不足或晋升速度超过回收速度时触发 Major / Full GC,对整个堆乃至方法区做回收。面试里值得主动补一句:具体用哪套回收器决定了这些过程怎么发生——Serial、Parallel 走经典分代,G1 把堆切成 Region 做可预测停顿的回收、不建议再手动设 -Xmn,ZGC 与 Shenandoah 则以低延迟为目标、并发标记与整理,选型看的是延迟与吞吐的取舍。
分代大小的设置要讲清「默认值、可调参数、以及为什么不该无脑调」。 默认比例是新生代与老年代 1 : 2(-XX:NewRatio=2),新生代内部 Eden 与两个 Survivor 是 8 : 1 : 1(-XX:SurvivorRatio=8),堆的初始值与最大值一般设成相等(-Xms = -Xmx)以避免运行时反复扩容缩容带来的抖动。手动设置的价值在于调整对象晋升的节奏:新生代偏小,短命对象来不及在 Survivor 里被回收就晋升,会推高老年代占用并引发更频繁的 Full GC;新生代偏大,单次 Minor GC 的停顿时间会变长,因为复制与标记的工作量随存活对象规模上升。回答「有没有手动设置过」时,务实的说法是:没有在生产上负责过 GC 调优,但会做对比实验来理解参数——固定堆大小,只改新生代尺寸,用 -Xlog:gc* 或 -XX:+PrintGCDetails 观察 Minor GC 次数、单次停顿与总停顿的变化,再看晋升量与老年代增长速度,用日志而不是感觉来下结论;另外要知道参数之间会互相影响,比如设了 -Xmn 就等于锁死了 G1 的新生代自适应,这类冲突必须先确认回收器再谈调参。
堆溢出与栈溢出要分开讲,并给出各自的典型成因。 堆溢出(OutOfMemoryError: Java heap space)是对象无法在堆上分配:要么是内存泄漏——对象被无意中长期持有(静态集合只增不减、监听器未注销、ThreadLocal 未 remove、缓存没有淘汰策略),要么是内存溢出——单次处理的数据量确实超过堆容量(一次性查出全表、超大文件读进内存)。定位手段是 jmap / jcmd 导出堆快照后用 MAT 分析支配树,看谁占着内存、被谁引用。栈溢出(StackOverflowError)是线程栈帧超过 -Xss 限制,最常见的原因是无终止条件的递归、递归深度过大或方法栈帧过大(局部变量多、大对象),改成迭代或加深栈只是权宜,根因通常还是递归边界写错。还要区分一种常被搞混的情况:创建线程过多可能报 OutOfMemoryError: unable to create native thread,那不是堆不够,而是线程数与栈空间总量触顶。
Spring Boot 依赖注入的原理,答法是「容器做什么 + 怎么装配 + 怎么解决循环依赖」。 启动时容器通过组件扫描(@SpringBootApplication 上的 @ComponentScan)和自动配置找到候选类,解析成 BeanDefinition 注册进容器;随后在刷新阶段实例化单例 Bean,按类型(必要时按名称、@Qualifier、@Primary)解析构造器参数或注入点,完成依赖装配。注入方式有三种:构造器注入把依赖作为构造函数参数,对象创建即完备、依赖不可变、便于测试,是首选;setter 注入适合可选依赖;字段注入写起来最短但绕过了构造器,不利于不变性与测试。循环依赖方面,单例 Bean 之间的 setter / 字段注入靠三级缓存解决——一级放成品、二级放提前暴露的半成品、三级放生成半成品的工厂,让 A 在未完成时能先拿到 B 需要的早期引用;构造器注入的循环依赖无法这样绕开,会直接启动失败,这也正是它更被推荐的原因之一。
高并发下的线程安全,按「共享什么、怎么保护」来组织答案最清晰。 先分清问题是互斥还是可见性:互斥用锁,synchronized(JVM 层面,可重入、支持偏向与轻量级锁优化)或 ReentrantLock(显式加解锁、支持公平锁、可中断、可超时、可多条件队列);可见性与有序性用 volatile,它能保证写入立即可见并禁止指令重排,但不保证复合操作的原子性,所以 i++ 这类读改写仍需 CAS 或加锁。原子类(AtomicInteger、LongAdder)走 CAS 无锁路径,在高并发计数场景比锁更划算,LongAdder 通过分段累加进一步减少争用。容器层面用 ConcurrentHashMap、CopyOnWriteArrayList 这类并发容器替代加锁包裹的普通集合;协调层面用 CountDownLatch、Semaphore、CyclicBarrier 控制并发度与执行顺序。还有一类容易漏的「隐式共享」:SimpleDateFormat 不是线程安全的、要用 DateTimeFormatter 或 ThreadLocal;ThreadLocal 本身能隔离数据但要记得在 finally 里 remove(),否则线程池复用会串数据也会泄漏。答题时把「减少共享 > 不可变 > 隔离 > 同步」这个优先级说出来,比罗列 API 更能体现思路。
大文件找重复整数,标准解法是位图,边界条件决定要不要升级方案。 如果整数的取值范围可控(比如都在 32 位整数空间里但实际分布在一个较小的区间),用 BitSet 或自己用 long[] 实现的位图最省内存:每一位代表一个数是否出现过,第一次置位、第二次发现已置位即判为重复,内存开销是值域大小除以 8 字节,遍历一遍即可,时间线性。需要提醒的是布隆过滤器不适用——它有假阳性,只能判断「可能存在」,无法作为精确的重复判定。如果值域太大或整数个数远超内存预算,就不能靠位图:退一步用哈希分桶(按 hash % k 把数据切成能装进内存的若干份,同一份内的重复必然落在同一个桶里,逐桶用哈希表判定),或者先外部排序再去重(排序后相邻相等即为重复),再或者用数据库临时表加唯一索引靠冲突检测。回答时先反问「整数的范围有多大、有多少个、内存有多少」,把方案的选择条件摆出来,比直接给一个答案更像工程师。
字符串全排列去重,用回溯加剪枝。 把字符串转成字符数组排序,然后按位置做回溯:每一层在当前位置尝试放一个还没用过的字符,用 used 数组标记,遇到重复字符时只在「它是这组重复字符里第一个未被使用的」情况下才继续(即 i > 0 && chars[i] == chars[i-1] && !used[i-1] 时跳过),这样天然消掉重复排列。也可以用交换法生成全排列、把结果放进 TreeSet 去重,写法短但在重复字符多时做的无用功更多。两个边界要处理:空串或长度为 1 的输入;以及题目给的输出格式(示例里是 [aab,aba,baa],注意用方括号包住、逗号分隔)——机考和面试手写都常因为输出格式不匹配被判错。复杂度方面有 m 个位置上的重复字符时结果是 n! / (各重复字符出现次数的阶乘) 个排列,回溯的时间与结果数量同阶,属于最优量级。