Java集合核心知识总结
# Java集合概述
# 概述
从集合特点角度出发,Java
集合可分为映射集
、和单元素集合
collection包 : 单元素集合可以分为:存储不可重复元素的
Set
集合,可顺序存储重复元素的List
,以及FIFO
的Queue
。Map接口:另一大类就是映射集,每一个元素都是由键值对组成,可以通过
key
找到对应的value
# 集合框架底层数据结构总结
List
ArrayList
:Object[]
数组。Vector
:Object[]
数组。LinkedList
:双向链表(JDK1.6
之前为循环链表,JDK1.7
取消了循环)。
Set
HashSet
(无序,唯一): 基于HashMap
实现的,底层采用HashMap
来保存元素。LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其内部是通过LinkedHashMap
来实现的。TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)。
Queue
PriorityQueue
:Object[]
数组来实现小顶堆。DelayQueue
:PriorityQueue
。ArrayDeque
: 可扩容动态双向数组。
Map
HashMap
:JDK1.8
之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8
以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。LinkedHashMap
:LinkedHashMap
继承自HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
:数组+链表组成的,数组是Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的。TreeMap
:红黑树(自平衡的排序二叉树)。
# List
# ArrayList 和 数组 的区别?
ArrayList
内部基于动态数组实现,比 Array
(静态数组) 使用起来更加灵活:
ArrayList
会根据实际存储的元素动态地扩容或缩容,而Array
被创建之后就不能改变它的长度了。ArrayList
允许你使用泛型来确保类型安全,Array
则不可以。ArrayList
中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array
可以直接存储基本类型数据,也可以存储对象。ArrayList
支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如add()
、remove()
等。Array
只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。ArrayList
创建时不需要指定大小,而Array
创建时必须指定大小
# ArrayList 和 Vector 的区别?
ArrayList
是List
的主要实现类,底层使用Object[]
存储,适用于频繁的查找工作,线程不安全 。Vector
是List
的古老实现类,底层使用Object[]
存储,线程安全。
# Vector 和 Stack 的区别?
Vector
和Stack
两者都是线程安全的,都是使用synchronized
关键字进行同步处理。Stack
继承自Vector
,是一个后进先出的栈,而Vector
是一个列表。
# ArrayList 插入和删除元素的时间复杂度
对于插入:
- 头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。
- 尾部插入:当
ArrayList
的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 - 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。
对于删除:
- 头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。
- 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。
- 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
# LinkedList 插入和删除元素的时间复杂度?
- 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
# ArrayList 与 LinkedList 区别
是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,都保证线程安全;底层数据结构:
ArrayList
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环)是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
(实现了RandomAccess
接口) 支持。因为LinkedList
底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现RandomAccess
接口。内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而LinkedList
的空间花费则体现在它的每一个元素都需要消耗比ArrayList
更多的空间(要存放直接后继和直接前驱以及数据)。插入和删除是否受元素位置的影响**:
ArrayList
采用数组存储,插入和删除元素的时间复杂度受元素位置的影响。 执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
),时间复杂度就为 O(n)。LinkedList
采用链表存储,在头尾插入或者删除元素不受元素位置的影响(add(E e)
、addFirst(E e)
、addLast(E e)
、removeFirst()
、removeLast()
),时间复杂度为 O(1),如果是要在指定位置i
插入和删除元素的话(add(int index, E element)
,remove(Object o)
,remove(int index)
), 时间复杂度为 O(n) 。
# Set
# Comparable 和 Comparator 的区别
Comparable
接口实际上是出自java.lang
包 它有一个compareTo(Object obj)
方法用来排序Comparator
接口实际上是出自java.util
包它有一个compare(Object obj1, Object obj2)
方法用来排序
Comparator 定制排序
ArrayList<Integer> arrayList = new ArrayList<Integer>();
arrayList.add(-1);
arrayList.add(3);
arrayList.add(3);
arrayList.add(-5);
arrayList.add(7);
arrayList.add(4);
arrayList.add(-9);
arrayList.add(-7);
System.out.println("原始数组:");
System.out.println(arrayList);
// void reverse(List list):反转
Collections.reverse(arrayList);
System.out.println("Collections.reverse(arrayList):");
System.out.println(arrayList);
// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
System.out.println("Collections.sort(arrayList):");
System.out.println(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
});
System.out.println("定制排序后:");
System.out.println(arrayList);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
原始数组:
[-1, 3, 3, -5, 7, 4, -9, -7]
Collections.reverse(arrayList):
[-7, -9, 4, 7, -5, 3, 3, -1]
Collections.sort(arrayList):
[-9, -7, -5, -1, 3, 3, 4, 7]
定制排序后:
[7, 4, 3, 3, -1, -5, -7, -9]
2
3
4
5
6
7
8
Comparable排序
// person对象没有实现Comparable接口,所以必须实现
// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
/**
* T重写compareTo方法实现按年龄来排序
*/
@Override
public int compareTo(Person o) {
if (this.age > o.getAge()) {
return 1;
}
if (this.age < o.getAge()) {
return -1;
}
return 0;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public static void main(String[] args) {
TreeMap<Person, String> pdata = new TreeMap<Person, String>();
pdata.put(new Person("张三", 30), "zhangsan");
pdata.put(new Person("李四", 20), "lisi");
pdata.put(new Person("王五", 10), "wangwu");
pdata.put(new Person("小红", 5), "xiaohong");
// 得到key的值的同时得到key所对应的值
Set<Person> keys = pdata.keySet();
for (Person key : keys) {
System.out.println(key.getAge() + "-" + key.getName());
}
}
2
3
4
5
6
7
8
9
10
11
12
5-小红
10-王五
20-李四
30-张三
2
3
4
# HashSet、LinkedHashSet 和 TreeSet 三者的异同
HashSet
、LinkedHashSet
和TreeSet
都是Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。
# Queue
# Queue 与 Deque 的区别
Queue
是单端队列,只能从一端插入元素,另一端删除元素。
Queue
扩展了 Collection
的接口,根据 容量问题导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队尾 | add(E e) | offer(E e) |
删除队首 | remove() | poll() |
查询队首元素 | element() | peek() |
Deque
是双端队列,在队列的两端均可以插入或删除元素。
Deque
扩展了 Queue
的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队首 | addFirst(E e) | offerFirst(E e) |
插入队尾 | addLast(E e) | offerLast(E e) |
删除队首 | removeFirst() | pollFirst() |
删除队尾 | removeLast() | pollLast() |
查询队首元素 | getFirst() | peekFirst() |
查询队尾元素 | getLast() | peekLast() |
事实上,Deque
还提供有 push()
和 pop()
等其他方法,可用于模拟栈。
# ArrayDeque 与 LinkedList 的区别
ArrayDeque
和 LinkedList
都实现了 Deque
接口,两者都具有队列的功能
ArrayDeque
是基于可变长的数组和双指针来实现,而LinkedList
则通过链表来实现。ArrayDeque
不支持存储NULL
数据,但LinkedList
支持。ArrayDeque
插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。LinkedList
不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque
来实现队列要比 LinkedList
更好。此外,ArrayDeque
也可以用于实现栈。
# PriorityQueue
PriorityQueue
与 Queue
的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue
利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue
通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue
是非线程安全的,且不支持存储NULL
和non-comparable
的对象。PriorityQueue
默认是小顶堆,但可以接收一个Comparator
作为构造参数,从而来自定义元素优先级的先后。
更多关于PriorityQueue
内容:PriorityQueue 源码分析 (opens new window)
# BlockingQueue
BlockingQueue
是一个接口,继承自 Queue
。BlockingQueue
阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
public interface BlockingQueue<E> extends Queue<E> {
// ...
}
2
3
BlockingQueue
常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。
BlockingQueue的一些实现类
ArrayBlockingQueue
:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue
:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE
。和ArrayBlockingQueue
类似, 它也支持公平和非公平的锁访问机制。PriorityBlockingQueue
:支持优先级排序的无界阻塞队列。元素必须实现Comparable
接口或者在构造函数中传入Comparator
对象,并且不能插入 null 元素。SynchronousQueue
:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue
通常用于线程之间的直接传递数据。DelayQueue
:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
# ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别
ArrayBlockingQueue
和 LinkedBlockingQueue
是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
- 底层实现:
ArrayBlockingQueue
基于数组实现,而LinkedBlockingQueue
基于链表实现。 - 是否有界:
ArrayBlockingQueue
是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue
创建时可以不指定容量大小,默认是Integer.MAX_VALUE
,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue
中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue
中的锁是分离的,即生产用的是putLock
,消费是takeLock
,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue
需要提前分配数组内存,而LinkedBlockingQueue
则是动态分配链表节点内存。这意味着,ArrayBlockingQueue
在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue
则是根据元素的增加而逐渐占用内存空间
# Map
# HashMap 和 Hashtable 的区别
线程是否安全:
HashMap
是非线程安全的,Hashtable
是线程安全的,因为Hashtable
内部的方法基本都经过synchronized
修饰。效率: 因为线程安全的问题,
HashMap
要比Hashtable
效率高一点。Hashtable
基本被淘汰,如果需要线程安全,可以使用ConcurrentHashMap
对 Null key 和 Null value 的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable
不允许有 null 键和 null 值,否则会抛出NullPointerException
。初始容量大小和每次扩充容量大小的不同:
① 创建时不指定容量初始值,
Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时给定了容量初始值,
Hashtable
会直接使用给定的大小,而HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证)底层数据结构:
JDK1.8
之前两者的底层数据结构都是有数组加链表组成,JDK1.8
以后的HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间。Hashtable
没有这样的机制。
# HashMap 的长度为什么是 2 的幂次方
为了能让 HashMap
存取高效,需要尽量较少碰撞,也就是要尽量把数据分配均匀Hash 值的范围值-2147483648 到 2147483647,大概 有40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是一个 40 亿长度的数组,内存是放不下的。所以散列值是不能直接拿来用的。用之前需要先对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash
”。(n 代表数组长度)。这也就解释了 HashMap
的长度为什么是 2 的幂次方。
“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方)。” 并且 采用二进制位操作 &相对于%能够提高运算效率。
# HashMap 和 HashSet 区别
HashSet
底层就是基于 HashMap
实现的。(除了 clone()
、writeObject()
、readObject()
是 HashSet
自己实现之外,其他方法都是直接调用 HashMap
中的方法。
HashMap | HashSet |
---|---|
实现了 Map 接口 | 实现 Set 接口 |
存储键值对 | 仅存储对象 |
调用 put() 向 map 中添加元素 | 调用 add() 方法向 Set 中添加元素 |
HashMap 使用键(Key)计算 hashcode | HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals() 方法用来判断对象的相等性 |
# HashMap 和 TreeMap 区别
TreeMap
实现了NavigableMap
接口和SortedMap
接口。
- 实现
NavigableMap
接口让TreeMap
有了对集合内元素的搜索的能力。 - 实现
SortedMap
接口让TreeMap
有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过也可以指定排序的比较器
指定排序的比较器:
public class Person {
private Integer age;
public Person(Integer age) {
this.age = age;
}
public Integer getAge() {
return age;
}
public static void main(String[] args) {
TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
@Override
public int compare(Person person1, Person person2) {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
}
});
treeMap.put(new Person(3), "person1");
treeMap.put(new Person(18), "person2");
treeMap.put(new Person(35), "person3");
treeMap.put(new Person(16), "person4");
treeMap.entrySet().stream().forEach(personStringEntry -> {
System.out.println(personStringEntry.getValue());
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
person1
person4
person2
person3
2
3
4
# HashSet 如何检查重复
在 JDK1.8
中,HashSet
的add()
方法只是简单的调用了HashMap
的put()
方法,并且判断了一下返回值以确保是否有重复元素。
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
2
3
4
也就是说,在 JDK1.8
中,无论HashSet
中是否已经存在了某元素,HashSet
都会直接插入,只是会在add()
方法的返回值处告诉我们插入前是否存在相同元素
# HashMap 为什么线程不安全
JDK1.7
及之前版本,在多线程环境下,HashMap
扩容时会造成死循环和数据丢失的问题。
JDK 1.8
后, 采用了尾插法而不是头插法来避免链表倒置,但多个线程对 HashMap
的 put
操作仍会导致线程不安全,会有数据覆盖的风险。
举个例子:
- 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
- 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
- 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
还有一种情况是这两个线程同时 put
操作导致 size
的值不正确,进而导致数据覆盖的问题:
- 线程 1 执行
if(++size > threshold)
判断时,假设获得size
的值为 10,由于时间片耗尽挂起。 - 线程 2 也执行
if(++size > threshold)
判断,获得size
的值也为 10,并将元素插入到该桶位中,并将size
的值更新为 11。 - 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。
- 线程 1、2 都执行了一次
put
操作,但是size
的值只增加了 1,也就导致实际上只有一个元素被添加到了HashMap
中。
# HashMap 扩容造成死循环问题
HashMap
的rehash:
Put一个(Key,Value)对到Hash
表中
public V put(K key, V value)
{
......
//算Hash值
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
//如果该key已被插入,则替换掉旧的value (链接操作)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//该key不存在,需要增加一个结点
addEntry(hash, key, value, i);
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
检查容量是否超标:
void addEntry(int hash, K key, V value, int bucketIndex)
{
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
//查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
if (size++ >= threshold)
resize(2 * table.length);
}
2
3
4
5
6
7
8
新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中。
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
2
3
4
5
6
7
8
9
10
11
12
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
正常的ReHash的过程:
假设hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
接下来的三个步骤是Hash表 resize成4,然后所有的<key,value> 重新rehash的过程
并发下的Rehash:
1)假设我们有两个线程。
回头看一下 transfer
代码中的这个细节:
do {
Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
2
3
4
5
6
7
而线程二执行完成了。于是我们有下面的这个样子。
注意,因为Thread1的 e 指向了key(3)
,而next指向了key(7)
,其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。
2)线程一被调度回来继续执行
- 先是执行
newTalbe[i] = e
- 然后是
e = next
,导致了e指向了key(7)
- 而下一次循环的
next = e.next
导致了next指向了key(3)
3)一切安好。
线程一接着工作。把key(7)
摘下来,放到newTable[i]
的第一个,然后把e和next往下移。
4)环形出现。
e.next = newTable[i]
导致 key(3).next 指向了 key(7)
但此时的key(7).next
已经指向了key(3)
, 环形链表就这样出现了。
于是,当我们的线程一调用到,HashMap.get(11)
时,就会出现死循环(Infinite Loop)。
# ConcurrentHashMap 和 Hashtable 的区别
底层数据结构:
JDK1.7
的ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8
采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和JDK1.8
之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的实现线程安全的方式:
在
JDK1.7
的时候,ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。到了
JDK1.8
的时候,ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和CAS
来操作。(JDK1.6
以后synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在JDK1.8
中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本;Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
JDK1.7 的 ConcurrentHashMap:
JDK1.8 的 ConcurrentHashMap:
JDK1.8 的 ConcurrentHashMap
不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
。
TreeNode
是存储红黑树节点,被TreeBin
包装。TreeBin
通过root
属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap
中TreeBin
通过waiter
属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
...
}
2
3
4
5
6
7
8
9
10
11
# ConcurrentHashMap 线程安全的具体实现方式
JDK1.8
之前:
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。Segment
继承了 ReentrantLock
,所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
2
一个 ConcurrentHashMap
里包含一个 Segment
数组,Segment
的个数一旦初始化就不能改变。 Segment
数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
Segment
的结构和 HashMap
类似,是一种数组和链表结构,一个 Segment
包含一个 HashEntry
数组,每个 HashEntry
是一个链表结构的元素,每个 Segment
守护着一个 HashEntry
数组里的元素,当对 HashEntry
数组的数据进行修改时,必须首先获得对应的 Segment
的锁。也就是说,对同一 Segment
的并发写入会被阻塞,不同 Segment
的写入是可以并发执行的。
JDK1.8
之后:
ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
# ConcurrentHashMap 为什么 key 和 value 不能为 null
null 是一个特殊的值,表示没有对象或没有引用。
- 用 null 作为键,无法区分这个键是否存在于
ConcurrentHashMap
中,还是根本没有这个键。 - 用 null 作为值,无法区分这个值是否是真正存储在
ConcurrentHashMap
中的,还是因为找不到对应的键而返回的。
拿 get 方法取值来说,返回的结果为 null 存在两种情况:
- 值没有在集合中 ;
- 值本身就是 null。
如果允许为null,就会产生二义性。
如果确实需要在 ConcurrentHashMap
中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。
public static final Object NULL = new Object();
# ConcurrentHashMap 能保证复合操作的原子性吗
ConcurrentHashMap
是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7
及之前版本的 HashMap
多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的。
复合操作是指由多个基本操作(如put
、get
、remove
、containsKey
等)组成的操作,例如先判断某个键是否存在containsKey(key)
,然后根据结果进行插入或更新put(key, value)
。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
有两个线程 A 和 B 同时对 ConcurrentHashMap
进行复合操作,如下:
// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}
2
3
4
5
6
7
8
如果线程 A 和 B 的执行顺序是这样:
- 线程 A 判断 map 中不存在 key
- 线程 B 判断 map 中不存在 key
- 线程 B 将
(key, anotherValue)
插入 map - 线程 A 将
(key, value)
插入 map
那么最终的结果是 (key, value)
,而不是预期的 (key, anotherValue)
。这就是复合操作的非原子性导致的问题。
那如何保证 ConcurrentHashMap
复合操作的原子性呢?
ConcurrentHashMap
提供了一些原子性的复合操作,如 putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
、merge
等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
// 线程 A
map.putIfAbsent(key, value);
// 线程 B
map.putIfAbsent(key, anotherValue);
2
3
4
或者
// 线程 A
map.computeIfAbsent(key, k -> value);
// 线程 B
map.computeIfAbsent(key, k -> anotherValue);
2
3
4