集合类List、Map、Set
一、List集合
1、ArrayList
1.1 数据结构
ArrayList 底层是一个数组结构
图中展示是长度为 10 的数组,从 1 开始计数,index 表示数组的下标,从 0 开始计数,elementData 表示数组本身,还有以下三个基本概念:
DEFAULT_CAPACITY
表示数组的初始大小,默认是 10;- size 表示当前数组的大小,类型 int,没有使用 volatile 修饰,非线程安全的;
- modCount 统计当前数组被修改的版本次数,数组结构有变动,就会 +1
1.2 初始化
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {
};
//无参数直接初始化,数组大小为空
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//指定初始数据初始化
public ArrayList(Collection<? extends E> c) {
//elementData 是保存数组的容器,默认为 null
elementData = c.toArray();
//如果给定的集合(c)数据有值
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
//如果集合元素类型不是 Object 类型,我们会转成 Object
if (elementData.getClass() != Object[].class) {
elementData = Arrays.copyOf(elementData, size, Object[].class);
}
} else {
// 给定集合(c)无值,则默认空数组
this.elementData = EMPTY_ELEMENTDATA;
}
}
1.3 新增
新增就是往数组中添加元素,主要分成两步:
- 判断是否需要扩容,如果需要执行扩容操作;
- 直接赋值
public boolean add(E e) {
//确保数组大小是否足够,不够执行扩容,size 为当前数组的大小
ensureCapacityInternal(size + 1); // Increments modCount!!
//直接赋值,线程不安全的
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
//如果初始化数组大小时,有给定初始值,以给定的大小为准,不走 if 逻辑
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
//确保容积足够
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
//记录数组被修改
modCount++;
// 如果我们期望的最小容量大于目前数组的长度,那么就扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//扩容,并把现有数据拷贝到新的数组里面去
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// oldCapacity >> 1 是把 oldCapacity 除以 2 的意思
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果扩容后的值 < 我们的期望值,扩容后的值就等于我们的期望值
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果扩容后的值 > jvm 所能分配的数组的最大值,那么就用 Integer 的最大值
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 通过复制进行扩容
elementData = Arrays.copyOf(elementData, newCapacity);
}
- 扩容的规则并不是翻倍,是原来容量大小 + 容量大小的一半,直白来说,扩容后的大小是原来容量的 1.5 倍;
- ArrayList 中的数组的最大值是
Integer.MAX_VALUE
,超过这个值,JVM 就不会给数组分配内存空间了。 - 新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的
elementData [size++] = e
,这种简单赋值,没有任何锁控制,是线程不安全的
1.4 扩容
扩容是通过这行代码来实现的:Arrays.copyOf(elementData, newCapacity);
,这行代码描述的本质是数组之间的拷贝,扩容是会先新建一个符合我们预期容量的新数组,然后把老数组的数据拷贝过去,我们通过 System.arraycopy
方法进行拷贝,此方法是 native 的方法
/**
* @param src 被拷贝的数组
* @param srcPos 从数组那里开始
* @param dest 目标数组
* @param destPos 从目标数组那个索引位置开始拷贝
* @param length 拷贝的长度
* 此方法是没有返回值的,通过 dest 的引用进行传值
*/
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
1.5 线程不安全
只有当 ArrayList 作为共享变量时,才会有线程安全问题,当 ArrayList 是方法内的局部变量时,是没有线程安全的问题的。
ArrayList 有线程安全问题的本质,是因为 ArrayList 自身的 elementData、size、modConut 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况
1.6 总结
- ArrayList是List接口的一个可变大小的数组的实现
- ArrayList的内部是使用一个Object对象数组来存储元素的
- 初始化ArrayList的时候,可以指定初始化容量的大小,如果不指定,就会使用默认大小,为10
- 当添加一个新元素的时候,首先会检查容量是否足够添加这个元素,如果够就直接添加,如果不够就进行扩容,扩容为原数组容量的1.5倍
- 当删除一个元素的时候,会将数组右边的元素全部左移
2、LinkedList
2.1 数据结构
LinkedList 适用于集合元素先入先出和先入后出的场景
LinkedList 底层数据结构是一个双向链表
LinkedList类实现了List接口和Deque接口,是一种链表类型的数据结构,支持高效的插入和删除操作,同时也实现了Deque接口,使得LinkedList类也具有队列的特性。
链表中的元素叫做 Node
private static class Node<E> {
E item;// 节点值
Node<E> next; // 指向的下一个节点
Node<E> prev; // 指向的前一个节点
// 初始化参数顺序分别是:前一个节点、本身节点值、后一个节点
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
2.2 查询
链表查询某一个节点是比较慢的,需要挨个循环查找才行
LinkedList 并没有采用从头循环到尾的做法,而是采取了简单二分法,首先看看 index 是在链表的前半部分,还是后半部分。如果是前半部分,就从头开始寻找,反之亦然。通过这种方式,使循环的次数至少降低了一半,提高了查找的性能
// 根据链表索引位置查询节点
Node<E> node(int index) {
// 如果 index 处于队列的前半部分,从头开始找,size >> 1 是 size 除以 2 的意思。
if (index < (size >> 1)) {
Node<E> x = first;
// 直到 for 循环到 index 的前一个 node 停止
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// 如果 index 处于队列的后半部分,从尾开始找
Node<E> x = last;
// 直到 for 循环到 index 的后一个 node 停止
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
2.3 新增
追加节点时,我们可以选择追加到链表头部,还是追加到链表尾部,add 方法默认是从尾部开始追加,addFirst 方法是从头部开始追加
// 从尾部开始追加节点
void linkLast(E e) {
// 把尾节点数据暂存
final Node<E> l = last;
// 新建新的节点,初始化入参含义:
// l 是新节点的前一个节点,当前值是尾节点值
// e 表示当前新增节点,当前新增节点后一个节点是 null
final Node<E> newNode = new Node<>(l, e, null);
// 新建节点追加到尾部
last = newNode;
//如果链表为空(l 是尾节点,尾节点为空,链表即空),头部和尾部是同一个节点,都是新建的节点
if (l == null)
first = newNode;
//否则把前尾节点的下一个节点,指向当前尾节点。
else
l.next = newNode;
//大小和版本更改
size++;
modCount++;
}
// 从头部追加
private void linkFirst(E e) {
// 头节点赋值给临时变量
final Node<E> f = first;
// 新建节点,前一个节点指向null,e 是新建节点,f 是新建节点的下一个节点,目前值是头节点的值
final Node<E> newNode = new Node<>(null, e, f);
// 新建节点成为头节点
first = newNode;
// 头节点为空,就是链表为空,头尾节点是一个节点
if (f == null)
last = newNode;
//上一个头节点的前一个节点指向当前节点
else
f.prev = newNode;
size++;
modCount++;
}
2.4 删除
节点删除的方式和追加类似,我们可以选择从头部删除,也可以选择从尾部删除,删除操作会把节点的值,前后指向节点都置为 null,帮助 GC 进行回收
3、Vector
其是ArrayList的改进版本,其采用Synchronized加锁的方式保证了线程安全
二、Map集合
1、HashMap
1.1 数据结构
1.8 : 数组 + 单链表(尾插) + 红黑树(链表元素大于等于8,并且数组长度大于64的时候,链表就会转化为红黑树, 当红黑树的大小小于等于 6 时,红黑树会转化成链表)
1.7: 数组 + 单链表(头插)
如图是JDK1.8 - HashMap的图
1.2 类注释
- 允许 null 值,不同于
HashTable
,是线程不安全的; - load factor(影响因子) 默认值是 0.75, 是均衡了时间和空间损耗算出来的值
- 较高的值会减少空间开销(扩容减少,数组大小增长速度变慢),但增加了查找成本(
hash
冲突增加,链表长度变长) - 较小的值会增大空间开销(扩容频繁,数组大小增长速度变快),但
hash
冲突减小
- 较高的值会减少空间开销(扩容减少,数组大小增长速度变慢),但增加了查找成本(
- 如果有很多数据需要储存到 HashMap 中,建议
HashMap
的容量一开始就设置成足够的大小,这样可以防止在其过程中不断的扩容,影响性能; - HashMap 是非线程安全的,我们可以自己在外部加锁,或者通过
Collections#synchronizedMap
来实现线程安全,Collections#synchronizedMap
的实现是在每个方法上加上了 synchronized 锁; - 在迭代过程中,如果 HashMap 的结构被修改,会快速失败。
//初始容量为 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//桶上的链表长度大于等于8时,链表转化成红黑树
static final int TREEIFY_THRESHOLD = 8;
//桶上的红黑树大小小于等于6时,红黑树转化成链表
static final int UNTREEIFY_THRESHOLD = 6;
//当数组容量大于 64 时,链表才会转化成红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
//记录迭代过程中 HashMap 结构是否发生变化,如果有变化,迭代时会 fail-fast
transient int modCount;
//HashMap 的实际大小,可能不准(因为当你拿到这个值的时候,可能又发生了变化)
transient int size;
//存放数据的数组
transient Node<K,V>[] table;
// 扩容的门槛,有两种情况
// 如果初始化时,给定数组大小的话,通过 tableSizeFor 方法计算,数组大小永远接近于 2 的幂次方,比如你给定初始化大小 19,实际上初始化大小为 32,为 2 的 5 次方。
// 如果是通过 resize 方法进行扩容,大小 = 数组容量 * 0.75
int threshold;
//链表的节点
static class Node<K,V> implements Map.Entry<K,V> {
//红黑树的节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
1.3 新增
- 空数组有无初始化,没有的话初始化;
- 如果通过key的hash计算出下标
如果key值为null, 则将节点插入到第一个桶,
否则看当前下标节点为空 或者 key值相同,则跳转第6步,否则到第3步 - 如果 hash 冲突,两种解决方案:链表 or 红黑树;
- 如果是链表,循环,把新元素追加到队尾;
- 如果是红黑树,调用红黑树新增的方法;
- 通过 2、4、5 将新元素追加成功,再根据 onlyIfAbsent 判断是否需要覆盖;
- 判断是否需要扩容,需要扩容进行扩容,结束。
为什么链表大小长度为8时会转化为红黑树?
链表查询的时间复杂度是 `O (n)`,红黑树的查询复杂度是 `O (log (n))`。在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗,所以我们需要定义出转化的边界值。
在考虑设计 8 这个值的时候,我们参考了泊松分布概率函数,由泊松分布中得出结论,链表各个长度的命中概率为:
当链表的长度是 8 的时候,出现的概率是 0.00000006,不到千万分之一,所以说正常情况下,链表的长度不可能到达 8 ,而一旦到达 8 时,肯定是 hash 算法出了问题,所以在这种情况下,为了让`HashMap`仍然有较高的查询性能,所以让链表转化成红黑树,我们正常写代码,使用 HashMap 时,几乎不会碰到链表转化成红黑树的情况,毕竟概率只有千万分之一。
1.4 查找
HashMap 的查找主要分为以下三步:
- 根据 hash 算法定位数组的索引位置,
equals
判断当前节点是否是我们需要寻找的 key,是的话直接返回,不是的话往下。 - 判断当前节点有无
next
节点,有的话判断是链表类型,还是红黑树类型。 - 分别走链表和红黑树不同类型的查找方法。
1.5 扩容
什么时候进行扩容
1.当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12
的时候,就把数组的大小扩展为2*16=32
,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能
2.数组长度小于64,且某个桶中的元素大于8
为什么扩容是2的次幂
HashMap的初始容量是2的n次幂,扩容也是2倍的形式进行扩容,是因为容量是2的n次幂,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞,避免形成链表的结构,使得查询效率降低
根据数学规律,对n取模,就是和n-1进行与运算。与运算的效率远远高于求模运算
当数组长度是2的幂次时,hash值对数组长度取模(计算出key值的下标)的效果和hash&(n-1)
(hash(key) & (n-1))是一样的
扩容步骤
一个hash
值在扩容前后的table下标是这么计算的:
判断条件(e.hash & oldCap) == 0
计算出来的值,如果等于0,就放入低位节点,低位节点在新数组的位置跟原数组一样。 等于1,就放入高位节点,高位放在新数组的位置等于原来位置在加上原数组的长度
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引 + oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
// 将原索引放到哈希桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将原索引 + oldCap 放到哈希桶中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
其它问题:
JDK1.8中对hash算法优化
JDK1.7中通过多次位移异或计算得到关键字的hash值
hash算法的优化:
//JDK1.8以后的HashMap部分源码
static final int hash(Object key){
int h;
return (key == null)?0(h=key.hashCode())^(h>>>16);
}
将32位的hash值右移16位,结果是将高16位推到了低16位上,高16位用0来补齐,优化的实质是将高16位与低16位进行异或运算,对于两个hash值的低16位相等,高16位不等的情况,尽量减少了hash冲突 (将高低位的数据特性混合,使hashCode更加离散)
寻址算法的优化:
寻址算法就是对长度为n的数组取模,得到在数组中的位置。根据数学规律,对n取模,就是和n-1进行与运算。与运算的效率远远高于求模运算,所以采用与运算。
当数组长度是2的幂次时,hash值对数组长度取模的效果和hash&(n-1)
是一样的
加载因子为什么是 0.75
加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap
的初始化容量是 16,那么当 HashMap 中有 16*0.5=8
个元素时,HashMap 就会进行扩容。
这其实是出于容量和性能之间平衡的结果:
- 当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
- 而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。
为什么线程不安全
HashMap 在并发时可能出现的问题主要是两方面:
- 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞,那么根据
HashMap
的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖 - 如果多个线程同时检测到元素个数超过
数组大小 * loadFactor
,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失
一般用什么作为HashMap的key
一般用Integer、String这种不可变类当HashMap
当key,而且String最为常用。
- 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
- 因为获取对象的时候要用到
equals()
和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
用可变类当HashMap的key有什么问题
hashcode可能发生改变,导致put进去的值,无法get出,如下所示
HashMap<List<String>, Object> changeMap = new HashMap<>();
List<String> list = new ArrayList<>();
list.add("hello");
Object objectValue = new Object();
changeMap.put(list, objectValue);
System.out.println(changeMap.get(list));
list.add("hello world");//hashcode发生了改变
System.out.println(changeMap.get(list));
java.lang.Object@74a14482
null
实现一个自定义的class作为HashMap的key该如何实现
记住下面四个原则
- 两个对象相等,hashcode一定相等
- 两个对象不等,hashcode不一定不等
- hashcode相等,两个对象不一定相等
- hashcode不等,两个对象一定不等
和HashTable的区别
1.Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null
2.Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类,Dictionary
的子类同时也实现了Map
接口
3.HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75,当现有容量大于总容量 * 负载因子
时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1,int newCapacity = (oldCapacity << 1) + 1;
5.HashTable
是直接使用key的hashCode(key.hashCode()
)作为hash值,不像HashMap
内部使用static final int hash(Object key)
扰动函数对key的hashCode进行扰动后作为hash值
6.HashTable
取哈希桶下标是直接用模运算%
(因为其默认容量也不是2的n次方。所以也无法用位运算替代模运算)
7.Hashtable是线程安全的,方法是Synchronized的
尾插法好处
尾插法主要是为了安全,防止环化,因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上
使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了
死循环造成 CPU 100%
HashMap 有可能会发生死循环并且造成 CPU 100% ,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。
2、Hashtable
2.1 数据结构
Entry数组 (哈希表)
Hashtable是HashMap的线程安全版本,采用Synchronized的方式进行上锁
注意Hashtable和ConcurrentHashMap都不允许插入空键和空值,而Hashmap则允许插入
2.2 初始化
初始容量为11,扩容时容量翻倍 + 1
2.3 key的hash值
直接使用key的hashCode作为hash值
插入节点的下标为取模运算
3、ConcurrentHashMap
3.1 数据结构
-
JDK1.7
基本结构为Segment数组+HashEntry数组+链表的形式,通过对每个Segment进行上锁来保证高并发。
-
JDK1.8
Node数组 + 链表 + 红黑树
ConcurrentHashMap和HashMap两者的相同之处:
- 数组、链表结构几乎相同,所以底层对数据结构的操作思路是相同的(只是思路相同,底层实现不同)
- 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以大多数的方法也都是相同的,HashMap 有的方法,ConcurrentHashMap 几乎都有,所以当我们需要从 HashMap 切换到 ConcurrentHashMap 时,无需关心两者之间的兼容问题
不同之处:
- 红黑树结构略有不同,HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等;ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了
TreeBin
,来维护红黑树结构,并负责根节点的加锁和解锁 - 新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全
3.2 新增
步骤:
- 如果数组为空,初始化,初始化完成之后,走 2;
- 计算当前槽点有没有值,没有值的话,cas 创建,失败继续自旋(for 死循环),直到成功,槽点有值的话,走 3;
- 如果槽点是转移节点(正在扩容),就会一直自旋等待扩容完成之后再新增,不是转移节点走 4;
- 槽点有值的,先锁定当前槽点(synchronized),保证其余线程不能对该槽点操作,如果是链表,新增值到链表的尾部,如果是红黑树,使用红黑树新增的方法新增;
- 新增完成之后 check 需不需要扩容,需要的话去扩容。
3.3 扩容
ConcurrentHashMap 扩容的方法叫做 transfer,从 put 方法的 addCount 方法进去,就能找到 transfer 方法
transfer方法的主要思路是:
- 首先需要把老数组的值全部拷贝到扩容之后的新数组上,先从数组的队尾开始拷贝;
- 拷贝数组的槽点时,先把原数组槽点锁住,保证原数组槽点不能操作,成功拷贝到新数组时,把原数组槽点赋值为转移节点;
- 这时如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在扩容完成之前,该槽点对应的数据是不会发生变化的;
- 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组中的节点设置成转移节点;
- 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。
扩容中的关键点,就是如何保证是线程安全的:
- 拷贝槽点时,会把原数组的槽点锁住;
- 拷贝成功之后,会把原数组的槽点设置成转移节点,这样如果有数据需要 put 到该节点时,发现该槽点是转移节点,会一直等待,直到扩容成功之后,才能继续 put,可以参考 put 方法中的 helpTransfer 方法;
- 从尾到头进行拷贝,拷贝成功就把原数组的槽点设置成转移节点。
- 等扩容拷贝都完成之后,直接把新数组的值赋值给数组容器,之前等待 put 的数据才能继续 put。
扩容方法还是很有意思的,通过在原数组上设置转移节点,put 时碰到转移节点时会等待扩容成功之后才能 put 的策略,来保证了整个扩容过程中肯定是线程安全的,因为数组的槽点一旦被设置成转移节点,在没有扩容完成之前,是无法进行操作的
3.4 查找
ConcurrentHashMap 读的话,就比较简单,先获取数组的下标,然后通过判断数组下标的 key 是否和我们的 key 相等,相等的话直接返回,如果下标的槽点是链表或红黑树的话,分别调用相应的查找数据的方法,整体思路和 HashMap 很像
3.5 Hash算法
与HashMap类似
通过高低16位进行异或运算,减少碰撞的概率
3.6 JDK1.7和1.8的不同
数据结构
Java 7 采用 Segment 分段锁来实现,而 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树
并发度
Java 7 中,每个 Segment 独立加锁,最大并发个数就是 Segment 的个数,默认是 16。
但是到了 Java 8 中,锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高。
保证并发安全的原理
Java 7 采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock。
Java 8 中放弃了 Segment 的设计,采用 Node + CAS + synchronized
保证线程安全。
遇到 Hash 碰撞
Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。
Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率。
查询时间复杂度
Java 7 遍历链表的时间复杂度是 O(n)
,n 为链表长度。
Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(log(n))
,n 为树的节点个数。
3.7 线程安全
1.使用volatile保证当Node中的值变化时对于其他线程是可见的
2.使用table数组的头结点作为synchronized的锁来保证写操作的安全
3.当头结点为null时,使用CAS操作来保证数据能正确的写入
4、TreeMap
4.1 数据结构
红黑树
TreeMap利用红黑树的左节点小,右节点大的性质,根据key进行排序,使每个元素能够插入到红黑树适当的位置,维护了key的大小关系,适用于key需要排序的场景。
因为底层使用的是平衡红黑树的结构,所以 containsKey、get、put、remove 等方法的时间复杂度都是log(n)
5、LinkedHashMap
5.1 数据结构
继承自HashMap,提供了两大特性:
- 按照插入顺序进行访问
- 实现了访问最少最先删除功能
// 链表头
transient LinkedHashMap.Entry<K,V> head;
// 链表尾
transient LinkedHashMap.Entry<K,V> tail;
// 继承 Node,为数组的每个元素增加了 before 和 after 属性
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
// 控制两种访问模式的字段,默认 false
// true 按照访问顺序,会把经常访问的 key 放到队尾
// false 按照插入顺序提供访问
final boolean accessOrder;
5.2 新增
LinkedHashMap 初始化时,默认 accessOrder 为 false,就是会按照插入顺序提供访问,插入方法使用的是父类 HashMap 的 put 方法,不过覆写了 put 方法执行中调用的 newNode/newTreeNode 和 afterNodeAccess 方法。
// 新增节点,并追加到链表的尾部
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
// 新增节点
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
// 追加到链表的尾部
linkNodeLast(p);
return p;
}
// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
// 新增节点等于位节点
tail = p;
// last 为空,说明链表为空,首尾节点相等
if (last == null)
head = p;
// 链表有数据,直接建立新增节点和上个尾节点之间的前后关系即可
else {
p.before = last;
last.after = p;
}
}
LinkedHashMap 通过新增头节点、尾节点,给每个节点增加 before、after 属性,每次新增时,都把节点追加到尾节点等手段,在新增的时候,就已经维护了按照插入顺序的链表结构了
5.3 访问最少删除策略
叫做 LRU(Least recently used,最近最少使用),大概的意思就是经常访问的元素会被追加到队尾,这样不经常访问的数据自然就靠近队头,然后我们可以通过设置删除策略,比如当 Map 元素个数大于多少时,把头节点删除
// 新建 LinkedHashMap
LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>(4,0.75f,true) {
{
put(10, 10);
put(9, 9);
put(20, 20);
put(1, 1);
}
@Override
// 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > 3;
}
};
三、Set集合
1、HashSet
底层实现基于 HashMap
// 把 HashMap 组合进来,key 是 Hashset 的 key,value 是下面的 PRESENT
private transient HashMap<E,Object> map;
// HashMap 中的 value
private static final Object PRESENT = new Object();
2、TreeSet
底层实现基于 TreeMap(红黑树)
3、LinkedHashSet
底层实现基于 LinkedHashMap(双向链表)
按放入顺序有序不重复。
是经常访问的元素会被追加到队尾,这样不经常访问的数据自然就靠近队头,然后我们可以通过设置删除策略,比如当 Map 元素个数大于多少时,把头节点删除
// 新建 LinkedHashMap
LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>(4,0.75f,true) {
{
put(10, 10);
put(9, 9);
put(20, 20);
put(1, 1);
}
@Override
// 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > 3;
}
};
四、快速失败、安全失败机制
1、快速失败机制(fail-fast)
在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出 Concurrent Modification Exception。
1.1 原理
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。
1.2 注意
这里异常的抛出条件是检测到 modCount != expectedmodCount 这个条件。如果集合发生变化时修改 modCount 值刚好又设置为了 expectedmodCount 值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的 bug。
1.3 场景
java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。
2、安全失败机制(fail—safe)
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
2.1 原理
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception。
2.2 缺点
基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
2.3 场景
java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
转载:https://blog.csdn.net/whc__/article/details/116614602