前言
在我们的Java学习中,常用的集合有set、map、list,我们今天在这里简单了解一下什么map集合的使用和分类(自己的理解能力有限,大家凑活看吧)。
一、Map介绍
1.我们常用的几种map:HashMap、Hashtable、LinkedHashMap、TreeMap,如下图所示,做下简单的了解。
二、HashMap结构
hashmap”的简单介绍。
1.JDK 1.7
在JDK 1.7的时候,我们可以了解到,HashMap是由两部分组成:
- 数组
- 链表
2.JDK 1.8
在JDK 1.8的时候,HashMap的组成发成了变化:
- 数组
- 链表/红黑树(此处不做介绍,如果想了解,只能自行查询)
可能说到这有人会问为什么要用“数组”,“链表”。那么现在我们就重新了解一下什么是“数组”,什么是“链表”,他们都有什么优缺点。
3.什么是数组,它的优缺点是什么
数组是一种最简单的复合数据类型,它是有序数据的集合,数组中的每个元素具有相同的数据类型,可以用一个统一的数组名和不同的下标来唯一确定数组中的元素。
3.1 数组的优点:
- 数据连续
- 数据有序
- 查询速度快(数组是将元素在内存中连续存放,由于每个元素占用内存相同,可以通过下标迅速访问数组中任何元素。)
3.2 数组的缺点:
- 数据大小固定后,不能进行扩容
- 数组只能存储单一类型的数据
- 添加,删除的操作慢(如果要在数组中增加一个元素,需要移动大量元素,在内存中空出一个元素的空间,然后将要增加的元素放在其中。同样的道理,如果想删除一个元素,同样需要移动大量元素去填掉被移动的元素。)
3.3 适用场景:
频繁查询,对存储空间要求不大,很少增加和删除的情况
4.什么是链表,链表的优缺点又是什么呢
通过一个链子把多个结点(元素)连接起来,由数据和地址组成的一个元素,节点本身必须有一个地址值(就是下一个元素的地址值)
4.1 链表的优点:
- 大小不固定,扩展灵活
- 插入、删除速度快(添加或者删除元素时只需要改变前后两个元素结点的指针域指向地址即可)
4.2 链表的缺点:
- 因为含有大量的指针域,占用空间较大
- 查询效率慢(查找元素需要遍历链表来查找)
4.3 适用场景:
数据量较小,需要频繁增加,删除操作的场景
5.hash的理解
所谓的hash,简单的说就是散列,即将输入的数据通过hash函数得到一个key值,输入的数据存储到数组中下标为key值的数组单元中去。
我们发现,不相同的数据通过hash函数得到相同的key值。这时候,就产生了hash冲突。解决hash冲突的方式有两种。一种是挂链式,也叫拉链法。挂链式的思想在产生冲突的hash地址指向一个链表,将具有相同的key值的数据存放到链表中。另一种是建立一个公共溢出区。将所有产生冲突的数据都存放到公共溢出区,也可以使问题解决。
三、HashMap的简单介绍以及使用原理
前面都是一些知识的回顾,都是一些开胃菜,接下来才是真正的主菜。
HashMap是基于哈希表的Map接口非同步实现的。 大家都了解Map是一个键值对的对象,且线程不安全。
我们为什么要使用HashMap
- HashMap是一个散列桶(数组和链表),它存储的内容是键值对(key-value)映射
- HashMap采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
- HashMap是非synchronized,所以HashMap很快
- HashMap可以接受null键和值,而Hashtable则不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以)
- hashcode和equals
hashCode是用于查找使用的,而equals是用于比较两个对象的是否相等的。
这里不会做过多的介绍,有兴趣的可以自己上网搜索或者查看(自己写的,将就看吧,就当打免费广告了):
叶落渲染一季悲凉:关于==和equals的总结以及序列化的简单描述zhuanlan.zhihu.com
HashMap的静态常量:
/**
* The default initial capacity - MUST be a power of two.
* 默认初始容量 - 必须是2的幂。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
* 最大容量,如果具有参数的任一构造函数隐式指定*,则使用最大容量。 *必须是2的幂<= 1 << 30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
* 在构造函数中未指定时使用的加载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表转为红黑树的阈值:8
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
* 使用树而不是列表用于* bin的bin计数阈值。将元素添加到具有至少这么多节点的* bin时,将转换为树。
* 该值必须大于*且应该至少为8才能与*树移除中的假设相关联,以便在收缩时转换回普通箱。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转为链表的阈值:6
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
* 在*调整大小操作期间解除(拆分)bin的unitification的bin计数阈值。
* 应该小于TREEIFY_THRESHOLD,并且在最多6个与去除时的收缩检测啮合
*/
static final int UNTREEIFY_THRESHOLD = 6;
接下来我们看一下hashmap的构造方法:
如果你通过带参数构造方法初始化HashMap时,会调用tableSizeFor
方法计算出一个比initialCapacity大的第一个2的n次幂的值存入threshold。tableSizeFor
的具体实现如下:
下面我们自己做一下实验,看看结果如何:
public static void main(String[] args) {
try{
//调用容量测试
capacityTest(5);
}catch (Exception e){
e.printStackTrace();
System.out.println("报错了");
}
}
/**
* 容量测试
* @param initCapacity 入参容量
* @return 实际容量
* @throws Exception
*/
public static int capacityTest(int initCapacity) throws Exception {
Map<String, String> map = new HashMap<String, String>(initCapacity);
// 通过反射获取容量变量capacity,并调用map对象
Method capacity = map.getClass().getDeclaredMethod("capacity");
capacity.setAccessible(true);
Integer realCapacity = (Integer) capacity.invoke(map);
System.out.println("入参容量为" + initCapacity + "时,实际容量为" + realCapacity);
return realCapacity;
}
执行结果大家可以想的到吗,闲话不多说,直接上结果,
JDK 8 中在进行get和put操作时,会先根据key的hashCode进行再散列,再进行bucket对应节点位置计算,接下来我们来做个简单的运算:
从这个小例子可以看出:h >>> 16,高16位补0,由于任意数跟0异或不变,所以hash的作用就是高16位不变,低16位和高16位做异或运算,来达到减少碰撞的目的。
hash方法的具体实现如下:
当然,为了提高碰撞下的性能,JDK 8引入了rbtree来代替链表,将原有链表部分查询的时间复杂度o(n)提升为o(logn),接下来我们就来看看JDK 8中的put方法的具体实现。
从上图的代码可以看出,putVal具体流程如下:
- 如果当前bucket为空时,调用resize方法进行初始化;
- 根据key的hash值计算出所在bucket节点位置;
- 如果没有发生冲突,调用newNode方法封装key-value键值对,并将其挂到 bucket对应位置下,否则,跳转到步骤4;
- 如果发生冲突:
- 如果该key已存在,更新原有oldValue为新的value,并返回oldValue;
- 如果key所在的节点为treeNode,调用rbtree的putTreeVal方法将改节点挂到rbtree上;
- 如果插入节点后,当前bucket节点下链表长度超过8,需要将原有的数据结构链表变为rbtree;
- 数据put完成之后,如果当前长度 > threshold,调用resize方法扩容。
resize的实现
resize的前半部分主要完成了新的capacity和threshold的计算。从代码实现可以看出,每一次扩容,newCapacity和newThreshold均是扩容前值的两倍,为什么如此设计呢?还是照样举个例子来说明这样子设计的原因:
从小例子可以看出,resize后,key所在bucket的节点位置保持不变。首先,table.length也就是capacity肯定是2的n次方,根据所在bucket节点下标计算公式:index = hash & (table.length - 1),其实在进行&运算的时候,只是多了一个最高位1,那么新位置要么保持原位置不变,要么在原位置 + oldCapacity,这个设计的巧妙就在于节省了一部分重新计算hash的时间,而且hash值高位出现0和1的概率均等,在resize的过程又将节点平均分配到两个bucket节点。
四、总结:
1. HashMapd的特点
是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。
2. HashMap的工作原理
通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
3. get和put的原理
通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点
4. hash的实现
在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
5. 扩容
如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。
参考地址1:https://www.jianshu.com/p/a11b9c1002f1?from=singlemessage