【 java 集合知识 第二篇 】
目录
1.Map集合
1.1.快速遍历Map
1.2.HashMap实现原理
1.3.HashMap的扩容机制
1.4.HashMap在多线程下的问题
1.5.解决哈希冲突的方法
1.6.HashMap的put过程
1.7.HashMap的key使用什么类型
1.8.HashMapkey可以为null的原因
1.9.HashMap为什么不采用平衡二叉树
1.10.HashMap的负载因子
1.11.HashTable介绍
1.12.ConcurrentHashMap的原理
2.Set集合
2.1.特点
2.2.原理
1.Map集合
1.1.快速遍历Map
方式一:使用entrySet()与forEach循环
for (Map.Entry<String, Integer> entry : map.entrySet()) {System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
方式二:使用keySet()与forEach循环
for (String key : map.keySet()) {System.out.println("Key: " + key + ", Value: " + map.get(key));
}
方式三:使用forEach与Lambda循环
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value)
);
方式四:使用迭代器与entrySet()或keySet()
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<String> keyIterator = map.keySet().iterator();
while (keyIterator.hasNext()) {String key = keyIterator.next();System.out.println("Key: " + key + ", Value: " + map.get(key));
}
方式五:使用Stream()流Api
map.entrySet().stream().forEach(entry -> System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue()));// 或者并行流(大数据量时可能提升性能)
map.entrySet().parallelStream().forEach(entry -> System.out.println("[Parallel] Key: " + entry.getKey() + ", Value: " + entry.getValue()));
1.2.HashMap实现原理
HashMap在jdk1.7及以前底层数据结构采用数组加链表的形式,当你需要添加一个键值对时,它会计算key的哈希值,再通过一定的运算(hash&(数组长度-1)其实就是hash%数组长度,不过位运算的速度快)来确定要添加到数组的哪个索引位置,确定数组索引位置后,看该数组槽位是否为空,如果为空,那么直接在该槽位中创建一个Entry对象存入要添加的键值对和key计算出的哈希值和下一个引用位置,并且将HashMap的修改次数加一,那么如果不为空,则会使用链表进行链接(头插法)在同一个哈希桶中(限制:链表过长,查询时间效率为O(n))
前提:数据结构为数组加链表(头插法)
1.添加元素,计算key的哈希值,通过运算找到数组索引位置
2.判断索引位置是否为空
3.为空,添加键值对
4.不为空,通过链表来链接在一个哈希桶中
HashMap在jdk1.8之后底层采用数组加链表或红黑树的数据结构,然后就是添加判断,数组槽位为空会在槽位中创建一个Node对象存入要添加的键值对和key计算出的哈希值和下一个引用位置(其实就是该名称了),并且将HashMap的修改次数加一,那么如果不为空,那么就会查看其数据结构,如果是链表(尾插法)添加完元素之后,)会判断该链表长度是否大于等于8,如果此时数组的长度也大于等于64,那么链表就会转换成红黑树增加查询效率和添加效率(O(n) -》 O(log n),如果数组长度没有满足,那么就会对数组进行一个扩容操作
前提:数据结构为数组加链表(尾插法)或红黑树
1.添加元素,计算key的哈希值,通过运算找到数组索引位置
2.判断索引位置是否为空
3.为空,添加键值对
4.不为空,通过链表或红黑树来链接在一个哈希桶中
5.如果使用的链表,那么判断链表的长度是否大于等于8
6.没有满足,直接结束
7.满足,判断数组长度是否大于等于64
8.满足,链表转红黑树(从链接的数据结构出发增加效率)
9.没有满足,直接将数组扩容(从数组出发,会将链接的键值对再分配)
1.3.HashMap的扩容机制
HashMap底层默认数组长度为16(2^4),当然你可以指定
它扩容的时机有两个:
- 方式一:链表长度大于等于8,但是数组长度没有大于等于64,直接将数组长度扩容到原来的两倍
- 方式二:此时键值对长度(实际长度)大于等于数组长度乘以负载因子(默认为0.75),直接将数组长度扩容到原来的两倍
怎么扩容的:
判断可以进行扩容时,那么会计算出新数组的长度(长度为旧数组的两倍),当然如果没有满足实际扩容要求,还是需要继续2倍,直到满足长度后,创建一个新数组,将旧数组的对象遍历(entry或node),取出哈希值,jdk1.7采用重新再哈希,当然分布更均匀,性能低,jdk1.8采用进行运算(hash&(新数组长度-1))结果大于等于旧数组长度,性能高,那么此时存入新数组的索引为旧数组长度加上原来存入旧数组索引长度,相反直接采用原先的索引长度即可,然后进行存值即可,最后改变hashMap中的数组指向引用,指向新数组
细节:为什么我们jdk1.8之后会使用的是位运算,原理其实是我们初始化的数组长度位为16(是不是就是2^4),每次扩容都是两倍,那么循环反复数组的长度都满足2的几次方,而&的规则就是全为1才是1,其余全是0,那么我们将数组长度减一,就会形成最高位为0,其余为全是1的情况,再&上哈希值,只要最终的高位为1代表需要索引需要改变
1.4.HashMap在多线程下的问题
在jdk1.7,在多线程背景下会出现Entry链死循环和数据丢失问题
在jdk1.8,解决了Entry死循环和数据丢失问题,但是在多线程背景下出现了新的问题put方法数据覆盖问题
分析:为什么jdk1.7会出现该问题,首先要了解jdk1.7链表添加一个元素采用的是头插法,好处不需要遍历添加元素,坏处自然就是以上问题,怎么导致的?
解释:此时链表:A->B->C,由于进行了扩容操作,链表中的元素需要进行添加到新的链表中(并且此时链表存入还是ABC),怎么存呢?拿出A采用头插法依次遍历,最后形成C->B->A,那如果多线程情况下对它添加操作并发执行,会不会导致出现A->B->A的问题,死循环与数据丢失
解决:jdk1.8怎么解决的呢?将头插法换成尾插法,保证了顺序
分析:为什么jdk1.8会出现put方法覆盖问题,HashMap本身是线程不安全的集合,并发执行put方法肯定会出现问题
解释:当线程1将key计算出数组索引位置,判断为空,线程2也计算另一个key它的数组索引位置与线程1一致,判断为空,那么最终数据出现了覆盖
解决:本质就是没有保证一个原子性操作,那么我们可以采用对该集合加锁或直接使用线程安全的集合
1.5.解决哈希冲突的方法
方法一:链接法
使用链表或其他的数据结构存储冲突的键值对,链接到一个哈希桶中
方法二:开放寻址法
在哈希表中找另外一个可以存储的地方进行存储键值对,常见的方法:线性探测,二次探测,双重散列
方法三:再哈希法
通过使用另外一种哈希算法,直到可以存储键值对
方法四:哈希桶扩容
哈希冲突过多,动态的对哈希桶数据进行增加,再分配键值对,减少哈希冲突的概率
1.6.HashMap的put过程
1.添加元素,计算key的哈希值,通过运算找到数组索引位置
2.判断索引位置是否为空
-----
3.为空,添加键值对(创建entry或node对象)
------
4.不为空,通过链表或红黑树来链接在一个哈希桶中
5.如果使用的链表(头还是尾插法),那么判断链表的长度是否大于等于8
6.没有满足,跳转到10
--------
7.满足,判断数组长度是否大于等于64
8.满足,链表转红黑树(从链接的数据结构出发增加效率)
9.没有满足,直接将数组扩容(从数组出发,会将链接的键值对再分配)
10.看实际键值对长度是否大于等于数据长度乘以负载因子(0.75)
11.满足,对数据进行扩容
---
12.不满足,结束
1.7.HashMap的key使用什么类型
使用String类型,因为String类型不可变,每次修改都是创建一个新的对象,那么就保证的key的唯一性和安全性
1.8.HashMapkey可以为null的原因
就是说它底层在进行key的哈希运算时会先进行一个判断,判断key是否为null,为null那么直接赋值hash为0,不进行hash运算,并且由于key是唯一的,保证了只能有一个key为null,当然value没有要求
细节:如果你的hashMap没有进行初始化,那么你进行key赋值为null时会出现空指针异常
1.9.HashMap为什么不采用平衡二叉树
平衡二叉树:它追求过度平衡,它需要左右子树的左右节点长度不能超过1,那么这个要求就很高,你如果进行一个高插入和删除的操作,一定会破坏该规则,那么它就需要进行左旋和右旋来平衡数,这个成本就很高,优点:查询速度快,缺点:维护树平衡的成本高
红黑树:它不追求过度平衡,它只需要树的最长路径不超过最短路径的两倍就行,它牺牲了一部分的查询效率换成了一部分维护树成本,并且我们对它进行一个插入和删除,就不会破坏其规则
1.10.HashMap的负载因子
负载因子 = 实际键值对长度除以数组长度
默认为0.75
为什么设置为这个值?
分析:值高了,代表数组内存使用率高了,那么哈希冲突的概率也高了,值低了,数组的空间利用率低,需要频繁的扩容,非常影响性能
1.11.HashTable介绍
它底层数据结构采用数组加链表,数组初始容量为11,每次扩容为2n+1,它采用了synchronized对其方法加锁,使得该集合线程安全,不过由于锁的是整个方法,性能不高
1.12.ConcurrentHashMap的原理
简单来说:jdk1.7采用分段锁的形式将数据分为一段段进行加锁,并发执行,既保证了线程安全也提高了性能,jdk1.8采用CAS与volatile或synchronized方式进行加锁
具体来说:
jdk1.7:
采用数组加链表,数组分为两个数组,一个大数组,一个小数组,大数组为Segment,小数组为HashEntry,Segment其实就是一个可重入锁,而HashEntry数组可以看作一个hashMap,就是说一个ConcurrentHashMap包含一个Segment数组,一个Segment元素包含一个HashEntry数组,而HashEntry就是存储键值对的链表元素,因此是根据Segment来进行分段加锁
jdk1.8:
采用数组加链表或红黑树,引入红黑树还是增加效率,并且1.8使用的是乐观锁加悲观锁,
简单来说,当你添加元素时,会判断该容器是否为空,为空,代表只需要进行存入即可,这个操作的线程竞争压力不大,采用CAS(乐观锁)+volatile(所有线程都可见并且不可重排序),如果不为空,判断key是否存在,不存在还是一个添加操作采用CAS(乐观锁)+volatile(所有线程都可见并且不可重排序),存在,我们需要进行覆盖操作,线程竞争压力大,使用悲观锁synchronized保证安全
区别:就是说jdk1.8采用的是对头节点进行加锁,锁的粒度更小,并且引入红黑树,大大增加了性能
2.Set集合
2.1.特点
不可重复,元素唯一
2.2.原理
其实就是在计算添加元素的哈希值时,如果发现有哈希值一样的,会使用equals()方法进行内容的判断,如果相同则不存人该值,不同存入即可