企鹅后端日常实习一面
面试官很温柔,没有别的帖子那样遇到拷打的面试官,甚至手撕也建议我用本地IDEA写,但我本地的ai提示一时半会关不掉,所以又转到leetcode上进行,最后通过idea调试完成了整个代码,面试官看我写代码也没强行掐时间,面试感觉非常好。八股项目半小时,剩下的时间手撕算法leetcode
以下记录面试相关问题,简单的就直接略过,有点难度的就ai看看标准答案。我面试的是游戏组,所以有一些关于游戏的场景题。
首先问了我学了什么课程、项目中模型调优方面的内容,我逐个解答了一下,深入的ai细节没答上来,因为不是我负责的,我主要负责后端方面的内容
1、简要介绍ArrayList和LinkedList,比较简单直接跳过
2、对于游戏中,玩家的背包系统,使用ArrayList存储好,还是LinkedList更好?
✅ 结论:绝大多数游戏场景下应选择
ArrayList。
理由如下👇
- 背包以“格子”为核心每个格子都有编号(index),ArrayList 的随机访问性能更优(O(1))。
- 背包容量通常固定或变化少例如 30 个格子、60 个格子,不会频繁扩容,因此 ArrayList 的“扩容成本”可忽略。
- 遍历展示是高频操作玩家一打开背包就要渲染所有物品,ArrayList 在连续内存中遍历更快。
- 插入/删除通常在尾部或指定位置例如拾取新物品 → 放入第一个空格,ArrayList 调整代价不大。
- LinkedList 带来额外内存开销与缓存未命中问题节点分散在堆内存中,不利于 CPU 缓存优化。
3、JUC包内部有哪些常见的核心模块
java.util.concurrent(简称 JUC 包)是 Java 并发编程的核心库,它封装了大量高性能、线程安全的并发工具类。
🧩 一、JUC 的整体结构
JUC 主要分为以下 6 大核心模块:
模块名称 | 代表类 | 作用 |
① 线程池框架 |
| 管理和复用线程资源 |
② 并发工具类(同步器) |
| 线程协作、同步与限流 |
③ 锁与同步机制 |
| 取代 |
④ 原子类 |
| 无锁(CAS)实现的线程安全变量操作 |
⑤ 并发集合 |
| 支持高并发读写的集合容器 |
⑥ 其他并发辅助类 |
| 异步任务、线程工具等 |
🚀 二、核心模块详解
1️⃣ 线程池框架(Executor Framework)
核心类:
- Executor:最顶层接口,定义执行任务的标准。
- ExecutorService:扩展了生命周期管理(shutdown、submit 等)。
- ThreadPoolExecutor:线程池的核心实现类。
- ScheduledThreadPoolExecutor:支持定时与周期性任务。
- ForkJoinPool:支持任务拆分与并行执行(分治思想)。
📘作用:避免频繁创建销毁线程,控制并发量,提高性能。
3️⃣ 锁与同步机制
锁类型 | 特点 |
| 可重入独占锁,支持公平/非公平模式、条件队列(Condition) |
| 读写分离锁,允许多个线程同时读、写独占 |
| 乐观读锁,性能更高但不支持可重入 |
| 配合 |
📘这些锁的底层都依赖 AQS 的“队列同步器”机制。
4️⃣ 原子类(Atomic)
分类 | 代表类 | 特点 |
基本类型 |
| 基于 CAS 保证单变量原子操作 |
引用类型 |
| 支持对象引用更新,防止 ABA 问题 |
数值累加器 |
| 高并发下比 Atomic 更高效(分段累加) |
📘原理:基于 CAS(Compare-And-Swap)+ volatile + Unsafe 实现无锁并发。
5️⃣ 并发集合(Concurrent Collections)
类名 | 功能 |
| 高并发哈希表(分段锁 / CAS) |
| 读多写少场景(写时复制) |
| 非阻塞队列(CAS) |
📘并发容器通过锁分段、CAS、volatile 等机制实现线程安全。
6️⃣ 其他工具类
工具类 | 功能 |
| 异步任务结果获取 |
| 链式异步编程(JDK8 引入) |
| 低层线程阻塞/唤醒工具,AQS 的基础 |
| 线程局部变量,防止共享冲突 |
| 高性能随机数生成器(避免锁竞争) |
✅ 面试总结口诀
“三锁两池一集合,一原子多工具”
即:
- 三锁 → ReentrantLock、ReadWriteLock、StampedLock
- 两池 → ThreadPoolExecutor、ForkJoinPool
- 一集合 → ConcurrentHashMap
- 一原子 → Atomic 系列
- 多工具 → CountDownLatch、Semaphore、CyclicBarrier、Phaser、Exchanger
4、讲解synchronized和volatile两个关键字
🧩 一、volatile 关键字
1️⃣ 作用
volatile 主要用于 保证变量的可见性 和 禁止指令重排序,但不保证操作的原子性。
2️⃣ 可见性问题
在 Java 中,每个线程都有自己的 工作内存(缓存)。
普通变量被线程修改后,可能不会立即刷新到主内存中。
这时,其他线程就看不到最新值——这就是“可见性问题”。
✅ volatile 的作用:
当一个变量被 volatile 修饰时:
- 线程对该变量的写操作会立刻刷新到主内存;
- 线程对该变量的读操作会直接从主内存读取最新值。
3️⃣ 禁止指令重排
在 JVM 中,为了优化性能,指令可能会乱序执行(编译器或 CPU 优化)。
但在并发环境下,这会造成错误。
🔒 二、synchronized 关键字
1️⃣ 作用
synchronized 是一种悲观锁机制,用来保证:
- 原子性(同一时刻只有一个线程执行)
- 可见性(退出同步块时刷新变量)
- 有序性(同步块中操作按顺序执行)
2️⃣ 使用方式
synchronized 可以修饰:
- 实例方法锁的是 当前对象(this) public synchronized void method() {}
- 静态方法锁的是 类对象(Class) public static synchronized void method() {}
- 代码块自定义锁对象(推荐)
synchronized(lockObj) {
// 临界区
}
3️⃣ 底层原理
synchronized 是基于 JVM 的 Monitor(监视器锁) 实现的。
每个对象在 JVM 中都有一个 对象头(Object Header),其中包含一个 Mark Word,保存锁状态、线程 ID 等。
锁有多种优化状态:
锁状态 | 特点 |
无锁 | 普通状态 |
偏向锁 | 只被一个线程使用 |
轻量级锁 | 多线程交替执行 |
重量级锁 | 多线程竞争激烈(使用 OS 互斥量) |
JVM 会根据竞争情况 自动升级锁状态(但不会降级)。
5、JVM有哪些区域
下面图片内容来自神哥的八股
🧩 二、各个区域的详细说明
1️⃣ 程序计数器(Program Counter Register)
- 每个线程都有一个独立的计数器;
- 作用:记录当前线程执行的字节码指令地址;
- 如果当前线程执行的是 native 方法,计数器为空;
- 特点:线程私有,不会造成内存溢出。
2️⃣ Java 虚拟机栈(Java Stack)
- 每个线程独立,存放方法调用的局部变量、操作数栈、动态链接、方法出口信息。
- 当方法被调用时,会创建一个栈帧(Stack Frame)。
- 常见异常:StackOverflowError:递归调用过深;OutOfMemoryError:虚拟机栈无法分配足够内存(一般在固定内存模式下)。
3️⃣ 本地方法栈(Native Method Stack)
- 作用与虚拟机栈类似,但服务于native方法(C/C++实现)。
- HotSpot VM 将虚拟机栈与本地方法栈合二为一。
4️⃣ 堆(Heap)
- JVM 内存中最大的一块,所有对象实例、数组都在堆上分配;
- 所有线程共享;
- 分区结构(逻辑上):
新生代(Young Gen) ├─ Eden区 ├─ Survivor From区 └─ Survivor To区 老年代(Old Gen)
- 特点:新生代主要用于存放新创建的对象;老年代主要存放生命周期长的对象;使用 GC(垃圾回收器) 管理,如 G1、CMS、ParallelGC。
- 常见异常:OutOfMemoryError: Java heap space
5️⃣ 元空间(Metaspace)
- 存放:类的元信息(类名、修饰符、常量池、字段、方法)静态变量(static)JIT 编译后的代码,运行时常量池(Runtime Constant Pool)在 JDK 1.7 属于方法区的一部分,而在 JDK 1.8 变成元空间的一部分。
- 区别:JDK1.7 及之前:使用 JVM 内部内存(永久代 PermGen);JDK1.8:使用 本地内存(Native Memory),称为 Metaspace;
- 优点:不再受 JVM 参数 -XX:MaxPermSize 限制;使用本地内存可动态扩展,减少 OOM 频率;
- 参数调节:-XX:MetaspaceSize=128m-XX:MaxMetaspaceSize=512m
- 异常:OutOfMemoryError: Metaspace
6、假如JVM出现OOM异常,应该如何定位?有哪些内存检测工具可以使用辅助检测排查
🧨 一、OOM(OutOfMemoryError)是什么?
OOM 表示 JVM 在申请内存时失败,即:
没有足够的内存空间供对象分配,且 GC 无法再回收可用空间。
但不同的内存区域会抛出不同类型的 OOM:
区域 | 异常类型 | 常见原因 |
堆(Heap) |
| 对象太多、内存泄漏 |
元空间(Metaspace) |
| 动态生成类过多、类加载器泄漏 |
线程栈(Stack) |
| 线程数过多或系统资源耗尽 |
GC 开销 |
| GC 频繁但无效,堆太小或泄漏 |
直接内存(Direct Memory) |
| NIO 分配了太多直接缓冲区 |
物理内存 |
| 操作系统级别内存不足 |
🧭 二、定位思路(五步排查法)
🥇 第1步:确认 OOM 类型
- 从日志中找到 OutOfMemoryError 的完整堆栈;
- 不同类型 → 对应不同区域(堆、元空间、线程等)。
🥈 第2步:保留现场(重点)
运行时添加:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof
💡 作用:当出现 OOM 时自动生成 堆转储文件(.hprof),包含所有对象与引用关系。
🥉 第3步:使用分析工具查看内存快照
常用工具如下👇
jmap | JDK自带 | 导出堆内存快照:
|
jvisualvm | 图形化 | 分析堆、线程、GC、CPU 使用情况 |
MAT (Memory Analyzer Tool) | 专业分析 | Eclipse 提供的强大泄漏检测工具 |
Arthas | 阿里开源 | 在线诊断 JVM 内存、线程、类加载问题 |
jstat/jconsole | JDK自带 | 实时监控 JVM 内存与GC状态 |
GCeasy / GCViewer | 可视化工具 | 分析GC日志,查看回收频率、耗时等 |
🏅 第4步:分析结果
🎯 A. 如果是堆 OOM:
- 用 MAT 打开 .hprof 文件;
- 看 Histogram 视图(对象类型+数量+占用);
- 看 Dominator Tree,找出内存占用最大的根对象;
- 若存在 引用链未断开 → 属于内存泄漏(Memory Leak);
- 若无泄漏,仅对象过多 → 属于内存溢出(Memory Overflow)。
💡 举例:
某缓存类
Map不断往里 put 元素但未清理 → 导致泄漏。
🎯 B. 如果是 Metaspace OOM:
- 检查是否频繁动态加载类(如反射、CGLIB 动态代理、Groovy 等);
- 检查 Spring、MyBatis 是否存在重复生成 ClassLoader;
- 适当扩大 -XX:MaxMetaspaceSize,或排查类加载器泄漏。
🎯 C. 如果是线程 OOM:
- 查看系统线程数:
- 分析创建线程的代码逻辑;
- 检查是否无限制地 new Thread();
- 使用线程池替代。
🏆 第5步:结合 GC 日志分析
开启 GC 日志参数:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
然后用以下工具分析:
- GCViewer(本地分析)
- GCeasy.io(在线可视化)
- Arthas dashboard(实时查看内存使用)
重点关注:
- Full GC 频率是否过高
- 堆回收后是否有明显下降
- 老年代使用率是否长期接近100%
7、在游戏对局中,一场战斗会产生大量的交互数据,把它存入数据库中会带来很大的读写压力,如何对战斗记录这个表进行优化,提升读写性能?除了构建缓存和索引,还有什么方式?
🧱 一、架构层面优化
✅ 1. 冷热数据分离
- 热数据:近期(如1小时内)的战斗记录,放在 Redis / MongoDB / Elasticsearch;
- 冷数据:历史记录批量转存至 MySQL 或 HDFS / ClickHouse;
- 可以采用异步落库,即写入消息队列后再异步入库。
客户端 -> 游戏逻辑服 -> MQ -> 异步写入器 -> MySQL + ClickHouse
这样写操作在主流程中几乎是 非阻塞的。
✅ 2. 异步写入 + 批量落库
- 使用 消息队列(Kafka / RocketMQ / RabbitMQ) 做削峰;
- 逻辑服只负责发送消息,不直接写DB;
⚙️ 二、数据库层面优化
✅ 1. 避免实时更新索引
- 对写密集表,索引越多,写入越慢;
- 尽量只保留主键索引;
- 复杂的查询需求交给异构数据库(如ES)。
✅ 2. 使用合适的存储引擎
- MyISAM 写性能高但不支持事务;
- InnoDB 默认使用页锁,适合并发;
- 如果写多读少,可考虑 InnoDB + 批量写入 + 延迟索引刷新。
🧩 三、存储策略优化
✅ 1. 半结构化存储(JSON压缩)
将一场战斗的所有交互事件压缩成一条记录:
{
"battle_id": 101,
"actions": [
{"player":1,"type":"attack","damage":10,"time":"10:01"},
{"player":2,"type":"heal","hp":5,"time":"10:02"}
]
}
👉 优点:
- 大幅减少写入次数;
- 适合后续分析、回放;
- MySQL 5.7+ JSON 支持 + 压缩存储。
✅ 2. 使用专用日志数据库
战斗日志不适合存传统 MySQL,可以迁移到:
- MongoDB(灵活文档结构)
- ClickHouse(高性能分析)
- Elasticsearch(快速检索)
- InfluxDB / TimescaleDB(时间序列数据)
特别推荐:ClickHouse —— 高压缩比 + 写入极快,非常适合“战斗日志”这类 append-only 数据。
8、玩家的数据用什么持久化的方式来存储更加合适?
这个就比较简单,对局内改动比较小的信息,直接AOF进行记录,大的间隔之间使用RDB持久化
9、spring自动装配是怎么实现的?
🌱 一、什么是自动装配?
自动装配(AutoConfiguration) 是 Spring Boot 的核心特性之一,
它让你“不用写配置类或 XML,就能自动注册常用 Bean”。
举个例子:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello Spring Boot!";
}
}
虽然你没有显式写:
@Configuration @EnableWebMvc
但 Spring Boot 启动后,Tomcat、WebMvc、Jackson、DispatcherServlet 都自动装配好了。
👉 这就是 自动装配 的功劳。
🧩 二、自动装配的触发点:@SpringBootApplication
Spring Boot 项目启动类通常长这样:
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
其实这个注解是复合注解:
@SpringBootConfiguration // = @Configuration @EnableAutoConfiguration // 自动装配核心 @ComponentScan // 扫描用户自定义组件
其中的 @EnableAutoConfiguration 才是关键入口。
🧮 三、自动装配的执行顺序(简化流程)
@SpringBootApplication
│
▼
@EnableAutoConfiguration
│
▼
@Import(AutoConfigurationImportSelector)
│
▼
读取 META-INF/spring.factories
│
▼
加载所有 xxxAutoConfiguration 类
│
▼
根据 @Conditional 条件过滤
│
▼
将符合条件的类注册为 BeanDefinition
│
▼
Spring IoC 容器启动完成,Bean 实例化
🧠 一句话总结
Spring Boot 自动装配的本质是:
“Spring 在启动时从
META-INF/spring.factories中加载一批配置类(xxxAutoConfiguration),结合
@Conditional按需实例化 Bean,实现开箱即用的功能。”
10、Java中深拷贝和浅拷贝的区别
深拷贝和浅拷贝的核心区别在于是否递归地复制对象内部的引用类型数据,接下来,我会从定义、实现方式以及使用场景三个方面详细讲解它们的区别。
首先是定义上的区别,
浅拷贝是指创建一个新对象,但新对象中的引用类型字段仍然指向原对象中引用类型的内存地址。换句话说,浅拷贝只复制了对象本身,而没有复制对象内部的引用类型数据。修改新对象中的引用类型数据会影响原对象。
深拷贝是指创建一个新对象,并且递归地复制对象内部的所有引用类型数据。换句话说,深拷贝不仅复制了对象本身,还复制了对象内部的所有引用类型数据。修改新对象中的引用类型数据不会影响原对象。
其次是实现方式上的区别,
浅拷贝可以使用 Object 类的 clone() 方法,也可以使用实现 Cloneable 接口并重写 clone() 的方法。
深拷贝可以手动对引用类型字段进行递归拷贝,也可以使用序列化(Serialization)的方式将对象序列化为字节流,再反序列化为新对象。
最后是使用场景上的区别,
浅拷贝适用于当对象内部的引用类型数据不需要独立复制的情况。
深拷贝适用于当对象内部的引用类型数据需要完全独立的情况。
11、如果前端页面向后端请求数据,后端一次性返回数据量过大导致前端页面崩溃,这时候如何优化?
🚨 一、问题根源
一次性返回大数据,问题出在:
- 数据量过大(例如几十万条);
- 数据结构复杂(层级深、嵌套多);
- 序列化/反序列化耗时(如 Jackson 转 JSON);
- 前端渲染时间过长(例如 React/Vue 虚拟 DOM diff 太慢)。
🧩 二、常见优化方式
✅ 1. 分页 / 分片加载(最推荐)
后端在数据库层分页查询,只返回当前页的数据:
@GetMapping("/battleRecords")
public Page<BattleRecord> getRecords(@RequestParam int page, @RequestParam int size) {
Pageable pageable = PageRequest.of(page, size);
return recordRepository.findAll(pageable);
}
前端配合分页展示或滚动加载(infinite scroll)。
🔹 优点:最根本、最安全,减少传输量
🔹 缺点:需要前后端都配合改造
✅ 2. 数据懒加载(Lazy Load)
比如一个战斗记录中包含详细的每一帧动作:
- 第一次只返回战斗基本信息;
- 当用户点击“详情”时,前端再发请求加载详细内容。
这可以显著减少首屏加载压力。
✅ 3. 数据压缩传输
启用 GZIP 压缩(Spring Boot 默认支持):
server:
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/plain
min-response-size: 1024
🔹 优点:减小传输体积(通常可减少 70%)
🔹 缺点:压缩与解压有 CPU 开销,但一般值得
✅ 4. 数据裁剪 / 精简字段
只返回前端真正需要展示的字段,而不是整个对象。
使用 DTO(数据传输对象):
@Data
@AllArgsConstructor
public class BattleRecordSummary {
private Long id;
private String playerName;
private int totalDamage;
private LocalDateTime time;
}
通过 map() 将 Entity 转换为 DTO,减少冗余字段。
✅ 5. 流式返回(Stream 传输)
适合极大数据集(例如导出日志)。
Spring 支持 响应流式输出:
@GetMapping(value = "/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void streamExport(HttpServletResponse response) throws IOException {
response.setHeader("Content-Disposition", "attachment; filename=data.json");
try (OutputStream os = response.getOutputStream()) {
dataService.streamAll(os); // 边查询边写出
}
}
🔹 优点:边读边写,不占大量内存
🔹 缺点:前端无法随机访问,需要特殊处理(如下载)
