
欢迎来到预见猿份,本站项目均为站长原创,学习中有问题可直接提交给站长老苗解决(微信:mrt_0607)。
苗润土老师,20余年一线项目经验,2014年加入黑马,星辰wms、云岚到家、学成在线项目作者,历任高级讲师、教学主管及课程研究员。 b站老苗
Java基础常见面试题
Java基础是同学们最容易忽略的一篇内容,因为觉得有些过于简单,有些过于太难
但是这些简单的问题一旦没答上来,会给面试官留下基础不扎实的印象,所以同学们一定要注意。
1.基础篇
八大基本数据类型
按我下面说的来背:整形--》浮点--》其他。切记没有字符串String! 整型:byte(8)、short(16)、int(32)、long(64) 浮点:float(32)、double(64) 其他:boolean(8)、char(16)
访问控制修饰符public、protected、默认、private
- public(公共的)
- 访问级别最高,可以被任何其他类访问,不受类所在的包限制。
- protected(受保护的)
- 可以被同一个包中的其他类访问,也可以被不同包中的子类访问。
- 不能被不同包中的非子类访问。
- 默认(无修饰符)
- 类成员的默认访问级别,只能被同一个包中的其他类访问。
- 不能在包外部访问,即使这些类是同一个类的子类。
- private(私有的)
- 访问级别最低,只能在其所在的类内部访问。
- 不能被任何子类或不同包中的类访问,即使是同一个包中的其他类也无法访问。
Exception和Error的区别
Exception是异常,Error是错误,这两个都继承自Throwable Exception还有两大分类:运行时异常(RuntimeException)和非运行时异常(例如IOException等) Error大部分是我们程序员捕获也无法解决的问题 常见Error: StackOverflowError栈溢出错误,一般出现自递归没有正确退出(无限递归) OutOfMemoryError堆内存不足
String常用方法
length():返回字符串的字符数 chatAt():返回指定索引处的字符。索引从 0 开始计数 indexOf():返回字符或子字符串首次出现的位置。如果没有找到,返回 -1 substring():字符串截取 replace():字符串替换 split():按指定字符或字符串分割 intern():返回字符串对象的规范化表示。对于字符串常量池中已有的字符串, 返回常量池中的实例;如果常量池中没有,它会将字符串放入常量池并返回这个新实例。
JDK新版本特性
其中JDK8最重要,主要考察你会不会关心一些JDK的新闻动态
JDK 8
- Lambda表达式:引入了Lambda表达式支持,使得匿名函数的使用更加简洁。(重要)
- Stream API:提供了新的Stream API,支持函数式编程,使得集合操作更加高效和易于编写。(重要)
- 新的日期时间API:java.time包,用以替代旧的日期时间API,提供更好的时区处理和日期时间计算。
- 接口中的默认方法:允许在接口中添加具有默认实现的方法。
- 新的ConcurrentHashMap实现:提高了并发集合的性能。
JDK 11
- 改进的G1垃圾收集器:G1成为默认的垃圾收集器。
JDK 17
- Records:提供了更简洁的方式来定义只包含数据的类,通过
record关键字,编译器会自动生成构造函数、getter、setter、equals()、hashCode()等方法。 - 性能优化:JDK 17在性能方面进行了多项优化,包括垃圾回收、编译器和内存模型等方面的改进,旨在提高应用程序的响应速度和吞吐量。
- SpringBoot3.0以后必须JDK17以上版本(重要)
JDK 21
- 虚拟线程(Project Loom):引入了虚拟线程的预览特性,允许创建数以百万计的线程,而不会显著增加资源消耗。
深拷贝和浅拷贝
浅拷贝(Shallow Copy)
- 定义:浅拷贝创建一个新对象,但是这个对象的成员变量中引用类型的数据是指向原始对象中成员变量的引用。
- 实现方式:通常通过对象的复制构造函数或使用克隆(
Cloneable接口的clone()方法)实现。
深拷贝(Deep Copy)
- 定义:深拷贝创建一个新对象,并且递归地复制对象中的所有成员变量,包括引用类型的成员变量。
- 实现方式:需要手动实现,确保所有层次的引用都被复制。在Java中,可以通过实现
Cloneable接口并重写clone()方法,同时确保所有引用类型的成员变量也能被递归复制。 简单地说就是浅拷贝对象的成员变量如果是引用类型,那么复制完之后两个成员其实指向的一个地址 深拷贝就是完全复制一份新的出来,实际开发中我们通常使用JSON库来实现深拷贝
Stream流式运算的常用方法
forEach() 遍历 filter() 过滤 map() 转换 toMap() 把list转换成map,一对一 groupingBy() 把list转换成map,一对多 findAny() findFirst() 查询并返回一个Optional
如何解决跨域
跨域指的是浏览器不能执行其它网站的脚本,它是由浏览器的同源策略造成的,是浏览器对JavaScript 施加的安全限制。 所谓同源指的是:协议、域名、端口号都相同,只要有一个不相同,那么都是非同源。 解决方案:
- 使用ajax的jsonp(杰森P)
- nginx 转发:利用nginx反向代理,将请求分发到部署相应项目的tomcat服务器,当然也不存在跨域问题
- 使用cors:写一个配置类实现WebMvcConfigurer接口或者配置FilterRegistrationBean 网关也可以配置跨域,如果网关配置了跨域,后边微服务一般不需要配置
计算机网络的七层模型
从下往上 物理层:实现 比特流(0/1)的物理传输 数据链路层:将物理层的比特流封装为 帧(Frame),解决物理层传输的可靠性问题(如差错检测、帧同步),并实现相邻设备间的直接通信(如主机与路由器、路由器与交换机) 网络层:实现跨网段的端到端路由转发,解决如何找到目标设备的网络路径问题,定义逻辑地址(IP 地址)。 传输层:为应用层提供 可靠 / 不可靠的端到端数据传输服务,解决数据传输的顺序、完整性、流量控制问题。(TCP、UDP所在层) 会话层:建立、管理和终止 应用程序之间的会话连接,解决会话同步和断点续传问题表示层:负责 数据的格式转换和加密解密,解决应用层数据格式不一致的问题,确保接收方能够正确解析发送方的数据。 应用层:为最终用户提供 具体的网络应用服务,是用户与网络的接口(应用程序直接调用该层协议)。(HTTP所在层)
HTTP和HTTPS的区别
- 协议:
- HTTP基于TCP协议,明文传输,客户端服务端双方都无法验证对方的身份
- HTTPS基于SSL,SSL基于TCP,是添加了加密和认证机制的HTTP
- 默认端口:
- HTTP默认80
- HTTPS默认443
- 证书:
- HTTP不需要证书
- HTTPS需要证书
- 加密机制:
- HTTP没有加密机制
- HTTPS共享密钥加密和公开密钥加密并用的混合加密机制
- 安全性:
- HTTP安全性弱
- HTTPS由于带有加密机制,安全性强
HTTPS的执行流程

