目录
2.2.7 为什么 HashMap 桶中的节点个数超过 8 才转为红黑树?
2.2.10 HashMap 在 jdk1.7 和 jdk1.8 中的对比
2.3 HashMap添加的对象为什么要重写 equals 和 hashcode
2.3.2 为什么要重写 hashCode 和 equals
一、Hash
1.1 什么是 hash
哈希是将任意长度的输入通过散列算法,变换成固定长度的输出,这个输出就是哈希值。
● 空间压缩
这种转换实际是一种空间映射,哈希值的空间通常小于原输入占用的空间
● 没有唯一性
不同的输入可能转成出同一个哈希值,但不同的哈希值必定对应着不同的输入
1.2 哈希冲突(碰撞)
哈希冲突即出现输入不同,转换出的哈希值却相同的情况
二、Map 集合类
2.1 HashMap
2.1.1 概念
HashMap 是基于哈希表的 Map 接口的非同步实现,提供键值对的映射操作,允许保存 null,不保证映射的顺序。
在 JDK 1.8 之前是由 链表+数组 组成的,主体是数组,链表这是为了处理哈希冲突
在 JDK 1.8 后,引入了红黑树的方式解决哈希冲突,当链表的长度大于阈值(默认为8)且当前的数组长度大于 64 时,就会将这个节点上的所有数据改为用红黑树存储。若链表长度大于8但数组长度小于64时,则依旧使用链表,且数组扩容
● 为什么要数组长度不小于64,节点存储才转变成红黑树
当数组长度较小时,红黑树的深度也会增加,反而会降低效率。由于红黑树需要进行左旋、右旋、变色这些操作来保持平衡,深度越深,重排的耗费也就越高。
2.1.2 特点总结
- 存取是无序的
- 键和值都可以是null,但键值要求唯一,即只能存一个null
- 键值唯一
- jdk1.8 前的数据结构是:数组+链表,1.8后变为:数组+链表+红黑树
- HashMap 中可能同时存在链表和红黑树,只有当某个数组节点的数据节点大于8,且整个数组的长度大于64时,这个节点的链表才会转换成红黑树
2.1.3 存储过程
● 拉链法
- 先根据 Key 值,使用 hashCode() 方法计算对应的 hash 值,之后结合数组长度,采用算法计算出在数组中存储数据的节点索引值。
- 若计算后的这个数组节点中没有数据,则直接将原始键值对数据存储到节点中。
- 若有数据,则会使用 equeals() 方法逐个比较原数据的 key 的 hash 值,和节点中已存在的 key 的 hash 值是否相等,相等则将 Value 覆盖,不相等则将数据添加到链表后端。
size: 表示 HashMap 中 K-V 的实时数量
threshold (临界值) = capacity (容量) * loanFactor (加载因子),这个值是当前已占用数组长度的最大值,当 size 超过这个临界值就会进行扩容,扩容后的 HashMap 容量是之前容量的两倍
2.2 常见问题
2.2.1 计算节点索引值的几种算法
● 底层默认
使用对 key 的 hashCode值结合数组长度进行无符号右移(>>>)、按位异或(^),按位与(&)计算出索引
● 其他方法
平方取中法,取余数,伪随机法等,但默认的位运算方式效率更高
2.2.2 当出现哈希碰撞会怎么样
当两个元素的key的hash值相等时,会产生哈希碰撞,若 key 值相同,则新的 value 值会覆盖旧的 value,若 key 值不同,则会添加到链表的后面,若链表长度超过阈值8且数组长度查过64,则会转为红黑树存储
2.2.3 扩容
当超出临界值时,会进行扩容,默认扩容为原容量的两倍,创建一个容量是当前容量一倍的数据,并将原来的数组数据复制过来
扩容后的节点要么在原位置,要么会被分配到 原位置+旧容量 的位置,由此在扩充 HashMap 时,不需要重新计算 hash,只需要看原来的 hash 新增的那个 bit 是 0 还是 1,若是 0 则在原位置,若是 1 则在 原位置+旧容器 的位置,如:
2.2.4 Hash 的继承关系
继承关系如下:
Cloneable: 表示可以进行克隆,创建并返回一个 HashMap 对象的副本
Serializable: 序列化接口,表示 HashMap 可以被序列化和反序列化
AbstractMap: 父类提供了 Map 实现接口,以最大限度地减少实现此接口所需的工作
2.2.5 默认容量是多少。为什么必须是2的n次幂?
● 默认容量
默认容量为 16(1<<4),可以在初始化时指定容量。
集合的最大容量为 1<< 30,即 2 的 30 次幂
● 为什么必须是 2 的 n 次幂
HashMap 为了存取高效,尽量减少碰撞,将数据均匀分配到数组中,故一般使用取模的方式进行分配索引。
由于直接取模效率不如位运算,故源码做了优化,使用 hash&(length-1),效果等同于 hash%length,而等价的前提就是 length 是 2 的 n 次幂。
● 若数组长度不是 2 的 n 次幂会发生什么
计算出的索引十分容易发生先沟通,容易发生哈希碰撞导致数组其他空间空闲
● 若数组初始化时传入的值不是 2 的 n 次幂会怎样
HashMap 允许在初始化时指定 initialCapacity 来指定数组容量
HashMap 会自动找到大于等于 initialCapacity 的最小的 2 的幂(如传入是10, 则会找到12)
● 源码分析
1) 先对 cap 减一,防止一直输入的是 2 的 n 次幂了。如输入的 cap 为16, 已经是 2 的 n 次幂了,若直接使用 16 则移位后会得到 32.故先减一变为15,移位得到16
2) 通过不断的移位和或操作,使得最高位1之后全部变成1
如 00000000 00000000 00000000 00001001 移位后得到 00000000 00000000 00000000 00001111
3) 最后得到的是一个奇数,故最后再加一
2.2.6 加载因子的作用。默认加载因子是多少?
● 为什么要有加载因子
HashMap 扩容并不是数组满了才扩容的,而是存在界限值,界限值 = 数组长度 * 加载因子。
loadFactor(加载因子) 是用来衡量 HashMap 疏密程度的,影响 hash 操作到同一个数组位置的概率。
● 加载因子设置不当会导致什么
loadFactor 太大:会导致数组填充过满,链表中的节点数量增加,也会导致生成更多的红黑树,降低元素的查找效率
loadFactor 太小:由于临界值 = 数组长度 x 影响因子,影响因子过小会导致数组容易扩容,
会导致数组的利用率低,存放的数据分散,
故官方给出了 0.75 这一个较为合适的临界值。
● 默认加载因子
默认的加载因子为 0.75,也可以在 HashMap 初始化时自定义
2.2.7 为什么 HashMap 桶中的节点个数超过 8 才转为红黑树?
https://mp.weixin.qq.com/s/QgkBRoADcO8Wgj0dHCc9dw?
HashMap 在桶中节点个数达到8,会进行树化,当节点小于等于6个时,会转为链化。这个 8 和 6 是通过衡量时间和空间后得出的。
原因总结:
● 节省内存空间
TreeNodes 占用的空间是普通 Nodes 的两倍
● 触发概率计算
从下图中可以看到,根据泊松分布计算,当临界值是 6 的时候的触发概率,比临界值是 8 的时候的概率大了 200 多倍
● 效率权衡
类型 | 平均查找长度 | 6个节点情况 | 8个节点情况 |
红黑树 | log(n) | log(6) = 2.6 | log(8) = 3 |
链表 | n/2 | 6/2 = 3 | 8/2 = 4 |
根据计算结果,若是将阈值设为6,由于2.6 和 3 相差不大,树结构的转换和生成也需要额外的时间开销,以及考虑到树节点的占用空间更大,故使用 8 作为阈值
2.2.8 存储结构-字段
HashMap 的实现数据结构是以 数组+链表+红黑树的方式实现的,但其具体底层是一个 Node(jdk1.8之前叫 Entry) 节点的数组,即哈希桶数组,其中的 Node 节点是负责存储键值对数据
2.2.9 遍历 HashMap 的四种方式
● keys() 获取所有 key,values() 获取所有 value
HashMap map = new HashMap();
map.put(1,1);
map.put(2,2);
map.put(3,3);
map.put(4,4);
map.put(5,5);
map.put(6,6);
Set<Integer> keys = map.keySet();
for (Integer key : keys) {
System.out.println(key);
}
Collection<Integer> values = map.values();
for (Integer value : values) {
System.out.println(value);
}
● 使用 Iterator 迭代器迭代
Iterator iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, Integer> mapEntry = (Map.Entry<Integer, Integer>) iterator.next();
System.out.println(mapEntry.getKey() + " === " + mapEntry.getValue());
}
● 使用 get 方式
不建议使用这种方式,因为会迭代两次。KeySet 获取迭代器一次,get又迭代一次
Set<Integer> keySet = map.keySet();
for(Integer item: keySet) {
System.out.println( item + "====" + map.get(item));
}
● jdk1.8 后使用 Map 接口中的默认方法
map.forEach((key,value) -> {
System.out.println(key + "========" + value);
});
2.2.10 HashMap 在 jdk1.7 和 jdk1.8 中的对比
JDK1.8主要解决或优化了一下问题:
- resize 扩容优化
- 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
- 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了扩容函数resize() 中 |
hash值计算方式 | 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算 |
存放数据的规则 | 无冲突时,存放数组;冲突时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树 |
插入数据方式 | 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存储位置的计算方式 | 全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量) |
其他:
https://blog.csdn.net/ThinkWon/article/details/104588551/
2.3 HashMap添加的对象为什么要重写 equals 和 hashcode
2.3.1 equals 和 == 的区别
equals 比较的是目标的内容是否相同,而 == 比较的可能是目标的指针指向地址是否相同。
2.3.2 为什么要重写 hashCode 和 equals
● 流程
在将对象作为 hashCode() 的 key 值时,会使用自定义类的 hashCode() 函数计算出对应的数组下标
在找到对应节点后,使用 equals() 来比较当前下标上的节点是否有相同的 key,有则覆盖 value,没有则添加到尾部
● 对象的 hashCode() 的问题
重写 hashCode() 主要是解决参数相同的自定义类,会得到不同的数组下标的问题
对于对象,默认的 hashCode() 方法会根据对象的引用计算出一个散列码(整形值),并被处理形成数组下标。
若存入 HashMap 中的是不同的对象,即使内部的值都是相等的,但是不同对象的引用是不同的,则根据引用得出的 hash 值也是不同,这两个对象都会被存入 HashMap 中,存储在不同的索引位置
得到的 hash 值是不同的
● 对象的 equals 的问题
equals() 的问题主要是父类 Object 类的 equals() 方法之只比较了地址,会导致属性相同的不同自定义对象比较时返回 false
在自定义类中,父类为 Object 类,可以查看源码,父类的 equals 只比较了地址:
故当传入的是两个属性相同,但不是同一个对象的自定义类对象时,equals 会返回 false
修改方案:
一般 hashCode() 方法和 equals() 方法会一起被重写,使用内部的属性值来作为比较依据
public class MapTest {
public static void main(String[] args) {
HashMap<Person, String> map = new HashMap<Person, String>();
map.put(new Person("001"), "tom");
map.put(new Person("002"), "juddy");
map.put(new Person("003"), "alic");
map.put(new Person("003"), "pink");
System.out.println(map.toString());
System.out.println(map.get(new Person("001")));
System.out.println(map.get(new Person("002")));
System.out.println(map.get(new Person("003")));
}
}
class Person {
private String id;
public Person(String id) {
this.id = id;
}
@Override
public int hashCode() {
return id != null?id.hashCode():0;
}
@Override
public boolean equals(Object obj) {
if(this==obj) {
return true; //测试检测的对象是否为空,是就返回false
}
if(obj==null) {
return false; //测试两个对象所属的类是否相同,否则返回false
}
if(getClass()!=obj.getClass()) {
return false; //对obj进行类型转换以便和类A的对象进行比较
}
Person person = (Person)obj; //对于值可能为null的属性,检测时应使用Object的equals方法,不为null的可以直接使用==检测
return Objects.equals(id, person.id);
}
}
2.4 HashTable 和 HashMap 的区别
Hashtable 和 HashMap 的使用方式相同,但 HashTable 已经不建议使用了,要保证线程安全应使用 ConcurrentHashMap
区别是什么:
● 线程安全
HashMap 是非线程安全的,而 HashTable 是线程安全的。HashTable 内部的关键方法都经过 synchronized。
● 效率
由于线程安全问题,HashTable 效率低于 HashMap
● 对 NULL 的支持
HashMap 支持存储 NULL,而 HashTable 不支持
● 初始容量大小和扩充容量不同
HashMap 默认大小为16,每次扩容为扩大一倍。而 HashTable 默认大小为11,每次扩充为 2n+1
● 底层数据结构
HashMap 在 jdk1.8 之后引入了红黑树,而 HashTable 没有
● 父类不同
HashTable 继承自 Dictionary 类,而 HashMap 是 Map 接口的一个实现
2.5 LinkedHashMap
LInkedHashMap 结合了 HashMap 和 LinkedList,实现了一个有序的 Map。虽然其增加了时间和空间上的开销,但通过维护一个运行于所有条目的双向链表,LinkedHashMap 保证了元素迭代的顺序,该迭代顺序可以是插入顺序,也可以是访问顺序。
2.6 TreeMap
TreeMap 是完全的一个红黑树,适用于对一个有序key进行遍历。而 HashMap 更适用于插入、删除、定位元素
2.7 ConcurrentHashMap
ConcurrentHashMap 通过分段锁机制实现线程安全,效率高于 HashTable。
由于 HashTable 中只有一把锁,所有的并发线程都必须同时竞争一把锁。而 ConcurrentHashMap 将数据分段,每段数据持有一把自己的锁,这样就允许多个并发线程同时访问,同时也保证了线程安全。
三、Set 类
3.1 概念
Set 是一个不存储重复元素的集合,有无序、值不能重复的特点
3.2 HashSet
HashSet 是 Set 的实现类,存储无序、不重复的元素的集合,但与 HashMap 类似,由于 HashSet 判断值是否相等是通过比较其hash 值是否相同来判断的,故如果要存储的是自定义类的话,需要重写自定义类的 hashCode() 和 equals() 方法
● HashSet 如何检查重复
先对传入的对象调用 hashCode() 来获取 hash 值,并以此确定元素在内存中的位置。
但是一个存储位置上可能存在多个元素,这时候需要使用 equals() 方法对位置上已存储的元素,与传入的新对象进行比较。
若相同,则返回存入失败,若不相同,则存入 HashSet 对象中
3.3 TreeSet
TreeSet 的本质是一个"有序的,并且没有重复元素"的集合,它是通过TreeMap实现的。
使用方式:
public class TestList2 {
public static void main(String[] args) {
testTreeSetAPIs();
}
// 测试TreeSet的api
public static void testTreeSetAPIs() {
String val;
// 新建TreeSet
TreeSet tSet = new TreeSet();
// 将元素添加到TreeSet中
tSet.add("aaa");
// Set中不允许重复元素,所以只会保存一个“aaa”
tSet.add("aaa");
tSet.add("bbb");
tSet.add("eee");
tSet.add("ddd");
tSet.add("ccc");
System.out.println("TreeSet:"+tSet);
// 打印TreeSet的实际大小
System.out.printf("size : %d\n", tSet.size()); // 导航方法
// floor(小于、等于)
System.out.printf("floor bbb: %s\n", tSet.floor("bbb"));
// lower(小于)
System.out.printf("lower bbb: %s\n", tSet.lower("bbb"));
// ceiling(大于、等于)
System.out.printf("ceiling bbb: %s\n", tSet.ceiling("bbb"));
System.out.printf("ceiling eee: %s\n", tSet.ceiling("eee"));
// ceiling(大于)
System.out.printf("higher bbb: %s\n", tSet.higher("bbb"));
// subSet()
System.out.printf("subSet(aaa, true, ccc, true): %s\n", tSet.subSet("aaa", true, "ccc", true));
System.out.printf("subSet(aaa, true, ccc, false): %s\n", tSet.subSet("aaa", true, "ccc", false));
System.out.printf("subSet(aaa, false, ccc, true): %s\n", tSet.subSet("aaa", false, "ccc", true));
System.out.printf("subSet(aaa, false, ccc, false): %s\n", tSet.subSet("aaa", false, "ccc", false));
// headSet()
System.out.printf("headSet(ccc, true): %s\n", tSet.headSet("ccc", true));
System.out.printf("headSet(ccc, false): %s\n", tSet.headSet("ccc", false));
// tailSet()
System.out.printf("tailSet(ccc, true): %s\n", tSet.tailSet("ccc", true));
System.out.printf("tailSet(ccc, false): %s\n", tSet.tailSet("ccc", false));
// 删除“ccc”
tSet.remove("ccc");
// 将Set转换为数组
String[] arr = (String[])tSet.toArray(new String[0]);
for (String str:arr)
System.out.printf("for each : %s\n", str);
// 打印TreeSet
System.out.printf("TreeSet:%s\n", tSet);
// 遍历TreeSet
for(Iterator iter = tSet.iterator(); iter.hasNext(); ) {
System.out.printf("iter : %s\n", iter.next());
}
// 删除并返回第一个元素
val = (String)tSet.pollFirst();
System.out.printf("pollFirst=%s, set=%s\n", val, tSet);
// 删除并返回最后一个元素
val = (String)tSet.pollLast();
System.out.printf("pollLast=%s, set=%s\n", val, tSet);
// 清空HashSet
tSet.clear();
// 输出HashSet是否为空
System.out.printf("%s\n", tSet.isEmpty()?"set is empty":"set is not empty");
}
}
3.4 LinkedHashSet
LInkedHashSet 结合了 HashSet 和 LinkedList,实现了一个有序的 Set。虽然其增加了时间和空间上的开销,但通过维护一个运行于所有条目的双向链表,LinkedHashSet 保证了元素迭代的顺序,该迭代顺序可以是插入顺序,也可以是访问顺序。