一、数组和集合的区别?使用过哪些?
在Java中,数组和集合(Collection Framework)是两种不同的数据结构,以下是它们的核心区别和常用集合类的详细分析:
1、数组与集合的核心区别
| 特性 | 数组 | 集合 |
|---|---|---|
| 存储类型 | 支持基本数据类型(int[])和对象类型 | 仅存储对象类型(基本类型需装箱,如Integer) |
| 容量固定性 | 固定长度(初始化后不可变) | 动态扩容(如ArrayList自动扩容1.5倍) |
| 功能方法 | 无内置方法,需手动操作(如遍历、增删) | 提供丰富API(add(), remove(), iterator()等) |
| 线程安全 | 非线程安全 | 部分集合线程安全(如Vector, ConcurrentHashMap) |
| 性能优化 | 内存连续,访问高效 | 不同实现性能差异大(如ArrayList查询O(1),LinkedList插入O(1)) |
| 泛型支持 | 无泛型(编译时类型检查) | 支持泛型(编译时类型安全) |
2、常用集合类及适用场景
1>. List接口(有序、可重复)
-
ArrayList:- 底层实现:动态数组。
- 特点:随机访问快(O(1)),尾部插入高效;中间插入/删除需移动元素(O(n))。
- 扩容机制:默认初始容量10,扩容为原容量1.5倍。
- 适用场景:读多写少,如缓存数据。
-
LinkedList:- 底层实现:双向链表。
- 特点:插入/删除高效(O(1)),随机访问慢(O(n))。
- 适用场景:频繁增删(如实现队列/栈)。
2>. Set接口(无序、唯一)
-
HashSet:- 底层实现:基于
HashMap(哈希表 + 链表/红黑树)。 - 特点:去重依赖
equals()和hashCode(),查询O(1)。 - 适用场景:快速去重。
- 底层实现:基于
-
TreeSet:- 底层实现:基于
TreeMap(红黑树)。 - 特点:元素有序(自然排序或自定义
Comparator),查询O(log n)。 - 适用场景:需要有序且唯一的元素集合。
- 底层实现:基于
3>. Map接口(键值对)
-
HashMap:- 底层实现:数组 + 链表/红黑树(JDK8+)。
- 特点:允许
null键/值,非线程安全。 - 哈希冲突:链表长度≥8时转红黑树,提高查询效率。
- 适用场景:高频键值存取。
-
LinkedHashMap:- 底层实现:在
HashMap基础上维护双向链表。 - 特点:保持插入顺序或访问顺序(LRU缓存)。
- 适用场景:需要有序的键值对集合。
- 底层实现:在
-
ConcurrentHashMap:- 底层实现:分段锁(JDK7)或CAS + synchronized(JDK8+)。
- 特点:线程安全,高并发性能优于
Hashtable。 - 适用场景:高并发环境下的键值存储。
二、说说Java中的集合