最简回答:
- 客户端向服务端发起请求
- 服务器向客户端发送数字证书
- 客户端验证数字证书。
- 服务器得到会话密钥
- 客户端与服务端进行加密会话
详细解释:
第一步:客户端向服务端发起请求 a. 客户端生成随机数R1 发送给服务端 b. 告诉服务端自己支持哪些加密算法和哈希算法
第二步:服务器向客户端发送数字证书 a. 服务端生成随机数R2 b. 从客户端支持的加密算法中选择一种双方都支持的加密算法(此算法用于后面的会话密钥生成)和哈希算法用机构的证书公钥解密得到证书的内容和证书签名 c. 服务端生成把证书、随机数R2、会话密钥生成算法,一同发给客户端
第三步:客户端验证数字证书。 这一部分是浏览器内置的 TSL(Transport Layer Security)是一种安全协议 完成的: a. 首先浏览器会从内置的证书列表中索引,找到服务器下发证书对应的机构,如果没有找到,此时就会提示用户该证书是不是由权威机构颁发,是不可信任的。如果查到了对应的机构,则取出该机构颁发的公钥、会话密钥生成算法、随机数R2。 b. 用机构的证书公钥解密得到证书的内容和证书签名,内容包括网站的网址、网站的公钥、证书的有效期等。浏览器会先验证证书签名的合法性。签名通过后,浏览器验证证书记录的网址是否和当前网址是一致的,不一致会提示用户。如果网址一致会检查证书有效期,证书过期了也会提示用户。这些都通过认证时,浏览器就可以安全使用证书中的网站公钥了。 c. 浏览器生成一个随机数 R3,根据会话密钥算法使用R1、R2、R3生成会话密钥。 d. 用服务端证书的公钥加密随机数R3并发送给服务端。 注意:以上其实就是 HTTPS 的握手过程,这个过程主要是认证服务端证书(内置的公钥)的合法性。因为非对称加密计算量较大,整个通信过程只会用到一次非对称加密算法(主要是用来保护传输客户端生成的用于对称加密的随机数私钥)。后续内容的加解密都是通过一开始约定好的对称加密算法进行的。
第四步:服务器得到会话密钥 a. 服务器用私钥解密客户端发过来的随机数R3 b. 根据会话密钥算法使用R1、R2、R3生成会话密钥
第五步:客户端与服务端进行加密会话 1) 客户端发送加密数据给服务端 发送加密数据:客户端加密数据后发送给服务端。 2)服务端响应客户端 解密接收数据:服务端用会话密钥解密客户端发送的数据 加密响应数据:用会话密钥把响应的数据加密发送给客户端。 3)客户端解密服务端响应的数据 解密数据:客户端用会话密钥解密响应数据
ThreadLocal
概念
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享
使用场景
在我们平时做Web开发时,通常会采用ThreadLocal中存储用户信息,在拦截器或过滤器中放入ThreadLocal中,而在对应的Service中取出用户信息
原理
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离。每个线程都有一个自己的 ThreadLocalMap 对象。这个 Map 存储了 ThreadLocal 对象作为键,以及对应线程的局部变量副本作为值。
内存泄露
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收 所以从结构上来看ThreadLocalMap中的 key 是弱引用,值为强引用; key 会被GC 释放内存,关联 value 的内存并不会释放。所以我们平时使用ThreadLocal的时候,在用完的时候通常要手动remove清空避免内存泄漏


