美团(一面)
1. Java异常有哪些?
Java的异常体系主要基于两大类:Throwable类及其子类。Throwable有两个重要的子类:Error和Exception,它们分别代表了不同类型的异常情况。
-
Error(错误):表示运行时环境的错误。错误是程序无法处理的严重问题,如系统崩溃、虚拟机错误、动态链接失败等。通常,程序不应该尝试捕获这类错误。例如,OutOfMemoryError、StackOverflowError等。
-
Exception(异常):表示程序本身可以处理的异常条件。异常分为两大类:
-
非运行时异常:这类异常在编译时期就必须被捕获或者声明抛出。它们通常是外部错误,如文件不存在(FileNotFoundException)、类未找到(ClassNotFoundException)等。非运行时异常强制程序员处理这些可能出现的问题,增强了程序的健壮性。
-
运行时异常:这类异常包括运行时异常(RuntimeException)和错误(Error)。运行时异常由程序错误导致,如空指针访问(NullPointerException)、数组越界(ArrayIndexOutOfBoundsException)等。运行时异常是不需要在编译时强制捕获或声明的。
-
2. MySQL索引类型你知道哪些?
MySQL可以按照四个角度来分类索引。
-
按「数据结构」分类:B+tree索引、Hash索引、Full-text索引。
-
按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)。
-
按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引。
-
按「字段个数」分类:单列索引、联合索引。
3. 聚集索引和非聚集索引区别是什么?
-
数据存储:在聚簇索引中,数据行按照索引键值的顺序存储,也就是说,索引的叶子节点包含了实际的数据行。这意味着索引结构本身就是数据的物理存储结构。非聚簇索引的叶子节点不包含完整的数据行,而是包含指向数据行的指针或主键值。数据行本身存储在聚簇索引中。
-
索引与数据关系:由于数据与索引紧密相连,当通过聚簇索引查找数据时,可以直接从索引中获得数据行,而不需要额外的步骤去查找数据所在的位置。当通过非聚簇索引查找数据时,首先在非聚簇索引中找到对应的主键值,然后通过这个主键值回溯到聚簇索引中查找实际的数据行,这个过程称为“回表”。
-
唯一性:聚簇索引通常是基于主键构建的,因此每个表只能有一个聚簇索引,因为数据只能有一种物理排序方式。一个表可以有多个非聚簇索引,因为它们不直接影响数据的物理存储位置。
-
效率:对于范围查询和排序查询,聚簇索引通常更有效率,因为它避免了额外的寻址开销。非聚簇索引在使用覆盖索引进行查询时效率更高,因为它不需要读取完整的数据行。但是需要进行回表的操作,使用非聚簇索引效率比较低,因为需要进行额外的回表操作。
4. 联合索引是什么?
将将多个字段组合成一个索引,该索引就被称为联合索引。
比如,将商品表中的 product_no 和 name 字段组合成联合索引(product_no, name),创建联合索引的方式如下:
CREATE INDEX index_product_no_name ON product(product_no, name);
联合索引(product_no, name) 的 B+Tree 示意图如下:
可以看到,联合索引的非叶子节点用两个字段的值作为 B+Tree 的 key 值。当在联合索引查询数据时,先按 product_no 字段比较,在 product_no 相同的情况下再按 name 字段比较。
也就是说,联合索引查询的 B+Tree 是先按 product_no 进行排序,然后再 product_no 相同的情况再按 name 字段排序。
因此,使用联合索引时,存在最左匹配原则,也就是按照最左优先的方式进行索引的匹配。在使用联合索引进行查询的时候,如果不遵循「最左匹配原则」,联合索引会失效,这样就无法利用到索引快速查询的特性了。
比如,如果创建了一个 (a, b, c) 联合索引,如果查询条件是以下这几种,就可以匹配上联合索引:
-
where a=1;
-
where a=1 and b=2 and c=3;
-
where a=1 and b=2;
需要注意的是,因为有查询优化器,所以 a 字段在 where 子句的顺序并不重要。
但是,如果查询条件是以下这几种,因为不符合最左匹配原则,所以就无法匹配上联合索引,联合索引就会失效:
-
where b=2;
-
where c=3;
-
where b=2 and c=3;
上面这些查询条件之所以会失效,是因为(a, b, c) 联合索引,是先按 a 排序,在 a 相同的情况再按 b 排序,在 b 相同的情况再按 c 排序。所以,b 和 c 是全局无序,局部相对有序的,这样在没有遵循最左匹配原则的情况下,是无法利用到索引的。
我这里举联合索引(a,b)的例子,该联合索引的 B+ Tree 如下:
可以看到,a 是全局有序的(1, 2, 2, 3, 4, 5, 6, 7 ,8),而 b 是全局是无序的(12,7,8,2,3,8,10,5,2)。因此,直接执行where b = 2这种查询条件没有办法利用联合索引的,利用索引的前提是索引里的 key 是有序的。
只有在 a 相同的情况才,b 才是有序的,比如 a 等于 2 的时候,b 的值为(7,8),这时就是有序的,这个有序状态是局部的,因此,执行where a = 2 and b = 7是 a 和 b 字段能用到联合索引的,也就是联合索引生效了。
5. hashmap是线程安全的吗?
hashmap不是线程安全的,hashmap在多线程会存在下面的问题:
-
JDK 1.7 HashMap 采用数组 + 链表的数据结构,多线程背景下,在数组扩容的时候,存在 Entry 链死循环和数据丢失问题。
-
JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。
如果要保证线程安全,可以通过这些方法来保证:
-
多线程环境可以使用Collections.synchronizedMap同步加锁的方式,还可以使用HashTable,但是同步的方式显然性能不达标,而ConurrentHashMap更适合高并发场景使用。
-
ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题。
6. currenthasmap是如何实现线程安全的?
JDK 1.7 ConcurrentHashMap
在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。
JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
JDK 1.8 ConcurrentHashMap
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:
JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:
-
如果为空则使用 volatile 加 CAS 来初始化
-
如果容器不为空,则根据存储的元素计算该位置是否为空。
-
如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;
-
如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。
而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。
7. 计网七层模型有哪些?分别的作用是什么?
为了使得多种设备能通过网络相互通信,和为了解决各种不同设备在网络互联中的兼容性问题,国际标准化组织制定了开放式系统互联通信参考模型(Open System Interconnection Reference Model),也就是 OSI 网络模型,该模型主要有 7 层,分别是应用层、表示层、会话层、传输层、网络层、数据链路层以及物理层。
每一层负责的职能都不同,如下:
-
应用层,负责给应用程序提供统一的接口;
-
表示层,负责把数据转换成兼容另一个系统能识别的格式;
-
会话层,负责建立、管理和终止表示层实体之间的通信会话;
-
传输层,负责端到端的数据传输;
-
网络层,负责数据的路由、转发、分片;
-
数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址;
-
物理层,负责在物理网络中传输数据帧;
由于 OSI 模型实在太复杂,提出的也只是概念理论上的分层,并没有提供具体的实现方案。
事实上,我们比较常见,也比较实用的是四层模型,即 TCP/IP 网络模型,Linux 系统正是按照这套网络模型来实现网络协议栈的。
TCP/IP协议被组织成四个概念层,其中有三层对应于ISO参考模型中的相应层。ICP/IP协议族并不包含物理层和数据链路层,因此它不能独立完成整个计算机网络系统的功能,必须与许多其他的协议协同工作。TCP/IP 网络通常是由上到下分成 4 层,分别是应用层,传输层,网络层和网络接口层。
-
应用层 支持 HTTP、SMTP 等最终用户进程
-
传输层 处理主机到主机的通信(TCP、UDP)
-
网络层 寻址和路由数据包(IP 协议)
-
链路层 通过网络的物理电线、电缆或无线信道移动比特
8. TCP和UDP区别是什么?
-
连接:TCP 是面向连接的传输层协议,传输数据前先要建立连接;UDP 是不需要连接,即刻传输数据。
-
服务对象:TCP 是一对一的两点服务,即一条连接只有两个端点。UDP 支持一对一、一对多、多对多的交互通信
-
可靠性:TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。UDP 是尽最大努力交付,不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议,比如 QUIC 协议
-
拥塞控制、流量控制:TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
-
首部开销:TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
-
传输方式:TCP 是流式传输,没有边界,但保证顺序和可靠。UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。
9. 对称加密和非对称加密区别是什么?
对称加密用同一密钥进行加密和解密,而非对称加密则使用一对关联的密钥(公钥和私钥)分别处理
-
对称加密:加密和解密使用相同的密钥,就像用同一把钥匙锁门和开门。发送方用密钥加密数据,接收方必须使用一模一样的密钥才能解密。常见算法有 DES、AES 等。
-
非对称加密:使用一对密钥(公钥和私钥)。公钥可以公开传递,用于加密数据;私钥由用户秘密保存,用于解密用公钥加密的数据。例如,RSA、ECC 算法都属于非对称加密。
对称加密的算法逻辑简单(如 AES 的轮加密),而非对称加密依赖大质数分解、椭圆曲线等复杂数学问题,运算步骤更多,因此效率较低。例如,AES 的加密速度通常比 RSA 快 1000 倍以上。
在实际应用过程中,两者会混合使用,比如用 RSA 交换 AES 密钥,再用 AES 加密实际数据,这正是 HTTPS 的典型加密逻辑。
10. 算法:逆序输出Z型遍历二叉树
要实现逆序输出 Z 型遍历二叉树,我们需要先理解什么是 Z 型遍历(也称为之字形遍历):即二叉树的节点按照层次遍历,但奇数层从左到右,偶数层从右到左(或相反),然后将结果逆序输出。
下面是实现这一功能的 Java 代码,使用了队列来实现层次遍历,并通过标志位控制每层的遍历方向:
import java.util.*;
// 二叉树节点定义
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) {
val = x;
left = null;
right = null;
}
}
publicclass ZigzagReverseTraversal {
public static void main(String[] args) {
// 构建一个示例二叉树
TreeNode root = new TreeNode(3);
root.left = new TreeNode(9);
root.right = new TreeNode(20);
root.right.left = new TreeNode(15);
root.right.right = new TreeNode(7);
// 执行逆序Z型遍历
List<List<Integer>> result = zigzagLevelOrderReverse(root);
// 输出结果
System.out.println("逆序Z型遍历结果:");
for (List<Integer> level : result) {
System.out.println(level);
}
}
/**
* 逆序输出Z型遍历二叉树
* @param root 二叉树根节点
* @return 逆序Z型遍历结果
*/
publicstatic List<List<Integer>> zigzagLevelOrderReverse(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
// 用于层次遍历的队列
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
// 控制当前层的遍历方向,true表示从左到右
boolean leftToRight = true;
// 进行层次遍历
while (!queue.isEmpty()) {
int levelSize = queue.size();
List<Integer> currentLevel = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
currentLevel.add(node.val);
// 将下一层的节点加入队列
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
// 如果当前层需要从右到左,则反转当前层的元素
if (!leftToRight) {
Collections.reverse(currentLevel);
}
// 将当前层的结果加入总结果
result.add(currentLevel);
// 切换下一层的遍历方向
leftToRight = !leftToRight;
}
// 逆序输出整个结果
Collections.reverse(result);
return result;
}
}
代码解析:
-
首先定义了二叉树节点类
TreeNode
,包含节点值和左右子节点引用 -
核心方法
zigzagLevelOrderReverse
的实现思路:
-
使用队列实现二叉树的层次遍历
-
用
leftToRight
标志控制每层的遍历方向 -
对于需要从右到左遍历的层,收集完节点后进行反转
-
所有层遍历完成后,对整个结果列表进行逆序操作
-
最终返回逆序后的 Z 型遍历结果
示例中构建的二叉树结构如下:
3
/ \
9 20
/ \
15 7
-
正常 Z 型遍历结果为:[[3], [20, 9], [15, 7]]
-
逆序 Z 型遍历结果为:[[15, 7], [20, 9], [3]]
这种实现方式的时间复杂度为 O (n),其中 n 是二叉树的节点数,空间复杂度也是 O (n),主要用于存储队列和结果列表。