List是有序的Collection,使用此接口能够精确的控制每个元素的插入位置,用户能根据索引访问List中元素。常用的实现List的类有LinkedList,ArrayList,Vector,Stack。
ArrayList是容量可变的非线程安全列表,其底层使用数组实现。当几何扩容时,会创建更大的数组,并把原数组复制到新数组。ArrayList支持对元素的快速随机访问,但插入与删除速度很慢。
LinkedList本质是一个双向链表,与ArrayList相比,,其插入和删除速度更快,但随机访问速度更慢。
Set不允许存在重复的元素,与List不同,set中的元素是无序的。常用的实现有HashSet,LinkedHashSet和TreeSet。
HashSet通过HashMap实现,HashMap的Key即HashSet存储的元素,所有Key都是用相同的Value,一个名为PRESENT的Object类型常量。使用Key保证元素唯一性,但不保证有序性。由于HashSet是HashMap实现的,因此线程不安全。
LinkedHashSet继承自HashSet,通过LinkedHashMap实现,使用双向链表维护元素插入顺序。
TreeSet通过TreeMap实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。
Map 是一个键值对集合,存储键、值和之间的映射。Key 无序,唯一;value 不要求有序,允许重复。Map 没有继承于 Collection 接口,从 Map 集合中检索元素时,只要给出键对象,就会返回对应的值对象。主要实现有TreeMap、HashMap、HashTable、LinkedHashMap、ConcurrentHashMap
HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突),JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间
LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
HashTable:数组+链表组成的,数组是 HashTable 的主体,链表则是主要为了解决哈希冲突而存在的
TreeMap:红黑树(自平衡的排序二叉树)
ConcurrentHashMap:Node数组+链表+红黑树实现,线程安全的(jdk1.8以前Segment锁,1.8以后volatile + CAS 或者 synchronized)
三、Java中线程安全的集合有哪些?
在 Java 中,线程安全的集合类主要位于 java.util.concurrent 包中,也有一些在 java.util 包中的集合类通过同步机制实现线程安全。以下是常见的线程安全集合:
1. java.util.concurrent 包中的线程安全集合
这些集合是为高并发场景设计的,性能较好。
1.1 CopyOnWriteArrayList
- 线程安全的
List实现。 - 通过写时复制(Copy-On-Write)机制实现线程安全。
- 适合读多写少的场景。
1.2 CopyOnWriteArraySet
- 线程安全的
Set实现。 - 基于
CopyOnWriteArrayList实现。 - 适合读多写少的场景。
1.3 ConcurrentHashMap
- 线程安全的
Map实现。 - 使用分段锁(JDK 1.7)或
CAS+synchronized(JDK 1.8)实现高并发。 - 性能优于
Hashtable和Collections.synchronizedMap。
1.4 ConcurrentSkipListMap
- 线程安全的有序
Map实现。 - 基于跳表(Skip List)实现,支持高并发。
1.5 ConcurrentSkipListSet
- 线程安全的有序
Set实现。 - 基于
ConcurrentSkipListMap实现。
1.6 BlockingQueue 及其实现类
- 线程安全的队列,支持阻塞操作。
- 常见实现类:
ArrayBlockingQueue:基于数组的有界阻塞队列。LinkedBlockingQueue:基于链表的可选有界阻塞队列。PriorityBlockingQueue:支持优先级排序的无界阻塞队列。SynchronousQueue:不存储元素的阻塞队列。DelayQueue:基于优先队列实现的延迟队列。
1.7 ConcurrentLinkedQueue
- 线程安全的无界非阻塞队列。
- 基于链表实现,适合高并发场景。
1.8 ConcurrentLinkedDeque
- 线程安全的无界非阻塞双端队列。
- 基于链表实现。
2. java.util 包中的线程安全集合
这些集合通过同步机制实现线程安全,但性能较低,适合低并发场景。
2.1 Vector
- 线程安全的
List实现。 - 通过
synchronized方法实现线程安全。 - 性能较差,不推荐在高并发场景使用。
2.2 Hashtable
- 线程安全的
Map实现。 - 通过
synchronized方法实现线程安全。 - 性能较差,不推荐在高并发场景使用。
2.3 Stack
- 线程安全的栈实现。
- 继承自
Vector,通过synchronized方法实现线程安全。
2.4 Collections.synchronizedXXX 方法
- 通过
Collections工具类提供的同步包装方法,可以将非线程安全的集合转换为线程安全的集合。 - 例如:
Collections.synchronizedList(List<T> list)Collections.synchronizedSet(Set<T> set)Collections.synchronizedMap(Map<K, V> map)
- 这些方法返回的集合是通过
synchronized块实现线程安全的,性能较低。
3. 线程安全集合的选择建议
- 高并发场景:优先选择
java.util.concurrent包中的集合,如ConcurrentHashMap、CopyOnWriteArrayList等。 - 低并发场景:可以使用
Vector、Hashtable或Collections.synchronizedXXX方法包装的集合。 - 需要阻塞操作:选择
BlockingQueue及其实现类。 - 需要有序集合:选择
ConcurrentSkipListMap或ConcurrentSkipListSet。
4. 总结
| 集合类型 | 线程安全实现类 | 适用场景 |
|---|---|---|
| List | CopyOnWriteArrayList | 读多写少 |
| Set | CopyOnWriteArraySet | 读多写少 |
| Map | ConcurrentHashMap | 高并发 |
| Map | ConcurrentSkipListMap | 高并发且需要有序 |
| Set | ConcurrentSkipListSet | 高并发且需要有序 |
| Queue | BlockingQueue 及其实现类 | 生产者-消费者模型 |
| Queue | ConcurrentLinkedQueue | 高并发非阻塞队列 |
| Deque | ConcurrentLinkedDeque | 高并发非阻塞双端队列 |
| List | Vector | 低并发(不推荐) |
| Map | Hashtable | 低并发(不推荐) |
| 通用 | Collections.synchronizedXXX | 低并发(不推荐) |
根据具体需求选择合适的线程安全集合,可以显著提高程序的性能和可靠性。
四、Collections和Collection的区别?
Collection 和 Collections 是 Java 集合框架中两个容易混淆的类,但它们的功能和用途完全不同。以下是它们的区别:
1. Collection
Collection是一个接口,位于java.util包中。- 它是 Java 集合框架的根接口之一,表示一组对象的集合。
Collection接口 定义了集合的基本操作,例如添加、删除、遍历等。- 常见的子接口有:
List:有序且允许重复元素的集合。Set:无序且不允许重复元素的集合。Queue:队列,支持先进先出(FIFO)或优先级排序。
主要方法
add(E e):添加元素。remove(Object o):删除元素。contains(Object o):判断是否包含某个元素。size():返回集合中元素的数量。iterator():返回迭代器,用于遍历集合。
示例
Collection<String> collection = new ArrayList<>();
collection.add("Java");
collection.add("Python");
System.out.println(collection.size()); // 输出: 2
2. Collections
Collections是一个工具类,位于java.util包中。- 它提供了一系列静态方法,用于操作或返回集合(如
List、Set、Map等)。 Collections类 的主要功能包括:- 排序
- 查找
- 同步化集合
- 返回不可变集合
- 其他实用操作
常用方法
sort(List<T> list):对列表进行排序。binarySearch(List<? extends Comparable<? super T>> list, T key):在有序列表中二分查找元素。reverse(List<?> list):反转列表中的元素顺序。shuffle(List<?> list):随机打乱列表中的元素顺序。synchronizedList(List<T> list):返回线程安全的列表。unmodifiableList(List<? extends T> list):返回不可修改的列表。
示例
List<Integer> list = new ArrayList<>();
list.add(3);
list.add(1);
list.add(2);
Collections.sort(list); // 排序
System.out.println(list); // 输出: [1, 2, 3]
Collections.reverse(list); // 反转
System.out.println(list); // 输出: [3, 2, 1]
3. Collection 和 Collections 的区别
| 特性 | Collection | Collections |
|---|---|---|
| 类型 | 接口 | 工具类 |
| 功能 | 定义集合的基本操作(如添加、删除、遍历等) | 提供操作集合的静态方法(如排序、查找等) |
| 包 | java.util | java.util |
| 是否可实例化 | 不能直接实例化,需要通过实现类(如 ArrayList、HashSet)实例化 | 不能实例化,直接通过类名调用静态方法 |
| 常见用途 | 表示一组对象的集合 | 对集合进行操作或返回集合 |
4. 总结
Collection是一个接口,表示集合的抽象,定义了集合的基本行为。Collections是一个工具类,提供了操作集合的实用方法(如排序、查找、同步化等)。- 两者虽然名字相似,但功能和用途完全不同,需要根据具体需求选择使用。
五、集合遍历的方法有哪些?
在 Java 中,集合的遍历是常见的操作,针对不同的集合类型(如 List、Set、Map 等),有多种遍历方式。以下是常见的集合遍历方法:
1. 遍历 Collection(如 List、Set)
Collection 是 List 和 Set 的父接口,以下方法适用于所有 Collection 的实现类(如 ArrayList、HashSet 等)。
1.1 使用 for-each 循环
- 最简单、最常用的遍历方式。
- 适用于所有实现了
Iterable接口的集合。
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
for (String item : list) {
System.out.println(item);
}
1.2 使用 Iterator
Iterator是集合的迭代器,支持遍历时删除元素。- 适用于所有
Collection的实现类。
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
System.out.println(item);
// iterator.remove(); // 可以在遍历时删除元素
}
1.3 使用 forEach 方法(Java 8+)
- 使用 Lambda 表达式或方法引用来遍历集合。
- 适用于所有
Collection的实现类。
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
list.forEach(item -> System.out.println(item)); // Lambda 表达式
list.forEach(System.out::println); // 方法引用
1.4 使用普通 for 循环(仅适用于 List)
- 通过索引访问元素,仅适用于
List这种有序集合。
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
2. 遍历 Map
Map 是键值对的集合,遍历方式与 Collection 有所不同。
2.1 遍历 Map 的键(key)
- 使用
keySet()获取所有键,然后遍历。
Map<String, Integer> map = new HashMap<>();
map.put("Java", 1);
map.put("Python", 2);
map.put("C++", 3);
for (String key : map.keySet()) {
System.out.println("Key: " + key);
}
2.2 遍历 Map 的值(value)
- 使用
values()获取所有值,然后遍历。
Map<String, Integer> map = new HashMap<>();
map.put("Java", 1);
map.put("Python", 2);
map.put("C++", 3);
for (Integer value : map.values()) {
System.out.println("Value: " + value);
}
2.3 遍历 Map 的键值对(entry)
- 使用
entrySet()获取所有键值对,然后遍历。
Map<String, Integer> map = new HashMap<>();
map.put("Java", 1);
map.put("Python", 2);
map.put("C++", 3);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
2.4 使用 Iterator 遍历 Map
- 通过
entrySet()获取Iterator,然后遍历。
Map<String, Integer> map = new HashMap<>();
map.put("Java", 1);
map.put("Python", 2);
map.put("C++", 3);
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
2.5 使用 forEach 方法(Java 8+)
- 使用 Lambda 表达式遍历
Map。
Map<String, Integer> map = new HashMap<>();
map.put("Java", 1);
map.put("Python", 2);
map.put("C++", 3);
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
3. 遍历 Queue
Queue 是队列,通常使用 Iterator 或 for-each 循环遍历。
Queue<String> queue = new LinkedList<>();
queue.add("Java");
queue.add("Python");
queue.add("C++");
// 使用 for-each 遍历
for (String item : queue) {
System.out.println(item);
}
// 使用 Iterator 遍历
Iterator<String> iterator = queue.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
4. 遍历 Stack
Stack 是栈,可以使用 Iterator 或 for-each 循环遍历。
Stack<String> stack = new Stack<>();
stack.push("Java");
stack.push("Python");
stack.push("C++");
// 使用 for-each 遍历
for (String item : stack) {
System.out.println(item);
}
// 使用 Iterator 遍历
Iterator<String> iterator = stack.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
5. 总结
| 集合类型 | 遍历方式 |
|---|---|
List | for-each、Iterator、forEach(Java 8+)、普通 for 循环(仅 List) |
Set | for-each、Iterator、forEach(Java 8+) |
Map | keySet()、values()、entrySet()、Iterator、forEach(Java 8+) |
Queue | for-each、Iterator |
Stack | for-each、Iterator |
根据集合类型和具体需求,选择合适的遍历方式可以提高代码的可读性和性能。
六、讲一下java里面list的几种实现,几种实现有什么不同?
在 Java 中,List 是一个有序的集合接口,允许存储重复元素。List 接口的常见实现类包括 ArrayList、LinkedList、Vector 和 Stack。这些实现类在底层数据结构、性能特性和适用场景上有所不同。以下是它们的详细对比:
1. ArrayList
- 底层实现:基于动态数组。
- 特点:
- 支持快速随机访问(通过索引访问元素,时间复杂度为 O(1))。
- 插入和删除元素的速度较慢(需要移动元素,时间复杂度为 O(n))。
- 非线程安全。
- 容量动态增长,默认初始容量为 10,扩容时会创建一个更大的数组并复制原有数据。
- 适用场景:
- 需要频繁随机访问元素的场景。
- 元素数量相对固定或变化不大的场景。
示例
List<String> arrayList = new ArrayList<>();
arrayList.add("Java");
arrayList.add("Python");
System.out.println(arrayList.get(0)); // 输出: Java
2. LinkedList
- 底层实现:基于双向链表。
- 特点:
- 插入和删除元素的速度较快(时间复杂度为 O(1),但需要先找到插入位置)。
- 随机访问速度较慢(需要遍历链表,时间复杂度为 O(n))。
- 非线程安全。
- 除了实现
List接口外,还实现了Deque接口,可以用作队列或双端队列。
- 适用场景:
- 需要频繁插入和删除元素的场景。
- 需要实现队列或双端队列的场景。
示例
List<String> linkedList = new LinkedList<>();
linkedList.add("Java");
linkedList.add("Python");
linkedList.addFirst("C++"); // 在头部插入元素
System.out.println(linkedList.getFirst()); // 输出: C++
3. Vector
- 底层实现:基于动态数组。
- 特点:
- 与
ArrayList类似,但所有方法都是同步的(线程安全)。 - 性能较低(由于同步开销)。
- 容量动态增长,默认初始容量为 10。
- 与
- 适用场景:
- 需要线程安全的列表(但在高并发场景下,推荐使用
CopyOnWriteArrayList或Collections.synchronizedList)。
- 需要线程安全的列表(但在高并发场景下,推荐使用
示例
List<String> vector = new Vector<>();
vector.add("Java");
vector.add("Python");
System.out.println(vector.get(0)); // 输出: Java
4. Stack
- 底层实现:基于数组,继承自
Vector。 - 特点:
- 实现了栈数据结构(后进先出,LIFO)。
- 线程安全(继承自
Vector)。 - 提供了栈特有的方法,如
push()、pop()、peek()。
- 适用场景:
- 需要实现栈数据结构的场景。
示例
Stack<String> stack = new Stack<>();
stack.push("Java");
stack.push("Python");
System.out.println(stack.pop()); // 输出: Python
5. CopyOnWriteArrayList
- 底层实现:基于动态数组,写时复制(Copy-On-Write)机制。
- 特点:
- 线程安全。
- 写操作(添加、删除、修改)会复制一个新数组,读操作无锁。
- 适合读多写少的场景。
- 适用场景:
- 高并发场景下需要线程安全的列表。
示例
List<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
copyOnWriteArrayList.add("Java");
copyOnWriteArrayList.add("Python");
System.out.println(copyOnWriteArrayList.get(0)); // 输出: Java
6. 对比总结
| 特性 | ArrayList | LinkedList | Vector | Stack | CopyOnWriteArrayList |
|---|---|---|---|---|---|
| 底层数据结构 | 动态数组 | 双向链表 | 动态数组 | 动态数组 | 动态数组(写时复制) |
| 线程安全 | 非线程安全 | 非线程安全 | 线程安全 | 线程安全 | 线程安全 |
| 随机访问性能 | O(1) | O(n) | O(1) | O(1) | O(1) |
| 插入/删除性能 | O(n) | O(1) | O(n) | O(n) | O(n) |
| 扩容机制 | 动态扩容 | 无需扩容 | 动态扩容 | 动态扩容 | 动态扩容 |
| 适用场景 | 随机访问频繁 | 插入/删除频繁 | 需要线程安全 | 栈数据结构 | 读多写少的高并发场景 |
7. 选择建议
- 如果需要频繁随机访问元素,选择
ArrayList。 - 如果需要频繁插入和删除元素,选择
LinkedList。 - 如果需要线程安全的列表,选择
Vector或CopyOnWriteArrayList(推荐后者)。 - 如果需要实现栈数据结构,选择
Stack。
根据具体需求选择合适的 List 实现,可以显著提高程序的性能和可维护性。
七、list可以一边遍历一边修改元素吗?
在 Java 中,List 是否可以一边遍历一边修改元素,取决于具体的遍历方式和 List 的实现类。以下是不同情况的分析:
1. 使用 for-each 循环遍历
- 不允许修改:
for-each循环底层使用的是Iterator,如果在遍历过程中直接修改List的结构(如添加、删除元素),会抛出ConcurrentModificationException异常。- 修改元素的值(不改变结构)是允许的。
示例(抛出异常)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
for (String item : list) {
if (item.equals("Java")) {
list.remove(item); // 抛出 ConcurrentModificationException
}
}
示例(允许修改值)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
for (String item : list) {
if (item.equals("Java")) {
item = "C++"; // 修改值(不会影响原列表)
}
}
System.out.println(list); // 输出: [Java, Python]
2. 使用 Iterator 遍历
- 允许修改:
- 使用
Iterator的remove()方法可以在遍历时删除元素。 - 使用
ListIterator的set()方法可以在遍历时修改元素的值。 - 直接修改
List的结构(如调用List的add()或remove()方法)仍然会抛出ConcurrentModificationException。
- 使用
示例(删除元素)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if (item.equals("Java")) {
iterator.remove(); // 删除元素
}
}
System.out.println(list); // 输出: [Python]
示例(修改元素)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
ListIterator<String> listIterator = list.listIterator();
while (listIterator.hasNext()) {
String item = listIterator.next();
if (item.equals("Java")) {
listIterator.set("C++"); // 修改元素
}
}
System.out.println(list); // 输出: [C++, Python]
3. 使用普通 for 循环遍历
- 允许修改:
- 可以通过索引直接修改元素的值。
- 可以通过索引删除或添加元素,但需要注意索引的变化。
示例(修改元素)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
for (int i = 0; i < list.size(); i++) {
if (list.get(i).equals("Java")) {
list.set(i, "C++"); // 修改元素
}
}
System.out.println(list); // 输出: [C++, Python]
示例(删除元素)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
for (int i = 0; i < list.size(); i++) {
if (list.get(i).equals("Java")) {
list.remove(i); // 删除元素
i--; // 调整索引
}
}
System.out.println(list); // 输出: [Python]
4. 使用 forEach 方法(Java 8+)
- 不允许修改:
forEach方法底层使用的是Iterator,如果在遍历过程中直接修改List的结构,会抛出ConcurrentModificationException异常。- 修改元素的值(不改变结构)是允许的。
示例(抛出异常)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.forEach(item -> {
if (item.equals("Java")) {
list.remove(item); // 抛出 ConcurrentModificationException
}
});
示例(允许修改值)
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.forEach(item -> {
if (item.equals("Java")) {
item = "C++"; // 修改值(不会影响原列表)
}
});
System.out.println(list); // 输出: [Java, Python]
5. 线程安全的 List(如 CopyOnWriteArrayList)
- 允许修改:
CopyOnWriteArrayList是线程安全的List实现,支持在遍历时修改元素。- 修改操作会创建一个新的数组,不影响遍历过程。
示例
List<String> list = new CopyOnWriteArrayList<>();
list.add("Java");
list.add("Python");
for (String item : list) {
if (item.equals("Java")) {
list.remove(item); // 允许删除元素
}
}
System.out.println(list); // 输出: [Python]
6. 总结
| 遍历方式 | 是否允许修改元素的值 | 是否允许修改结构(添加/删除) | 备注 |
|---|---|---|---|
for-each 循环 | 允许 | 不允许(抛出异常) | 修改值不影响原列表 |
Iterator | 允许 | 允许(仅通过 Iterator 方法) | 使用 remove() 或 set() 方法 |
普通 for 循环 | 允许 | 允许 | 需注意索引变化 |
forEach 方法 | 允许 | 不允许(抛出异常) | 修改值不影响原列表 |
CopyOnWriteArrayList | 允许 | 允许 | 线程安全,适合读多写少场景 |
7. 建议
- 如果需要遍历时修改结构,推荐使用
Iterator或ListIterator。 - 如果需要线程安全的遍历和修改,推荐使用
CopyOnWriteArrayList。 - 避免在
for-each循环或forEach方法中直接修改List的结构。
八、 list如何快速删除某个指定下标的元素?
在 Java 中,List 提供了多种删除元素的方式。如果需要快速删除某个指定下标的元素,可以根据 List 的具体实现类选择合适的方法。以下是几种常见的方式:
1. 使用 List.remove(int index) 方法
- 适用场景:所有
List实现类(如ArrayList、LinkedList等)。 - 特点:
- 直接通过下标删除元素。
ArrayList的删除操作需要移动元素,时间复杂度为 O(n)。LinkedList的删除操作时间复杂度为 O(1)(如果已知下标)。
示例
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
list.remove(1); // 删除下标为 1 的元素
System.out.println(list); // 输出: [Java, C++]
2. 使用 Iterator 删除元素
- 适用场景:需要在遍历时删除元素。
- 特点:
- 通过
Iterator的remove()方法删除元素。 - 适用于所有
List实现类。
- 通过
示例
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
Iterator<String> iterator = list.iterator();
int indexToRemove = 1;
int currentIndex = 0;
while (iterator.hasNext()) {
iterator.next();
if (currentIndex == indexToRemove) {
iterator.remove(); // 删除当前元素
break;
}
currentIndex++;
}
System.out.println(list); // 输出: [Java, C++]
3. 使用 LinkedList.remove(int index) 方法
- 适用场景:
LinkedList。 - 特点:
LinkedList的remove(int index)方法会遍历链表找到对应下标的元素,时间复杂度为 O(n)。- 如果已知下标,可以直接删除。
示例
List<String> list = new LinkedList<>();
list.add("Java");
list.add("Python");
list.add("C++");
list.remove(1); // 删除下标为 1 的元素
System.out.println(list); // 输出: [Java, C++]
4. 使用 ArrayList 的 remove(int index) 方法
- 适用场景:
ArrayList。 - 特点:
ArrayList的remove(int index)方法会移动数组中的元素,时间复杂度为 O(n)。- 如果只需要删除一个元素,直接使用该方法即可。
示例
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
list.remove(1); // 删除下标为 1 的元素
System.out.println(list); // 输出: [Java, C++]
5. 使用 CopyOnWriteArrayList 删除元素
- 适用场景:
CopyOnWriteArrayList(线程安全的List)。 - 特点:
CopyOnWriteArrayList的remove(int index)方法会创建一个新数组,时间复杂度为 O(n)。- 适合读多写少的并发场景。
示例
List<String> list = new CopyOnWriteArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
list.remove(1); // 删除下标为 1 的元素
System.out.println(list); // 输出: [Java, C++]
6. 性能对比
| 方法 | ArrayList 时间复杂度 | LinkedList 时间复杂度 | 适用场景 |
|---|---|---|---|
List.remove(int index) | O(n) | O(n) | 通用 |
Iterator.remove() | O(n) | O(1) | 遍历时删除 |
LinkedList.remove(int index) | - | O(n) | 已知下标时删除 |
ArrayList.remove(int index) | O(n) | - | 已知下标时删除 |
CopyOnWriteArrayList.remove(int index) | O(n) | - | 线程安全,读多写少场景 |
7. 总结
- 如果需要快速删除指定下标的元素:
- 对于
ArrayList,直接使用remove(int index)方法。 - 对于
LinkedList,直接使用remove(int index)方法。 - 如果需要在线程安全的环境下删除元素,使用
CopyOnWriteArrayList。
- 对于
- 如果需要遍历时删除元素,使用
Iterator的remove()方法。
根据具体需求和 List 的实现类选择合适的方法,可以高效地删除指定下标的元素。
九、Arraylist和LinkedList的区别,哪个集合是线程安全的?
ArrayList 和 LinkedList 是 Java 中两种常用的 List 实现类,它们在底层数据结构、性能特性和适用场景上有显著区别。以下是它们的详细对比,以及关于线程安全的说明。
1. 底层数据结构
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 数据结构 | 动态数组 | 双向链表 |
| 内存占用 | 内存连续,占用较少 | 每个元素需要额外的指针(前驱和后继),占用较多内存 |
| 扩容机制 | 动态扩容(默认初始容量为 10,扩容为 1.5 倍) | 无需扩容,按需分配节点 |
2. 性能对比
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 随机访问 | O(1)(通过索引直接访问) | O(n)(需要遍历链表) |
| 插入/删除 | O(n)(需要移动元素) | O(1)(修改指针即可) |
| 头部插入/删除 | O(n)(需要移动元素) | O(1)(修改指针即可) |
| 尾部插入/删除 | O(1)(如果不需要扩容) | O(1)(修改指针即可) |
| 遍历性能 | 较快(缓存友好,内存连续) | 较慢(内存不连续,缓存不友好) |
3. 适用场景
| 场景 | ArrayList | LinkedList |
|---|---|---|
| 随机访问 | 适合频繁随机访问的场景 | 不适合随机访问 |
| 插入/删除 | 适合尾部插入/删除,不适合频繁中间插入/删除 | 适合频繁插入/删除,尤其是头部和中间操作 |
| 内存占用 | 适合内存敏感的场景 | 适合内存不敏感的场景 |
| 队列/栈 | 不适合 | 适合(实现了 Deque 接口) |
4. 线程安全
ArrayList和LinkedList都不是线程安全的。- 如果多个线程同时访问一个
ArrayList或LinkedList,并且至少有一个线程修改了列表的结构(添加、删除等操作),可能会导致数据不一致或抛出ConcurrentModificationException异常。
- 如果多个线程同时访问一个
- 如何实现线程安全:
- 使用
Collections.synchronizedList()方法将ArrayList或LinkedList包装为线程安全的列表。List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>()); - 使用
CopyOnWriteArrayList(线程安全的List实现)。List<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
- 使用
5. 代码示例
ArrayList 示例
List<String> arrayList = new ArrayList<>();
arrayList.add("Java");
arrayList.add("Python");
arrayList.add("C++");
// 随机访问
System.out.println(arrayList.get(1)); // 输出: Python
// 删除元素
arrayList.remove(1); // 删除 "Python"
System.out.println(arrayList); // 输出: [Java, C++]
LinkedList 示例
List<String> linkedList = new LinkedList<>();
linkedList.add("Java");
linkedList.add("Python");
linkedList.add("C++");
// 头部插入
linkedList.addFirst("Go");
System.out.println(linkedList); // 输出: [Go, Java, Python, C++]
// 删除元素
linkedList.removeLast(); // 删除 "C++"
System.out.println(linkedList); // 输出: [Go, Java, Python]
6. 总结
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 数据结构 | 动态数组 | 双向链表 |
| 随机访问 | 快(O(1)) | 慢(O(n)) |
| 插入/删除 | 慢(O(n)) | 快(O(1)) |
| 内存占用 | 较少 | 较多 |
| 线程安全 | 非线程安全 | 非线程安全 |
| 适用场景 | 随机访问频繁,尾部插入/删除 | 频繁插入/删除,尤其是头部和中间操作 |
- 如果需要线程安全的
List,可以使用Collections.synchronizedList()或CopyOnWriteArrayList。 - 根据具体需求选择合适的
List实现,可以提高程序的性能和可维护性。
十、ArrayList线程安全吗?
把ArrayList变成线程安全有哪些方法?ArrayList 是 非线程安全 的集合类。如果多个线程同时访问一个 ArrayList,并且至少有一个线程修改了列表的结构(如添加、删除元素),可能会导致数据不一致或抛出 ConcurrentModificationException 异常。
1. ArrayList 为什么是非线程安全的?
ArrayList的底层实现是基于动态数组,其操作(如添加、删除、扩容等)没有同步机制。- 在多线程环境下,可能会出现以下问题:
- 数据不一致:多个线程同时修改
ArrayList,导致数据丢失或错误。 ConcurrentModificationException:在遍历时修改ArrayList的结构(如删除元素),会抛出此异常。
- 数据不一致:多个线程同时修改
2. 将 ArrayList 变成线程安全的方法
方法 1:使用 Collections.synchronizedList()
Collections.synchronizedList()方法可以将ArrayList包装成一个线程安全的列表。- 所有操作(如添加、删除、遍历)都会通过同步块(
synchronized)实现线程安全。 - 优点:简单易用。
- 缺点:性能较低,因为所有操作都需要加锁。
示例
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 多线程操作
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
list.add("Item " + i);
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(list.size()); // 输出: 2000
注意事项
- 遍历时仍需手动同步:
synchronized (list) { for (String item : list) { System.out.println(item); } }
方法 2:使用 CopyOnWriteArrayList
CopyOnWriteArrayList是java.util.concurrent包中的线程安全List实现。- 写操作(如添加、删除)会复制一个新的数组,读操作无锁。
- 优点:
- 读操作性能高,适合读多写少的场景。
- 无需手动同步。
- 缺点:
- 写操作性能较低,因为需要复制数组。
- 内存占用较高。
示例
List<String> list = new CopyOnWriteArrayList<>();
// 多线程操作
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
list.add("Item " + i);
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(list.size()); // 输出: 2000
方法 3:手动加锁(synchronized 或 ReentrantLock)
- 可以通过手动加锁的方式实现线程安全。
- 优点:灵活性高,可以根据需求控制锁的粒度。
- 缺点:代码复杂度较高。
示例(使用 synchronized)
List<String> list = new ArrayList<>();
Object lock = new Object(); // 锁对象
// 多线程操作
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
synchronized (lock) {
list.add("Item " + i);
}
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(list.size()); // 输出: 2000
示例(使用 ReentrantLock)
List<String> list = new ArrayList<>();
ReentrantLock lock = new ReentrantLock(); // 可重入锁
// 多线程操作
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
lock.lock();
try {
list.add("Item " + i);
} finally {
lock.unlock();
}
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(list.size()); // 输出: 2000
3. 方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
Collections.synchronizedList() | 简单易用 | 性能较低,遍历时需手动同步 | 低并发场景 |
CopyOnWriteArrayList | 读操作性能高,无需手动同步 | 写操作性能低,内存占用高 | 读多写少的高并发场景 |
手动加锁(synchronized) | 灵活性高,可控制锁粒度 | 代码复杂度高 | 需要精细控制锁的场景 |
手动加锁(ReentrantLock) | 灵活性高,支持公平锁和非公平锁 | 代码复杂度高 | 需要精细控制锁的场景 |
4. 总结
ArrayList是 非线程安全 的集合类。- 将
ArrayList变成线程安全的方法:- 使用
Collections.synchronizedList()。 - 使用
CopyOnWriteArrayList。 - 手动加锁(
synchronized或ReentrantLock)。
- 使用
- 根据具体需求选择合适的方法:
- 低并发场景:
Collections.synchronizedList()。 - 读多写少的高并发场景:
CopyOnWriteArrayList。 - 需要精细控制锁的场景:手动加锁。
- 低并发场景:
十一、为什么ArrayList不是线程安全的,具体来说是哪里不安全?ArrayList 是 非线程安全 的,主要是因为它的底层实现和操作没有进行同步控制。在多线程环境下,多个线程同时访问或修改 ArrayList 可能会导致数据不一致或抛出异常。以下是 ArrayList 非线程安全的具体原因和问题:
1. 底层数据结构
ArrayList 的底层是一个动态数组(Object[] elementData),它通过数组来存储元素。数组的大小会根据元素的增加或减少动态调整。
2. 非线程安全的具体表现
2.1 数据不一致
- 当多个线程同时修改
ArrayList时(如添加、删除元素),可能会导致数据丢失或错误。 - 原因:
ArrayList的add()、remove()等方法没有同步机制。- 多个线程同时修改数组时,可能会覆盖彼此的操作。
示例
List<Integer> list = new ArrayList<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
list.add(i); // 多个线程同时添加元素
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(list.size()); // 结果可能小于 2000
- 问题:
- 由于
ArrayList的add()方法没有同步,多个线程可能会同时修改数组的同一个位置,导致部分添加操作被覆盖。 - 最终
list.size()的结果可能小于预期的 2000。
- 由于
2.2 ConcurrentModificationException
- 当一个线程在遍历
ArrayList时,另一个线程修改了ArrayList的结构(如添加、删除元素),会抛出ConcurrentModificationException。 - 原因:
ArrayList的迭代器(Iterator)在遍历时会检查集合的修改次数(modCount)。- 如果遍历过程中
modCount发生变化(如其他线程修改了集合),迭代器会抛出ConcurrentModificationException。
示例
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
// 线程 1:遍历列表
Thread thread1 = new Thread(() -> {
for (String item : list) {
System.out.println(item);
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 线程 2:修改列表
Thread thread2 = new Thread(() -> {
list.remove("Python"); // 删除元素
});
thread1.start();
thread2.start();
- 问题:
- 线程 1 在遍历
ArrayList时,线程 2 删除了一个元素,导致modCount发生变化。 - 线程 1 会抛出
ConcurrentModificationException。
- 线程 1 在遍历
2.3 扩容时的竞争条件
- 当
ArrayList的容量不足时,会触发扩容操作(创建一个更大的数组并复制原有数据)。 - 如果多个线程同时触发扩容,可能会导致数组状态不一致。
示例
List<Integer> list = new ArrayList<>(10); // 初始容量为 10
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
list.add(i); // 多个线程同时添加元素,可能触发扩容
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(list.size()); // 结果可能小于 2000
- 问题:
- 多个线程同时触发扩容时,可能会导致数组复制不完全或数据丢失。
3. 源码分析
以下是 ArrayList 的部分源码,展示了其非线程安全的实现:
add() 方法
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 检查容量,必要时扩容
elementData[size++] = e; // 添加元素
return true;
}
- 问题:
size++不是原子操作,多个线程同时执行时可能会导致size的值不正确。- 多个线程可能同时修改
elementData的同一个位置,导致数据覆盖。
remove() 方法
public E remove(int index) {
rangeCheck(index); // 检查索引是否越界
modCount++; // 修改次数加 1
E oldValue = elementData(index); // 获取要删除的元素
int numMoved = size - index - 1; // 计算需要移动的元素数量
if (numMoved > 0)
System.arraycopy(elementData, index + 1, elementData, index, numMoved); // 移动元素
elementData[--size] = null; // 清空最后一个元素
return oldValue;
}
- 问题:
modCount++和size--不是原子操作,多个线程同时执行时可能会导致数据不一致。
4. 如何解决 ArrayList 的线程安全问题
-
使用
Collections.synchronizedList():- 将
ArrayList包装成线程安全的列表。 - 示例:
List<String> list = Collections.synchronizedList(new ArrayList<>());
- 将
-
使用
CopyOnWriteArrayList:- 适合读多写少的场景。
- 示例:
List<String> list = new CopyOnWriteArrayList<>();
-
手动加锁:
- 使用
synchronized或ReentrantLock控制对ArrayList的访问。 - 示例:
List<String> list = new ArrayList<>(); Object lock = new Object(); synchronized (lock) { list.add("Java"); }
- 使用
5. 总结
ArrayList是非线程安全的,主要体现在:- 多个线程同时修改数据时,可能导致数据不一致。
- 在遍历时修改集合结构,会抛出
ConcurrentModificationException。 - 扩容时可能出现竞争条件,导致数据丢失或错误。
- 通过使用
Collections.synchronizedList()、CopyOnWriteArrayList或手动加锁,可以将ArrayList变成线程安全的集合。
十二、ArrayList 和 LinkedList 的应用场景?
在 Java 中,ArrayList 和 LinkedList 虽然都是 List 接口的实现类,但由于底层数据结构和性能特性的不同,它们的应用场景有显著区别。以下是两者的对比及具体应用场景分析:
1. ArrayList 的应用场景
底层数据结构:动态数组
核心特性:
- 快速随机访问(通过索引直接访问元素,时间复杂度 O(1))。
- 尾部插入/删除高效(如果不需要扩容,时间复杂度 O(1))。
- 内存连续(缓存友好,遍历效率高)。
适用场景:
-
频繁的随机访问
- 需要根据索引快速获取元素(如
list.get(index))。 - 例如:排序、二分查找、遍历时频繁按索引取值。
- 需要根据索引快速获取元素(如
-
尾部操作为主
- 在列表末尾添加或删除元素(如日志记录、批量数据处理)。
- 例如:
List<String> logs = new ArrayList<>(); logs.add("Log1"); // 尾部插入 logs.add("Log2"); logs.remove(logs.size() - 1); // 尾部删除
-
内存敏感的场景
ArrayList的内存占用更小(链表节点需要额外存储指针)。
-
需要兼容数组操作
- 需要将列表转换为数组(
toArray()方法高效)。
- 需要将列表转换为数组(
2. LinkedList 的应用场景
底层数据结构:双向链表
核心特性:
- 高效的插入/删除(时间复杂度 O(1),但需要先定位到操作位置)。
- 支持队列和双端队列操作(实现了
Deque接口)。 - 内存不连续(缓存不友好,遍历效率低)。
适用场景:
-
频繁的头部或中间插入/删除
- 在列表头部或中间插入或删除元素(如实现队列、栈或实时任务调度)。
- 例如:
LinkedList<String> queue = new LinkedList<>(); queue.addFirst("Task1"); // 头部插入 queue.addLast("Task2"); // 尾部插入 queue.removeFirst(); // 头部删除
-
需要实现队列或双端队列
LinkedList直接支持addFirst()、addLast()、removeFirst()等操作。- 例如:
Deque<Integer> deque = new LinkedList<>(); deque.offerFirst(1); // 双端队列操作 deque.offerLast(2);
-
内存不敏感的场景
- 可以接受每个元素多占用两个指针(前驱和后继)的内存开销。
-
不确定元素数量的动态操作
- 无需担心扩容带来的性能损耗(链表按需分配节点)。
3. 性能对比与选择建议
| 操作 | ArrayList 性能 | LinkedList 性能 | 推荐选择 |
|---|---|---|---|
| 随机访问 | O(1) | O(n) | ArrayList |
| 尾部插入/删除 | O(1)(无需扩容) | O(1) | 平手(均可选) |
| 头部插入/删除 | O(n) | O(1) | LinkedList |
| 中间插入/删除 | O(n) | O(n)(需遍历到位置) | 平手(但 LinkedList 更优) |
| 遍历 | 快(缓存友好) | 慢(缓存不友好) | ArrayList |
4. 实际应用示例
场景 1:高频随机访问(适合 ArrayList)
List<Integer> scores = new ArrayList<>();
// 添加 1000 个随机分数
for (int i = 0; i < 1000; i++) {
scores.add((int) (Math.random() * 100));
}
// 快速计算平均分(需要频繁按索引访问)
double sum = 0;
for (int i = 0; i < scores.size(); i++) {
sum += scores.get(i); // ArrayList 的 get(i) 是 O(1)
}
double average = sum / scores.size();
场景 2:实现队列(适合 LinkedList)
Deque<String> messageQueue = new LinkedList<>();
// 生产者线程添加消息
messageQueue.offer("Message1");
messageQueue.offer("Message2");
// 消费者线程处理消息
while (!messageQueue.isEmpty()) {
String msg = messageQueue.pollFirst(); // 高效头部删除
processMessage(msg);
}
场景 3:频繁中间插入(适合 LinkedList)
LinkedList<String> taskList = new LinkedList<>();
taskList.add("TaskA");
taskList.add("TaskC");
// 在 "TaskA" 和 "TaskC" 之间插入 "TaskB"
ListIterator<String> iterator = taskList.listIterator(1); // 快速定位到中间位置
iterator.add("TaskB"); // LinkedList 的插入是 O(1)
5. 总结
-
选择
ArrayList的情况:- 需要频繁按索引访问元素。
- 尾部插入/删除为主。
- 内存敏感或需要缓存友好性。
-
选择
LinkedList的情况:- 需要频繁在头部或中间插入/删除。
- 需要实现队列、双端队列或栈。
- 不介意额外的内存开销。
根据具体需求选择合适的数据结构,可以显著提升程序性能。在不确定时,默认选择 ArrayList(因其在大多数场景下综合性能更优),仅在需要特定操作时切换到 LinkedList。
十三、ArrayList的扩容机制说一下?
ArrayList 是 Java 中常用的动态数组实现,它可以根据需要自动扩容。ArrayList 的扩容机制是其核心特性之一,理解它有助于更好地使用和优化 ArrayList。
1. 初始容量
• ArrayList 的默认初始容量是 10。
• 你可以通过构造函数指定初始容量,例如 new ArrayList<>(20),这样初始容量就是 20。
2. 扩容触发条件
当 ArrayList 中的元素数量(size)达到当前容量(capacity)时,ArrayList 会自动触发扩容。
3. 扩容机制
• 当需要扩容时,ArrayList 会创建一个新的数组,新数组的容量通常是原数组容量的 1.5 倍(具体实现可能因 JDK 版本不同而略有差异)。
• 然后将原数组中的所有元素复制到新数组中。
• 最后,将新数组设置为 ArrayList 的内部数组。
4. 扩容的实现
ArrayList 的扩容逻辑主要在 grow() 方法中实现。以下是 JDK 中的简化代码:
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 新容量 = 旧容量 * 1.5
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
5. 扩容的性能影响
• 扩容操作涉及创建新数组和复制元素,因此是一个 O(n) 的操作。
• 频繁的扩容会影响性能,尤其是在数据量较大的情况下。
• 为了避免频繁扩容,可以通过 ensureCapacity(int minCapacity) 方法预先设置足够的容量。
6. 示例
ArrayList<Integer> list = new ArrayList<>();
list.add(1); // 初始容量为 10
// 添加 10 个元素后,容量达到 10
for (int i = 0; i < 10; i++) {
list.add(i);
}
list.add(11); // 触发扩容,容量变为 15
7. 注意事项
• ArrayList 的扩容机制是基于数组的动态增长,因此它适合频繁查询和随机访问,但在频繁插入和删除操作时性能可能不如 LinkedList。
• 如果需要存储大量数据,建议在创建 ArrayList 时指定一个合理的初始容量,以减少扩容次数。
通过理解 ArrayList 的扩容机制,可以更好地优化代码性能,避免不必要的性能开销。
十四、线程安全的 List, CopyonWriteArraylist是如何实现线程安全的?嗯,用户之前问了ArrayList的扩容机制,现在接着问线程安全的List,特别是CopyOnWriteArrayList如何实现线程安全。可能用户正在处理多线程环境下的集合使用问题,之前了解了ArrayList不是线程安全的,现在需要找一个替代方案。
首先,我需要确定用户的需求。他们可能是在开发中遇到了多线程并发修改的问题,比如ConcurrentModificationException,或者数据不一致的情况。这时候需要线程安全的List,而CopyOnWriteArrayList是一个常见的选择。
接下来,用户想知道CopyOnWriteArrayList如何实现线程安全。我应该先解释它的基本工作原理,即写时复制。同时,需要对比Vector,因为Vector也是线程安全的,但使用同步方法,性能较差。而CopyOnWriteArrayList采用读写分离,适合读多写少的场景。
需要详细说明其实现机制,比如在修改时复制新数组,加锁的过程,以及读操作是否加锁。这里要注意,虽然写操作加锁,但读操作不需要,所以读的性能很高。但写的时候会有内存开销,因为要复制数组。
另外,用户可能关心适用场景和注意事项,比如数据一致性的问题,迭代器的弱一致性。这些都需要在回答中提到,帮助用户理解何时使用以及潜在的限制。
可能用户没有明确提到性能问题,但作为扩展,可以比较CopyOnWriteArrayList和Collections.synchronizedList的区别,或者和ConcurrentLinkedQueue等其他并发容器的不同,但这里问题主要集中在CopyOnWriteArrayList,所以先聚焦于此。
需要确保回答结构清晰,分点说明,比如实现原理、锁机制、优缺点、适用场景等。同时结合代码示例或场景例子,让用户更容易理解。
CopyOnWriteArrayList 是 Java 中线程安全的 List 实现,通过 写时复制(Copy-On-Write) 机制实现线程安全。它在读多写少的场景中表现优异,但写操作会有性能开销。以下是其核心实现原理和线程安全机制:
1. 写时复制(Copy-On-Write)机制
• 读操作无锁:所有读操作(如 get、size、iterator)直接访问底层数组,无需加锁。
• 写操作加锁:所有写操作(如 add、set、remove)会先复制原数组,生成一个新数组,在新数组上执行修改,最后将底层数组引用指向新数组。
整个过程在锁的保护下进行,确保写操作的原子性。
// 示例:CopyOnWriteArrayList 的 add 方法源码(简化)
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 加锁
try {
Object[] elements = getArray(); // 获取原数组
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); // 复制新数组
newElements[len] = e; // 在新数组中修改
setArray(newElements); // 替换底层数组引用
return true;
} finally {
lock.unlock(); // 释放锁
}
}
2. 线程安全的核心设计
• 读写分离:读操作直接访问原数组,写操作修改的是新数组的副本。
读操作和写操作不会互相阻塞,因此读的性能极高。
• 写操作原子性:通过锁(ReentrantLock)保证同一时间只有一个线程执行写操作,避免并发修改导致数据不一致。
• 最终一致性:写操作完成后,新数组才会被其他线程可见,但读操作可能读取到旧数据(取决于何时触发读操作)。
3. 适用场景
• 读多写少:如监听器列表、配置信息的读取。
• 弱一致性容忍:允许读操作获取的数据可能不是最新的(如迭代器遍历的是旧数组)。
4. 优缺点
| 优点 | 缺点 |
|---|---|
| 读操作完全无锁,性能极高 | 写操作需要复制数组,内存和时间开销大 |
| 避免读写冲突,天然线程安全 | 数据弱一致性,不保证实时最新数据 |
| 适合高频读、低频写的场景 | 频繁写操作会导致性能急剧下降 |
5. 与 Vector 的对比
| 特性 | CopyOnWriteArrayList | Vector |
|---|---|---|
| 锁机制 | 写操作加锁(细粒度锁) | 所有方法加 synchronized 锁 |
| 读写性能 | 读无锁,写有锁 | 读写均加锁,性能较低 |
| 数据一致性 | 弱一致性(读可能看到旧数据) | 强一致性(读写互斥) |
| 适用场景 | 读多写少 | 读写均衡(已逐渐被淘汰) |
6. 注意事项
• 内存消耗:每次写操作都会复制数组,如果数组较大,频繁修改会导致内存压力。
• 迭代器弱一致性:通过 iterator() 获取的迭代器遍历的是写操作发生前的数组快照。
• 不适合实时性要求高的场景:如需要实时同步数据的场景应选择其他并发容器(如 ConcurrentLinkedQueue)。
总结
CopyOnWriteArrayList 通过 写时复制 + 读写分离 的设计,在保证线程安全的同时优化了读性能,但牺牲了写操作的效率和实时一致性。它在读多写少且允许弱一致性的场景中是最佳选择,但在写频繁的场景中应谨慎使用。
十五、如何对map进行快速遍历?
在 Java 中,Map 是一个常用的键值对集合,提供了多种遍历方式。根据具体需求选择合适的遍历方式可以提高代码的性能和可读性。以下是常见的 Map 遍历方式及其适用场景:
1. 使用 entrySet() 遍历(推荐)
通过 entrySet() 获取 Map 的键值对集合,然后遍历每个 Entry。这是最常见的遍历方式,性能较好。
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
优点:
• 同时获取键和值,无需二次查找。
• 性能较高,适合大多数场景。
2. 使用 keySet() 遍历
通过 keySet() 获取 Map 的键集合,然后遍历每个键,并通过键获取值。
for (String key : map.keySet()) {
Integer value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}
优点:
• 代码简单,易于理解。
缺点:
• 需要二次查找(通过 get(key) 获取值),性能略低于 entrySet()。
3. 使用 values() 遍历
通过 values() 获取 Map 的值集合,然后遍历每个值。
for (Integer value : map.values()) {
System.out.println("Value: " + value);
}
优点:
• 只关心值时非常方便。
缺点:
• 无法直接获取键。
4. 使用 Java 8 的 forEach() 方法
通过 forEach() 方法结合 Lambda 表达式遍历 Map。
map.forEach((key, value) -> {
System.out.println("Key: " + key + ", Value: " + value);
});
优点:
• 代码简洁,可读性强。
• 适合函数式编程风格。
5. 使用迭代器遍历
通过 entrySet().iterator() 获取迭代器,然后遍历每个 Entry。
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
优点:
• 可以在遍历时通过 iterator.remove() 删除元素。
缺点:
• 代码较冗长。
6. 并行流遍历(Java 8+)
通过 parallelStream() 并行遍历 Map,适合大规模数据且需要并行处理的场景。
map.entrySet().parallelStream().forEach(entry -> {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
});
优点:
• 充分利用多核 CPU,适合大数据量处理。
缺点:
• 并行流有额外的线程开销,小数据量时性能可能不如串行遍历。
7. 遍历性能对比
以下是常见遍历方式的性能对比(从高到低):
entrySet()遍历:性能最高,推荐使用。keySet()遍历:需要二次查找,性能略低。values()遍历:只遍历值,性能较高。forEach()方法:代码简洁,性能与entrySet()相当。- 迭代器遍历:性能与
entrySet()相当,但代码较冗长。 - 并行流遍历:适合大数据量,小数据量时性能较差。
8. 选择建议
• 需要键和值:优先使用 entrySet() 或 forEach()。
• 只需要键:使用 keySet()。
• 只需要值:使用 values()。
• 需要删除元素:使用迭代器遍历。
• 大数据量并行处理:使用并行流。
示例代码汇总
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
// 1. entrySet() 遍历
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
// 2. keySet() 遍历
for (String key : map.keySet()) {
Integer value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}
// 3. values() 遍历
for (Integer value : map.values()) {
System.out.println("Value: " + value);
}
// 4. forEach() 方法
map.forEach((key, value) -> {
System.out.println("Key: " + key + ", Value: " + value);
});
// 5. 迭代器遍历
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
// 6. 并行流遍历
map.entrySet().parallelStream().forEach(entry -> {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
});
通过选择合适的遍历方式,可以高效地操作 Map 集合。
十六、HashMap实现原理介绍一下?
嗯,用户问的是HashMap的实现原理,这个我之前有准备过相关的资料,不过得仔细想想怎么组织语言。首先,HashMap是Java中非常常用的数据结构,基于哈希表实现,允许null键和null值,非线程安全。那它的结构是怎样的呢?我记得在JDK1.8之后,HashMap引入了红黑树优化链表过长的问题,所以底层结构应该是数组+链表+红黑树。
接下来需要解释哈希表的基本原理,比如通过哈希函数计算键的哈希值,然后映射到数组的索引位置。如果发生哈希冲突,会用链表来处理,当链表长度超过阈值(默认8),就会转换为红黑树,这样查询效率会从O(n)提升到O(logn)。不过红黑树转换还有一个条件,就是数组的长度必须大于等于64,否则会先进行数组扩容。
然后要讲一下put方法的流程,计算哈希值,确定数组索引,处理冲突,扩容机制。扩容的时候,容量是原来的两倍,加载因子默认0.75,当元素数量超过容量乘以加载因子时就会触发扩容。扩容后需要重新计算每个元素的位置,这个过程比较耗性能,所以如果能预估容量,最好初始化时设置合适的初始容量,减少扩容次数。
还有哈希函数的设计,如何减少碰撞。比如,HashMap的哈希计算并不是直接使用对象的hashCode,而是将高16位和低16位进行异或运算,这样可以让高位的信息也参与到索引计算中,减少冲突。
另外,线程安全的问题,HashMap不是线程安全的,多线程环境下可能会导致死循环或者数据不一致,这时候应该用ConcurrentHashMap。不过用户没问这个,可能不需要展开,但可以稍微提一下。
需要注意的是,用户可能对红黑树的部分不太理解,是否需要详细说明?不过可能用户更关注整体结构和关键点,比如为什么用红黑树,扩容机制等。所以这部分可以简要带过,重点放在数组、链表、哈希冲突的处理,以及扩容的过程。
还有可能用户想知道HashMap的时间复杂度,在理想情况下是O(1),但在最坏情况下是O(n)(当所有元素都哈希到同一个桶,链表的情况下),不过在引入红黑树后,最坏情况提升到了O(logn)。
最后,可能需要对比一下JDK1.7和1.8的区别,比如头插法和尾插法,1.7中多线程扩容可能导致死循环,1.8改用了尾插法,但依然非线程安全。不过用户问的是实现原理,可能不需要涉及版本差异,但如果有的话会更全面。
总结一下,回答的结构应该是:结构(数组+链表+红黑树)、哈希函数、put流程、处理哈希冲突的方法、扩容机制、性能特点、线程安全提醒。这样用户能有一个全面的理解,同时抓住重点。
HashMap 的实现原理
HashMap 是 Java 中最常用的键值对存储结构,基于 哈希表 实现,结合了 数组的高效随机访问 和 链表的动态扩展 特性。在 JDK 1.8 后,进一步引入了 红黑树 优化链表过长的问题。以下是其核心实现原理:
1. 底层数据结构
• 数组(哈希桶):默认初始容量为 16,用于存储链表的头节点或红黑树的根节点。
• 链表:当哈希冲突时,通过链表存储同一桶中的多个键值对(称为“桶冲突”)。
• 红黑树:当链表长度超过 8 且数组容量 ≥ 64 时,链表转换为红黑树,提升查询效率(从 O(n) → O(log n))。
2. 哈希函数与索引计算
• 哈希值计算:
static final int hash(Object key) {
int h;
// 将 key 的哈希码高16位与低16位异或,减少哈希冲突
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
• 索引计算:
index = (数组长度 - 1) & hash(key); // 等价于 hash % 数组长度
3. 核心操作流程
3.1 put 操作
- 计算哈希值:对 key 进行哈希计算。
- 确定桶位置:通过
index = (n-1) & hash定位到数组下标。 - 处理冲突:
• 无冲突:直接存入数组对应位置。
• 链表冲突:遍历链表,若 key 已存在则更新值,否则追加到链表尾部。
• 树节点冲突:遍历红黑树,插入或更新节点。 - 扩容检查:若元素总数超过
容量 × 负载因子(默认 0.75),触发扩容。 - 链表树化:若链表长度 ≥ 8 且数组容量 ≥ 64,链表转换为红黑树。
3.2 get 操作
- 计算 key 的哈希值和索引。
- 在桶中遍历链表或红黑树,通过
equals()匹配 key。
4. 扩容机制(Rehashing)
• 触发条件:元素数量 > 容量 × 负载因子。
• 扩容规则:容量扩大为原来的 2 倍(保证容量始终是 2 的幂)。
• 数据迁移:
• 遍历旧数组,重新计算每个键值对的索引位置。
• JDK 1.8 优化:利用 (e.hash & oldCap) == 0 判断元素是否需要留在原索引或迁移到 原索引 + oldCap 位置,避免重新计算所有哈希值。
5. 性能特性
| 场景 | 时间复杂度 | 说明 |
|---|---|---|
| 无冲突(理想情况) | O(1) | 直接通过索引访问数组元素 |
| 链表冲突 | O(n) | 遍历链表查找元素 |
| 红黑树冲突 | O(log n) | 树化后查询效率提升 |
| 扩容操作 | O(n) | 数据迁移成本较高 |
6. 关键参数
• 初始容量:默认 16,建议预估值设置为 2 的幂(如 new HashMap<>(32))。
• 负载因子:默认 0.75,权衡空间利用率和哈希冲突概率。
• 树化阈值:链表长度 ≥ 8 且数组容量 ≥ 64 时树化。
• 退化阈值:红黑树节点数 ≤ 6 时退化为链表。
7. 线程安全性
• 非线程安全:多线程并发修改可能导致数据丢失、死循环(JDK 1.7 头插法)或脏数据。
• 替代方案:使用 ConcurrentHashMap 或 Collections.synchronizedMap() 实现线程安全。
8. JDK 1.7 与 1.8 的区别
| 特性 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 哈希冲突处理 | 头插法(可能死循环) | 尾插法(避免死循环) |
| 扩容后索引 | 重新计算所有哈希值 | 利用高位判断索引位置 |
总结
HashMap 通过哈希表实现高效的键值对存储,其核心设计在于 哈希函数优化、动态扩容、链表树化 等机制。理解其原理有助于在实际开发中合理设置初始容量、优化性能,并规避线程安全问题。
十七、了解的哈希冲突解决方法有哪些?
好的,我现在需要回答用户关于哈希冲突解决方法的问题。用户之前已经询问过HashMap的实现原理,现在进一步深入,问到了哈希冲突的解决方法。我需要详细说明不同的方法,并且结合实际情况,比如Java中的HashMap是如何处理冲突的。
首先,我得回忆一下哈希冲突的基本概念。哈希冲突是指不同的键经过哈希函数计算后得到相同的哈希值,导致它们被映射到哈希表的同一个位置。这时候就需要解决冲突,常见的解决方法有哪些呢?
我记得主要有开放地址法、链地址法、再哈希法、建立公共溢出区等。接下来需要逐一解释这些方法,以及它们的优缺点和应用场景。
开放地址法包括线性探测、平方探测和双重哈希等。比如,当发生冲突时,线性探测会顺序查找下一个空槽,直到找到位置。这种方法可能会导致聚集现象,影响性能。平方探测则通过二次方程跳过一些位置,减少聚集,但可能无法遍历所有位置。双重哈希使用另一个哈希函数来计算步长,这样分布更均匀,但计算量可能更大。
链地址法是将每个哈希桶设计成一个链表,所有哈希值相同的元素都放在这个链表中。Java的HashMap就是采用这种方法,结合红黑树来优化长链表。这种方法的优点是不会产生聚集,但需要额外的空间存储指针,且如果链表过长会影响性能。
再哈希法则是当冲突发生时,使用另一个哈希函数重新计算哈希值,直到找到空位。这种方法需要多个哈希函数,可能会增加计算时间,但可以减少聚集。
公共溢出区是单独维护一个区域存放冲突的元素,适用于冲突较少的情况。当哈希表的主存储区满时,将冲突元素放入溢出区。这种方法实现简单,但溢出区过大时会降低查找效率。
接下来,我需要结合Java中的HashMap来具体说明链地址法的应用,尤其是在JDK1.8之后引入红黑树的优化。比如当链表长度超过8时,转换为红黑树,提高查询效率;当节点数少于6时,转换回链表,节省空间。这解释了为什么选择8和6作为阈值,可能与统计学上的泊松分布有关,减少极端情况下的性能问题。
另外,要提到开放地址法在ThreadLocalMap中的应用,比如线性探测解决冲突。这里可以比较不同方法在具体场景下的选择原因,比如开放地址法适合数据量较小且装载因子低的情况,而链地址法适合高负载的情况。
还需要分析各种方法的优缺点,比如链地址法处理简单但需要额外空间,开放地址法节省空间但容易聚集。再哈希法可能更均匀但计算复杂。公共溢出区实现简单但效率可能不高。
最后,总结不同方法的适用场景,帮助用户根据实际需求选择合适的冲突解决策略。比如在内存敏感的场景下,开放地址法可能更合适;在要求高并发和高效查找的情况下,链地址法结合树化结构更有优势。
在组织内容时,需要结构清晰,分点说明每种方法,结合实例,对比优缺点,并给出应用建议。同时,要注意用词准确,避免技术错误,比如区分开放地址法的不同探测方式,正确解释红黑树转换的条件等。
可能需要注意的细节是,在解释链地址法时,要明确说明Java中HashMap的处理过程,包括哈希计算、索引定位、链表和红黑树的转换条件,以及扩容机制如何影响冲突解决。此外,提到其他语言或库中的实现差异,比如Python的字典使用开放地址法,这样可以增加回答的全面性。
最后,确保回答逻辑连贯,由浅入深,先介绍基本方法,再结合具体实现,最后总结优缺点和应用场景,帮助用户全面理解哈希冲突的解决方案。
哈希冲突解决方法详解
哈希冲突是指多个不同的键(Key)通过哈希函数计算后得到相同的哈希值,导致它们需要存储在哈希表的同一个位置。为了解决这一问题,常见的冲突解决策略有以下几种:
1. 开放地址法(Open Addressing)
核心思想:当发生冲突时,通过某种探测方法在哈希表中寻找下一个空闲位置,直到找到可用的槽位。
1.1 线性探测(Linear Probing)
• 操作方式:
若当前索引 i 冲突,则依次检查 i+1, i+2, ..., i+n,直到找到空槽。
• 公式:
index = (hash(key) + k) % capacity, 其中 k = 1, 2, 3, ...
• 优点:实现简单,无需额外数据结构。
• 缺点:
• 聚集现象:连续的冲突导致数据聚集,降低查询效率。
• 删除复杂:需标记删除位置,避免后续探测失效。
1.2 平方探测(Quadratic Probing)
• 操作方式:
通过平方跳跃避免线性探测的聚集问题。
• 公式:
index = (hash(key) + k²) % capacity, 其中 k = 1, 2, 3, ...
• 优点:减少聚集现象。
• 缺点:
• 无法覆盖所有槽位:某些情况下可能无法找到空槽(如哈希表长度是素数时效率较高)。
1.3 双重哈希(Double Hashing)
• 操作方式:
使用第二个哈希函数计算步长,确保跳跃分布均匀。
• 公式:
index = (hash1(key) + k * hash2(key)) % capacity, 其中 k = 1, 2, 3, ...
• 优点:分布更均匀,减少聚集。
• 缺点:需要设计两个哈希函数,计算复杂度略高。
应用场景:
• 内存敏感场景(如嵌入式系统),避免指针开销。
• 典型示例:Java 的 ThreadLocalMap 使用线性探测。
2. 链地址法(Separate Chaining)
核心思想:将哈希表的每个桶(Bucket)设计为链表或树结构,所有哈希冲突的元素存储在同一个桶中。
2.1 链表法
• 操作方式:
每个桶维护一个链表,冲突元素追加到链表尾部。
• 优点:
• 实现简单,无需复杂的探测逻辑。
• 天然支持高并发(如分段锁)。
• 缺点:
• 链表过长时查询效率下降(O(n))。
• 需要额外空间存储指针。
2.2 红黑树优化(JDK 1.8+)
• 操作方式:
• 当链表长度 ≥ 8 且哈希表容量 ≥ 64 时,链表转换为红黑树。
• 当红黑树节点数 ≤ 6 时,退化为链表。
• 优点:
• 将最坏情况下的查询复杂度从 O(n) 优化到 O(log n)。
• 典型示例:Java 的 HashMap。
应用场景:
• 高频读写场景,且数据规模较大。
• 需要平衡查询效率和内存开销的场景。
3. 再哈希法(Rehashing)
核心思想:当发生冲突时,使用另一个哈希函数重新计算哈希值,直到找到空槽。
• 公式:
index = hashN(key) % capacity, 其中 N 为哈希函数序号(如 hash1, hash2, ...)
• 优点:减少聚集,分布更均匀。
• 缺点:
• 需要设计多个哈希函数,实现复杂。
• 哈希函数设计不当可能导致效率低下。
应用场景:
• 对哈希函数设计有较高要求的场景(如分布式哈希表)。
4. 公共溢出区法(Overflow Area)
核心思想:将冲突元素统一存储在一个独立的区域(溢出区),通过额外空间解决冲突。
• 操作方式:
• 主表存储无冲突元素。
• 冲突元素存入溢出区(如链表或另一个数组)。
• 优点:实现简单,适合冲突较少的情况。
• 缺点:
• 溢出区过大时查询效率降低。
• 需维护额外数据结构。
应用场景:
• 冲突频率低且内存充足的情况。
5. 方法对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 线性探测 | O(1) ~ O(n) | 低 | 内存敏感,冲突较少 |
| 链地址法(链表) | O(1) ~ O(n) | 中 | 通用场景,适合高频读写 |
| 链地址法(红黑树) | O(1) ~ O(log n) | 高 | 大规模数据,长链表优化 |
| 双重哈希 | O(1) ~ O(n) | 低 | 需要均匀分布的哈希表 |
| 公共溢出区 | O(1) ~ O(m) | 高 | 冲突极少,内存充足 |
6. Java 中 HashMap 的冲突解决
• 链地址法为主:默认使用链表存储冲突元素。
• 红黑树优化:链表长度超过阈值时转换为树结构。
• 扩容机制:当元素数量超过 容量 × 负载因子(默认 0.75)时,哈希表容量翻倍并重新散列。
代码示例(JDK 1.8+):
// 链表节点定义
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 链表指针
}
// 树节点定义(继承自链表节点)
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // 红黑树父节点
TreeNode<K,V> left;
TreeNode<K,V> right;
boolean red;
}
总结
哈希冲突的解决方法需根据实际场景权衡性能、内存和实现复杂度:
• 链地址法适合通用场景,Java 的 HashMap 通过红黑树优化提升了极端情况下的性能。
• 开放地址法适合内存敏感但冲突较少的场景(如 ThreadLocalMap)。
• 选择方法时需考虑哈希函数设计、数据规模、负载因子及并发需求。
十八、HashMap是线程安全的吗?
HashMap 不是线程安全的。 在多线程环境下,直接使用 HashMap 可能会导致数据不一致、死循环(JDK 1.7 中)或其他未定义行为。以下是详细分析:
1. 为什么 HashMap 不是线程安全的?
1.1 数据竞争
• 多个线程同时修改 HashMap(如 put、remove)可能导致数据丢失或覆盖。
• 例如:两个线程同时执行 put 操作,可能导致其中一个线程的修改被忽略。
1.2 死循环(JDK 1.7 及之前)
• JDK 1.7 中,HashMap 在扩容时采用 头插法,可能导致链表成环,进而引发死循环。
• JDK 1.8 改为 尾插法,解决了死循环问题,但数据竞争仍然存在。
1.3 迭代器失效
• 一个线程在遍历 HashMap 时,另一个线程修改了 HashMap,可能导致 ConcurrentModificationException。
2. 如何使 HashMap 线程安全?
2.1 使用 Collections.synchronizedMap
• 通过 Collections.synchronizedMap 将 HashMap 包装为线程安全的 Map。
• 实现原理:在 HashMap 的所有方法上加 synchronized 锁。
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
优点:
• 实现简单,适合并发度不高的场景。
缺点:
• 锁粒度较粗,性能较差。
2.2 使用 ConcurrentHashMap
• ConcurrentHashMap 是专门为高并发设计的线程安全 Map。
• 实现原理:
• JDK 1.7:分段锁(Segment),每个段独立加锁。
• JDK 1.8:CAS + synchronized 锁桶头节点,锁粒度更细。
Map<String, Integer> map = new ConcurrentHashMap<>();
优点:
• 锁粒度更细,性能更高。
• 支持高并发读写。
缺点:
• 实现复杂,适合高并发场景。
2.3 使用 Hashtable
• Hashtable 是线程安全的 Map 实现。
• 实现原理:在 Hashtable 的所有方法上加 synchronized 锁。
Map<String, Integer> map = new Hashtable<>();
优点:
• 实现简单。
缺点:
• 锁粒度较粗,性能较差。
• 已被 ConcurrentHashMap 取代,不推荐使用。
3. HashMap 与 ConcurrentHashMap 对比
| 特性 | HashMap | ConcurrentHashMap |
|---|---|---|
| 线程安全 | 非线程安全 | 线程安全 |
| 锁机制 | 无锁 | CAS + synchronized(JDK 1.8) |
| 性能 | 高(单线程) | 高(高并发) |
| 适用场景 | 单线程或线程封闭场景 | 高并发场景 |
4. 示例代码
4.1 非线程安全的 HashMap
Map<String, Integer> map = new HashMap<>();
// 多线程操作可能导致数据不一致
new Thread(() -> map.put("a", 1)).start();
new Thread(() -> map.put("b", 2)).start();
4.2 线程安全的 ConcurrentHashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
// 多线程操作安全
new Thread(() -> map.put("a", 1)).start();
new Thread(() -> map.put("b", 2)).start();
5. 总结
• HashMap 不是线程安全的,直接在多线程环境下使用可能导致数据不一致或异常。
• 如果需要线程安全的 Map,推荐使用 ConcurrentHashMap,它在高并发场景下性能优异。
• 对于低并发场景,可以使用 Collections.synchronizedMap,但性能不如 ConcurrentHashMap。
十九、 hashmap的put过程介绍一下
HashMap 的 put 过程是 HashMap 的核心操作之一,涉及 哈希计算、冲突解决、扩容机制 等关键步骤。以下是 put 方法的详细过程分析(基于 JDK 1.8):
1. put 方法的核心流程
以下是 put 方法的简化流程:
- 计算 key 的哈希值:通过
hash(key)方法计算哈希值。 - 确定桶位置:通过
(n - 1) & hash计算索引(n是数组长度)。 - 插入或更新节点:
• 如果桶为空,直接插入新节点。
• 如果桶不为空,遍历链表或红黑树,检查是否存在相同 key:
◦ 如果存在,更新 value。
◦ 如果不存在,插入新节点。 - 检查扩容:如果元素数量超过
容量 × 负载因子,触发扩容。 - 返回旧值:如果 key 已存在,返回旧 value;否则返回
null。
2. 详细步骤分析
2.1 计算哈希值
HashMap 通过 hash(key) 方法计算 key 的哈希值,以减少哈希冲突:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
• 目的:将 key 的哈希码高 16 位与低 16 位异或,使哈希值分布更均匀。
• 结果:返回最终的哈希值 hash。
2.2 确定桶位置
通过 (n - 1) & hash 计算 key 在数组中的索引:
index = (n - 1) & hash; // n 是数组长度
• 原理:n 是 2 的幂,(n - 1) 的二进制形式为全 1,与 hash 进行按位与操作,相当于取模运算。
2.3 插入或更新节点
- 桶为空:直接创建新节点并插入数组对应位置。
- 桶不为空:
• 链表遍历:从头节点开始遍历链表,检查是否存在相同 key(通过equals方法):
◦ 如果存在,更新 value。
◦ 如果不存在,将新节点插入链表尾部。
• 红黑树遍历:如果桶中存储的是红黑树,则通过树结构查找或插入节点。 - 树化检查:如果链表长度 ≥ 8 且数组容量 ≥ 64,将链表转换为红黑树。
2.4 检查扩容
• 触发条件:元素数量 > 容量 × 负载因子(默认 0.75)。
• 扩容规则:容量扩大为原来的 2 倍。
• 数据迁移:遍历旧数组,重新计算每个节点的索引位置,并迁移到新数组。
2.5 返回旧值
• 如果 key 已存在,返回旧 value。
• 如果 key 不存在,返回 null。
3. 代码实现(JDK 1.8 源码简化)
以下是 put 方法的简化源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; // 数组
Node<K,V> p; // 当前桶的头节点
int n, i; // n: 数组长度; i: 索引
// 1. 如果数组为空,初始化数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算索引,检查桶是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 直接插入新节点
else {
Node<K,V> e; // 目标节点
K k;
// 3. 检查头节点是否匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 头节点匹配
else if (p instanceof TreeNode) // 红黑树处理
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 链表处理
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // 遍历到链表尾部
p.next = newNode(hash, key, value, null); // 插入新节点
if (binCount >= TREEIFY_THRESHOLD - 1) // 检查是否树化
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; // 找到匹配节点
p = e;
}
}
// 4. 更新 value 并返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
// 5. 检查扩容
if (++size > threshold)
resize();
return null;
}
4. 关键点总结
| 步骤 | 描述 |
|---|---|
| 哈希计算 | 通过 hash(key) 计算哈希值,减少冲突。 |
| 索引计算 | 通过 (n - 1) & hash 确定桶位置。 |
| 冲突解决 | 通过链表或红黑树处理哈希冲突。 |
| 树化检查 | 链表长度 ≥ 8 且数组容量 ≥ 64 时,链表转换为红黑树。 |
| 扩容机制 | 元素数量 > 容量 × 负载因子 时,容量扩大为原来的 2 倍,并迁移数据。 |
| 返回旧值 | 如果 key 已存在,返回旧 value;否则返回 null。 |
5. 示例
Map<String, Integer> map = new HashMap<>();
map.put("a", 1); // 插入键值对
map.put("b", 2);
map.put("a", 3); // 更新键值对
System.out.println(map); // 输出: {a=3, b=2}
6. 总结
HashMap 的 put 过程通过 哈希计算、冲突解决、扩容机制 等步骤,实现了高效的键值对存储。理解其原理有助于在实际开发中优化 HashMap 的使用,避免性能问题。
二十、HashMap的put(key,val)和get(key)过程
HashMap 的 put(key, val) 和 get(key) 是 HashMap 的核心操作,分别用于插入键值对和获取值。以下是它们的详细过程分析(基于 JDK 1.8):
1. put(key, val) 过程
1.1 核心流程
- 计算 key 的哈希值:通过
hash(key)方法计算哈希值。 - 确定桶位置:通过
(n - 1) & hash计算索引(n是数组长度)。 - 插入或更新节点:
• 如果桶为空,直接插入新节点。
• 如果桶不为空,遍历链表或红黑树,检查是否存在相同 key:
◦ 如果存在,更新 value。
◦ 如果不存在,插入新节点。 - 检查扩容:如果元素数量超过
容量 × 负载因子,触发扩容。 - 返回旧值:如果 key 已存在,返回旧 value;否则返回
null。
1.2 详细步骤
1.2.1 计算哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
• 目的:将 key 的哈希码高 16 位与低 16 位异或,使哈希值分布更均匀。
1.2.2 确定桶位置
index = (n - 1) & hash; // n 是数组长度
• 原理:n 是 2 的幂,(n - 1) 的二进制形式为全 1,与 hash 进行按位与操作,相当于取模运算。
1.2.3 插入或更新节点
- 桶为空:直接创建新节点并插入数组对应位置。
- 桶不为空:
• 链表遍历:从头节点开始遍历链表,检查是否存在相同 key(通过equals方法):
◦ 如果存在,更新 value。
◦ 如果不存在,将新节点插入链表尾部。
• 红黑树遍历:如果桶中存储的是红黑树,则通过树结构查找或插入节点。 - 树化检查:如果链表长度 ≥ 8 且数组容量 ≥ 64,将链表转换为红黑树。
1.2.4 检查扩容
• 触发条件:元素数量 > 容量 × 负载因子(默认 0.75)。
• 扩容规则:容量扩大为原来的 2 倍。
• 数据迁移:遍历旧数组,重新计算每个节点的索引位置,并迁移到新数组。
1.2.5 返回旧值
• 如果 key 已存在,返回旧 value。
• 如果 key 不存在,返回 null。
1.3 代码实现(简化)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; // 数组
Node<K,V> p; // 当前桶的头节点
int n, i; // n: 数组长度; i: 索引
// 1. 如果数组为空,初始化数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算索引,检查桶是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 直接插入新节点
else {
Node<K,V> e; // 目标节点
K k;
// 3. 检查头节点是否匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 头节点匹配
else if (p instanceof TreeNode) // 红黑树处理
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 链表处理
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // 遍历到链表尾部
p.next = newNode(hash, key, value, null); // 插入新节点
if (binCount >= TREEIFY_THRESHOLD - 1) // 检查是否树化
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; // 找到匹配节点
p = e;
}
}
// 4. 更新 value 并返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
// 5. 检查扩容
if (++size > threshold)
resize();
return null;
}
2. get(key) 过程
2.1 核心流程
- 计算 key 的哈希值:通过
hash(key)方法计算哈希值。 - 确定桶位置:通过
(n - 1) & hash计算索引。 - 查找节点:
• 如果桶为空,返回null。
• 如果桶不为空,遍历链表或红黑树,检查是否存在相同 key:
◦ 如果找到,返回 value。
◦ 如果未找到,返回null。
2.2 详细步骤
2.2.1 计算哈希值
与 put 方法相同,使用 hash(key) 计算哈希值。
2.2.2 确定桶位置
与 put 方法相同,使用 (n - 1) & hash 计算索引。
2.2.3 查找节点
- 桶为空:直接返回
null。 - 桶不为空:
• 链表遍历:从头节点开始遍历链表,检查是否存在相同 key(通过equals方法):
◦ 如果找到,返回 value。
◦ 如果未找到,返回null。
• 红黑树遍历:如果桶中存储的是红黑树,则通过树结构查找节点。
2.3 代码实现(简化)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; // 数组
Node<K,V> first, e; // first: 头节点; e: 当前节点
int n; // 数组长度
K k;
// 1. 检查数组和桶是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 2. 检查头节点是否匹配
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 3. 遍历链表或红黑树
if ((e = first.next) != null) {
if (first instanceof TreeNode) // 红黑树处理
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { // 链表处理
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null; // 未找到
}
3. 总结
| 操作 | 核心步骤 |
|---|---|
put(key, val) | 计算哈希值 → 确定桶位置 → 插入或更新节点 → 检查扩容 → 返回旧值。 |
get(key) | 计算哈希值 → 确定桶位置 → 查找节点 → 返回 value 或 null。 |
• put:通过哈希计算、冲突解决、扩容机制实现高效的键值对插入。
• get:通过哈希计算和遍历链表或红黑树实现高效的键值对查找。
理解 put 和 get 的实现原理,有助于更好地使用和优化 HashMap。
二十一、hashmap 调用get方法一定安全吗?
HashMap 的 get 方法在多线程环境下并不安全。 尽管 get 方法本身不会直接修改 HashMap 的内部结构,但在多线程并发访问时,仍然可能引发以下问题:
1. 数据不一致
• 问题描述:
一个线程在调用 get 方法时,另一个线程可能正在修改 HashMap(如 put、remove),导致 get 方法返回的数据可能不是最新的,或者与预期不符。
• 示例:
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
// 线程 1:获取值
new Thread(() -> {
System.out.println(map.get("a")); // 可能输出 1 或 2
}).start();
// 线程 2:修改值
new Thread(() -> {
map.put("a", 2);
}).start();
2. 死循环(JDK 1.7 及之前)
• 问题描述:
在 JDK 1.7 及之前,HashMap 在扩容时采用 头插法,可能导致链表成环。此时,get 方法在遍历链表时可能陷入死循环。
• 原因:
一个线程在扩容时修改链表结构,另一个线程在遍历链表时访问到不一致的状态。
• JDK 1.8 修复:
改为 尾插法,避免了死循环问题,但数据竞争仍然存在。
3. ConcurrentModificationException
• 问题描述:
一个线程在遍历 HashMap(如使用迭代器)时,另一个线程修改了 HashMap,可能导致 ConcurrentModificationException。
• 示例:
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
// 线程 1:遍历 map
new Thread(() -> {
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}).start();
// 线程 2:修改 map
new Thread(() -> {
map.put("c", 3); // 可能导致 ConcurrentModificationException
}).start();
4. 弱一致性
• 问题描述:
在多线程环境下,get 方法返回的数据可能不是最新的,因为其他线程的修改可能尚未对当前线程可见。
• 原因:
HashMap 未使用任何同步机制(如 volatile 或 synchronized),无法保证线程间的可见性。
5. 如何确保 get 方法安全?
5.1 使用 ConcurrentHashMap
• 优点:
• ConcurrentHashMap 是线程安全的,支持高并发读写。
• get 方法完全无锁,性能极高。
• 示例:
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
// 线程 1:获取值
new Thread(() -> {
System.out.println(map.get("a")); // 安全
}).start();
// 线程 2:修改值
new Thread(() -> {
map.put("a", 2); // 安全
}).start();
5.2 使用 Collections.synchronizedMap
• 优点:
• 将 HashMap 包装为线程安全的 Map。
• 缺点:
• 锁粒度较粗,性能较差。
• 示例:
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
map.put("a", 1);
// 线程 1:获取值
new Thread(() -> {
System.out.println(map.get("a")); // 安全
}).start();
// 线程 2:修改值
new Thread(() -> {
map.put("a", 2); // 安全
}).start();
5.3 使用显式同步
• 优点:
• 通过 synchronized 或 ReentrantLock 手动控制并发访问。
• 缺点:
• 实现复杂,性能较低。
• 示例:
Map<String, Integer> map = new HashMap<>();
Object lock = new Object();
// 线程 1:获取值
new Thread(() -> {
synchronized (lock) {
System.out.println(map.get("a")); // 安全
}
}).start();
// 线程 2:修改值
new Thread(() -> {
synchronized (lock) {
map.put("a", 2); // 安全
}
}).start();
6. 总结
| 方案 | 线程安全 | 性能 | 适用场景 |
|---|---|---|---|
HashMap | 不安全 | 高 | 单线程或线程封闭场景 |
ConcurrentHashMap | 安全 | 高 | 高并发读写场景 |
Collections.synchronizedMap | 安全 | 中 | 低并发场景 |
| 显式同步 | 安全 | 低 | 需要精细控制同步的场景 |
• HashMap 的 get 方法在多线程环境下不安全,可能导致数据不一致、死循环或异常。
• 如果需要线程安全的 Map,推荐使用 ConcurrentHashMap,它在高并发场景下性能优异。
二十二、HashMap一般用什么做Key?为啥String适合做Key呢?
HashMap 的 Key 选择非常重要,因为它直接影响到 HashMap 的性能和正确性。以下是 HashMap 中 Key 的选择原则,以及为什么 String 是适合作为 Key 的原因:
1. HashMap 中 Key 的选择原则
1.1 不可变性(Immutability)
• 原因:
• 如果 Key 是可变的,修改 Key 后会导致哈希值发生变化,从而导致 HashMap 无法正确找到对应的值。
• 示例:
Map<MutableKey, Integer> map = new HashMap<>();
MutableKey key = new MutableKey("a");
map.put(key, 1);
key.setValue("b"); // 修改 Key
System.out.println(map.get(key)); // 返回 null,因为哈希值已改变
1.2 正确实现 hashCode() 和 equals()
• 原因:
• HashMap 依赖 hashCode() 计算哈希值,依赖 equals() 判断 Key 是否相等。
• 如果 hashCode() 或 equals() 实现不正确,会导致 HashMap 无法正确存储或查找数据。
• 规则:
• 如果两个对象通过 equals() 比较相等,它们的 hashCode() 必须相等。
• 如果两个对象通过 equals() 比较不相等,它们的 hashCode() 可以相等(哈希冲突),但尽量不相等以提高性能。
1.3 简单性
• 原因:
• 简单的 Key 更容易实现正确的 hashCode() 和 equals(),且性能更高。
• 示例:
• 使用 String、Integer 等简单类型作为 Key,而不是复杂的自定义对象。
2. 为什么 String 适合作为 Key?
String 是 HashMap 中最常用的 Key 类型,原因如下:
2.1 不可变性
• String 是不可变的,一旦创建,其值无法修改。
• 这保证了 String 作为 Key 时,哈希值不会发生变化。
2.2 高效的 hashCode() 实现
• String 的 hashCode() 实现基于字符串的每个字符计算哈希值,具有良好的分布性,减少哈希冲突。
• 示例:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
2.3 高效的 equals() 实现
• String 的 equals() 实现基于字符数组的逐字符比较,性能高效。
• 示例:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
2.4 广泛使用
• String 是 Java 中最常用的数据类型,作为 Key 时语义清晰,易于理解。
• 示例:
Map<String, Integer> map = new HashMap<>();
map.put("age", 25);
map.put("height", 180);
3. 其他适合作为 Key 的类型
除了 String,以下类型也适合作为 HashMap 的 Key:
3.1 Integer
• 原因:
• 不可变,且 hashCode() 和 equals() 实现简单高效。
• 示例:
Map<Integer, String> map = new HashMap<>();
map.put(1, "one");
map.put(2, "two");
3.2 Enum
• 原因:
• 不可变,且每个枚举值在 JVM 中是唯一的。
• 示例:
enum Color { RED, GREEN, BLUE }
Map<Color, String> map = new HashMap<>();
map.put(Color.RED, "FF0000");
3.3 自定义不可变类
• 要求:
• 类不可变,且正确实现 hashCode() 和 equals()。
• 示例:
public final class MyKey {
private final int id;
private final String name;
public MyKey(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
MyKey other = (MyKey) obj;
return id == other.id && Objects.equals(name, other.name);
}
}
4. 总结
Key 类型 | 优点 | 适用场景 |
|---|---|---|
String | 不可变,哈希分布均匀,性能高 | 字符串作为 Key 的场景 |
Integer | 不可变,哈希计算简单 | 整数作为 Key 的场景 |
Enum | 不可变,JVM 唯一 | 枚举值作为 Key 的场景 |
| 自定义不可变类 | 灵活,需正确实现 hashCode() 和 equals() | 复杂对象作为 Key 的场景 |
• String 是 HashMap 中最常用的 Key 类型,因为它不可变、哈希分布均匀、性能高效。
• 如果需要使用其他类型作为 Key,确保其不可变且正确实现 hashCode() 和 equals()。
二十三、为什么HashMap要用红黑树而不是平衡二叉树?
HashMap 在 JDK 1.8 中引入了红黑树(Red-Black Tree)来优化链表过长时的查询性能。选择红黑树而不是平衡二叉树(如 AVL 树)的原因主要有以下几点:
1. 红黑树与平衡二叉树的对比
| 特性 | 红黑树 | 平衡二叉树(AVL 树) |
|---|---|---|
| 平衡性 | 弱平衡(最长路径不超过最短路径的 2 倍) | 强平衡(左右子树高度差 ≤ 1) |
| 插入/删除性能 | 更快(旋转次数较少) | 较慢(旋转次数较多) |
| 查询性能 | O(log n) | O(log n) |
| 实现复杂度 | 简单 | 复杂 |
| 适用场景 | 频繁插入/删除的场景 | 频繁查询的场景 |
2. 为什么 HashMap 选择红黑树?
2.1 插入和删除性能更高
• 红黑树:
红黑树的平衡性要求较低,插入和删除操作需要的旋转次数较少,性能更高。
• AVL 树:
AVL 树的平衡性要求严格,插入和删除操作可能需要频繁旋转,性能较低。
• HashMap 场景:
HashMap 中的键值对可能频繁插入和删除,红黑树的性能优势更明显。
2.2 查询性能满足需求
• 红黑树:
虽然红黑树的平衡性不如 AVL 树,但其查询性能仍然是 O(log n),足以满足 HashMap 的需求。
• AVL 树:
AVL 树的查询性能略优于红黑树,但在 HashMap 的场景中,这种优势并不明显。
2.3 实现更简单
• 红黑树:
红黑树的实现相对简单,适合作为 HashMap 的优化方案。
• AVL 树:
AVL 树的实现复杂,维护成本较高。
2.4 更适合哈希冲突场景
• 红黑树:
红黑树的弱平衡性更适合哈希冲突场景,因为冲突链表的长度通常不会特别长。
• AVL 树:
AVL 树的强平衡性在哈希冲突场景中显得过于严格,且性能优势不明显。
3. HashMap 中的红黑树实现
3.1 树化条件
• 当链表的长度 ≥ 8 且 HashMap 的容量 ≥ 64 时,链表会转换为红黑树。
• 当红黑树的节点数 ≤ 6 时,红黑树会退化为链表。
3.2 红黑树的优势
• 查询性能优化:
链表查询的时间复杂度为 O(n),而红黑树为 O(log n),显著提高了查询效率。
• 插入/删除性能:
红黑树的插入和删除操作性能优于 AVL 树,更适合 HashMap 的动态场景。
3.3 代码实现
HashMap 中的红黑树实现基于 TreeNode 类,继承自 Node 类:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左子节点
TreeNode<K,V> right; // 右子节点
TreeNode<K,V> prev; // 前驱节点(用于链表)
boolean red; // 节点颜色(红/黑)
}
4. 总结
| 特性 | 红黑树 | AVL 树 |
|---|---|---|
| 平衡性 | 弱平衡 | 强平衡 |
| 插入/删除性能 | 更高 | 较低 |
| 查询性能 | O(log n) | O(log n) |
| 实现复杂度 | 简单 | 复杂 |
| 适用场景 | 频繁插入/删除的场景 | 频繁查询的场景 |
• HashMap 选择红黑树的原因:
红黑树在插入/删除性能、实现复杂度和适用场景方面更符合 HashMap 的需求。
• 红黑树的优势:
在哈希冲突场景中,红黑树能够显著优化查询性能,同时保持较高的插入/删除性能。
二十四、hashmap key可以为null吗?
是的,HashMap 的 key 可以为 null。 这是 HashMap 的一个特性,允许使用 null 作为键。以下是关于 HashMap 中 null 键的详细说明:
1. HashMap 对 null 键的支持
• null 键的处理:
HashMap 允许 key 为 null,并且会将其存储在哈希表的第一个桶(即索引为 0 的位置)中。
• null 值的处理:
HashMap 也允许 value 为 null,可以存储多个 null 值。
2. 源码分析
在 HashMap 的源码中,null 键的处理是通过 hash(key) 方法实现的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
• null 键的哈希值:
如果 key 为 null,hash() 方法返回 0,因此 null 键总是存储在索引为 0 的位置。
3. 示例代码
以下是一个使用 null 键的示例:
Map<String, Integer> map = new HashMap<>();
map.put(null, 1); // 使用 null 作为 key
map.put("a", 2);
System.out.println(map.get(null)); // 输出: 1
System.out.println(map.containsKey(null)); // 输出: true
4. 注意事项
• null 键的唯一性:
HashMap 中只能有一个 null 键,因为 null 键的哈希值固定为 0。
• null 值的存储:
HashMap 可以存储多个 null 值,但需要注意 null 值的语义。
• ConcurrentHashMap 不支持 null 键:
与 HashMap 不同,ConcurrentHashMap 不允许 key 或 value 为 null,因为无法区分 null 值是不存在还是存在但值为 null。
5. 总结
| 特性 | HashMap | ConcurrentHashMap |
|---|---|---|
null 键 | 支持 | 不支持 |
null 值 | 支持 | 不支持 |
null 键的唯一性 | 只能有一个 null 键 | 不支持 |
• HashMap 支持 null 键:
这是 HashMap 的一个特性,允许在需要时使用 null 作为键。
• ConcurrentHashMap 不支持 null 键:
这是为了在并发环境下避免语义歧义。
在实际开发中,如果需要使用 null 键,可以选择 HashMap;如果需要线程安全的 Map,可以使用 ConcurrentHashMap,但需要注意其不支持 null 键和值。
二十五、重写HashMap的equal和hashcode方法需要注意什么?
在重写 HashMap 的 equals 和 hashCode 方法时,需要注意以下几点,以确保 HashMap 的正确性和性能:
1. equals 和 hashCode 的契约
1.1 equals 方法的规则
• 自反性:x.equals(x) 必须返回 true。
• 对称性:如果 x.equals(y) 返回 true,那么 y.equals(x) 也必须返回 true。
• 传递性:如果 x.equals(y) 返回 true,且 y.equals(z) 返回 true,那么 x.equals(z) 也必须返回 true。
• 一致性:如果对象没有修改,x.equals(y) 的多次调用必须返回相同的结果。
• 非空性:x.equals(null) 必须返回 false。
1.2 hashCode 方法的规则
• 一致性:如果对象没有修改,hashCode() 的多次调用必须返回相同的结果。
• 相等性:如果 x.equals(y) 返回 true,那么 x.hashCode() 必须等于 y.hashCode()。
• 不等性:如果 x.equals(y) 返回 false,x.hashCode() 和 y.hashCode() 可以相等(哈希冲突),但尽量不相等以提高性能。
2. 重写 equals 和 hashCode 的步骤
2.1 重写 equals 方法
- 检查是否为同一对象:如果是,返回
true。 - 检查是否为
null:如果是,返回false。 - 检查类型是否一致:如果不是同一类型,返回
false。 - 比较关键字段:比较对象的字段是否相等。
示例:
@Override
public boolean equals(Object obj) {
if (this == obj) return true; // 检查是否为同一对象
if (obj == null || getClass() != obj.getClass()) return false; // 检查类型
MyKey other = (MyKey) obj; // 类型转换
return Objects.equals(this.id, other.id) && Objects.equals(this.name, other.name); // 比较字段
}
2.2 重写 hashCode 方法
- 使用
Objects.hash():将关键字段作为参数传入Objects.hash(),生成哈希值。 - 确保一致性:哈希值的计算应基于
equals方法中使用的字段。
示例:
@Override
public int hashCode() {
return Objects.hash(id, name); // 基于关键字段生成哈希值
}
3. 注意事项
3.1 关键字段的选择
• 不可变字段:优先选择不可变字段(如 final 字段)作为关键字段,避免哈希值变化。
• 所有字段:确保 hashCode() 方法中使用的字段与 equals() 方法中使用的字段一致。
3.2 避免哈希冲突
• 分布均匀:尽量使哈希值分布均匀,减少冲突。
• 避免简单哈希:避免直接返回字段的哈希值(如 return id;),这样容易导致冲突。
3.3 性能优化
• 缓存哈希值:如果计算哈希值的开销较大,可以缓存哈希值(适用于不可变对象)。
• 避免重复计算:在 hashCode() 方法中避免重复计算。
3.4 线程安全性
• 不可变对象:如果对象不可变,哈希值可以缓存,且线程安全。
• 可变对象:如果对象可变,哈希值可能变化,导致 HashMap 行为异常。
4. 示例代码
以下是一个完整的示例:
public final class MyKey {
private final int id;
private final String name;
public MyKey(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
MyKey other = (MyKey) obj;
return id == other.id && Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
5. 总结
| 规则 | 说明 |
|---|---|
equals 方法 | 比较对象的关键字段,确保满足自反性、对称性、传递性、一致性和非空性。 |
hashCode 方法 | 基于关键字段生成哈希值,确保一致性、相等性和分布均匀性。 |
| 关键字段选择 | 使用不可变字段,确保与 equals() 方法一致。 |
| 避免哈希冲突 | 使哈希值分布均匀,减少冲突。 |
| 性能优化 | 缓存哈希值,避免重复计算。 |
| 线程安全性 | 不可变对象更适合作为 HashMap 的键。 |
通过正确重写 equals 和 hashCode 方法,可以确保 HashMap 的正确性和性能。
二十六、 重写HashMap的equal方法不当会出现什么问题?
如果重写 HashMap 的 equals 方法不当,可能会导致 HashMap 的行为异常,甚至引发严重的逻辑错误。以下是常见的问题及其原因和影响:
1. 数据丢失或覆盖
问题描述:
如果 equals 方法实现不正确,HashMap 可能无法正确识别相同的键,导致数据丢失或覆盖。
示例:
class MyKey {
private int id;
public MyKey(int id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
return true; // 错误实现:所有对象都相等
}
}
Map<MyKey, String> map = new HashMap<>();
map.put(new MyKey(1), "A");
map.put(new MyKey(2), "B"); // 覆盖了 MyKey(1) 的值
System.out.println(map.size()); // 输出: 1
原因:
equals 方法错误地认为所有对象都相等,导致 HashMap 无法区分不同的键。
影响:
• 数据被错误地覆盖。
• HashMap 无法正确存储和查找数据。
2. 无法找到已存储的数据
问题描述:
如果 equals 方法实现不一致,HashMap 可能无法找到已存储的数据。
示例:
class MyKey {
private int id;
public MyKey(int id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof MyKey) {
return id == ((MyKey) obj).id;
}
return false;
}
}
Map<MyKey, String> map = new HashMap<>();
MyKey key1 = new MyKey(1);
MyKey key2 = new MyKey(1);
map.put(key1, "A");
System.out.println(map.get(key2)); // 输出: null
原因:
equals 方法正确,但未重写 hashCode 方法,导致 HashMap 无法正确查找数据。
影响:
• 无法找到已存储的数据。
• HashMap 的行为不符合预期。
3. 违反 equals 方法的契约
问题描述:
如果 equals 方法违反自反性、对称性、传递性或一致性,HashMap 的行为将不可预测。
示例:
class MyKey {
private int id;
public MyKey(int id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof MyKey) {
return id < ((MyKey) obj).id; // 错误实现:违反对称性
}
return false;
}
}
Map<MyKey, String> map = new HashMap<>();
MyKey key1 = new MyKey(1);
MyKey key2 = new MyKey(2);
map.put(key1, "A");
map.put(key2, "B");
System.out.println(map.get(key1)); // 输出: null
原因:
equals 方法违反了对称性,导致 HashMap 无法正确比较键。
影响:
• HashMap 的行为不可预测。
• 数据存储和查找结果错误。
4. 性能问题
问题描述:
如果 equals 方法实现复杂或效率低下,HashMap 的性能将受到影响。
示例:
class MyKey {
private int id;
public MyKey(int id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
return obj instanceof MyKey && id == ((MyKey) obj).id;
}
}
Map<MyKey, String> map = new HashMap<>();
map.put(new MyKey(1), "A");
map.get(new MyKey(1)); // 耗时操作
原因:
equals 方法包含耗时操作,导致 HashMap 的查找性能下降。
影响:
• HashMap 的性能显著下降。
• 不适合高频操作场景。
5. 线程安全问题
问题描述:
如果 equals 方法依赖于可变字段,多线程环境下可能导致 HashMap 行为异常。
示例:
class MyKey {
private int id;
public MyKey(int id) {
this.id = id;
}
public void setId(int id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
return obj instanceof MyKey && id == ((MyKey) obj).id;
}
}
Map<MyKey, String> map = new HashMap<>();
MyKey key = new MyKey(1);
map.put(key, "A");
key.setId(2); // 修改 key 的字段
System.out.println(map.get(key)); // 输出: null
原因:
equals 方法依赖于可变字段,修改字段后哈希值发生变化,导致 HashMap 无法找到数据。
影响:
• 多线程环境下数据不一致。
• HashMap 的行为不可预测。
6. 总结
| 问题 | 原因 | 影响 |
|---|---|---|
| 数据丢失或覆盖 | equals 方法实现错误 | 数据被错误地覆盖,无法正确存储和查找数据 |
| 无法找到数据 | equals 或 hashCode 方法实现不一致 | 无法找到已存储的数据,行为不符合预期 |
违反 equals 契约 | equals 方法违反自反性、对称性等规则 | 行为不可预测,数据存储和查找结果错误 |
| 性能问题 | equals 方法实现复杂或效率低下 | HashMap 性能显著下降 |
| 线程安全问题 | equals 方法依赖于可变字段 | 多线程环境下数据不一致,行为不可预测 |
通过正确实现 equals 和 hashCode 方法,可以避免上述问题,确保 HashMap 的正确性和性能。
二十七、列举HashMap在多线程下可能会出现的问题?
HashMap 是 非线程安全 的,在多线程环境下使用可能会导致一系列问题。以下是 HashMap 在多线程下可能出现的常见问题及其原因和影响:
1. 数据丢失或覆盖
问题描述:
多个线程同时执行 put 操作时,可能会导致数据丢失或覆盖。
原因:
• 多个线程同时插入数据,可能导致其中一个线程的修改被忽略。
示例:
Map<String, Integer> map = new HashMap<>();
new Thread(() -> map.put("a", 1)).start();
new Thread(() -> map.put("a", 2)).start();
影响:
• 数据不一致,无法保证所有插入操作都生效。
2. 死循环(JDK 1.7 及之前)
问题描述:
在 JDK 1.7 及之前,HashMap 在扩容时采用 头插法,可能导致链表成环,进而引发死循环。
原因:
• 多个线程同时扩容时,链表结构被破坏,导致成环。
示例:
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
new Thread(() -> map.put(UUID.randomUUID().toString(), 1)).start();
}
影响:
• CPU 占用率飙升,程序卡死。
3. ConcurrentModificationException
问题描述:
一个线程在遍历 HashMap(如使用迭代器)时,另一个线程修改了 HashMap,可能导致 ConcurrentModificationException。
原因:
• HashMap 的迭代器是快速失败的(fail-fast),在遍历时检测到结构修改会抛出异常。
示例:
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
new Thread(() -> {
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}).start();
new Thread(() -> map.put("c", 3)).start();
影响:
• 程序抛出异常,无法继续执行。
4. 数据不一致
问题描述:
多个线程同时修改 HashMap,可能导致数据不一致。
原因:
• 线程间操作未同步,导致部分修改未生效。
示例:
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
new Thread(() -> map.put("a", 2)).start();
new Thread(() -> map.put("a", 3)).start();
影响:
• 最终结果不符合预期,数据状态不确定。
5. 弱一致性
问题描述:
一个线程读取 HashMap 时,另一个线程修改了 HashMap,可能导致读取到的数据不是最新的。
原因:
• HashMap 未使用任何同步机制(如 volatile 或 synchronized),无法保证线程间的可见性。
示例:
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
new Thread(() -> {
System.out.println(map.get("a")); // 可能输出 1 或 2
}).start();
new Thread(() -> map.put("a", 2)).start();
影响:
• 读取到的数据可能不是最新的,导致逻辑错误。
6. 哈希冲突加剧
问题描述:
多个线程同时插入数据,可能导致哈希冲突加剧,影响性能。
原因:
• 线程间操作未同步,可能导致链表或红黑树结构被破坏。
示例:
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
new Thread(() -> map.put(UUID.randomUUID().toString(), 1)).start();
}
影响:
• 哈希冲突加剧,查询性能下降。
7. 线程安全问题总结
| 问题 | 原因 | 影响 |
|---|---|---|
| 数据丢失或覆盖 | 多个线程同时修改数据 | 数据不一致,无法保证所有修改生效 |
| 死循环 | JDK 1.7 及之前扩容时链表成环 | CPU 占用率飙升,程序卡死 |
ConcurrentModificationException | 遍历时结构被修改 | 程序抛出异常,无法继续执行 |
| 数据不一致 | 线程间操作未同步 | 数据状态不确定,结果不符合预期 |
| 弱一致性 | 未保证线程间可见性 | 读取到的数据可能不是最新的 |
| 哈希冲突加剧 | 多个线程同时插入数据 | 查询性能下降 |
8. 解决方案
8.1 使用 ConcurrentHashMap
• 优点:
• 线程安全,支持高并发读写。
• 性能优异,锁粒度更细。
• 示例:
Map<String, Integer> map = new ConcurrentHashMap<>();
8.2 使用 Collections.synchronizedMap
• 优点:
• 将 HashMap 包装为线程安全的 Map。
• 缺点:
• 锁粒度较粗,性能较差。
• 示例:
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
8.3 使用显式同步
• 优点:
• 通过 synchronized 或 ReentrantLock 手动控制并发访问。
• 缺点:
• 实现复杂,性能较低。
• 示例:
Map<String, Integer> map = new HashMap<>();
Object lock = new Object();
synchronized (lock) {
map.put("a", 1);
}
9. 总结
• HashMap 不是线程安全的,在多线程环境下使用可能导致数据丢失、死循环、异常等问题。
• 推荐使用 ConcurrentHashMap,它是线程安全且高性能的替代方案。
• 如果并发度较低,可以使用 Collections.synchronizedMap 或显式同步,但需要注意性能开销。
二十八、HashMap的扩容机制介绍一下
HashMap 的扩容机制是其核心设计之一,用于在元素数量增加时动态调整哈希表的容量,以保持较低的哈希冲突率和较高的性能。以下是 HashMap 扩容机制的详细分析(基于 JDK 1.8):
1. 扩容的触发条件
HashMap 的扩容在以下情况下触发:
- 元素数量超过阈值:
当HashMap中的元素数量(size)超过容量 × 负载因子时,触发扩容。
• 默认容量:16
• 默认负载因子:0.75
• 示例:默认情况下,当元素数量超过16 × 0.75 = 12时,触发扩容。 - 链表转换为红黑树:
当链表长度 ≥ 8 且数组容量 < 64 时,优先扩容而不是树化。
2. 扩容的核心步骤
扩容过程主要包括以下步骤:
- 创建新数组:
新数组的容量是原数组的 2 倍(例如,从 16 扩容到 32)。 - 重新计算索引:
遍历原数组中的每个元素,重新计算其在新数组中的索引位置。 - 迁移数据:
将原数组中的元素迁移到新数组的对应位置。 - 更新引用:
将HashMap的内部数组引用指向新数组。
3. 索引重新计算优化
在 JDK 1.8 中,HashMap 对索引重新计算进行了优化,避免了重新计算所有哈希值:
• 旧索引位置:index = (n - 1) & hash,其中 n 是原数组长度。
• 新索引位置:
• 如果 (e.hash & oldCap) == 0,元素在新数组中的索引位置不变(index)。
• 如果 (e.hash & oldCap) != 0,元素在新数组中的索引位置为 index + oldCap。
• 原理:
通过检查哈希值的第 log2(oldCap) 位是否为 1,判断元素是否需要迁移到新位置。
4. 扩容的性能影响
• 时间复杂度:
扩容的时间复杂度为 O(n),其中 n 是原数组中的元素数量。
• 性能开销:
扩容涉及创建新数组、重新计算索引和迁移数据,是一个相对耗时的操作。
• 优化建议:
如果能够预估 HashMap 的元素数量,可以在创建时指定初始容量,避免频繁扩容。
5. 源码分析
以下是 HashMap 扩容的核心代码(简化):
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; // 原数组
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 原容量
int oldThr = threshold; // 原阈值
int newCap, newThr = 0;
// 1. 计算新容量和新阈值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 新阈值 = 原阈值 × 2
}
else if (oldThr > 0) // 初始容量设置为阈值
newCap = oldThr;
else { // 使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 2. 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 3. 迁移数据
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) // 只有一个节点
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 红黑树处理
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表处理
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 索引不变
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 索引 = 原索引 + oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null)
loTail.next = null;
if (hiTail != null)
hiTail.next = null;
newTab[j] = loHead;
newTab[j + oldCap] = hiHead;
}
}
}
}
// 4. 更新阈值
threshold = newThr;
return newTab;
}
6. 总结
| 特性 | 说明 |
|---|---|
| 触发条件 | 元素数量 > 容量 × 负载因子 或链表长度 ≥ 8 且容量 < 64 |
| 新容量 | 原容量的 2 倍 |
| 索引重新计算优化 | 通过 (e.hash & oldCap) 判断元素是否需要迁移到新位置 |
| 性能影响 | 时间复杂度为 O(n),涉及创建新数组、重新计算索引和迁移数据 |
| 优化建议 | 预估元素数量,指定初始容量,避免频繁扩容 |
通过理解 HashMap 的扩容机制,可以更好地优化其使用,避免性能问题。
二十九、HashMap的大小为什么是2的n次方大小呢?
HashMap 的大小(容量)设计为 2 的 n 次方,这是为了优化哈希表的性能,具体原因如下:
1. 快速计算索引
HashMap 通过 (n - 1) & hash 计算 key 的索引位置,其中 n 是数组长度,hash 是 key 的哈希值。
• 为什么是 2 的 n 次方:
当 n 是 2 的 n 次方时,(n - 1) 的二进制形式为全 1(例如,16 - 1 = 15,二进制为 1111)。
这样,(n - 1) & hash 相当于对 hash 取模运算(hash % n),但位运算的效率远高于取模运算。
• 示例:
int n = 16; // 2^4
int hash = 25; // 二进制: 11001
int index = (n - 1) & hash; // 15 & 25 = 9
• 优势:
位运算比取模运算更快,提升了索引计算的性能。
2. 均匀分布哈希值
HashMap 的哈希值分布越均匀,哈希冲突越少,性能越高。
• 为什么是 2 的 n 次方:
当 n 是 2 的 n 次方时,(n - 1) & hash 的结果取决于 hash 的低位,而 HashMap 的 hash() 方法通过将 hashCode() 的高位与低位异或,确保哈希值的低位更加随机,从而减少冲突。
• 示例:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
• 优势:
哈希值分布更均匀,减少了哈希冲突。
3. 扩容时的高效数据迁移
HashMap 在扩容时,新容量是原容量的 2 倍,这可以高效地迁移数据。
• 为什么是 2 的 n 次方:
当容量是 2 的 n 次方时,扩容后元素的索引位置只有两种可能:
• 索引不变((e.hash & oldCap) == 0)。
• 索引 = 原索引 + 原容量((e.hash & oldCap) != 0)。
• 示例:
int oldCap = 16; // 原容量
int newCap = 32; // 新容量
int hash = 25; // 二进制: 11001
int oldIndex = (oldCap - 1) & hash; // 15 & 25 = 9
int newIndex = (newCap - 1) & hash; // 31 & 25 = 25
• 优势:
扩容时只需检查哈希值的某一位即可确定新索引,避免了重新计算所有哈希值,提升了扩容性能。
4. 保证容量始终为 2 的 n 次方
HashMap 在初始化或扩容时,会通过 tableSizeFor() 方法将容量调整为大于等于指定值的最小 2 的 n 次方。
• 源码:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
• 示例:
int cap = 10;
int size = tableSizeFor(cap); // 返回 16
• 优势:
确保容量始终为 2 的 n 次方,优化哈希表的性能。
5. 总结
| 原因 | 说明 |
|---|---|
| 快速计算索引 | 通过 (n - 1) & hash 计算索引,位运算比取模运算更快。 |
| 均匀分布哈希值 | 哈希值的低位更加随机,减少冲突。 |
| 高效数据迁移 | 扩容时只需检查哈希值的某一位即可确定新索引,提升性能。 |
| 保证容量为 2 的 n 次方 | 通过 tableSizeFor() 方法调整容量,优化哈希表性能。 |
通过将 HashMap 的大小设计为 2 的 n 次方,可以显著提升哈希表的性能,减少哈希冲突,并简化扩容操作。
三十、往hashmap存20个元素,会扩容几次?
在 HashMap 中,扩容的次数取决于初始容量和负载因子。以下是详细分析:
1. 默认参数
• 默认初始容量:16
• 默认负载因子:0.75
• 扩容阈值:容量 × 负载因子 = 16 × 0.75 = 12
2. 扩容过程
• 第一次扩容:
当元素数量超过 12 时,触发第一次扩容。
新容量 = 原容量 × 2 = 16 × 2 = 32。
新阈值 = 32 × 0.75 = 24。
• 第二次扩容:
当元素数量超过 24 时,触发第二次扩容。
新容量 = 32 × 2 = 64。
新阈值 = 64 × 0.75 = 48。
3. 插入 20 个元素的扩容情况
• 插入前 12 个元素:
不触发扩容,容量为 16。
• 插入第 13 个元素:
触发第一次扩容,容量从 16 扩容到 32。
• 插入第 13 到 20 个元素:
元素数量为 20,未达到新阈值 24,不触发扩容。
4. 结论
往 HashMap 中插入 20 个元素,会触发 1 次扩容。
5. 扩容次数总结
| 元素数量 | 扩容次数 | 最终容量 |
|---|---|---|
| 1-12 | 0 | 16 |
| 13-20 | 1 | 32 |
6. 优化建议
如果预先知道需要存储 20 个元素,可以通过指定初始容量来避免扩容:
int initialCapacity = (int) Math.ceil(20 / 0.75); // 计算初始容量
Map<String, Integer> map = new HashMap<>(initialCapacity);
• 计算初始容量:
initialCapacity = ceil(元素数量 / 负载因子) = ceil(20 / 0.75) = 27。
• 最终容量:
HashMap 会将初始容量调整为大于等于 27 的最小 2 的 n 次方,即 32。
通过指定初始容量,可以避免扩容,提升性能。
三十一、说说hashmap的负载因子
HashMap 的负载因子(Load Factor)是一个重要的参数,它决定了 HashMap 在什么情况下会触发扩容。以下是关于负载因子的详细说明:
1. 负载因子的定义
• 负载因子:
负载因子是一个浮点数,表示 HashMap 中元素数量与容量的比值,用于衡量哈希表的填充程度。
• 默认值:
HashMap 的默认负载因子是 0.75。
2. 负载因子的作用
负载因子主要用于控制 HashMap 的扩容时机:
• 扩容阈值:
扩容阈值 = 容量 × 负载因子。
当 HashMap 中的元素数量超过扩容阈值时,触发扩容。
• 示例:
默认容量为 16,负载因子为 0.75,扩容阈值为 16 × 0.75 = 12。
当元素数量超过 12 时,HashMap 会扩容。
3. 负载因子的影响
3.1 负载因子过高
• 优点:
扩容阈值较高,减少扩容次数,节省内存空间。
• 缺点:
哈希冲突概率增加,查询性能下降。
3.2 负载因子过低
• 优点:
哈希冲突概率降低,查询性能提高。
• 缺点:
扩容阈值较低,增加扩容次数,占用更多内存空间。
4. 负载因子的选择
• 默认值 0.75:
这是 HashMap 的设计者在时间和空间成本之间的一个折中选择。
• 在大多数场景下,0.75 的负载因子能够平衡查询性能和内存开销。
• 哈希冲突概率较低,同时扩容次数适中。
• 自定义负载因子:
如果对性能或内存有特殊需求,可以通过构造函数指定负载因子:
Map<String, Integer> map = new HashMap<>(16, 0.5f); // 初始容量 16,负载因子 0.5
5. 负载因子与扩容的关系
• 扩容过程:
当元素数量超过扩容阈值时,HashMap 会将容量扩大为原来的 2 倍,并重新计算所有元素的索引位置。
• 示例:
默认容量为 16,负载因子为 0.75,扩容阈值为 12。
• 插入第 13 个元素时,触发扩容,容量从 16 扩容到 32。
• 新扩容阈值为 32 × 0.75 = 24。
6. 负载因子与性能优化
• 高查询性能场景:
如果查询性能是主要关注点,可以设置较低的负载因子(如 0.5),以减少哈希冲突。
• 内存敏感场景:
如果内存是主要关注点,可以设置较高的负载因子(如 0.8),以减少扩容次数。
7. 总结
| 负载因子 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 高(如 0.8) | 减少扩容次数,节省内存空间 | 哈希冲突概率增加,查询性能下降 | 内存敏感场景 |
| 低(如 0.5) | 哈希冲突概率降低,查询性能提高 | 增加扩容次数,占用更多内存空间 | 高查询性能场景 |
| 默认(0.75) | 平衡查询性能和内存开销 | 无显著缺点 | 大多数场景 |
• 负载因子是 HashMap 的重要参数,直接影响哈希表的性能和内存开销。
• 默认值 0.75 是一个折中选择,适合大多数场景。
• 如果需要优化性能或内存,可以根据需求调整负载因子。
三十二、Hashmap和Hashtable有什么不一样的?Hashmap一般怎么用?HashMap 和 Hashtable 是 Java 中两种常用的键值对存储结构,尽管它们的功能相似,但在设计、性能和线程安全性等方面有显著区别。以下是它们的对比以及 HashMap 的常见用法:
1. HashMap 和 Hashtable 的区别
| 特性 | HashMap | Hashtable |
|---|---|---|
| 线程安全性 | 非线程安全 | 线程安全 |
| 性能 | 更高 | 较低 |
null 键和值 | 允许 null 键和值 | 不允许 null 键和值 |
| 继承关系 | 继承自 AbstractMap | 继承自 Dictionary |
| 初始容量和扩容 | 默认容量 16,扩容为 2 倍 | 默认容量 11,扩容为 2 倍 + 1 |
| 锁机制 | 无锁 | 所有方法加 synchronized 锁 |
| 推荐使用场景 | 单线程或线程封闭场景 | 多线程场景(已被 ConcurrentHashMap 取代) |
1.1 线程安全性
• HashMap:
非线程安全,多线程环境下使用可能导致数据不一致或异常。
• Hashtable:
线程安全,所有方法都使用 synchronized 锁,但性能较低。
1.2 性能
• HashMap:
无锁设计,性能更高,适合单线程或线程封闭场景。
• Hashtable:
所有方法加锁,性能较低,适合多线程场景。
1.3 null 键和值
• HashMap:
允许 null 键和值。
• Hashtable:
不允许 null 键和值,否则会抛出 NullPointerException。
1.4 继承关系
• HashMap:
继承自 AbstractMap,是现代 Java 集合框架的一部分。
• Hashtable:
继承自 Dictionary,是早期的 Java 集合类。
1.5 初始容量和扩容
• HashMap:
默认容量为 16,扩容时容量扩大为原来的 2 倍。
• Hashtable:
默认容量为 11,扩容时容量扩大为原来的 2 倍 + 1。
1.6 推荐使用场景
• HashMap:
单线程或线程封闭场景,性能更高。
• Hashtable:
多线程场景,但已被 ConcurrentHashMap 取代。
2. HashMap 的常见用法
2.1 基本操作
2.1.1 插入数据
Map<String, Integer> map = new HashMap<>();
map.put("a", 1); // 插入键值对
map.put("b", 2);
2.1.2 获取数据
int value = map.get("a"); // 获取值
System.out.println(value); // 输出: 1
2.1.3 删除数据
map.remove("a"); // 删除键值对
2.1.4 检查键或值是否存在
boolean containsKey = map.containsKey("a"); // 检查键是否存在
boolean containsValue = map.containsValue(1); // 检查值是否存在
2.2 遍历 HashMap
2.2.1 遍历键值对
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
2.2.2 遍历键
for (String key : map.keySet()) {
System.out.println(key);
}
2.2.3 遍历值
for (int value : map.values()) {
System.out.println(value);
}
2.3 初始化与优化
2.3.1 指定初始容量和负载因子
Map<String, Integer> map = new HashMap<>(16, 0.75f); // 初始容量 16,负载因子 0.75
2.3.2 避免频繁扩容
int initialCapacity = (int) Math.ceil(expectedSize / 0.75); // 计算初始容量
Map<String, Integer> map = new HashMap<>(initialCapacity);
2.4 使用 null 键和值
map.put(null, 1); // 使用 null 作为键
map.put("a", null); // 使用 null 作为值
2.5 线程安全场景
2.5.1 使用 Collections.synchronizedMap
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
2.5.2 使用 ConcurrentHashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
3. 总结
• HashMap 和 Hashtable 的区别:
HashMap 非线程安全,性能更高,允许 null 键和值;Hashtable 线程安全,性能较低,不允许 null 键和值。
• HashMap 的常见用法:
插入、获取、删除数据,遍历键值对,初始化优化,使用 null 键和值,以及线程安全场景的替代方案。
• 推荐使用 HashMap:
在单线程或线程封闭场景中,HashMap 是更高效的选择;在多线程场景中,推荐使用 ConcurrentHashMap。
三十三、ConcurrentHashMap怎么实现的?
ConcurrentHashMap 是 Java 中线程安全的哈希表实现,专为高并发场景设计。与 Hashtable 和 Collections.synchronizedMap 不同,ConcurrentHashMap 通过更细粒度的锁机制和 CAS(Compare-And-Swap)操作实现了更高的并发性能。以下是 ConcurrentHashMap 的实现原理和关键设计:
1. 核心设计思想
ConcurrentHashMap 的设计目标是 高并发性能 和 线程安全性,主要采用了以下技术:
- 分段锁(JDK 1.7):
将哈希表分为多个段(Segment),每个段独立加锁,减少锁的竞争。 - CAS +
synchronized(JDK 1.8):
取消分段锁,改用 CAS 和synchronized锁桶头节点,进一步降低锁粒度。 - 红黑树优化:
当链表长度超过阈值时,将链表转换为红黑树,提升查询性能。
2. JDK 1.7 的实现(分段锁)
2.1 数据结构
• Segment 数组:
ConcurrentHashMap 内部维护一个 Segment 数组,每个 Segment 是一个独立的哈希表。
• HashEntry 数组:
每个 Segment 内部维护一个 HashEntry 数组,用于存储键值对。
2.2 锁机制
• 分段锁:
每个 Segment 独立加锁,不同 Segment 的操作可以并发执行。
• 锁粒度:
锁的粒度从整个哈希表降低到单个 Segment,减少了锁竞争。
2.3 操作流程
• put 操作:
根据 key 的哈希值定位到对应的 Segment,然后对 Segment 加锁,执行插入操作。
• get 操作:
根据 key 的哈希值定位到对应的 Segment,无需加锁,直接读取数据。
2.4 优缺点
• 优点:
锁粒度较细,并发性能较高。
• 缺点:
实现复杂,锁的粒度仍然较大。
3. JDK 1.8 的实现(CAS + synchronized)
3.1 数据结构
• Node 数组:
ConcurrentHashMap 内部维护一个 Node 数组,每个 Node 是一个链表节点或红黑树节点。
• 红黑树:
当链表长度超过阈值(默认 8)时,将链表转换为红黑树。
3.2 锁机制
• CAS 操作:
在插入、删除等操作中,使用 CAS 保证原子性。
• synchronized 锁:
当 CAS 操作失败时,对桶头节点加 synchronized 锁。
3.3 操作流程
• put 操作:
- 根据 key 的哈希值定位到对应的桶。
- 如果桶为空,使用 CAS 插入新节点。
- 如果桶不为空,对桶头节点加
synchronized锁,执行插入操作。 - 如果链表长度超过阈值,将链表转换为红黑树。
•get操作: - 根据 key 的哈希值定位到对应的桶。
- 遍历链表或红黑树,查找目标节点。
3.4 优缺点
• 优点:
锁粒度更细,并发性能更高。
• 缺点:
实现复杂,CAS 操作可能导致 CPU 开销增加。
4. 关键特性
4.1 线程安全性
• put 和 get 操作:
通过 CAS 和 synchronized 锁保证线程安全性。
• 弱一致性:
ConcurrentHashMap 的迭代器是弱一致的,遍历时可能反映部分修改。
4.2 高并发性能
• 锁粒度:
锁的粒度从整个哈希表降低到单个桶头节点,减少了锁竞争。
• CAS 操作:
使用 CAS 实现无锁操作,提升了并发性能。
4.3 红黑树优化
• 链表转红黑树:
当链表长度超过阈值时,将链表转换为红黑树,提升查询性能。
• 红黑树退化为链表:
当红黑树节点数小于阈值时,将红黑树退化为链表。
5. 示例代码
5.1 基本操作
ConcurrentMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1); // 插入键值对
map.put("b", 2);
int value = map.get("a"); // 获取值
System.out.println(value); // 输出: 1
map.remove("a"); // 删除键值对
5.2 遍历操作
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
5.3 线程安全操作
map.computeIfAbsent("c", k -> 3); // 如果键不存在,插入值
map.computeIfPresent("b", (k, v) -> v + 1); // 如果键存在,更新值
6. 总结
| 特性 | JDK 1.7(分段锁) | JDK 1.8(CAS + synchronized) |
|---|---|---|
| 锁机制 | 分段锁 | CAS + synchronized 锁 |
| 锁粒度 | 单个 Segment | 单个桶头节点 |
| 并发性能 | 较高 | 更高 |
| 红黑树优化 | 无 | 支持链表转红黑树 |
| 推荐使用场景 | 高并发场景 | 高并发场景 |
• ConcurrentHashMap 是线程安全的哈希表,通过分段锁或 CAS + synchronized 锁实现高并发性能。
• JDK 1.8 的实现更高效,锁粒度更细,支持红黑树优化,适合高并发场景。
三十四、分段锁怎么加锁的?
在 JDK 1.7 中,ConcurrentHashMap 使用 分段锁(Segment Locking) 来实现线程安全和高并发性能。分段锁的核心思想是将整个哈希表分为多个段(Segment),每个段独立加锁,从而减少锁的竞争。以下是分段锁的详细实现原理:
1. 数据结构
1.1 Segment 数组
• ConcurrentHashMap 内部维护一个 Segment 数组,每个 Segment 是一个独立的哈希表。
• Segment 继承自 ReentrantLock,具备加锁和解锁的能力。
1.2 HashEntry 数组
• 每个 Segment 内部维护一个 HashEntry 数组,用于存储键值对。
• HashEntry 是一个链表节点,包含 key、value 和 next 指针。
2. 分段锁的实现
2.1 分段锁的原理
• 分段:
将哈希表分为多个段(默认 16 个段),每个段独立加锁。
• 锁粒度:
锁的粒度从整个哈希表降低到单个段,减少了锁竞争。
2.2 加锁过程
• 定位段:
根据 key 的哈希值定位到对应的段。
• 加锁:
对目标段加锁,其他段不受影响,可以并发操作。
• 解锁:
操作完成后,释放目标段的锁。
3. 操作流程
3.1 put 操作
- 计算哈希值:
根据 key 的哈希值定位到对应的段。 - 加锁:
对目标段加锁。 - 插入数据:
在目标段的HashEntry数组中插入或更新键值对。 - 解锁:
释放目标段的锁。
3.2 get 操作
- 计算哈希值:
根据 key 的哈希值定位到对应的段。 - 读取数据:
在目标段的HashEntry数组中查找键值对,无需加锁。 - 返回结果:
返回找到的值或null。
4. 源码分析
以下是 JDK 1.7 中 ConcurrentHashMap 的部分源码(简化):
4.1 Segment 类
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table; // HashEntry 数组
transient int count; // 段中的元素数量
// 插入键值对
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
try {
// 插入或更新键值对
} finally {
unlock(); // 解锁
}
}
}
4.2 HashEntry 类
static final class HashEntry<K,V> {
final K key;
volatile V value;
final int hash;
final HashEntry<K,V> next;
HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
this.key = key;
this.hash = hash;
this.next = next;
this.value = value;
}
}
4.3 put 方法
public V put(K key, V value) {
int hash = hash(key.hashCode());
return segmentFor(hash).put(key, hash, value, false);
}
4.4 get 方法
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
5. 优缺点
5.1 优点
• 锁粒度较细:
锁的粒度从整个哈希表降低到单个段,减少了锁竞争。
• 并发性能较高:
不同段的操作可以并发执行,提升了并发性能。
5.2 缺点
• 实现复杂:
分段锁的实现较为复杂,增加了代码的维护成本。
• 锁粒度仍然较大:
锁的粒度是单个段,如果多个线程操作同一个段,仍然存在锁竞争。
6. 总结
| 特性 | 说明 |
|---|---|
| 锁机制 | 分段锁,每个段独立加锁 |
| 锁粒度 | 单个段,比 Hashtable 的全局锁更细 |
| 并发性能 | 较高,不同段的操作可以并发执行 |
| 实现复杂度 | 较高,增加了代码的维护成本 |
| 适用场景 | 高并发场景,但已被 JDK 1.8 的 ConcurrentHashMap 取代 |
• 分段锁是 JDK 1.7 中 ConcurrentHashMap 的核心设计,通过将哈希表分为多个段,减少了锁竞争,提升了并发性能。
• JDK 1.8 中改用 CAS + synchronized 锁,进一步降低了锁粒度,提升了并发性能。
三十五、分段锁是可重入的吗?
是的,ConcurrentHashMap 中的分段锁(Segment Lock)是可重入的。 在 JDK 1.7 中,Segment 类继承自 ReentrantLock,而 ReentrantLock 是一种可重入锁。以下是关于分段锁可重入性的详细说明:
1. 什么是可重入锁?
• 可重入锁:
允许同一个线程多次获取同一把锁,而不会造成死锁。
• 示例:
如果一个线程已经持有了锁,它可以再次获取该锁,而不会被阻塞。
2. Segment 类的实现
在 JDK 1.7 中,Segment 类继承自 ReentrantLock,因此它具备可重入锁的特性。
2.1 Segment 类源码(简化)
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// Segment 的其他字段和方法
}
2.2 ReentrantLock 的特性
• 可重入性:
同一个线程可以多次获取锁,每次获取锁时,锁的计数器加 1;释放锁时,计数器减 1。
• 公平性:
ReentrantLock 支持公平锁和非公平锁,默认是非公平锁。
3. 分段锁的可重入性
3.1 可重入场景
• 如果一个线程已经持有了某个 Segment 的锁,它可以再次获取该 Segment 的锁,而不会被阻塞。
• 例如,在递归调用或嵌套同步块中,线程可以多次获取同一把锁。
3.2 示例代码
Segment<K,V> segment = ...; // 获取某个 Segment
segment.lock(); // 第一次获取锁
try {
// 线程可以再次获取锁
segment.lock(); // 第二次获取锁
try {
// 执行操作
} finally {
segment.unlock(); // 释放第二次获取的锁
}
} finally {
segment.unlock(); // 释放第一次获取的锁
}
4. 可重入锁的优势
• 避免死锁:
在递归调用或嵌套同步块中,可重入锁可以避免线程自己阻塞自己。
• 简化代码:
不需要担心线程多次获取同一把锁导致死锁,代码实现更简单。
5. 总结
| 特性 | 说明 |
|---|---|
| 可重入性 | Segment 继承自 ReentrantLock,支持可重入锁 |
| 锁计数器 | 每次获取锁时计数器加 1,释放锁时计数器减 1 |
| 避免死锁 | 允许同一个线程多次获取同一把锁,避免死锁 |
| 适用场景 | 递归调用、嵌套同步块等需要多次获取同一把锁的场景 |
• 分段锁是可重入的,这为复杂的并发操作提供了便利,同时避免了死锁问题。
• 在 JDK 1.8 中,ConcurrentHashMap 取消了分段锁,改用 CAS + synchronized 锁,但 synchronized 也是可重入的,因此仍然支持可重入性。
三十六、已经用了synchronized,为什么还要用CAS呢?
在并发编程中,synchronized 和 CAS(Compare-And-Swap)是两种不同的线程同步机制,它们各有优缺点。尽管 synchronized 提供了简单易用的锁机制,但在某些场景下,CAS 可以提供更高的性能和更细粒度的控制。以下是 synchronized 和 CAS 的结合使用的原因和优势:
1. synchronized 的局限性
1.1 锁粒度较粗
• 问题:
synchronized 锁的粒度通常是方法或代码块,可能导致不必要的锁竞争。
• 示例:
如果一个方法中有多个独立资源需要保护,使用 synchronized 会锁定整个方法,即使这些资源之间没有竞争关系。
1.2 性能开销
• 问题:
synchronized 在获取锁和释放锁时,会涉及线程的阻塞和唤醒,导致性能开销。
• 示例:
在高并发场景下,频繁的锁竞争会导致线程频繁切换,降低系统性能。
1.3 死锁风险
• 问题:
如果多个线程以不同的顺序获取锁,可能导致死锁。
• 示例:
线程 A 持有锁 X,等待锁 Y;线程 B 持有锁 Y,等待锁 X。
2. CAS 的优势
2.1 无锁操作
• 优势:
CAS 是一种无锁操作,通过硬件指令实现原子性,避免了线程阻塞和唤醒的开销。
• 示例:
在 ConcurrentHashMap 中,插入操作首先尝试使用 CAS,只有在 CAS 失败时才使用 synchronized。
2.2 细粒度控制
• 优势:
CAS 可以针对单个变量或资源进行操作,锁粒度更细,减少了锁竞争。
• 示例:
在 AtomicInteger 中,CAS 直接操作 int 值,无需锁定整个对象。
2.3 高并发性能
• 优势:
在低竞争场景下,CAS 的性能远高于 synchronized。
• 示例:
在 ConcurrentHashMap 中,大多数情况下插入操作可以通过 CAS 完成,只有在哈希冲突时才使用 synchronized。
3. synchronized 和 CAS 的结合使用
3.1 设计思想
• 乐观锁策略:
首先尝试使用 CAS 进行操作,如果 CAS 失败,再使用 synchronized 锁定资源。
• 降低锁竞争:
通过 CAS 减少 synchronized 的使用频率,降低锁竞争。
3.2 实现示例
以下是 ConcurrentHashMap 中 put 方法的简化实现:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 1. 尝试使用 CAS 插入数据
if (casInsert(key, value)) {
return null;
}
// 2. 如果 CAS 失败,使用 synchronized 锁定桶头节点
synchronized (lock) {
// 3. 再次尝试插入数据
if (tryInsert(key, value)) {
return null;
}
// 4. 如果仍然失败,执行链表或红黑树插入操作
return insertNode(key, value);
}
}
3.3 优势
• 性能优化:
在低竞争场景下,CAS 可以快速完成操作,避免了 synchronized 的开销。
• 锁竞争减少:
只有在 CAS 失败时才使用 synchronized,减少了锁竞争。
• 代码简洁:
结合使用 CAS 和 synchronized,代码逻辑更清晰,易于维护。
4. 总结
| 特性 | synchronized | CAS |
|---|---|---|
| 锁粒度 | 较粗(方法或代码块) | 较细(单个变量或资源) |
| 性能开销 | 较高(涉及线程阻塞和唤醒) | 较低(无锁操作) |
| 适用场景 | 高竞争场景 | 低竞争场景 |
| 结合使用优势 | 提供简单易用的锁机制 | 提供高性能和细粒度控制 |
• synchronized 和 CAS 的结合使用:
在高并发场景下,结合使用 synchronized 和 CAS 可以优化性能,减少锁竞争,同时保证线程安全性。
• ConcurrentHashMap 的设计:
通过 CAS + synchronized 的设计,ConcurrentHashMap 在高并发场景下表现出色,是并发编程的经典范例。
三十七、ConcurrentHashMap用了悲观锁还是乐观锁?
ConcurrentHashMap 在 JDK 1.8 中结合了 乐观锁(CAS) 和 悲观锁(synchronized) 的设计,具体使用哪种锁取决于操作场景和并发情况。以下是详细分析:
1. 乐观锁(CAS)
1.1 使用场景
• 低竞争场景:
在低竞争场景下(如大多数插入操作),ConcurrentHashMap 优先使用 CAS 实现无锁操作。
• 具体操作:
• 插入新节点时,首先尝试使用 CAS 将新节点插入到桶中。
• 更新节点值时,尝试使用 CAS 更新节点的 value 字段。
1.2 优势
• 无锁操作:
CAS 是一种无锁操作,避免了线程阻塞和唤醒的开销,性能更高。
• 细粒度控制:
CAS 直接操作单个变量或资源,锁粒度更细,减少了锁竞争。
1.3 实现示例
以下是 ConcurrentHashMap 中插入操作的简化代码:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 1. 尝试使用 CAS 插入数据
if (casInsert(key, value)) {
return null;
}
// 2. 如果 CAS 失败,使用 synchronized 锁定桶头节点
synchronized (lock) {
// 3. 再次尝试插入数据
if (tryInsert(key, value)) {
return null;
}
// 4. 如果仍然失败,执行链表或红黑树插入操作
return insertNode(key, value);
}
}
2. 悲观锁(synchronized)
2.1 使用场景
• 高竞争场景:
在高竞争场景下(如哈希冲突或链表/红黑树操作),ConcurrentHashMap 使用 synchronized 锁定桶头节点。
• 具体操作:
• 当 CAS 操作失败时,对桶头节点加 synchronized 锁。
• 在链表或红黑树操作中,使用 synchronized 锁定桶头节点。
2.2 优势
• 线程安全:
synchronized 提供了简单易用的锁机制,确保线程安全。
• 可重入性:
synchronized 是可重入锁,同一个线程可以多次获取同一把锁。
2.3 实现示例
以下是 ConcurrentHashMap 中链表操作的简化代码:
synchronized (lock) {
// 在锁定状态下执行链表操作
if (tryInsert(key, value)) {
return null;
}
return insertNode(key, value);
}
3. 结合使用的原因
3.1 性能优化
• 低竞争场景:
使用 CAS 实现无锁操作,避免线程阻塞和唤醒的开销,提升性能。
• 高竞争场景:
使用 synchronized 锁定桶头节点,确保线程安全,同时减少锁竞争。
3.2 锁粒度控制
• CAS:
锁粒度更细,直接操作单个变量或资源,减少锁竞争。
• synchronized:
锁粒度较粗,锁定桶头节点,适用于高竞争场景。
3.3 代码简洁
• 乐观锁优先:
首先尝试使用 CAS,只有在 CAS 失败时才使用 synchronized,代码逻辑更清晰,易于维护。
4. 总结
| 锁类型 | 使用场景 | 优势 |
|---|---|---|
| 乐观锁(CAS) | 低竞争场景(如大多数插入操作) | 无锁操作,性能高,锁粒度更细 |
悲观锁(synchronized) | 高竞争场景(如哈希冲突或链表/红黑树操作) | 线程安全,可重入,代码简单 |
• ConcurrentHashMap 结合了乐观锁和悲观锁:
在低竞争场景下使用 CAS 提升性能,在高竞争场景下使用 synchronized 确保线程安全。
• 设计思想:
通过乐观锁优先的策略,减少锁竞争,优化性能,同时保证线程安全。
三十八、HashTable 底层实现原理是什么?
Hashtable 是 Java 中早期提供的线程安全的哈希表实现,其底层实现原理与 HashMap 类似,但在线程安全性和一些细节上有显著区别。以下是 Hashtable 的底层实现原理的详细分析:
1. 数据结构
1.1 数组 + 链表
• 数组:
Hashtable 内部维护一个 Entry 数组,用于存储键值对。
• 链表:
当发生哈希冲突时,Hashtable 使用链表解决冲突,将冲突的键值对存储在同一个桶中。
1.2 Entry 类
Hashtable 中的键值对通过 Entry 类表示:
private static class Entry<K,V> implements Map.Entry<K,V> {
final int hash; // 哈希值
final K key; // 键
V value; // 值
Entry<K,V> next; // 下一个节点(链表)
Entry(int hash, K key, V value, Entry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
2. 核心操作
2.1 put 操作
- 计算哈希值:
根据 key 的hashCode()计算哈希值。 - 确定索引:
通过(数组长度 - 1) & hash计算键值对在数组中的索引。 - 插入或更新:
• 如果桶为空,直接插入新节点。
• 如果桶不为空,遍历链表,检查是否存在相同 key:
◦ 如果存在,更新 value。
◦ 如果不存在,将新节点插入链表头部。 - 扩容检查:
如果元素数量超过阈值(容量 × 负载因子),触发扩容。
2.2 get 操作
- 计算哈希值:
根据 key 的hashCode()计算哈希值。 - 确定索引:
通过(数组长度 - 1) & hash计算键值对在数组中的索引。 - 查找数据:
• 如果桶为空,返回null。
• 如果桶不为空,遍历链表,查找目标 key。
2.3 扩容机制
• 触发条件:
当元素数量超过阈值(容量 × 负载因子)时,触发扩容。
• 扩容规则:
新容量 = 原容量 × 2 + 1。
• 数据迁移:
遍历原数组,重新计算每个键值对的索引位置,并迁移到新数组。
3. 线程安全性
3.1 全局锁
• 锁机制:
Hashtable 的所有方法都使用 synchronized 锁,确保线程安全。
• 锁粒度:
锁的粒度是整个 Hashtable,即同一时间只能有一个线程操作 Hashtable。
3.2 性能问题
• 锁竞争:
由于锁粒度较粗,多线程环境下锁竞争激烈,性能较低。
• 替代方案:
在高并发场景下,推荐使用 ConcurrentHashMap,它通过分段锁或 CAS + synchronized 实现了更高的并发性能。
4. 源码分析
以下是 Hashtable 的部分源码(简化):
4.1 put 方法
public synchronized V put(K key, V value) {
// 检查 value 是否为 null
if (value == null) {
throw new NullPointerException();
}
// 计算哈希值
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
// 插入或更新键值对
for (Entry<K,V> e = table[index]; e != null; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
V old = e.value;
e.value = value;
return old;
}
}
// 插入新节点
addEntry(hash, key, value, index);
return null;
}
4.2 get 方法
public synchronized V get(Object key) {
// 计算哈希值
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
// 查找数据
for (Entry<K,V> e = table[index]; e != null; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return e.value;
}
}
return null;
}
4.3 扩容方法
protected void rehash() {
int oldCapacity = table.length;
Entry<K,V>[] oldMap = table;
// 计算新容量
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
return;
newCapacity = MAX_ARRAY_SIZE;
}
// 创建新数组
Entry<K,V>[] newMap = new Entry[newCapacity];
// 迁移数据
for (int i = oldCapacity; i-- > 0;) {
for (Entry<K,V> old = oldMap[i]; old != null;) {
Entry<K,V> e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = newMap[index];
newMap[index] = e;
}
}
table = newMap;
}
5. 总结
| 特性 | 说明 |
|---|---|
| 数据结构 | 数组 + 链表,使用 Entry 类存储键值对 |
| 线程安全性 | 所有方法加 synchronized 锁,确保线程安全 |
| 锁粒度 | 整个 Hashtable,锁粒度较粗 |
| 性能问题 | 锁竞争激烈,性能较低 |
| 替代方案 | 在高并发场景下,推荐使用 ConcurrentHashMap |
• Hashtable 是线程安全的哈希表,通过全局锁实现线程安全性,但性能较低。
• 在高并发场景下,ConcurrentHashMap 是更好的选择,它通过分段锁或 CAS + synchronized 实现了更高的并发性能。
三十九、HashTable线程安全是怎么实现的?
Hashtable 是 Java 中早期提供的线程安全的哈希表实现,其线程安全性是通过 全局锁 实现的。具体来说,Hashtable 的所有公共方法都使用了 synchronized 关键字,确保同一时间只有一个线程可以操作 Hashtable。以下是 Hashtable 线程安全实现的详细分析:
1. 全局锁机制
1.1 synchronized 关键字
• 锁对象:
Hashtable 的所有公共方法都使用了 synchronized 关键字,锁对象是 Hashtable 实例本身。
• 锁粒度:
锁的粒度是整个 Hashtable,即同一时间只能有一个线程操作 Hashtable。
1.2 实现方式
• 方法级别锁:
在方法声明中使用 synchronized 关键字,锁定整个方法。
• 代码块锁:
在代码块中使用 synchronized 关键字,锁定特定代码块。
2. 源码分析
以下是 Hashtable 的部分源码(简化),展示了其线程安全实现:
2.1 put 方法
public synchronized V put(K key, V value) {
// 检查 value 是否为 null
if (value == null) {
throw new NullPointerException();
}
// 计算哈希值
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
// 插入或更新键值对
for (Entry<K,V> e = table[index]; e != null; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
V old = e.value;
e.value = value;
return old;
}
}
// 插入新节点
addEntry(hash, key, value, index);
return null;
}
2.2 get 方法
public synchronized V get(Object key) {
// 计算哈希值
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
// 查找数据
for (Entry<K,V> e = table[index]; e != null; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return e.value;
}
}
return null;
}
2.3 remove 方法
public synchronized V remove(Object key) {
// 计算哈希值
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
// 查找并删除键值对
Entry<K,V> prev = table[index];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
if ((e.hash == hash) && e.key.equals(key)) {
if (prev == e) {
table[index] = next;
} else {
prev.next = next;
}
return e.value;
}
prev = e;
e = next;
}
return null;
}
3. 线程安全性的优缺点
3.1 优点
• 简单易用:
通过 synchronized 关键字实现线程安全,代码实现简单。
• 线程安全:
确保多线程环境下 Hashtable 的操作是线程安全的。
3.2 缺点
• 锁粒度较粗:
锁的粒度是整个 Hashtable,导致多线程环境下锁竞争激烈,性能较低。
• 性能问题:
在高并发场景下,频繁的锁竞争会导致性能下降。
4. 替代方案
在高并发场景下,Hashtable 的性能问题使其不再是最佳选择。以下是推荐的替代方案:
4.1 ConcurrentHashMap
• 锁粒度更细:
ConcurrentHashMap 使用分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8),锁粒度更细,性能更高。
• 高并发性能:
适合高并发场景,是 Hashtable 的现代替代品。
4.2 Collections.synchronizedMap
• 包装 HashMap:
通过 Collections.synchronizedMap 将 HashMap 包装为线程安全的 Map。
• 锁粒度较粗:
锁粒度与 Hashtable 类似,性能较低,适合低并发场景。
5. 总结
| 特性 | 说明 |
|---|---|
| 线程安全实现 | 所有公共方法使用 synchronized 关键字,锁粒度是整个 Hashtable |
| 优点 | 简单易用,线程安全 |
| 缺点 | 锁粒度较粗,性能较低 |
| 替代方案 | ConcurrentHashMap(高并发场景),Collections.synchronizedMap(低并发场景) |
• Hashtable 通过全局锁实现线程安全,但在高并发场景下性能较低。
• 推荐使用 ConcurrentHashMap,它在高并发场景下性能更高,是 Hashtable 的现代替代品。
四十、 hashtable 和concurrentHashMap有什么区别
Hashtable 和 ConcurrentHashMap 都是 Java 中线程安全的哈希表实现,但它们在设计、性能和适用场景上有显著区别。以下是它们的详细对比:
1. 线程安全性
| 特性 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 锁机制 | 全局锁(所有方法加 synchronized) | 分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8) |
| 锁粒度 | 整个 Hashtable | 单个段(JDK 1.7)或单个桶头节点(JDK 1.8) |
| 并发性能 | 较低 | 较高 |
2. 数据结构
| 特性 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树(JDK 1.8) |
| 红黑树优化 | 不支持 | 支持(链表长度 ≥ 8 时转换为红黑树) |
3. null 键和值
| 特性 | Hashtable | ConcurrentHashMap |
|---|---|---|
null 键 | 不允许 | 不允许 |
null 值 | 不允许 | 不允许 |
4. 性能
| 特性 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 读操作 | 加锁,性能较低 | 无锁(CAS),性能较高 |
| 写操作 | 加锁,性能较低 | 锁粒度更细,性能较高 |
| 适用场景 | 低并发场景 | 高并发场景 |
5. 扩容机制
| 特性 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 扩容规则 | 新容量 = 原容量 × 2 + 1 | 新容量 = 原容量 × 2 |
| 数据迁移 | 单线程迁移 | 并发迁移 |
6. 源码实现
6.1 Hashtable
• 锁机制:
所有方法加 synchronized 锁,锁粒度是整个 Hashtable。
• 示例:
public synchronized V put(K key, V value) {
// 插入或更新键值对
}
public synchronized V get(Object key) {
// 查找键值对
}
6.2 ConcurrentHashMap
• 锁机制:
JDK 1.7 使用分段锁,JDK 1.8 使用 CAS + synchronized 锁。
• 示例:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 尝试使用 CAS 插入数据
if (casInsert(key, value)) {
return null;
}
// 如果 CAS 失败,使用 synchronized 锁定桶头节点
synchronized (lock) {
// 插入或更新键值对
}
}
7. 总结
| 特性 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 线程安全性 | 全局锁,锁粒度较粗 | 分段锁或 CAS + synchronized,锁粒度更细 |
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
null 键和值 | 不允许 | 不允许 |
| 性能 | 较低 | 较高 |
| 适用场景 | 低并发场景 | 高并发场景 |
• Hashtable:
通过全局锁实现线程安全,锁粒度较粗,性能较低,适合低并发场景。
• ConcurrentHashMap:
通过分段锁或 CAS + synchronized 实现线程安全,锁粒度更细,性能更高,适合高并发场景。
在现代 Java 开发中,ConcurrentHashMap 是 Hashtable 的推荐替代品,尤其是在高并发场景下。
四十一、说一下HashMap和Hashtable、ConcurrentMap的区别
HashMap、Hashtable 和 ConcurrentHashMap 是 Java 中三种常用的键值对存储结构,它们在 线程安全性、性能 和 设计思想 上有显著区别。以下是它们的详细对比:
1. 线程安全性
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全性 | 非线程安全 | 线程安全 | 线程安全 |
| 锁机制 | 无锁 | 全局锁(所有方法加 synchronized) | 分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8) |
| 锁粒度 | 无锁 | 整个 Hashtable | 单个段(JDK 1.7)或单个桶头节点(JDK 1.8) |
2. 性能
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 读操作 | 无锁,性能最高 | 加锁,性能较低 | 无锁(CAS),性能较高 |
| 写操作 | 无锁,性能最高 | 加锁,性能较低 | 锁粒度更细,性能较高 |
| 适用场景 | 单线程或线程封闭场景 | 低并发场景 | 高并发场景 |
3. 数据结构
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 数据结构 | 数组 + 链表 + 红黑树(JDK 1.8) | 数组 + 链表 | 数组 + 链表 + 红黑树(JDK 1.8) |
| 红黑树优化 | 支持(链表长度 ≥ 8 时转换为红黑树) | 不支持 | 支持(链表长度 ≥ 8 时转换为红黑树) |
4. null 键和值
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
null 键 | 允许 | 不允许 | 不允许 |
null 值 | 允许 | 不允许 | 不允许 |
5. 扩容机制
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 扩容规则 | 新容量 = 原容量 × 2 | 新容量 = 原容量 × 2 + 1 | 新容量 = 原容量 × 2 |
| 数据迁移 | 单线程迁移 | 单线程迁移 | 并发迁移 |
6. 源码实现
6.1 HashMap
• 线程安全性:
非线程安全,适合单线程或线程封闭场景。
• 锁机制:
无锁,性能最高。
• 示例:
public V put(K key, V value) {
// 插入或更新键值对
}
public V get(Object key) {
// 查找键值对
}
6.2 Hashtable
• 线程安全性:
所有方法加 synchronized 锁,锁粒度是整个 Hashtable。
• 示例:
public synchronized V put(K key, V value) {
// 插入或更新键值对
}
public synchronized V get(Object key) {
// 查找键值对
}
6.3 ConcurrentHashMap
• 线程安全性:
JDK 1.7 使用分段锁,JDK 1.8 使用 CAS + synchronized 锁。
• 示例:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 尝试使用 CAS 插入数据
if (casInsert(key, value)) {
return null;
}
// 如果 CAS 失败,使用 synchronized 锁定桶头节点
synchronized (lock) {
// 插入或更新键值对
}
}
7. 总结
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全性 | 非线程安全 | 线程安全 | 线程安全 |
| 锁机制 | 无锁 | 全局锁 | 分段锁或 CAS + synchronized |
| 数据结构 | 数组 + 链表 + 红黑树 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
null 键和值 | 允许 | 不允许 | 不允许 |
| 性能 | 最高 | 较低 | 较高 |
| 适用场景 | 单线程或线程封闭场景 | 低并发场景 | 高并发场景 |
• HashMap:
非线程安全,性能最高,适合单线程或线程封闭场景。
• Hashtable:
线程安全,锁粒度较粗,性能较低,适合低并发场景。
• ConcurrentHashMap:
线程安全,锁粒度更细,性能较高,适合高并发场景。
在现代 Java 开发中,ConcurrentHashMap 是 Hashtable 的推荐替代品,尤其是在高并发场景下。
四十二、Set集合有什么特点?如何实现key无重复的?
Set 是 Java 集合框架中的一种集合类型,它的主要特点是 元素唯一 和 无序。以下是 Set 集合的特点以及如何实现 key 无重复的详细说明:
1. Set 集合的特点
1.1 元素唯一
• 特性:
Set 集合中的元素不允许重复,如果尝试添加重复元素,操作会被忽略。
• 实现原理:
Set 内部通过 equals() 和 hashCode() 方法判断元素是否重复。
1.2 无序
• 特性:
Set 集合中的元素没有固定的顺序(LinkedHashSet 除外)。
• 实现原理:
Set 的实现类(如 HashSet)基于哈希表存储元素,不保证元素的顺序。
1.3 常见实现类
• HashSet:
基于哈希表实现,元素无序,允许 null 值。
• LinkedHashSet:
基于哈希表和链表实现,元素按插入顺序排列。
• TreeSet:
基于红黑树实现,元素按自然顺序或自定义顺序排列。
2. 如何实现 key 无重复
Set 集合通过以下机制实现 key 无重复:
2.1 equals() 和 hashCode() 方法
• equals():
用于判断两个对象是否相等。
• hashCode():
用于计算对象的哈希值,哈希值相同的对象会被存储在同一个桶中。
• 判断重复:
在插入元素时,Set 会先计算元素的哈希值,然后通过 equals() 方法判断是否存在相同元素。
2.2 实现示例
以下是 HashSet 中插入元素的简化代码:
public boolean add(E e) {
return map.put(e, PRESENT) == null; // 使用 HashMap 存储元素
}
• map:
HashSet 内部使用 HashMap 存储元素,key 为元素,value 为一个固定的占位对象 PRESENT。
• put() 方法:
HashMap 的 put() 方法会通过 equals() 和 hashCode() 判断 key 是否重复。
2.3 自定义对象的唯一性
如果 Set 中存储的是自定义对象,需要重写 equals() 和 hashCode() 方法,以确保对象的唯一性。
示例:
class Person {
private String name;
private int age;
// 构造方法、getter、setter 省略
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person other = (Person) obj;
return age == other.age && Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
Set<Person> set = new HashSet<>();
set.add(new Person("Alice", 20));
set.add(new Person("Bob", 25));
set.add(new Person("Alice", 20)); // 重复元素,不会被添加
3. Set 的常见使用场景
3.1 去重
• 场景:
从一组数据中去除重复元素。
• 示例:
List<Integer> list = Arrays.asList(1, 2, 3, 2, 1);
Set<Integer> set = new HashSet<>(list); // 去重
System.out.println(set); // 输出: [1, 2, 3]
3.2 集合运算
• 场景:
对两个集合进行并集、交集、差集等操作。
• 示例:
Set<Integer> set1 = new HashSet<>(Arrays.asList(1, 2, 3));
Set<Integer> set2 = new HashSet<>(Arrays.asList(2, 3, 4));
// 并集
set1.addAll(set2); // set1: [1, 2, 3, 4]
// 交集
set1.retainAll(set2); // set1: [2, 3]
// 差集
set1.removeAll(set2); // set1: [1]
3.3 缓存唯一数据
• 场景:
缓存一组唯一的数据,避免重复处理。
• 示例:
Set<String> cache = new HashSet<>();
cache.add("data1");
cache.add("data2");
if (!cache.contains("data1")) {
// 处理 data1
}
4. 总结
| 特性 | 说明 |
|---|---|
| 元素唯一 | 通过 equals() 和 hashCode() 方法实现元素唯一性 |
| 无序 | 元素没有固定顺序(LinkedHashSet 和 TreeSet 除外) |
| 常见实现类 | HashSet、LinkedHashSet、TreeSet |
| 去重 | 使用 Set 可以轻松去除重复元素 |
| 集合运算 | 支持并集、交集、差集等操作 |
| 自定义对象唯一性 | 重写 equals() 和 hashCode() 方法,确保自定义对象的唯一性 |
Set 集合通过 equals() 和 hashCode() 方法实现 key 无重复,适合去重、集合运算和缓存唯一数据等场景。
四十三、有序的Set是什么?记录插入顺序的集合是什么?
在 Java 中,有序的 Set 集合有两种常见实现,分别基于 插入顺序 和 自然顺序/自定义顺序。以下是它们的详细说明:
1. 有序的 Set 集合
1.1 按插入顺序排序
• 实现类:LinkedHashSet
• 特点:
• 元素按插入顺序排列。
• 基于哈希表和链表实现,兼具 HashSet 的查询性能和链表的有序性。
• 示例:
Set<String> set = new LinkedHashSet<>();
set.add("apple");
set.add("banana");
set.add("cherry");
System.out.println(set); // 输出: [apple, banana, cherry]
1.2 按自然顺序或自定义顺序排序
• 实现类:TreeSet
• 特点:
• 元素按自然顺序(如数字从小到大、字符串字典序)或自定义顺序排列。
• 基于红黑树实现,查询、插入和删除操作的时间复杂度为 O(log n)。
• 示例:
Set<String> set = new TreeSet<>();
set.add("banana");
set.add("apple");
set.add("cherry");
System.out.println(set); // 输出: [apple, banana, cherry]
2. 记录插入顺序的集合
2.1 LinkedHashSet
• 特点:
• 元素按插入顺序排列。
• 基于哈希表和链表实现,兼具 HashSet 的查询性能和链表的有序性。
• 示例:
Set<String> set = new LinkedHashSet<>();
set.add("apple");
set.add("banana");
set.add("cherry");
System.out.println(set); // 输出: [apple, banana, cherry]
2.2 LinkedHashMap(键的有序性)
• 特点:
• 键按插入顺序排列。
• 基于哈希表和链表实现,兼具 HashMap 的查询性能和链表的有序性。
• 示例:
Map<String, Integer> map = new LinkedHashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
System.out.println(map.keySet()); // 输出: [apple, banana, cherry]
3. 有序集合的对比
| 特性 | LinkedHashSet | TreeSet |
|---|---|---|
| 排序方式 | 按插入顺序排序 | 按自然顺序或自定义顺序排序 |
| 底层实现 | 哈希表 + 链表 | 红黑树 |
| 查询性能 | O(1) | O(log n) |
| 插入性能 | O(1) | O(log n) |
| 删除性能 | O(1) | O(log n) |
允许 null 值 | 允许 | 不允许(除非自定义比较器支持) |
4. 总结
| 需求 | 推荐实现类 |
|---|---|
| 按插入顺序排序 | LinkedHashSet |
| 按自然顺序排序 | TreeSet |
| 按自定义顺序排序 | TreeSet(需提供 Comparator) |
| 记录插入顺序的集合 | LinkedHashSet 或 LinkedHashMap |
• LinkedHashSet:
适合需要按插入顺序排序的场景,兼具 HashSet 的查询性能和链表的有序性。
• TreeSet:
适合需要按自然顺序或自定义顺序排序的场景,基于红黑树实现,查询、插入和删除操作的时间复杂度为 O(log n)。

被折叠的 条评论
为什么被折叠?