其他
这些问题过于基础,但是问的频率还不低,只列问题,答案同学们自行搜索
final、finally、finallize的区别 final和static的区别 ==和equals的区别 接口和抽象类的区别 重写和重载的区别 i++和++i的区别 String、StringBuffer、StringBuilder区别 Comparable接口和Comparator的区别
2.JVM篇
JVM内存结构
方法区、堆、虚拟机栈、本地方法栈、程序计数器
详细介绍一下堆内存
- 线程共享的区域:主要用来保存对象实例,数组等,内存不够则抛出
OutOfMemoryError异常。 - 组成:年轻代+老年代
- 年轻代被划分为三部分,
Eden区和两个大小严格相同的Survivor区 - 老年代主要保存生命周期长的对象,一般是一些老的对象
- Jdk1.7和1.8的区别
- 1.7中有有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
- 1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出
什么是类加载器,有哪些类加载器
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

双亲委派模型
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类 JVM为什么要这么做? 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。 为了安全,保证类库API不会被修改 然而,在某些情况下,Tomcat(或任何自定义的Java Web容器)可能会打破双亲委派模型,但它这样做是为了提供更好的灵活性、安全性和应用程序的隔离性。 如何打破双亲委派 自定义类加载器并重写 loadClass() 方法
双亲委派模型的具体实现是在java.lang.ClassLoader的loadClass()方法中定义的,该方法默认遵循先委托父类加载器去加载类,如果父类加载器无法加载(返回null),再尝试自己加载的逻辑。通过自定义类加载器,并重写这个loadClass()方法,就可以改变原有的类加载顺序,从而打破双亲委派模型。比如可以直接先尝试自己加载类,再去委托父类加载器加载,或者根据特定条件来决定是按照双亲委派还是其他顺序加载类。
JVM如何确定一个对象是否可以被回收
结论:使用可达性分析法 流程:
- 定义GC Roots(根对象),这是可达性分析的起点,以 GC Roots 为起点遍历引用链,被引用的就是还存活的对象,没有被引用则疑似可回收
- 如果没有被引用,会被第一次标记,然后判断是否需要执行
finalize()方法:
- 若对象 未重写
finalize()方法,或finalize()方法已被执行过一次(JVM 对每个对象的finalize()仅执行一次):直接判定为可回收; - 若对象 重写了
finalize()且未执行过:将对象放入F-Queue队列,由 JVM 启动的低优先级线程(Finalizer 线程)执行finalize()方法。 - 执行完
F-Queue中的finalize()方法后,JVM 会再次检查队列中的对象:
- 若对象已通过
finalize()重新建立可达性:移除回收候选,对象继续存活; - 若对象仍不可达:最终标记为「可回收对象」,等待 GC 执行回收(释放内存)
垃圾回收算法
具体算法流程大家自行搜索
标记清除算法:垃圾回收分为2个阶段,分别是标记和清除,效率高,但是有磁盘碎片,内存不连续标记整理算法:标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,但是对象需要移动,效率低复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收;无碎片,内存使用率低
JVM的分代收集策略
堆的区域划分 堆被分为了两份:新生代和老年代1:2 对于新生代,内部又被分为了三个区域。Eden(伊甸园)区,幸存者区survivor(分成from和to)8:1:1 对象回收分代回收策略 新创建的对象,都会先分配到Eden区 当Eden区内存不足,标记Eden区与 from(现阶段没有)的存活对象 将存活对象采用复制算法复制到to中,复制完毕后,Eden区和 from内存都得到释放 经过一段时间后Eden区的内存又出现不足,标记Eden区域to区存活的对象,将其复制到from区 当survivor区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会提前晋升到老年代) GC方式
- MinorGC(young GC)发生在新生代的垃圾回收,暂停时间短(STW)
- Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
- FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免
- STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收的完成
G1垃圾回收器
从JDK9之后成为默认回收器,特点从以下几个部分来说
- 分区(Region)的概念 传统的垃圾回收器如CMS等往往是基于整个新生代、老年代这样的分代空间来进行垃圾回收操作。而 G1 打破了这种连续内存空间的划分方式,它将整个堆内存划分成大小相等且固定的一个个区域(Region),这些 Region 一部分被用作新生代,一部分用作老年代,还有一部分属于大对象专属区域,用于存放那些超过一定大小(通常是 Region 大小一半以上)的大对象。
- 基于优先级的区域回收G1 回收器在进行回收决策时,会根据各个 Region 中垃圾的多少(也就是回收的价值)来确定回收的优先级顺序。它通过在后台并发地对整个堆内存进行标记(标记哪些是垃圾、哪些是存活对象),然后计算每个 Region 的可回收空间大小以及回收所需要的成本(比如需要扫描的存活对象数量等),综合这些因素来给各个 Region 排出一个回收优先级列表,优先回收那些垃圾占比高、回收成本相对较低的高价值区域,这也是它名称 “Garbage-First” 的由来。
- 并发标记和并发整理G1 在标记阶段采用并发的方式,也就是在应用程序运行的同时,后台线程可以对存活对象进行标记,标记出哪些是存活的、哪些是垃圾对象。 标记过程大致分为初始标记、并发标记、最终标记等阶段。 初始标记阶段会短暂停顿应用程序,标记出直接与 GC Roots(比如栈中的局部变量、静态变量等能直接关联到对象的起点)相连的对象,这个过程很快; 并发标记阶段则是和应用程序并发运行,继续追踪标记存活对象; 最终标记阶段会再短暂停顿一下来处理在并发标记阶段中由于应用程序运行而产生的一些变动(比如新产生的对象等情况)。清理阶段同样也是并发进行的,在清理过程中,回收那些被标记为垃圾的 Region,释放内存空间。 通过并发标记与并发清理,极大地减少了垃圾回收过程中应用程序的停顿时间,相比传统的一些需要长时间停顿应用程序来进行标记和清理的回收器(如 Serial Old 等),G1 能够让应用程序在垃圾回收期间也可以保持较好的响应性能,提升了用户体验,更适合对响应时间要求较高的应用场景,像一些实时性要求较高的在线服务等。
- 可预测的停顿时间模型 G1 允许用户通过参数设定一个期望的最大垃圾回收停顿时间,G1 会基于这个目标值以及当前堆内存的状况(比如各个 Region 的垃圾占比、存活对象数量等),动态地调整每次回收的 Region 数量和回收频率等,尽量让每次垃圾回收产生的停顿时间都控制在设定的目标值以内,实现对垃圾回收停顿时间的可预测性管理。
- 大对象处理 当有大对象出现(大小超过 Region 大小一半以上)时,G1 会专门分配大对象专属的 Region(Humongous Region)来存放它。这些大对象在回收时也会按照相应的机制参与垃圾回收流程,当一个大对象所在的 Region 被标记为垃圾时,同样会被回收释放空间。而且在内存分配过程中,G1 会考虑大对象的情况,尽量合理地分配和管理这些特殊的区域,避免大对象对其他内存区域分配和回收造成不合理的影响。
JVM如何调优
首先我个人认为,JDK已经把JVM调到很不错的状态了,我们自己再去调的话,很容易调的更差,所以大部分情况下是不需要调整JVM参数的,这要求开发者对JVM参数的作用以及垃圾回收和自身业务都要非常熟悉才可以。
调优参数配置
jar包部署在启动参数设置 java -Xms512m -Xmx1024m -jar xxxx.jar
JVM 调优的参数都有哪些
对于JVM调优,核心就是调整堆(年轻代、老年代)与元空间的内存大小,并选择合适的垃圾回收器。下面按几个维度展开。
设置堆空间大小
- 设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值。
- -Xms:设置堆的初始化大小 -Xmx:设置堆的最大大小
堆空间设置多少合适?
- 最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64
- 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生stw,暂停用户线程
- 堆内存大肯定是好的,但是存在风险,假如发生了full gc,它会扫描整个堆空间,暂停用户线程的时间长
- 设置参考推荐:尽量大,也要考察一下当前计算机其他程序的内存使用情况
虚拟机栈的设置
- 每个线程默认会开启1M的内存,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
- -Xss 对每个线程stack大小的调整,-Xss128k
年轻代中Eden区和两个Survivor区的大小比例
- 设置年轻代中
Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小,来减少Young GC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。 - -XXSurvivorRatio=8,表示年轻代中的分配比率:survivor:eden = 2:8
年轻代晋升老年代阈值
- -XX:MaxTenuringThreshold=threshold 年轻代晋升老年代阈值
- 默认为15取值范围0-15
垃圾回收器选择
垃圾回收器决定了 GC 停顿表现:Serial / Parallel 主打吞吐量;CMS 主打低停顿(JDK9 标记废弃、JDK14 移除);G1 是 JDK9 起的默认收集器,分区模型、停顿可预测,适合大内存服务端应用;ZGC / Shenandoah 主打超低停顿(亚毫秒级),适合超大堆和对延迟极敏感的场景(ZGC 从 JDK15 起可用于生产)。JDK8 默认是 Parallel Scavenge + Parallel Old。选择依据:堆大小、对停顿时间的要求、机器核数与业务类型。
其他常用调优参数
- -XX:NewRatio:老年代与年轻代的比例,默认 2(老年代占 2/3);对象生命周期短的调大年轻代,生命周期长的调大老年代
- -Xmn:直接指定年轻代大小,精确控制更直观
- -XX:MetaspaceSize / -XX:MaxMetaspaceSize:元空间初始大小与最大值。类加载频繁、动态生成类的应用要重点关注,元空间过小会引发频繁 Full GC
- -XX:+UseG1GC:启用 G1;-XX:MaxGCPauseMillis=200:设定期望的最大 GC 停顿时间(毫秒),G1 会据此调整回收策略
- -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log(JDK9+ 用 -Xlog:gc*):打印 GC 日志,是调优和排查问题的基础
- -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/:OOM 时自动导出堆快照,方便事后分析
- -XX:MaxDirectMemorySize:直接内存上限,使用 NIO / Netty 时关注,避免 Direct buffer memory 类型的 OOM
调优目标与思路
调优围绕三个指标:吞吐量(应用线程运行时间占比)、延迟(GC 停顿时间)、内存占用。三者互相制约,调优的本质是结合业务找到平衡点。常见目标:减少 Full GC 次数、降低 GC 停顿时间、避免 OOM。核心思路是"先观察、后调整":不要凭感觉改参数,先用工具和 GC 日志确认瓶颈,一次只改一个参数并对比验证。
调优步骤
- 监控现状:用 jstat、jconsole、VisualVM、GC 日志采集运行数据,确定当前 GC 频率、停顿时间和各代内存占用,建立基线
- 分析瓶颈:判断问题是堆太小、对象频繁晋升老年代,还是垃圾回收器不适配
- 确定目标:明确是吞吐量优先还是低停顿优先,并量化为具体指标(如 Full GC 频率降到每天 1 次以下)
- 调整参数:一次只改一个参数,例如先固定 -Xms/-Xmx 为相同值,再调整年轻代比例,避免多个变量互相干扰
- 验证对比:用压测或观察线上指标对比调优前后效果,确认有效再固化到启动脚本,无效则回滚
JDK提供的一些工具
命令工具
jps进程状态信息jstack查看java进程内线程的堆栈信息jmap查看堆转信息jhat堆转储快照分析工具jstatJVM统计监测工具
可视化工具
jconsole用于对jvm的内存,线程,类 的监控VisualVM能够监控线程,内存情况





