不能安于现状太久,要安排时间让自己升升级,这样机会来了才能把握住,或者即使没把握住,能有面试的机会也是不错的。同样地,先在脑子里思考一下问题,答不上的再去看后面的解答。
问答题
(1)HashMap 是线程不安全的,那么两个不同的线程设置同一个 key 时会有冲突吗?
(2)HashMap 是怎么扩容的?扩容后原数据是怎么转移的?
(3)泛型是怎么实现的?
(4)介绍一下 Java 的内存模型。
(5)反射在虚拟机中是怎么实现的?
(6)虚拟内存有什么好处?是怎么实现的?
(7)介绍一下 select 和 epoll 的工作机制。
(8)Mysql 用的什么数据库引擎?聚集索引和非聚集索引的区别是什么?
(9)设计一个类似 TCP/IP 的硬件设备与服务端的交互协议。
(10)解决跨域有哪些方法?
(11)RESTful API 的设计规范有哪些?
编程题
(12)3 * 7 的表格,从左上走到右下,每次只能往右或者往下走一格,有几种不同的走法?写程序实现,要求最终能对任意大小的表格进行统计。
参考答案
(1)会有冲突的风险。因为 HashMap 的 put() 方法是非原子性的操作,它涉及多个步骤,包括计算 key 的哈希值、找到在数组中的位置、处理哈希冲突等,且没有同步机制,当多个线程对同一个 key 设置时,常见的问题是后设置的值会覆盖先设置的值,导致数据丢失。如果是在 JDK 1.7 中,多个线程同时 put 并都触发扩容时,扩容同样是一个非原子性的操作,那么新的链表可能出现环形结构,这会导致在 get 时陷入死循环,进而导致 CPU 利用率接近 100%。
在 JDK 1.8 及以后版本中,红黑树的使用大大降低了死循环出现的概率,但并非为 0,所以在多线程并发时,建议使用 ConcurrentHashMap 或 Hashtable 来保证线程安全,且 ConcurrentHashMap 的性能更高。因为 Hashtable 是对整个哈希表加锁,意味着在任一时刻只有一个线程可以访问 Hashtable,这在高并发环境下会发生锁的争用,导致性能显著下降,只适用于并发级别较低或需要兼容旧代码的场景。而 ConcurrentHashMap 采用了粒度更细的锁机制,它将整个哈希表分为多个段,每个段独立加锁,允许多个线程同时操作不同的段,从而避免了锁竞争,后又进一步引入了无锁的 CAS,这使得在低冲突的情况下可以完全无锁地执行操作,进一步提升了性能。
HashMap 的工作原理:
HashMap 是基于哈希表实现的数据结构,它结合了数组和链表(在 JDK 1.8 及以后版本中,还结合了红黑树,用这种结构的原因当然是为了利用它们各自的优点,使得查询、插入和删除的效率都很高)。HashMap 的主干是一个 Node 数组,Node 是 Entry 接口的实现类,每个 Node 对象包含一个 key-value 键值对和指向下一个节点的 next 引用。当要存储一个键值对时,首先会计算 key 的哈希值,然后通过这个哈希值确定该键值对在数组中的位置,如果两个不同的 key 计算出的哈希值相同(即发生了哈希冲突),HashMap 会采用链表存储(当链表长度超过阈值时,默认是 8,转为红黑树)的方式来解决。
接下来需要判断要存储的键值对是否已经存在。先判断 key 是否存在(通过先比较 key 的哈希值,再用 equals() 比较 key 的原始值的方式),如果 key 不存在,则调用 addEntry() 方法添加键值对,如果 key 已存在,则覆盖之前的 value。然后 Map.Entry 的哈希值就是存放在其中的 key 的哈希值,遍历 HashMap 效率最高的方式是对 entrySet().iterator() 进行遍历,使用 iterator.remove() 可以安全地删除元素。
(2)HashMap 中数组的长度是固定的,当存放的元素越来越多的时候,哈希冲突的几率也越来越高,所以当 HashMap 中的元素数量超过阈值时(当前容量与负载因子的乘积),就会进行扩容。默认情况下,HashMap 的负载因子为 0.75,这意味着当 HashMap 中的元素数量达到其容量的 75% 时,就会触发扩容操作,新容量通常是旧容量的两倍(比如 16 * 2 = 32),但不会超过 HashMap 允许的最大容量 2^30(数组扩容这个操作在 ArrayList 中也有)。
扩容的过程也是非原子性的,在多线程并发时会有问题。它包括创建新数组、重新计算所有 key 的哈希值、将原数组中的元素迁移到新数组中、设置新阈值等步骤,如果原数组中存在链表或红黑树结构的哈希冲突,这些结构在扩容过程中也会被拆分并重新分配到新数组上。扩容是一个昂贵的操作,很消耗性能,因为它涉及到大量的内存分配和元素重新映射,因此,为了避免频繁的扩容,可以通过预估 HashMap 要存放的元素个数,然后设置合适的初始容量,或者设置合理的负载因子来提高性能。
(3)泛型是通过类型擦除来实现的,这是一种在编译期将泛型类型替换为原始类型(通常是 Object)的机制,使得泛型代码在不支持泛型的 JVM 上也可以运行。
所谓泛型,就是指宽泛的数据类型、任意的数据类型,它是 JDK 1.5 的新特性,其本质是将数据类型参数化(用尖括号标识),可以用在类、接口和方法的创建中。在 Java 中,泛型信息只存在于编译阶段,当编译器遇到泛型代码时,它会进行类型检查,确保类型参数的使用是合法的,然后,编译器会将泛型代码中的类型参数替换为它们的原始类型(通常是 Object),并插入必要的类型转换和检查代码。因此,在运行期,JVM 中并没有泛型类型的信息,也称之为伪泛型。
泛型的好处是使用简单,可以提高代码的安全性、复用性和可读性。其局限性是不能在运行时检查到泛型的信息,比如无法用 instanceof 判断一个对象是否是一个 List<Integer> 的实例;不能创建泛型数组;也不能使用基本类型作为泛型参数(如 int、char),而只能使用它们的包装类型(如 Integer、Character)。此外,由于类型擦除,反射机制可以绕过编译期的类型检查,从而执行不安全的操作。
(4)JMM 是在多线程环境下变量访问和内存交互的规范,旨在解决多线程并发中的可见性、原子性和有序性问题。首先它在逻辑上分为主内存和工作内存,这与 JVM 的内存结构(堆、栈等)无直接对应关系,主内存大致对应堆中的对象实例部分,而工作内存可能对应栈、CPU 缓存、寄存器:
主内存:所有线程共享,存储所有共享变量,包括实例字段、静态字段、数组元素等;
工作内存:线程私有,保存了该线程使用到的变量的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存中的变量。
(A)可见性:一个线程修改共享变量后,其他线程能够立即看到这个修改。可见性出现问题的原因是,变量的副本未及时同步到主内存,解决方案为通过 volatile(写操作立即刷新到主内存,读操作从主内存读取)、synchronized 关键字或 Lock 类来保证可见性(释放锁前,将工作内存变量同步到主内存;获取锁时,从主内存重新加载变量)。
(B)原子性:一个操作不可中断。原子性出现问题的原因是,一个操作包含了多个子步骤,比如自增操作就包含读取、修改、写入三步,解决方案为通过 synchronized 关键字或 Lock、AtomicInteger 等类来保证原子性。
(C)有序性:程序执行顺序要符合代码顺序。有序性出现问题的原因是,现代计算机为了优化性能,编译器和处理器可能对指令进行重排序,解决方案为通过 volatile(通过插入内存屏障)、synchronized 关键字(限制了单线程执行)或 Happens-Before 原则来保证有序性。内存屏障能保证有序性和可见性,它是 Java 编译器在生成指令序列时,在适当位置插入的一种特殊指令,能够阻止重排序。
Happens-Before 原则定义了两个操作间的有序性和可见性,主要包括:
程序顺序规则:同一线程内的操作按代码顺序执行;
锁规则:解锁操作先于后续的加锁操作;
volatile 规则:volatile 变量的写操作先于后续的读操作;
传递性规则:若 A 先于 B,且 B 先于 C,则 A 先于 C;
线程启动规则:Thread.start() 先于线程内的任何操作;
线程终止规则:线程的所有操作先于其他线程检测到它已经终止。
(5)反射是通过在 JVM 中加载 .class 文件,然后通过这些 .class 文件反编译出对应的 Java 代码实现的,这个过程是在程序运行时动态进行的,因此可以实现动态加载和操作类。每个类都有一个与之对应的 Class 对象,这个 Class 对象包含了该类的所有信息,包括它的属性和方法,因此 JVM 可以通过这个 Class 对象来获取该类的所有信息,并可以动态地创建对象、调用方法、获取和设置属性等,而不需要提前在编译期就知道运行的对象是谁。
反射机制的好处是能提高应用的扩展性,有时也能简化代码。坏处是可能会破坏类的封装性,因为反射允许访问私有成员,这会使类变得不那么安全;另外它有一定的性能开销,在性能敏感的场景中应尽量避免使用。
(6)虚拟内存的好处:
(A)提高系统性能:当物理内存不足时,操作系统可以自动将部分数据暂时存储到硬盘上的虚拟内存区域,以释放物理内存空间供其他应用程序使用,从而提高系统响应速度和稳定性;
(B)扩大内存空间:在不增加物理内存的情况下扩大内存空间,使计算机能够处理更多的数据;
(C)提高数据安全性:可以将敏感数据暂时存储到硬盘上,而不是直接暴露在物理内存中,可防止数据泄露。
它的实现方式包括请求分页存储管理(Page)、请求分段存储管理(Segment)、请求段页式存储管理等,实现过程都涉及内存页面或者段的划分、页表或段表的建立、请求调页或调段、页面或分段置换算法、内存和硬盘之间的数据交换等步骤(在程序开始运行之前,仅装入当前要执行的部分页面或者部分段即可运行,若在运行过程中发现要访问的页面或者段不在内存,则将所需页面或者段从硬盘的虚拟内存区域调入物理内存,若物理内存不足,则操作系统根据置换算法(如 FIFO、LRU 等)选择一个页面或者一个段将其置换出去)。
虚拟内存是一种重要的计算机系统内存管理技术,它允许操作系统使用硬盘空间来模拟额外的 RAM(随机访问存储器),通俗的说,就是当物理内存不够用时,为避免系统故障,系统就分出一部分硬盘空间来当内存使用,存放暂时还没有被访问到的代码和数据,一般设置为物理内存的 1 至 1.5 倍最佳(根据代码的局部性原理,被高频访问的代码往往只有一小部分,所以可以把暂时不运行的代码放到硬盘的虚拟内存区域,用的时候再分块加载到物理内存,反之,如果物理内存已满,则把物理内存中已经运行完的代码写回硬盘的虚拟内存区域,以便腾出空间)。它的缺点就是会占用一定的物理硬盘空间,加大对硬盘的读写,如果设置不当会影响整机稳定。
(7)select 和 epoll 都是 Linux 下实现 I/O 多路复用的机制,也就是说能用单个线程同时监视多个文件描述符的 I/O 事件。
select 的工作过程:应用程序调用 select 函数时,会将文件描述符(fd)和超时时间传给它,然后 select 会将接收到的这些文件描述符都阻塞在当前线程,并将它们作为一个 fd_set 集合传给内核,内核再以线性扫描的方式检查这些文件描述符是否就绪(即是否有数据可读、可写或发生异常)或者超时,如果有任何文件描述符就绪或超时,select 就返回相应的文件描述符,应用程序就可以根据返回的结果无阻塞地进行读取、写入或其他操作了。
epoll 的工作过程:与 select 不同的是,epoll 会在内核空间中维护一个事件表(用 epoll_create 创建,用 epoll_ctl 操作),然后通过这个事件表来跟踪每个文件描述符的状态,而不是每次都线性检查所有的文件描述符。当调用 epoll_wait 函数时,它同样会将所有的 fd 阻塞在当前线程,直到事件表跟踪到某个文件描述符上有事件发生或超时,epoll 就返回给应用程序去处理,这种事件驱动的方式可以确保应用程序在等待时只占用较少的资源。
(8)Mysql 默认用的是 InnoDB。数据库引擎是用于存储、处理和保护数据的核心服务,当我们访问数据库时,不管是手动访问,还是程序访问,都不是直接读写数据库文件,而是通过数据库引擎访问数据库文件。主流的有:
(A)InnoDB:基于聚集索引,支持事务 ACID 特性,使用行级锁,支持外键约束,适合需要高可靠性和高并发处理的场景,如售票系统,缺点是占用的数据库空间较大。
(B)MyISAM:基于非聚集索引,使用表级锁,适合读多写少的场景,如数据仓库,缺点是不支持事务。
(C)Memory(也称为 Heap):将数据存储在内存中,读写速度非常快,适合需要高性能但不需要持久化的场景,如会话管理或缓存,缺点是服务器重启时数据会丢失。
聚集索引和非聚集索引的区别(两者的索引结构都是使用 B+ 树存储):
(A)聚集索引:将数据与索引放到一起,找到索引也就找到了数据,索引结构的叶子节点存储的是索引键值和完整的行数据(表中的数据行按照聚集索引的顺序进行存储),因此,一个表只能有一个聚集索引,通常为主键。
(B)非聚集索引:将数据与索引分开存储,索引结构的叶子节点存储的是索引键值和指向数据行的地址,因此,一个表可以有多个非聚集索引,这个过程需要两次查找,因此查询速度较慢,适用于频繁查询但不经常修改的列。
这里再介绍一下索引,数据库的索引就像书的目录,如果书没有目录,那么找某一章的时候就只能从第一页开始,一页一页翻,而有了目录后,我们就可以先翻到目录,然后根据页码快速找到想看的章节,这里的「目录」就是数据库中的索引,它通过某种规律(比如按字母排序)帮我们快速定位数据的位置,避免逐行查找(还比如图书馆的书籍指引)。索引的代价是需要占用额外空间,且新增或者修改数据时要维护索引(类似图书馆新进书后要更新目录)。一句话总结:用空间换时间——牺牲一些存储空间和维护成本,换来查询速度的飞跃提升。
索引的分类:
(A)若按数据结构分,索引分为 B+ 树索引、哈希索引、全文索引、空间索引;
(B)若按功能分,索引分为主键索引、唯一索引、普通索引、组合索引、覆盖索引;
(C)若按物理存储分,索引分为聚集索引、非聚集索引。
主键索引:唯一且非空,标识行数据。
唯一索引:唯一但允许 NULL,确保字段唯一性。
组合索引:优化多条件查询,遵循最左前缀原则(指查询条件必须从索引的最左列开始,且不能跳过中间的列,才能有效命中索引,其本质是由 B+ 树索引的存储结构决定的)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26-- 先定义一个组合索引 (col1, col2, col3)
CREATE INDEX idx_col1_col2_col3 ON table_name(col1, col2, col3);
-- 能命中索引的查询
WHERE col1 = 1 -- 命中,使用了索引的最左列 col1
WHERE col1 = 1 AND col2 = 2 -- 命中,连续使用 col1 和 col2
WHERE col1 = 1 AND col2 = 2 AND col3 = 3 -- 命中,完整使用所有索引列
WHERE col1 = 1 AND col3 = 3 -- 部分命中,仅 col1 走索引,col3 无法用索引(因为中间跳过了 col2)
-- 不能命中索引的查询
WHERE col2 = 2 -- 无法利用索引,因为未使用最左列 col1
WHERE col2 = 2 AND col3 = 3 -- 无法利用索引,因为未使用最左列 col1
WHERE col3 = 3 -- 无法利用索引,因为未使用最左列 col1
-- 特殊情况:范围查询后的列失效
WHERE col1 = 1 AND col2 > 10 AND col3 = 3;
-- 命中部分:col1 和 col2 走索引(存在范围查询 col2 > 10)
-- 失效部分:col3 无法利用索引(在范围查询后的列不参与索引匹配)
-- 优化建议:
-- 1.将等值查询列放在范围查询列的左侧(同时也要调整索引的顺序为 (col1, col3, col2))。
WHERE col1 = 1 AND col3 = 3 AND col2 > 10;
-- 2.查询条件的顺序不影响索引的使用,优化器会自动调整顺序,只要包含最左列即可。
WHERE col2 = 2 AND col1 = 1; -- 实际还是按 col1 优先匹配索引
-- 3.避免冗余索引:若已有索引 (col1, col2),则无需单独创建 col1 的索引。
-- 4.优先将区分度高(唯一性高)且频繁作为查询条件的列放在最左侧。其原理是组合索引会按 B+ 树的分层存储顺序,先按 col1 排序,col1 相同再按 col2 排序,依次类推,查询时像查字典一样,从根节点一层层缩小范围,最终直达目标数据的位置,这样可以大幅减少磁盘 I/O 次数。所以如果未指定 col1,则无法确定 col2 和 col3 的有序区间,只能全表扫描。
覆盖索引:是指查询的字段全部在某个索引中,使得数据库引擎无需访问数据行(即无需回表),仅通过索引即可获取所需数据,这种索引直接「覆盖」了查询需求,减少了磁盘 I/O,可显著提升查询性能。
1
2
3-- 索引 (col1, col2, col3)
SELECT col1, col2 FROM table WHERE col1 = 1; -- 索引已经包含了所有需要的字段,无需回表
SELECT * FROM table WHERE col1 = 1; -- 查询所有字段,大多数情况下必须回表
(9)以下是一个基本的设计思路:
(A)消息头:
协议版本:标识通信双方所使用的协议版本;
消息类型:标识消息的类型,如请求、响应、心跳等;
消息长度:整个消息的长度,包括消息头和消息体;
校验和:防止消息被篡改或损坏,并非加密技术,一般是对整个消息进行一些算法处理后得到,比如 IP/TCP/UDP 生成校验和的规则为:基于反码求和,接收方用相同规则生成后与发送方提供的对比,若不一致,则消息可能已损坏;
时间戳:记录时间。
(B)消息体:
根据消息类型,消息体包含不同的字段,例如:
请求消息:设备 ID、请求的操作码、参数等;
响应消息:操作结果、状态码、数据等。
(C)消息尾(可选):
包含一些额外信息,如签名。
(10)严格来说,只要协议、域名、端口中有任何一个不同,就被认为是跨域。解决 CORS 问题(即跨源资源共享)通常涉及配置服务器以允许来自不同源的请求,以下是几种常见方法:
(A)通过 @Configuration 注解配置一个类,再让这个类实现 WebMvcConfigurer 接口来全局设置 CORS 策略;
(B)创建一个 Servlet Filter 来处理 CORS,再将这个 Filter 配置到 web.xml 中;
(C)对于特定的控制器或方法,可以使用 @CrossOrigin 注解来设置 CORS 策略;
(D)通过 Nginx 等代理服务器配置。
注意,允许来自任意源的请求可能会带来安全风险,务必限制允许的源和方法。
(11)RESTful 是目前最流行的 API 设计规范,包括:
(A)使用 HTTP 动词来表达操作,比如 GET、POST、PUT、DELETE 等;
- GET:侧重从服务器获取资源,请求参数放在 URL 后,用户可见,不安全,参数长度有限制,传输的数据量小;
- POST:侧重向服务器发送数据,请求参数放在实体中,用户不可见,安全,参数长度无限制,传输的数据量大。
(B)使用名词来表示资源,是 HTTP 动词作用的对象,通常使用复数形式;
(C)使用 URI 来定位资源,确保每个资源都有一个唯一的标识符;
(D)使用查询参数来过滤和分页;
(E)使用标准的 HTTP 状态码来表示请求结果,比如 200、400、404、500;
- 302:临时重定向(重定向:要完成请求必须进行更进一步的操作);
- 400:客户端的请求有语法错误;
- 404:服务器无法找到对应资源;
- 500:服务器内部错误。
(F)使用 JSON 来表示数据,便于客户端进行数据解析和处理;
(G)具有无状态性,每次请求都包含所有必要的信息,比如 Token;
(H)使用版本号来管理 API 的不同版本,比如 GET /v1/users?limit=10。
(12)当场的解题思路:直接写不出来,那就从最简单的 2 * 2 表格开始,一共有多少种走法可以直接数出来,然后逐渐扩大表格,慢慢找出规律,如下:
表格 | 走法总数 | 拆分(加 2 指的是先一直往右再一直往下和先一直往下再一直往右两种走法) |
---|---|---|
2 * 2 | 2 | 0 + 2 |
2 * 3 | 3 | 1 + 2 |
…… | …… | …… |
2 * 7 | 7 | 5 + 2 |
3 * 2 | 3 | 1 + 2 |
3 * 3 | 6 | 4 + 2 |
3 * 4 | 10 | 8 + 2 |
…… | …… | …… |
3 * 7 | 28 | 26 + 2 |
可惜当时并没有找到适用于任意 m * n 表格的规律(m 是行数,n 是列数),另外我还有一个对题目理解有误的地方是,我统计的是从左上角格子到右下角格子的走法,所以才从最小的 2 * 2 表格开始,但出题人的意思是从表格左上角的点走到右下角的点,这样的话一个小格子就是最小单位了。不过,虽然当时没做出来,但我的解题思路是对的,就是把大问题化为一个个小问题来看。这里以出题人的意思讲解,那么对每一个小格子来说,从左上的点走到右下的点一共只有 2 种走法:向右一种,向下一种;只不过越往右或者越往下的点,走法数是与之相邻的两个点的走法数量之和,如下图:(画图更直观,如果当时画图标注而不是研究数字,可能也做出来了)

代码实现:
1 | public class Test { |
扩展题:在一个 3 * 7 的方格,从左下角到右上角,最短路线有多少种走法?
答案:最短路线需考虑不能走回头路,那么需要向上走 3 次,向右走 7 次。如果不能连续向上行走,可看作是将 3 次向上的移动插入到向右的 7 次之间,7 次向右一共有 8 个空可以插入,那么就一共有 C(8,3)= 56 种走法;如果可以连续向上行走,那就看作是在总共 10 步中选择 3 步向上,那就一共有 C(10,3)= 120 种走法。