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清空避免内存泄漏


==和equals的区别
区别1. ==是一个运算符 equals是Object类的方法
区别2. 比较时的区别
用于基本类型的变量比较时: ==用于比较值是否相等,equals不能直接用于基本数据类型的比较,需要转换为其对应的包装类型。
用于引用类型的比较时。==和equals都是比较栈内存中的地址是否相等 。相等为true 否则为false。但是通常会重写equals方法去实现对象内容的比较。
== 对于基本类型和引用类型的作用效果是不同的:
- 对于基本数据类型来说,== 比较的是值。
- 对于引用数据类型来说,== 比较的是对象的内存地址。
equals()不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。
equals() 方法存在两种使用情况:
- 类没有重写 equals()方法 :通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。
- 类重写了 equals()方法 :一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
什么是多态
多态,父类的引用指向子类的实例,通过父类的引用访问子类的方法所表现的不同效果。
实现方法:在父类有一个抽象方法,每个子类实现了父类的抽象方法,通过父类的引用访问子类的方法。
注意点:
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定父类的引用指向是哪一个子类对象;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
接口和抽象类有什么共同点和区别?
共同点 :
- 都不能被实例化。
- 都可以包含抽象方法。
- 都可以有默认实现的方法(Java 8 接口可以用 default 关键字在接口中定义默认方法)。
区别 :
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个类,但是可以实现多个接口。
- 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。
为什么重写 equals() 时必须重写 hashCode() 方法?
因为如果只重写equals方法,不重写hashCode方法就有可能导致a.equals(b)成立,但是两个对象的hashCode可能不同,向散列表HashMap存储时就会出现两个相同的对象存在不同的位置。
总结 :
- equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
- 两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。
因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。
思考 :重写 equals() 时没有重写 hashCode() 方法的话,使用 HashMap 可能会出现什么问题。
String、StringBuffer、StringBuilder 的区别?
1、String字符串是不可变的,每次凭借都会创建新的对象,性能较差;
2、StringBuffer是线程安全的,StringBuilder是线程不安全的
场景:
操作少量的数据: 通常使用 String
单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer
String常用api
charAt(int index):返回指定索引处的字符。
length():返回字符串的长度。
substring(int beginIndex):返回从指定索引开始到字符串末尾的子字符串。
substring(int beginIndex, int endIndex):返回从指定开始索引到结束索引之间的子字符串。
indexOf(String str):返回指定子字符串在字符串中第一次出现的索引。
lastIndexOf(String str):返回指定子字符串在字符串中最后一次出现的索引。
startsWith(String prefix):检查字符串是否以指定的前缀开头。
endsWith(String suffix):检查字符串是否以指定的后缀结尾。
isEmpty():检查字符串是否为空。
toLowerCase():将字符串中的所有字符转换为小写。
toUpperCase():将字符串中的所有字符转换为大写。
trim():去除字符串首尾的空格。
replace(char oldChar, char newChar):替换字符串中的字符。
replaceAll(String regex, String replacement):使用给定的替换字符串替换字符串中所有匹配给定正则表达式的子字符串。
split(String regex):使用给定的正则表达式将字符串拆分为子字符串数组。
equalsIgnoreCase(String anotherString):比较字符串与另一个字符串,忽略大小写。
contains(CharSequence s):检查字符串是否包含指定的字符序列。
Java集合框架
Java 容器分为 Collection 和 Map 两大类,Collection集合的子接口有Set、List、Queue三种子接口。我们比较常用的是Set、List,Map接口不是collection的子接口。
Collection集合主要有List和Set两大接口
- List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
- Set:不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
Map是一个键值对集合,存储键、值和之间的映射。 Key无序,唯一;value 不要求有序,允许重复。Map没有继承于Collection接口,从Map集合中检索元素时,只要给出键对象,就会返回对应的值对象。
- Map 的常用实现类:HashMap不是线程安全、HashTable线程安全、这个不是分段锁
LinkedHashMap、ConcurrentHashMap线程安全,这个是分段锁
Iterator接口:迭代器,可以通过迭代器遍历集合中的数据
Java中的所有集合类都实现了Iterator接口。
Java常用集合:
ArrayList:动态数组,可以根据需要自动增长容量。
LinkedList:双向链表,适合频繁的插入、删除操作。
HashSet:基于哈希表的无序集合,不允许重复元素。
TreeSet:基于红黑树的有序集合,元素按照自然顺序或者指定的比较器顺序进行排序。
HashMap:基于哈希表的无序键值对集合,其中键和值都可以为 null。
TreeMap:基于红黑树的有序键值对集合,按照键的自然顺序或者指定的比较器顺序进行排序。
LinkedHashMap:基于哈希表和链表的有序键值对集合,迭代顺序与插入顺序或者访问顺序一致。
LinkedHashSet:基于哈希表和链表的有序集合,保持插入顺序。
Stack:基于数组的后进先出(LIFO)栈。
Queue:队列接口,常见实现包括 LinkedList 和 ArrayDeque。
PriorityQueue:优先队列,基于堆实现的无界优先级队列。
HashMap 和 TreeMap 中的键和值都允许为 null,而 Hashtable 不允许。
ArrayList 和 LinkedList 的区别是什么?
- 数据结构实现:ArrayList 是动态数组的数据结构实现;而 LinkedList 是双向链表的数据结构实现。
- 通过下标随机访问效率:ArrayList 比 LinkedList 在通过下标访问的时候效率要高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。
- 增加和删除效率:LinkedList 头部插入删除性能高于ArrayList
- 内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
- 线程安全:ArrayList 和 LinkedList 都是不安全的,也就是不保证线程安全;
- 综合来说,在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推荐使用 LinkedList。
- LinkedList 的双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。
HashMap、HashTable、ConcurrentHashMap的区别
HashMap和Hashtable的区别。
HashMap线程不安全的,Hashtable是线程安全的(方法上使用了synchronized关键字)
HashMap允许键值对都可以为null, 而Hashtable不允许键值对为null
HashMap由数组+链表/红黑树,而Hashtable依然使用数组+链表
Hashtable与ConcurrentHashMap的区别。
Hashtable在进行元素操作的时候,是锁住整个table,而jdk1.7中的ConcurrentHashMap是采用分段锁,锁住某个段,而jdk1.8之后去除了“分段锁”,而是改成了局部同步代码块,且是锁住的是某个Node节点,这样其他槽里的节点依然可以并发进行操作。
Hashtable和ConcurrentHashMap一样不允许键值对为null
HashMap的工作原理
JDK1.8 之前 HashMap 底层是 数组和链表。
JDK1.8 之后: 数组+链表+红黑树
当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法进行扩容。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会 执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。
为什么要用红黑树?
链表太长影响查询性能,使用红黑树提高查询性能。
扩容规则?
初始数组为16,当链表长度达到8进行扩容,扩容后数组的大小是原来的2倍。
什么时候转红黑树?
链表长度大于等于8,数组长度超过64.
正常情况链表不会超过8,红黑树是一种异常情况。
选择链表长度为8进行树化也是为让将树化的概率降低到足够小。
为什么是加载因子是0.75?
当哈希表中的元素数量达到容量的 75% 时,触发扩容。
在空间占用与查询时间之间取得较好的权衡
大于这个值,空间节省了,但链表就会比较长影响性能
小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
put流程:
首先,根据键的 hashCode() 方法计算键的哈希值,这个哈希值用来确定键值对在哈希表中的存储位置。
接着,根据哈希值确定键值对在数组中的索引位置。在 Java 8 中,HashMap 采用数组+链表/红黑树的方式实现哈希表,具体决定是链表还是红黑树的依据是链表的长度是否超过阈值,如果超过则转换为红黑树。
如果该位置没有元素,直接插入键值对。
如果该位置已经有元素,发生了哈希冲突(即不同的键通过哈希计算得到的索引位置相同),此时会使用链表或者红黑树等数据结构将键值对连接到同一个位置。如果已经使用红黑树,直接插入红黑树中。
如果插入后,链表的长度超过了阈值(默认为 8),则将链表转换为红黑树,以提高查询效率。
如果插入操作导致哈希表的元素数量超过了负载因子(默认为 0.75),则会进行扩容操作,将哈希表的容量扩大为原来的两倍,并重新计算每个元素的存储位置。
哪些场景需要hashmap
哪些场景需要hasmap
快速查找:
需要快速查找键值对的场景。例如,根据键查找对应的值。
唯一性检查:
如果需要在集合中保持唯一的键,可以使用 HashMap 来实现,因为 HashMap 中的键是唯一的。
存储关联关系:
HashMap 用于存储一对一的关联关系,例如存储用户ID和对应的用户名,或者存储商品ID和对应的商品信息。
缓存实现:
HashMap 可以用作简单的缓存实现,其中键是缓存的键,值是缓存的内容。这可以用于加速对常用数据的访问,避免重复计算或者从慢速存储中读取。
数据分组:
当需要将数据按照某个属性进行分组时,可以使用 HashMap。例如,将一堆对象按照它们的类型分组。
synchronized与lock(接口)的区别
- Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
- synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
- Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
synchronized具有可重入性,不可中断性
不可中断是指,当一个线程获得锁后,另一个线程一直处于阻塞或等待状态,前一个线程不释放锁,后一个线程会一直阻塞或等待,不可被中断。
Reentrantlock 也具有可重入性,它可以中断
- 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
- Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
- 性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态
讲一下synchronized锁优化
先偏向锁: 就是锁偏向某个线程,引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径
再轻量级锁:
最后重量级锁:
锁的升级过程主要涉及到偏向锁、轻量级锁和重量级锁三个阶段:
偏向锁(Biased Locking): 当一个线程访问一个同步块并获取锁时,对象头中的标记位会被设置为指向该线程的线程ID。这样,当同一个线程再次访问同步块时,无需进行任何同步操作,因为它已经拥有了锁。这种情况下,锁是偏向于第一个获取它的线程的。
轻量级锁(Lightweight Locking): 当有多个线程竞争同一个锁时,偏向锁会升级为轻量级锁。轻量级锁使用了CAS(Compare And Swap)操作来尝试获取锁。如果获取锁的操作成功,线程会将对象头中的标记位修改为指向锁记录(Lock Record),这个记录包含了指向锁拥有者的指针。如果获取锁的操作失败,说明有其他线程在竞争锁,此时轻量级锁会膨胀为重量级锁。
重量级锁(Heavyweight Locking): 当轻量级锁无法满足锁的需求时,锁会膨胀为重量级锁。重量级锁会使用操作系统的互斥量来实现同步,进入阻塞状态。这种情况下,除了持有锁的线程外,其他线程均被阻塞,直到锁被释放。
Java的锁升级过程是逐步的,从偏向锁到轻量级锁,再到重量级锁。这种设计的目的是为了在大部分情况下能够以最低的开销进行同步操作,只有在高并发的情况下才会采用更加消耗资源的重量级锁。
锁的升级是不可逆的。一旦锁升级到了重量级锁,就不会再回到轻量级锁或偏向锁状态了。
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提供的一些工具jstack等
命令工具
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