3.集合篇
常用集合

ArrayList底层原理
底层结构
- 数组(查询快,增删慢)
容量
- ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
扩容
- ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
新增
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
- 返回添加成功布尔值。
和LinkedList的区别
主要就是数组和链表对比查询和增删的效率,但实际上没人用LinkedList,包括LinkedList的作者都推荐ArrayList,具体大家自己查。 回答参考:ArrayList底层是数组,有下标,所以查询比LinkedList更快,但是数组长度固定,所以新增删除的时候可能要涉及到扩容和移动元素,性能不如链表。LinkedList底层是链表,所以查询效率不如ArrayList,但是新增删除的性能某些情况下优于ArrayList。 所以如果我们要对一个集合进行遍历查询(读多写少)操作,理论上使用ArrayList更好,如果要对这个集合多次新增删除(写多读少)操作,理论上LinkedList更好
HashMap实现原理
问得最多的集合面试题,必须掌握,而且得掌握的非常深入
建议大家自己看JDK源码,配合图片来结合记忆,能记忆的比较清晰
HashMap的数据结构
底层使用hash表数据结构,即数组和链表或红黑树
- 当我们往HashMap中
put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标 - 存储时,如果出现hash值相同的key,此时有两种情况。
- 如果key相同,则覆盖原始值;
- 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中
- 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
1.7和1.8的区别
- JDK1.8之前采用的拉链法,数组+链表
- JDK1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树
Put元素的具体流程
- 判断键值对数组table是否为空或为null,否则执行
resize()进行扩容(初始化) - 根据键值key计算hash值,得到数组索引
- 判断
table[i]==null,条件成立,直接新建节点添加 - 如果
table[i]==null,不成立
- 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
- 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
- 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
- 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
扩容机制
- 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值
(数组长度 * 0.75) - 每次扩容的时候,都是扩容之前容量的2倍;
- 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有hash冲突的节点,则直接使用
e.hash & (newCap - 1)计算新数组的索引位置 - 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断
(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上 为何HashMap的数组长度一定是2的次幂?
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高:
hash & oldCap == 0的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
HashMap在1.7的多线程情况下的死循环问题
jdk7的的数据结构是:数组+链表 在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环 1.8之后改成尾插法解决了这个问题,具体原因这里不详细介绍,大家自行搜索
底层结构

put流程

扩容机制

4.设计模式
单例模式也会问到,但是太简单,这里不讲了,尤其要注意的懒汉式为什么做双重校验
建造者模式
比较简单好理解的设计模式,我们平时使用的Lombok的@Builder注解就是这个设计模式 实现思路:
- 私有化构造函数,并且只接受
xxBuilder对象 - 提供一个
public的静态方法,通常是builder()返回一个对应xxBuilder对象(如下面的UserBuilder) - 写一个静态内部类
xxBuilder,带上原始类的所有字段,并且给每一个字段提供一个xx方法(如username字段就提供一个UserBuilder userame(String username)方法,返回xxBuilder对象方便链式调用) - 写一个
build()方法返回对应对象
public class User {
// 1. 所有字段声明为 final(保证不可变性)
private final String username;
private final String password;
private final String email;
private final String phone; // 可选
private final Integer age; // 可选
private final boolean isAdmin; // 可选
// 2. 私有构造函数:只接受 Builder 实例
private User(UserBuilder builder) {
this.username = builder.username;
this.password = builder.password;
this.email = builder.email;
this.phone = builder.phone;
this.age = builder.age;
this.isAdmin = builder.isAdmin;
}
// 3. 公共静态方法:返回 Builder 实例
public static UserBuilder builder() {
return new UserBuilder();
}
// 4. 静态内部 Builder 类
public static class UserBuilder {
// 必填字段(可强制校验)
private String username;
private String password;
private String email;
// 可选字段(带默认值)
private String phone = "";
private Integer age = null;
private boolean isAdmin = false;
// 链式 setter(返回 this)
public UserBuilder username(String username) {
this.username = username;
return this;
}
public UserBuilder password(String password) {
this.password = password;
return this;
}
public UserBuilder email(String email) {
this.email = email;
return this;
}
public UserBuilder phone(String phone) {
this.phone = phone;
return this;
}
public UserBuilder age(Integer age) {
this.age = age;
return this;
}
public UserBuilder admin(boolean isAdmin) {
this.isAdmin = isAdmin;
return this;
}
// 5. 构建方法:校验必填项 + 生成 User
public User build() {
if (username == null || password == null || email == null) {
throw new IllegalStateException("必填字段缺失: username/password/email");
}
return new User(this);
}
}
}工厂模式
工厂模式属于创建型设计模式,核心思想就是把实例化的过程给封装起来 简单地说就帮我们new对象 那为什么我们不自己new? 自己new每次都设设置可能要很多参数或准备数据或者类型较多,比较麻烦,所以准备个工厂帮我们new 案例:比如我要设计一个咖啡店的点餐系统 先设计一个咖啡类Coffee,并定义两个子类美式咖啡(AmericanCoffee)、拿铁咖啡(LatteCoffee) 在设计一个咖啡店类(CoffeeStore),并提供一个点咖啡的功能,代码如下 但是我们也发现一个问题,如果后面又有新的咖啡,比如摩卡,那我们就得改咖啡店,违反了开闭原则(扩展开放,对修改关闭) 此时我们就可以准备一个工厂SimpleCoffeeFactory,这就是简单工厂模式,咖啡店不需要知道咖啡怎么做的,只需要告诉工厂需要什么咖啡即可 但是此时我们如果需要新增咖啡种类,还是要修改工厂的代码 所以在此基础上又提出了工厂方法的设计模式 就是把工厂也给抽象出来,具体代码如下。满足开闭原则,但是增加了系统的复杂性
原版
public static Coffee orderCoffee(String type){
Coffee coffee = null;
if("american".equals(type)){
coffee = new AmericanCoffee();
}else if("latte".equals(type)){
coffee = new LatteCoffee();
}
coffee.addMilk();
coffee.addSugar();
return coffee;
}简单工厂:
public class SimpleCoffeeFactory {//定义一个工厂类
public static Coffee createCoffee(String type) {//提供生成咖啡的方法
Coffee coffee = null;
if("americano".equals(type)) {
coffee = new AmericanCoffee();
} else if("latte".equals(type)) {
coffee = new LatteCoffee();
}
return coffee;
}
}public class CoffeeStore {
public static void main(String[] args) {
Coffee coffee = orderCoffee("latte");
System.out.println(coffee.getName());
}
public static Coffee orderCoffee(String type) {
//通过工厂获得对象,不需要知道对象实现的细节
SimpleCoffeeFactory factory = new SimpleCoffeeFactory();
Coffee coffee = factory.createCoffee(type);
//添加配料
coffee.addMilk();
coffee.addSuqar();
return coffee;
}
}工厂方法
/**
* 咖啡工厂接口
*/
public interface CoffeeFactory {
/**
* 创建咖啡
* @return
*/
public Coffee createCoffee();
}/**
* 美式咖啡工厂类
*/
public class AmericanCoffeeFactory implements CoffeeFactory {
/**
* 创建美式咖啡
* @return
*/
@Override
public Coffee createCoffee() {
return new AmericanCoffee();
}
}/**
* 拿铁咖啡工厂类
*/
public class LatteCoffeeFactory implements CoffeeFactory {
/**
* 创建拿铁咖啡
* @return
*/
@Override
public Coffee createCoffee() {
return new LatteCoffee();
}
}public class CoffeeStore {
public static void main(String[] args) {
//可以根据不同的工厂,创建不同的产品
CoffeeStore coffeeStore = new CoffeeStore(new LatteCoffeeFactory());
Coffee latte = coffeeStore.orderCoffee();
System.out.println(latte.getName());
}
private CoffeeFactory coffeeFactory;
public CoffeeStore(CoffeeFactory coffeeFactory){
this.coffeeFactory = coffeeFactory;
}
public Coffee orderCoffee(){
Coffee coffee = coffeeFactory.createCoffee();
//添加配料
coffee.addMilk();
coffee.addSuqar();
return coffee;
}
}策略模式
策略模式(Strategy Pattern)是一种定义一系列算法的方法,在运行时可以互换使用这些算法。策略模式属于对象行为型模式,它将算法封装在独立的策略类中,使得算法可以在不改变客户端代码的情况下切换。主要就是为了避免过多的if else。 比如我们现在要旅游出行,而出行的方式有很多,可以选择不同的出行方式 具体代码如下 当然实现策略模式的方式还有很多,比如我们天机学堂里使用Map的方式,或者使用枚举的方式,这些核心的目的就是为了减少if else 我们在实际开发中往往是使用工厂+策略的方式一起使用。
- 订单的支付策略(支付宝、微信、银行卡…)
- 解析不同类型excel(xls格式、xlsx格式)
- 打折促销(满300元9折、满500元8折、满1000元7折…)
- 物流运费阶梯计算(5kg以下、5-10kg、10-20kg、20kg以上)
/**
* 出行策略接口
*/
public interface TravelStrategy {
/**
* 出行方式
*/
public void travel();
}/**
* 飞机策略类
*/
public class Aircraft implements TravelStrategy{
@Override
public void travel() {
System.out.println("选择飞机出行...");
}
}/**
* 火车策略类
*/
public class Train implements TravelStrategy{
@Override
public void travel() {
System.out.println("选择火车出行...");
}
}public class TravelContext {
private TravelStrategy travelStrategy;
public TravelContext(TravelStrategy travelStrategy){
this.travelStrategy = travelStrategy;
}
public void selectTravel(){
this.travelStrategy.travel();
}
public static void main(String[] args) {
TravelContext travelContext = new TravelContext(new Car());//选择不同的策略
travelContext.selectTravel();
}
}责任链模式
为了避免请求发送者与多个请求处理者耦合在一起,将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。 最常见的就是我们用过的filter过滤器,可以通过链式调用串起来多个filter过滤器逻辑 具体代码这里不举例了,我们说几个常见场景 内容审核(视频、文章、课程)
- 文本审核-》图片审核-》视频审核 订单创建
- 校验参数-》填充订单-》计算价格-》存储到数据库-》返回佣金 流程审批(简单版)
- 组长审批-》主管审批-》副总裁-》总裁
接下来举例说明
请假审批流程:
- 请假 ≤ 1 天 → 组长 审批
- 1 天 < 请假 ≤ 3 天 → 经理 审批
- 请假 3 天 → 总监 审批
(若无人处理,自动拒绝)
// 请假请求类
public class LeaveRequest {
private String employeeName;
private int days;
private String reason;
public LeaveRequest(String employeeName, int days, String reason) {
this.employeeName = employeeName;
this.days = days;
this.reason = reason;
}
// Getter 省略(实际需补充)
public String getEmployeeName() { return employeeName; }
public int getDays() { return days; }
public String getReason() { return reason; }
}
// 责任链处理器抽象接口
public abstract class Approver {
protected Approver nextApprover; // 链中的下一个处理器
// 设置下一个处理器(构建链的关键)
public void setNextApprover(Approver nextApprover) {
this.nextApprover = nextApprover;
}
// 处理请求的核心方法(模板方法)
public abstract void processRequest(LeaveRequest request);
}具体处理器实现
// 组长处理器
public class TeamLead extends Approver {
@Override
public void processRequest(LeaveRequest request) {
if (request.getDays() <= 1) {
System.out.printf("[组长审批] %s 的请假申请已批准(%d天):'%s'%n",
request.getEmployeeName(), request.getDays(), request.getReason());
} else if (nextApprover != null) {
// 超出权限,传递给下一个处理器
nextApprover.processRequest(request);
}
}
}
// 经理处理器
public class Manager extends Approver {
@Override
public void processRequest(LeaveRequest request) {
if (request.getDays() 1 && request.getDays() <= 3) {
System.out.printf("[经理审批] %s 的请假申请已批准(%d天):'%s'%n",
request.getEmployeeName(), request.getDays(), request.getReason());
} else if (nextApprover != null) {
nextApprover.processRequest(request);
}
}
}
// 总监处理器
public class Director extends Approver {
@Override
public void processRequest(LeaveRequest request) {
if (request.getDays() 3) {
System.out.printf("[总监审批] %s 的请假申请已批准(%d天):'%s'%n",
request.getEmployeeName(), request.getDays(), request.getReason());
} else {
// 无人处理时的兜底逻辑
System.out.printf("[系统] %s 的请假申请被拒绝(%d天):超出总监审批范围%n",
request.getEmployeeName(), request.getDays());
}
}
}责任链使用
public class Client {
public static void main(String[] args) {
// 1. 创建处理器实例
Approver teamLead = new TeamLead();
Approver manager = new Manager();
Approver director = new Director();
// 2. 动态构建责任链(关键步骤!)
teamLead.setNextApprover(manager);
manager.setNextApprover(director);
// 3. 发起不同请假请求
LeaveRequest req1 = new LeaveRequest("张三", 0.5, "感冒");
LeaveRequest req2 = new LeaveRequest("李四", 2, "家庭事务");
LeaveRequest req3 = new LeaveRequest("王五", 5, "婚礼");
LeaveRequest req4 = new LeaveRequest("赵六", 10, "环球旅行"); // 超出总监权限
// 4. 从链头开始处理请求
teamLead.processRequest(req1); // 组长处理
teamLead.processRequest(req2); // 传递给经理
teamLead.processRequest(req3); // 传递给总监
teamLead.processRequest(req4); // 无人处理,自动拒绝
}
}代理模式
概念
代理模式是一种结构型设计模式,它为另一个对象提供一个替代或占位符对象以控制对它的访问。代理模式在不直接暴露实际对象的情况下,提供了对目标对象的间接访问。这种模式使得间接访问可以提供额外的功能操作,例如访问控制、延迟初始化、日志记录等。 目的就是为了增强某个对象或者方法,但又不修改原代码
动态代理和静态代理
静态代理:编译时创建、不灵活,需要为每个目标对象写代理类 动态代理:运行时创建、很灵活、可以动态为任何接口创建代理
动态代理的两种实现方式
- 基于接口 使用JDK的
InvocationHandler实现,只能基于接口实现动态代理 - 基于类 使用CGLIB代理实现,是一个第三方依赖库,可以基于接口和类实现动态代理
框架中用到了那些设计模式
AOP使用了动态代理 Bean初始化使用工厂模式 SpringMVC中的HandlerAdpter使用适配器模式 MyBatis的SqlSessionFactory中也用到了工厂模式 MyBatis使用MapperProxy把Mapper接口和sql语句执行映射上,使用了适配器模式同时本身也是个代理对象所以也使用了代理模式
5.场景题
有没有遇到过内存泄漏?怎么排查解决
面试回答框架:先区分内存泄漏与内存溢出,再列举常见泄漏场景,最后按「确认现象 → 抓取快照 → 分析定位 → 修复预防」四步讲排查过程。
- 确认现象:用 jstat 观察 GC,判断是否内存泄漏
- 获取堆内存快照 dump
- 用 MAT / VisualVM 分析 dump,定位泄漏对象
- 反查引用链定位代码位置,修复并预防
内存泄漏与内存溢出的区别
内存泄漏(Memory Leak)指对象已经不再被使用,却因为仍被引用而无法被 GC 回收,长期占用内存;内存溢出(OOM)指可用内存不足,无法分配新对象。泄漏是"病根",溢出是"症状"——泄漏对象不断堆积,最终触发 OutOfMemoryError。
常见内存泄漏场景
- 静态集合类持有对象引用:对象存入 static 的 HashMap / ArrayList 后不再使用也没有移除,集合一直强引用它
- ThreadLocal 使用后未调用 remove():配合线程池时线程长期存活,ThreadLocal 持有的对象无法回收并持续累积
- 资源未关闭:数据库连接、IO 流、网络连接没有 close,底层 native 资源无法释放
- 监听器 / 回调未注销:注册到 Spring 事件监听器、MQ 消费者、第三方 SDK 回调后未解绑,容器强引用整个对象图
- 非静态内部类 / 匿名内部类:隐式持有外部类引用,导致外部类无法被回收
- 缓存设计不当:用强引用缓存大量对象且无淘汰策略,可用 WeakHashMap 或缓存框架替代
排查步骤
第一步:确认现象。执行 jstat -gcutil 进程号 1000 5 观察 GC:若 Full GC 频繁(每分钟超过 10 次)、老年代占用在 GC 后仍不下降,基本可判断存在内存泄漏。
第二步:获取堆内存快照 dump。通过 jmap 指定打印内存快照:jmap -dump:live,format=b,file=heap.hprof 进程号(Dump 文件是进程的内存镜像,可以把程序执行状态保存下来);或提前配置 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/,让 OOM 发生时自动生成 dump;也可用 Arthas 的 heapdump 命令在线抓取。
第三步:用 MAT / VisualVM 分析 dump 文件。把 dump 导入工具,按三步定位:① Leak Suspects 查看泄漏嫌疑对象与线程调用链;② Top Consumers 看哪个对象占内存大头(超过 50% 基本就是主因);③ Dominator Tree 支配树逐层展开,找到持有大对象的代码路径。
第四步:反查引用链,定位代码位置。对嫌疑对象用 Incoming References 查看"谁在持有它",结合线程栈上下文定位到具体代码行,再分析对象为什么无法被回收。
修复与预防
- 资源统一用 try-with-resources 或在 finally 中关闭
- ThreadLocal 在 finally 中调用 remove()
- 静态集合用完及时移除,或用 WeakHashMap、带淘汰策略的缓存
- 监听器与回调成对注册、注销
- 大查询加分页或流式读取,避免一次性加载海量数据
- 线上提前配置 HeapDumpOnOutOfMemoryError,OOM 时自动留证

生产环境服务CPU飙高遇到过吗,怎么解决
面试回答框架:先按「进程 → 线程 → 线程栈 → 代码」逐层定位问题线程,再用 jstat 判断是否 GC 引起,最后按原因分类给出处理与预防。
- top 定位 CPU 高的进程
- top -Hp 定位进程内 CPU 高的线程
- 把线程 ID 转成 16 进制
- jstack 抓线程栈,定位到具体代码行
排查步骤
第一步:使用 top 命令查看占用 CPU 的情况,确认是哪一个进程占用高。大概率是我们自己的 Java 服务;如果是其他进程(如 MySQL、Redis),则去对应组件排查。
第二步:定位到具体线程。执行 top -Hp 进程号 查看进程内线程级 CPU 占用,记下 CPU 最高的线程 ID(十进制)。
第三步:把线程 ID 转成 16 进制:printf "%x\n" 线程ID(例如 23645 转成 0x5c5d)。
第四步:抓取线程栈定位代码:执行 jstack 进程号 thread.txt,再 grep -A 20 "nid=0x5c5d" thread.txt 查看该线程的执行栈,重点看 RUNNABLE 状态的栈帧,定位到具体代码行。可连续抓取多次(间隔 1 秒),同一段栈多次出现基本就是问题点。
辅助判断:执行 jstat -gcutil 进程号 观察 GC 频率。若 Full GC 频繁,说明 CPU 高本质是内存问题引起(GC 线程吃满 CPU),应按内存泄漏思路排查。
更快的工具:Arthas 的 thread -n 3 可直接列出 CPU 占用最高的线程及其栈;复杂场景可用 async-profiler 生成火焰图分析热点。
常见原因分类
- 流量型:促销活动、爬虫、客户端异常重试、上游放量导致请求量突增,代码未变但 CPU 被打满
- 代码型:死循环 / 递归无出口、正则表达式灾难性回溯、大集合排序遍历、循环中频繁创建对象、Stream / parallelStream 滥用
- GC 型:频繁 Full GC 导致 GC 线程占满 CPU,根源往往是堆太小或内存泄漏
- 锁竞争:synchronized 争用严重、CAS 自旋过度
- 计算型:大对象 JSON 序列化 / 反序列化、加解密、压缩解压、日志量过大
应急处理与预防
- 先止血:CPU 告警时优先扩容、重启或对热点接口限流降级,恢复服务再排查
- 保留现场:先抓线程栈和堆 dump 再操作,避免丢失证据
- 监控先行:配置 CPU 使用率、线程数、GC 频率告警,提前发现问题
- 复盘沉淀:每次排查完记录现象、定位过程、根因与解决方案,形成自己的排查 checklist

