🐮🐮🐮
辛苦牛,掌握主流技术栈,包括前端后端,已经7年时间,曾在税务机关从事开发工作,目前在国企任职。希望通过自己的不断分享,可以帮助各位想或者已经走在这条路上的朋友一定的帮助
目录
前言
❤️金九银十马上就要来啦,各位小伙伴们有计划跳槽的要开始准备了,博主接下来一段时间会给大家持续更新面试题目,大家持续关注一下,感谢🙏🙏🙏
今天是IO和集合的相关面试题,欢迎指正
之前的面试文章链接也给到大家
金九银十面试题之Mysql
金九银十面试题之设计模式
金九银十面试题之数据结构和算法
金九银十面试题之Mybatis
金九银十面试题之《Spring Data JPA、Spring MVC、AOP》
金九银十面试题之《Spring IOC》
金九银十面试题之JUC
金九银十面试题之《并发》
金九银十面试题之《JVM》
IO
📟 Q1:同步/异步/阻塞/非阻塞 IO 的区别?
同步和异步是通信机制,阻塞和非阻塞是调用状态。
同步 IO 是用户线程发起 IO 请求后需要等待或轮询内核 IO 操作完成后才能继续执行。异步 IO 是用户线 程发起 IO 请求后可以继续执行,当内核 IO 操作完成后会通知用户线程,或调用用户线程注册的回调函 数。
阻塞 IO 是 IO 操作需要彻底完成后才能返回用户空间 。非阻塞 IO 是 IO 操作调用后立即返回一个状态 值,无需等 IO 操作彻底完成。
📟 Q2:什么是 BIO?
BIO 是同步阻塞式 IO,JDK1.4 之前的 IO 模型。服务器实现模式为一个连接请求对应一个线程,服务器 需要为每一个客户端]请求创建一个线程,如果这个连接不做任何事会造成不必要的线程开销。可以通过 线程池改善,这种 IO 称为伪异步 IO。适用连接数目少且服务器资源多的场景。
📟 Q3:什么是 NIO?
NIO 是 JDK1.4 引入的同步非阻塞 IO。服务器实现模式为多个连接请求对应一个线程,客户端连接请求 会注册到一个多路复用器 Selector ,Selector 轮询到连接有 IO 请求时才启动一个线程处理。适用连接 数目多且连接时间短的场景。
同步是指线程还是要不断接收客户端连接并处理数据,非阻塞是指如果一个管道没有数据,不需要等待,可以轮询下一个管道。
核心组件:
Selector: 多路复用器,轮询检查多个 Channel 的状态,判断注册事件是否发生,即判断 Channel 是否处于可读或可写状态。使用前需要将 Channel 注册到 Selector,注册后会得到一个 SelectionKey,通过 SelectionKey 获取 Channel 和 Selector 相关信息。
Channel: 双向通道,替换了 BIO 中的 Stream 流,不能直接访问数据,要通过 Buffer 来读写数 据,也可以和其他 Channel 交互。
Buffer: 缓冲区,本质是一块可读写数据的内存,用来简化数据读写。Buffer 三个重要属性: position 下次读写数据的位置,limit 本次读写的极限位置,capacity 最大容量。
flip 将写转为读,底层实现原理把 position 置 0,并把 limit 设为当前的 position 值。
clear 将读转为写模式(用于读完全部数据的情况,把 position 置 0,limit 设为capacity)。
compact 将读转为写模式(用于存在未读数据的情况,让 position 指向未读数据的下一 个)。
通道方向和 Buffer 方向相反,读数据相当于向 Buffer 写,写数据相当于从 Buffer 读。
使用步骤:向 Buffer 写数据,调用 flip 方法转为读模式,从 Buffer 中读数据,调用 clear 或 compact 方法清空缓冲区。
📟 Q4:什么是 AIO?
AIO 是 JDK7 引入的异步非阻塞 IO。服务器实现模式为一个有效请求对应一个线程,客户端的 IO 请求 都是由操作系统先完成 IO 操作后再通知服务器应用来直接使用准备好的数据。适用连接数目多且连接 时间⻓的场景。
异步是指服务端线程接收到客户端管道后就交给底层处理IO通信,自己可以做其他事情,非阻塞是指客 户端有数据才会处理,处理好再通知服务器。
实现方式包括通过 Future 的 get 方法进行阻塞式调用以及实现 CompletionHandler 接口,重写请求 成功的回调方法 completed 和请求失败回调方法 failed 。
📟 Q5:java.io 包下有哪些流?
主要分为字符流和字节流,字符流一般用于文本文件,字节流一般用于图像或其他文件。
字符流包括了字符输入流 Reader 和字符输出流 Writer,字节流包括了字节输入流 InputStream 和字节 输出流 OutputStream。字符流和字节流都有对应的缓冲流,字节流也可以包装为字符流,缓冲流带有 一个 8KB 的缓冲数组,可以提高流的读写效率。除了缓冲流外还有过滤流 FilterReader、字符数组流 CharArrayReader、字节数组流 ByteArrayInputStream、文件流 FileInputStream 等。
📟 Q6:序列化和反序列化是什么?
Java 对象 JVM 退出时会全部销毁,如果需要将对象及状态持久化,就要通过序列化实现,将内存中的 对象保存在二进制流中,需要时再将二进制流反序列化为对象。对象序列化保存的是对象的状态,因此 属于类属性的静态变量不会被序列化。
常⻅的序列化有三种:
Java 原生序列化
实现 Serializabale 标记接口,Java 序列化保留了对象类的元数据(如类、成员变量、继承类
信息)以及对象数据,兼容性最好,但不支持跨语言,性能一般。序列化和反序列化必须保持序列 化 ID 的一致,一般使用 private static final long serialVersionUID 定义序列化 ID,
如果不设置编译器会根据类的内部实现自动生成该值。如果是兼容升级不应该修改序列化 ID,防 止出错,如果是不兼容升级则需要修改。
Hessian 序列化
Hessian 序列化是一种支持动态类型、跨语言、基于对象传输的网络协议。Java 对象序列化的二进 制流可以被其它语言反序列化。Hessian 协议的特性:1 自描述序列化类型,不依赖外部描述文件, 用一个字节表示常用基础类型,极大缩短二进制流。2 语言无关,支持脚本语言。3 协议简单,比 Java 原生序列化高效。Hessian 会把复杂对象所有属性存储在一个 Map 中序列化,当父类和子类 存在同名成员变量时会先序列化子类再序列化父类,因此子类值会被父类覆盖。
JSON 序列化
JSON 序列化就是将数据对象转换为 JSON 字符串,在序列化过程中抛弃了类型信息,所以反序列 化时只有提供类型信息才能准确进行。相比前两种方式可读性更好,方便调试。
序列化通常会使用网络传输对象,而对象中往往有敏感数据,容易遭受攻击,Jackson 和 fastjson 等都 出现过反序列化漏洞,因此不需要进行序列化的敏感属性传输时应加上 transient 关键字。transient 的 作用就是把变量生命周期仅限于内存而不会写到磁盘里持久化,变量会被设为对应数据类型的零值。
集合
📟 Q1:说一说 ArrayList
ArrayList 是容量可变的非线程安全列表,使用数组实现,集合扩容时会创建更大的数组,把原有数组 复制到新数组。支持对元素的快速随机访问,但插入与删除速度很慢。ArrayList 实现了 RandomAcess 标记接口,如果一个类实现了该接口,那么表示使用索引遍历比迭代器更快。
elementData是 ArrayList 的数据域,被 transient 修饰,序列化时会调用 writeObject 写入流,反序 列化时调用 readObject 重新赋值到新对象的 elementData。原因是 elementData 容量通常大于实际 存储元素的数量,所以只需发送真正有实际值的数组元素。
size 是当前实际大小,elementData 大小大于等于 size。
modCount 记录了 ArrayList 结构性变化的次数,继承自 AbstractList。所有涉及结构变化的方法都会 增加该值。expectedModCount 是迭代器初始化时记录的 modCount 值,每次访问新元素时都会检查 modCount 和 expectedModCount 是否相等,不相等就会抛出异常。这种机制叫做 fail-fast,所有集 合类都有这种机制。
📟 Q2:说一说 LinkedList
LinkedList 本质是双向链表,与 ArrayList 相比插入和删除速度更快,但随机访问元素很慢。除继承 AbstractList 外还实现了 Deque 接口,这个接口具有队列和栈的性质。成员变量被 transient 修饰,原 理和 ArrayList 类似。
LinkedList 包含三个重要的成员:size、first 和 last。size 是双向链表中节点的个数,first 和 last 分别 指向首尾节点的引用。
LinkedList 的优点在于可以将零散的内存单元通过附加引用的方式关联起来,形成按链路顺序查找的线性 结构,内存利用率较高。
📟 Q3:Set 有什么特点,有哪些实现?
Set 不允许元素重复且无序,常用实现有 HashSet、LinkedHashSet 和 TreeSet。
HashSet 通过 HashMap 实现,HashMap 的 Key 即 HashSet 存储的元素,所有 Key 都使用相同的 Value ,一个名为 PRESENT 的 Object 类型常量。使用 Key 保证元素唯一性,但不保证有序性。由于 HashSet 是 HashMap 实现的,因此线程不安全。
HashSet 判断元素是否相同时,对于包装类型直接按值比较。对于引用类型先比较 hashCode 是否相 同,不同则代表不是同一个对象,相同则继续比较 equals,都相同才是同一个对象。
LinkedHashSet 继承自 HashSet,通过 LinkedHashMap 实现,使用双向链表维护元素插入顺序。 TreeSet 通过 TreeMap 实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。
📟 Q4:TreeMap 有什么特点?
TreeMap 基于红黑树实现,增删改查的平均和最差时间复杂度均为 O(logn) ,最大特点是 Key 有序。
Key 必须实现 Comparable 接口或提供的 Comparator 比较器,所以 Key 不允许为null。
HashMap 依靠 hashCode 和 equals 去重,而 TreeMap 依靠 Comparable 或 Comparator。 TreeMap排序时,如果比较器不为空就会优先使用比较器的 compare 方法,否则使用Key实现的 Comparable 的 compareTo 方法,两者都不满足会抛出异常。
TreeMap 通过 put 和 deleteEntry 实现增加和删除树节点。插入新节点的规则有三个:1 需要调整 的新节点总是红色的。2 如果插入新节点的父节点是黑色的,不需要调整。3 如果插入新节点的父节点是红色的,由于红黑树不能出现相邻红色,进入循环判断,通过重新着色或左右旋转来调整。 TreeMap 的插入操作就是按照 Key 的对比往下遍历,大于节点值向右查找,小于向左查找,先按照二 叉查找树的特性操作,后续会重新着色和旋转,保持红黑树的特性。
📟 Q5:HashMap 有什么特点?
JDK8 之前底层实现是数组 + 链表,JDK8 改为数组 + 链表/红黑树,节点类型从Entry 变更为 Node。主要成员变量包括存储数据的 table 数组、元素数量 size、加载因子 loadFactor。
table 数组记录 HashMap 的数据,每个下标对应一条链表,所有哈希冲突的数据都会被存放到同一条链表,Node/Entry 节点包含四个成员变量:key、value、next 指针和 hash 值。
HashMap 中数据以键值对的形式存在,键对应的 hash 值用来计算数组下标,如果两个元素 key 的hash 值一样,就会发生哈希冲突,被放到同一个链表上,为使查询效率尽可能高,键的 hash 值要尽可能分散。
HashMap 默认初始化容量为 16,扩容容量必须是 2 的幂次方、最大容量为 1<< 30 、默认加载因子为 0.75。
📟 Q6:HashMap 相关方法的源码?
JDK8 之前
hash:计算元素 key 的散列值
- 处理 String 类型时,调用 stringHash32 方法获取 hash 值。
- 处理其他类型数据时,提供一个相对于 HashMap 实例唯一不变的随机值 hashSeed 作为计算初始
量。 - 执行异或和无符号右移使 hash 值更加离散,减小哈希冲突概率。
indexFor:计算元素下标
将 hash 值和数组⻓度-1 进行与操作,保证结果不会超过 table 数组范围。
get:获取元素的 value 值
- 如果 key 为 null,调用 getForNullKey 方法,如果 size 为 0 表示链表为空,返回 null。如果 size 不为 0 说明存在链表,遍历 table[0] 链表,如果找到了 key 为 null 的节点则返回其 value,否则返回 null。
- 如果 key 为 不为 null,调用 getEntry 方法,如果 size 为 0 表示链表为空,返回 null 值。如果 size 不为 0,首先计算 key 的 hash 值,然后遍历该链表的所有节点,如果节点的 key 和 hash 值都和 要查找的元素相同则返回其 Entry 节点。
- 如果找到了对应的 Entry 节点,调用 getValue 方法获取其 value 并返回,否则返回 null。
put:添加元素
- 如果 key 为 null,直接存入 table[0]。
- 如果 key 不为 null,计算 key 的 hash 值。
- 调用 indexFor 计算元素存放的下标 i。
- 遍历 table[i] 对应的链表,如果 key 已存在,就更新 value 然后返回旧 value。
- 如果key不存在,将modCount值加1,使用 addEntry 方法增加一个节点并返回null。
resize:扩容数组
- 如果当前容量达到了最大容量,将阈值设置为 Integer 最大值,之后扩容不再触发。
- 否则计算新的容量,将阈值设为 newCapacity x loadFactor 和 最大容量 + 1 的较小值。
- 创建一个容量为 newCapacity 的 Entry 数组,调用 transfer 方法将旧数组的元素转移到新数组。
transfer:转移元素
- 遍历旧数组的所有元素,调用 rehash 方法判断是否需要哈希重构,如果需要就重新计算元素 key 的 hash 值。
- 调用 indexFor 方法计算元素存放的下标 i,利用头插法将旧数组的元素转移到新数组。
JDK8
hash:计算元素 key 的散列值
如果 key 为 null 返回 0,否则就将 key 的 hashCode 方法返回值高低16位异或,让尽可能多的位参与
运算,让结果的 0 和 1 分布更加均匀,降低哈希冲突概率。
put:添加元素
- 调用 putVal 方法添加元素。
- 如果 table 为空或⻓度为 0 就进行扩容,否则计算元素下标位置,不存在就调用 newNode 创建一个节点。
- 如果存在且是链表,如果首节点和待插入元素的 hash 和 key 都一样,更新节点的 value。
- 如果首节点是 TreeNode 类型,调用 putTreeVal 方法增加一个树节点,每一次都比较插入节点和当前节点的大小,待插入节点小就往左子树查找,否则往右子树查找,找到空位后执行两个方法: balanceInsert 方法,插入节点并调整平衡、 moveRootToFront 方法,由于调整平衡后根节点 可能变化,需要重置根节点。
- 如果都不满足,遍历链表,根据 hash 和 key 判断是否重复,决定更新 value 还是新增节点。如果遍 历到了链表末尾则添加节点,如果达到建树阈值 7,还需要调用 treeifyBin 把链表重构为红黑树。
- 存放元素后将 modCount 加 1,如果++size > threshold ,调用 resize 扩容。
get :获取元素的 value 值
- 调用 getNode 方法获取 Node 节点,如果不是 null 就返回其 value 值,否则返回 null。
- getNode 方法中如果数组不为空且存在元素,先比较第一个节点和要查找元素的 hash 和 key ,如 果都相同则直接返回。
- 如果第二个节点是 TreeNode 类型则调用 getTreeNode 方法进行查找,否则遍历链表根据 hash 和 key 查找,如果没有找到就返回 null。
resize:扩容数组
重新规划⻓度和阈值,如果⻓度发生了变化,部分数据节点也要重新排列。
重新规划⻓度
- 如果当前容量 oldCap > 0 且达到最大容量,将阈值设为 Integer 最大值,return 终止扩容。
- 如果未达到最大容量,当 oldCap << 1 不超过最大容量就扩大为 2 倍。
- 如果都不满足且当前扩容阈值oldThr > 0,使用当前扩容阈值作为新容量。
- 否则将新容量置为默认初始容量 16,新扩容阈值置为 12。
重新排列数据节点
- 如果节点为 null 不进行处理。
- 如果节点不为 null 且没有next节点,那么通过节点的 hash 值和 新容量-1 进行与运算计算下标存入 新的 table 数组。
- 如果节点为 TreeNode 类型,调用 split 方法处理,如果节点数 hc 达到6 会调用 untreeify 方 法转回链表。
- 如果是链表节点,需要将链表拆分为 hash 值超出旧容量的链表和未超出容量的链表。对于 hash & oldCap == 0 的部分不需要做处理,否则需要放到新的下标位置上,新下标 = 旧下标 + 旧容量。
📟 Q7:HashMap 为什么线程不安全?
JDK7 存在死循环和数据丢失问题。
数据丢失:
并发赋值被覆盖: 在 createEntry 方法中,新添加的元素直接放在头部,使元素之后可以被更快访问,但如果两个线程同时执行到此处,会导致其中一个线程的赋值被覆盖。
已遍历区间新增元素丢失: 当某个线程在 transfer 方法迁移时,其他线程新增的元素可能落在已遍历过的哈希槽上。遍历完成后,table 数组引用指向了 newTable,新增元素丢失。
新表被覆盖: 如果 resize 完成,执行了 table = newTable ,则后续元素就可以在新表上进行插入。但如果多线程同时 resize ,每个线程都会 new 一个数组,这是线程内的局部对象,线程之间不可⻅。迁移完成后 resize 的线程会赋值给 table 线程共享变量,可能会覆盖其他线程的 操作,在新表中插入的对象都会被丢弃。
死循环: 扩容时 resize 调用 transfer 使用头插法迁移元素,虽然 newTable 是局部变量,但原先 table 中的 Entry 链表是共享的,问题根源是 Entry 的 next 指针并发修改,某线程还没有将 table 设为 newTable 时用完了 CPU 时间片,导致数据丢失或死循环。
JDK8 在 resize 方法中完成扩容,并改用尾插法,不会产生死循环,但并发下仍可能丢失数据。可用 ConcurrentHashMap 或 Collections.synchronizedMap 包装成同步集合。
写在最后
希望博主收集的内容能帮到大家,祝大家能找到一个好的工作,过好的生活,如有错误欢迎指正。 💐💐💐