一、一面问题
1.线程池的主要参数
- 核心线程数
- 最大线程数
- 空闲线程存活时间
- 存活时间单位
- 任务队列
- 线程工厂
- 拒绝策略
- 允许核心线程超时
2. 线程的状态
- 新建状态
- 就绪状态
- 运行状态
- 阻塞状态
- 死亡状态
补充:线程阻塞的原因
- 线程调用sleep()方法进入睡眠状态
- 线程得到一个锁,但是该锁正在被其他线程占用
- 线程可能在等待某个触发条件
- 线程调用了一个在I/O上被阻塞的操作
3. 双精度类型转整型精度丢失问题(底层原因)
(1)在数据表示方式的差异
- 双精度类型:在Java等编程语言中,double类型使用64位二进制数表示一个双精度浮点数。这64位被分为三部分:1位符号位、11位指数位和52位尾数位。这种表示方式可以精确地表示非常大或非常小的数值,但在表示某些十进制小数时,由于二进制和十进制之间的转换限制,可能会出现精度损失。
- 整型:int类型是一个32位的有符号整数,没有小数部分。它只能表示整数范围内的数值。
(2)浮点数的运算特性
- 不精确性:由于浮点数采用二进制科学计数法表示,某些十进制小数在转换为二进制时无法精确表示,因此浮点数运算通常是不精确的。
- 舍入误差:当双精度浮点数转换为整型时,由于浮点数本身可能存在精度损失,且整型没有小数部分,因此必须进行舍入。
(3)底层实现的原因
- 二进制与十进制的转换:计算机内部使用二进制进行数据存储和运算,而人类通常使用十进制。当十进制小数转换为二进制浮点数时,可能会出现无法精确表示的情况。
- 硬件和指令集的限制:计算机硬件和指令集对浮点数的运算和表示方式有特定的要求。这些要求可能导致在浮点数转换为整型时出现精度丢失。
(4)代码示例
-
double originalDouble = 9.99; int convertedInt = (int) originalDouble; System.out.println(" 原始 double 值: " + originalDouble); System.out.println(" 转换后的 int 值: " + convertedInt);
(5)解决方法
- 使用BigDecimal类:BigDecimal类可以提供任意精度的浮点数运算,从而避免精度丢失。
四舍五入:在转换之前使用Math.round() 等方法对浮点数进行四舍五入处理,以减小精度丢失的影响。 - 四舍五入:在转换之前使用Math.round() 等方法对浮点数进行四舍五入处理,以减小精度丢失的影响。
4.static修饰的类和修饰方法在底层上有什么不同。
- 内存分配:静态内部类拥有独立的内存空间,而静态方法是分配在类的内存区域中,不占用对象的内存。
- 加载时机:静态内部类在编译时被加载,而静态方法在类加载时被初始化。
- 访问方式:静态内部类可以直接访问外部类的静态成员,但不能直接访问非静态成员;而静态方法只能访问静态属性和其他静态方法,不能直接访问实例变量和实例方法。
5.代码题
需求: 输入两个字符串类型的数,两数相加,最后返回一个字符串类型的结果。
(1)步骤
- 类定义:定义了一个名为StringNumberAdder的公开类。
- 方法定义:在StringNumberAdder类中定义了一个静态方法addStrings,该方法接收两个字符串参数num1和num2,并返回一个字符串类型的结果。
- 字符串转整数:使用Integer.parseInt(num1) 和Integer.parseInt(num2) 方法将输入的字符串转换为整数。
- 整数相加:将转换后的两个整数相加,得到和sum。
- 整数转字符串:使用Integer.toString(sum) 方法将和转换回字符串。
- 主方法:在main方法中,创建了两个字符串num1和num2,并调用addStrings方法将它们相加。打印出结果。
(2)代码
public class StringNumberAdder {
/**
* 将两个字符串类型的数字相加,并返回结果字符串。
*
* @param num1 第一个字符串数字
* @param num2 第二个字符串数字
* @return 相加后的结果字符串
*/
public static String addStrings(String num1, String num2) {
// 将字符串转换为整数进行相加
int intNum1 = Integer.parseInt(num1);
int intNum2 = Integer.parseInt(num2);
// 计算和
int sum = intNum1 + intNum2;
// 将和转换回字符串并返回
return Integer.toString(sum);
}
public static void main(String[] args) {
// 测试用例
String num1 = "123";
String num2 = "456";
String result = addStrings(num1, num2);
System.out.println("The result of adding " + num1 + " and " + num2 + " is: " + result);
}
}
二、二面问题
1.说一下数据库的存储引擎有哪些?
MySQL/MariaDB
-
InnoDB:
- MySQL的默认存储引擎。
- 支持ACID事务、行级锁定和外键约束。
- 适用于需要高并发和事务处理能力的应用。
- 提供了自动崩溃恢复功能。
-
MyISAM:
- MySQL早期的默认存储引擎之一。
- 提供了高速的读操作性能,但写操作性能相对较差。
- 支持表级锁定、全文索引和数据压缩。
- 适用于读多写少的应用场景。
-
Memory(HEAP):
- 将数据存储在内存中,提供极快的读写速度。
- 由于数据存储在内存中,重启后会丢失数据。
- 适用于需要快速访问的临时数据,如会话信息和缓存。
-
CSV:
- 将数据存储在CSV文件中,便于数据的导入和导出。
- 不支持事务和索引,性能较低。
- 适用于需要与其他应用进行数据交换的场景。
-
Archive:
- 适用于存储大量历史数据和归档数据。
- 提供了高效的数据压缩能力,但只支持INSERT和SELECT操作。
- 不支持事务和外键约束。
-
Federated:
- 允许在不同的MySQL实例之间创建分布式表。
- 适用于分布式数据库系统。
- 不存储实际数据和索引,只是一个指向远程表的接口。
-
Aria:
- MariaDB的默认存储引擎。
- 旨在替代MyISAM,提供了更好的崩溃恢复能力和事务支持。
- 支持事务和行级锁定,但不支持外键约束。
-
TokuDB:
- 适用于处理大数据量和高写入负载的应用。
- 采用了Fractal Tree索引技术,提供了高效的压缩和写入性能。
- 支持事务和行级锁定。
-
RocksDB:
- 由Facebook开发,以高效的写入性能和低延迟著称。
- 使用了LSM(Log-Structured Merge)树索引结构。
- 支持事务和行级锁定。
MySQL Cluster
-
NDB:
- MySQL Cluster的默认存储引擎。
- 支持高可用性和高扩展性。
- 提供了数据的水平分片和自动故障转移功能。
其他
-
XtraDB:
- Percona Server的默认存储引擎。
- 在InnoDB的基础上进行了多项优化,如支持更多的并发线程和更高效的缓存管理。
-
MERGE:
- 允许将多个MyISAM表组合成一个逻辑表。
- 适用于需要对大数据集进行分区管理的场景。
-
EXAMPLE:
- 一个示例存储引擎,主要用于学习和开发自定义存储引擎。
- 不实际存储数据,仅用于展示如何实现一个存储引擎的基础框架。
-
BLACKHOLE:
- 一种虚拟存储引擎,所有写入的数据都会被丢弃。
- 通常用于测试和调试数据库复制和日志记录机制。
2. 数据库的存储结构是?
(1)关系型数据库的存储结构
- 关系表
- 索引
- 视图
- 触发器
(2) 键值对
- 文档
- 图形
- 列族
(3)储存层次
- 页
- 区
- 段
- 表空间
(4)连接器
3. B树和B+树的区别?
(1)节点存储数据的方式不同
- B树:叶子结点和非叶子节点都会存储数据,指针和数据共同保存在同一节点中。
- B+树:数据均保存在叶子节点,非叶子节点只存储索引信息。
(2)查找数据过程不同
- B树:需要在各个节点上进行查找,查找数据的效率不稳定。
- B+树:需要在叶子节点上查找,非叶子节点只用于索引定位,每次查找都会从父节点到叶子节点结束。
(3)空间利用率不同
- B树:每个节点都存储数据,空间利用率相对较低。
- B+树:只有叶子节点存储数据,非叶子节点只存储索引信息,空间利用率更高。
(4)结构稳定性不同
- B树:插入和删除数据需要频繁变更树的结构,结构不稳定。
- B+树:插入和删除数据操作均放在叶子节点,维护了树结构的稳定性
(5)范围查找性能不同
- B树:需要在各个节点上逐个查找,范围查找效率较低。
- B+树:所有数据记录都存储在叶子节点上,且叶子节点同时还维护了一条双向链表,提高了范围查询的效率。
(6)使用场景不同
- B树:更适合于数据库的索引结构,处理大量点查询。
- B+树:更适合文件系统等场景,处理大量范围查询和排序操作。
4.HashMap底层实现(HashMap相关知识)
一、什么是
- 基于哈希表的数据结构
- 允许以O(1)的时间复杂度进行元素的插入,查询和删除
二、底层结构
1.数据结构
- 在1.8以后,数组+链表+红黑树
数组:HashMap底层是一个数组,每个数组元素存放一个链表或红黑树(在JDK 1.8之后,链表过长时会转化为红黑树)。
链表:当新元素插入HashMap时,它首先根据哈希值找到数组中的某个位置(桶)。如果该位置为空,则直接插入;如果该位置已经存在元素(发生碰撞),则通过链表解决冲突。
红黑树:在JDK 1.8中,当链表长度超过一定阈值(默认是8)时,链表会转换为红黑树,从而将时间复杂度从O(n)降低到O(log n)。
2.哈希函数和哈希值
- 每个键都会通过哈希函数计算出一个哈希值,然后通过哈希值决定数据应该存储在哪个桶中。桶是一个数组的存储位置。
- 哈希函数的主要目的是将数据均匀地分布在不同的桶中,从而减少哈希碰撞(即两个不同的键映射到同一个桶中的情况)。
3.负载因子和扩容
(1)负载因子
- HashMap有一个重要的参数叫负载因子,它决定了当数组中元素数量超过数组容量的多大比例时会触发扩容操作。默认的负载因子是0.75。
(2)扩容操作
- 当HashMap的元素数量达到数组容量的75%时,HashMap会自动进行扩容操作,通常会将数组容量扩展为原来的2倍。
- 扩容时,HashMap会重新分配一个更大的数组,并将原来的元素重新映射到新的数组中
三、底层代码
1.关键点解析
(1)构造函数
- HashMap提供了多个构造函数,包括无参构造函数、指定初始容量的构造函数、指定初始容量和负载因子的构造函数等。
(2)put方法
- put方法是插入元素的核心逻辑。
- 首先,计算键的哈希值。
- 其次,根据哈希值找到数组中的桶位置。
- 最后,如果该位置为空,则直接插入新元素;如果该位置已经存在元素,则通过链表或红黑树解决冲突。
- 如果链表长度超过阈值(默认是8),则链表会转换为红黑树。
- 如果元素数量超过扩容阈值(默认是数组容量的75%),则进行扩容操作。
(3)get方法
- get方法用于根据键查找对应的值。
- 首先,计算键的哈希值。
- 其次,根据哈希值找到数组中的桶位置。
- 最后,如果该位置为空,则返回null;如果该位置上有元素,则遍历该位置对应的链表或红黑树,找到与键相等的键值对,然后返回该键值对的值。
(4)resize方法
- resize方法是扩容的核心逻辑。
- 重新分配一个更大的数组,并将原来的元素重新映射到新的数组中。
- 在重新映射过程中,会根据元素的哈希值计算在新数组中的位置,并将元素插入到相应的桶中。
2.示例代码
(1)思路
- 首先创建了一个HashMap实例,并插入了一些键值对。
- 然后,通过键来访问元素,并遍历了HashMap中的所有键值对。
- 最后,通过插入大量元素来触发扩容操作,并再次遍历了HashMap。
(2)代码
import java.util.HashMap;
public class HashMapExample {
public static void main(String[] args) {
// 创建HashMap实例
HashMap<String, Integer> map = new HashMap<>();
// 插入键值对
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
// 访问元素
System.out.println(map.get("apple")); // 输出: 1
System.out.println(map.get("orange")); // 输出: null
// 遍历HashMap
for (String key : map.keySet()) {
System.out.println(key + ": " + map.get(key));
}
// 扩容操作(自动触发)
for (int i = 0; i < 1000; i++) {
map.put("key" + i, i);
}
// 再次遍历HashMap
for (String key : map.keySet()) {
System.out.println(key + ": " + map.get(key));
}
}
}
四、线程安全性
1.HashMap是非线程安全
- 多个线程同时访问和修改HashMap时,可能会出现数据不一致的情况。
- 解决方法:增加额外的同步机制
2.ConcurrentHashMap
- 一个线程安全的哈希表
- 通过分段锁来实现并发访问
五、键的null值
1.HashMap允许null值
- HashMap允许使用一个null键和多个null值。当使用null键时,它会被映射到数组的第一个位置(索引为0的桶)。
-
map.put(null, "value1"); // 允许键为 null map.put("key1", null); // 允许值为 null
2.与HashTable的区别
- Hashtable不允许键或值为null。
- 如果强制将null作为键或值插入Hashtable,会抛出
NullPointerException
。
六、遍历方式
1. 使用 for-each 循环遍历键值对
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
2. 使用 Iterator 遍历键值对
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, String> entry = iterator.next();
String key = entry.getKey();
String value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
3. 使用 keySet 遍历键
for (String key : map.keySet()) {
String value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}
4. 使用 values 遍历值
for (String value : map.values()) {
System.out.println("Value: " + value);
}
七、常见面试题
1.为什么 HashMap 在 JDK 1.8 中引入了红黑树?
- 在 JDK 1.8 之前,HashMap 使用链表来解决哈希冲突。当链表过长时,查找、插入和删除操作的时间复杂度会退化为 O(n)。引入红黑树后,当链表长度超过 8 时,链表会转换为红黑树,将时间复杂度降低到 O(log n),提高了性能。
2.HashMap 的初始容量和负载因子是多少?
- HashMap 的默认初始容量是 16,负载因子是 0.75。
3.HashMap 的扩容机制是什么?
- 当 HashMap 的元素数量超过数组容量的 75% 时,会触发扩容操作,数组容量会扩展为原来的 2 倍。扩容时,HashMap 会重新分配一个更大的数组,并将原来的元素重新映射到新的数组中。
4.HashMap 如何保证线程安全?
- HashMap 本身不是线程安全的。如果需要在多线程环境下使用 HashMap,可以使用 ConcurrentHashMap 或者对 HashMap 进行同步处理,例如使用 Collections.synchronizedMap 方法。
八、HashMap性能优化
1.初始容量的选择
2.负载因子的调整
3.自定义哈希函数
九、Hashmap、HashTable和LinkedHashMap区别
特点/实现类 | HashMap | HashTable | LinkedHashMap |
---|---|---|---|
线程安全 | 非线程安全 | 线程安全 | 非线程安全 |
允许null键/值 | 允许一个null键和多个null值 | 不允许null键和null值 | 允许一个null键和多个null值 |
迭代顺序 | 不保证顺序 | 不保证顺序 | 保持插入顺序 |
底层实现 | 数组+链表+红黑树(JDK 1.8及以后) | 数组+链表 | 继承自HashMap,内部维护一个双向链表 |
性能特点 | 通常性能最高,但不安全 | 性能较低,但线程安全 | 性能略低于HashMap,因为维护了顺序 |
同步机制 | 无 | 使用synchronized 关键字 | 无 |
扩容机制 | 当元素数量超过数组容量的75%时扩容 | 同HashMap | 同HashMap |
适用场景 | 单线程环境下,需要高性能的键值对存储 | 多线程环境下,需要线程安全的键值对存储 | 需要保持键值对插入顺序的场景 |
5.多线程的执行过程
- 线程创建:定义线程类并创建线程实例。
- 线程启动:调用
start()
方法启动线程。 - 线程调度:线程进入就绪状态,等待 CPU 时间片,然后进入运行状态。
- 线程同步:使用同步机制避免竞态条件。
- 线程阻塞:线程在等待某个条件或资源时进入等待或阻塞状态。
- 线程终止:线程正常或异常终止,或通过显式终止。
- 线程销毁:线程终止后,资源被释放,垃圾回收器回收不再使用的线程对象。
6.代码题
需求:字符串压缩。利用字符重复出现的次数,编写一种方法,实现基本的字符串压缩功能。比如,字符串aabcccccaaa会变为a2b1c5a3。若“压缩”后的字符串没有变短,则返回原先的字符串。你可以假设字符串中只包含大小写英文字母(a至z)。
(1)步骤
在这个实现中,我们使用了StringBuilder
来构建压缩后的字符串,因为它比直接使用字符串拼接(使用+
操作符)更加高效。StringBuilder
内部维护了一个可变的字符数组,可以在不创建新字符串对象的情况下追加字符和字符串。
另外,我们注意到在遍历字符串时,我们只需要遍历到n - 1
的位置,因为最后一个字符的处理是在循环之外单独完成的。这是为了避免在循环中处理最后一个字符时越界访问数组。
最后,我们通过比较压缩后的字符串长度和原始字符串长度来决定返回哪个字符串。如果压缩后的字符串更短,则返回它;否则,返回原始字符串。
(2)代码
public class StringCompression {
public static String compressString(String str) {
if (str == null || str.length() <= 1) {
return str;
}
StringBuilder compressed = new StringBuilder();
int count = 1;
char lastChar = str.charAt(0);
for (int i = 1; i < str.length(); i++) {
if (str.charAt(i) == lastChar) {
count++;
} else {
compressed.append(lastChar).append(count);
lastChar = str.charAt(i);
count = 1;
}
}
// 添加最后一个字符及其计数
compressed.append(lastChar).append(count);
// 比较原始字符串和压缩字符串的长度
return compressed.length() < str.length() ? compressed.toString() : str;
}
public static void main(String[] args) {
String input = "aabcccccaaa";
String compressed = compressString(input);
System.out.println("Original: " + input);
System.out.println("Compressed: " + compressed);
}
}
(3)结果
对于输入字符串 "aabcccccaaa"
,输出将是:
Original: aabcccccaaa
Compressed: a2b1c5a3