概述:HashMap是我们常用的一种集合类,数据以键值对的形式存储。我们可以在HashMap中存储指定的key,value键值对;也可以根据key值从HashMap中取出相应的value值;也可以通过keySet方法返回key视图进行迭代。以上是基于HashMap的常见应用,但是光会使用是远远不够的,接下来将我们深入剖析HashMap的实现原理并手写实现一个简单的HashMap。
HashMap的存储结构
如图:
通过图例我们可以发现,HashMap其实是由数组和链表组合而成的,综合了二者的优点,可以快速定位并且快速修改数据。数组的特点:数组是一种连续的存储结构,查找元素快,时间复杂度为O(1),但增删元素慢,需要移动整个数组,时间复杂度为O(n);链表的特点:链表是由一个个节点组成,节点之间通过引用联系起来,链表查找元素需要从根节点遍历,逐个查找,时间复杂度为O(n),但链表增删数据十分便捷,只要修改节点之间的引用即可实现。HashMap综合了两者的优点,我们都知道HashMap是通过key来定位的,接下来介绍一下中间环节具体是如何实现的:
1、获取hashCode()值:假设key值是一段字符串,我们先通过hashCode方法计算这段字符串的HashCode值,如下:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
hashCode()方法是可以改写的,对于不同数据类型我们可以采用不同的hashCode()方法,我们获得的hashCode值是一串数字,不同key值对应的hashCode是不同的,我们一般无法通过一个hashCode值获取到两个相同的key值。
2、通过哈希函数获取在数组中存储的下标位置:我们刚刚只是获取了一串数据来表示key的身份,但不知道它对应与HashMap的数组部分的存储位置,接下来我们还需要一步转化来获取一个范围在数组长度内的数字,那就是哈希函数,哈希函数也不唯一,有很多实现形式,性能也各有不同,但最终目的都是为了获得一个在长度范围内的值作为下标。这里给出一个哈希函数:
private int hash(K k) {
int hashCode=k.hashCode();//获取HashCode
hashCode^=(hashCode>>>12)^(hashCode>>>20);
return hashCode^(hashCode>>>7)^(hashCode>>>4);
}
其中“^”表示按位异或,“>>>”表示无符号右移。
3、找到具体的节点位置并插入:数组的每个位置都可能引出一串链表结构,这是为了防止发生哈希碰撞设计的,如果在这个位置原本就为空,那么直接创建一个新的节点(HashMap的键值对存储在节点中,节点内有key,value,下一个节点的引用等数据 )并存入,如果在下标处已经存在一个节点,当我们再次通过索引准备把一个新的节点存入时,就会发生哈希碰撞。对于哈希碰撞我们有如下几种处理方式:
- 若和之前存储在此处的节点的key值相同,则更新value即可。
- 若和之前存储在此处的节点的key值不同,只是因为hashCode相同而都安排在了这个地方,那么我们会通过新的键值对生成新的节点,把它放在原先节点的前面。之后在查询的时候,我们需要遍历这两个节点才能找到对应的key值,获取value。
- 扩容:当添加的数据较多,满足数组扩容条件时(链表扩容条件:当节点总数达到设定的节点数阈值后初次发生发生哈希碰撞,数组就需要扩容),完美就会创建一个长度为原来两倍的数组,把原节点数据迁移到新的数组,再插入新节点,即可解决哈希碰撞问题,一般数组默认的阈值是0.75,数组的长度一般默认16。
小结:通过以上介绍,我们对于HashMap的存储形式和数据结构已经有了基本的了解,概括一下就是:HashMap通过数组的结构可以迅速找到存储的位置,通过链表实现了相同hashCode的数据的存储以及便捷的增删操作,另外通过扩容机制保证了存储的高效和数组长度的自适应。
手写实现一个简单的HashMap
接下来我们手写实现一个简单的HashMap,可以实现键值对存储,以及通过key值获取value。
1、Map及节点接口(节点用内部接口实现):
public interface MyMap<K,V>{
public V put(K t,V v); //存方法
public V get(K T); //取方法
interface Entry<K,V> //Entry为Map中的元素,定义一个内部接口
{
public K getKey(); //获取Key值
public V getValue(); //获取Value值
}
}
2、MyHashMap类(支持泛型):
public class MyHashMap<K,V> implements MyMap<K, V>
{
private static final int LENGTH=16; //默认定义数组的初始长度
private static final float LOAD = 0.75f; //默认定义阈值比例
private int length; //主动定义数组的长度
private float load; //主动定义阈值比例
private int entryUseSize; //记录map中当前Entry数量
private Entry<K, V>[] table=null;//申明一个数组
/**
* 默认创建HashMap时,指定数组长度为默认值
*/
public MyHashMap(){
this(LENGTH,LOAD); //调用构造方法
}
/**
* 构造方法,在执行时生成对应长度的数组
* @param length
* @param load
*/
public MyHashMap(int length,float load) {
// TODO Auto-generated constructor stub
this.length=length;
this.load = load;
table = new Entry[this.length];
}
/**
* 修改数组长度,建立一个新的数组
* @param length
*/
public void resize(int length)
{
Entry<K,V>[] newTable = new Entry[length]; //创建新的数组
this.length=length;
this.entryUseSize=0;
rehash(newTable); //内容迁移
}
/**
* 老数组到新数组的数据迁移
* @param newTable
*/
public void rehash(Entry<K, V>[] newTable) {
ArrayList<Entry<K,V>> array = new ArrayList<>();
for(Entry<K,V> entry:table) //遍历数组table,添加到ArrayList,即取数据,先取出第一个Entry,再在while循环中取出后续的数据
{
if(entry!=null)
{
do
{
array.add(entry); //添加当前节点
entry=entry.next;//获得链表中下一个节点的引用
}
while(entry!=null);//若有后续节点,则继续添加到ArrayList
}
}
table=newTable; //覆盖旧引用
for(Entry<K,V> entry : array) //遍历ArrayList开始存放到新数组
{
put(entry.getKey(),entry.getValue()); //存值
}
}
/**
*
* 节点类,存放数据和指针
* @author mayifan
*
* @param <K>
* @param <V>
*/
class Entry<K,V> implements MyMap.Entry<K, V>
{
private K key; //存放key值
private V value; //存放value值
private Entry<K,V> next; //指向下一个相同hash对应的元素,也是它前一个添加的元素
/**
* 构造方法:定义一个空的节点
*/
public void Entry()
{
}
/**
* 构造方法
* @param key
* @param value
* @param entry
*/
public Entry (K key,V value,Entry<K,V> next){
this.key=key;
this.value=value;
this.next = next;
}
/**
* 获取节点的key值
*/
public K getKey() {
// TODO Auto-generated method stub
return key;
}
/**
* 获取节点的value值
*/
public V getValue() {
// TODO Auto-generated method stub
return value;
}
}
/**
* 存放键值对的方法
* 添加键值对的时候要考虑是否需要扩容
* 扩容条件:当节点总数超过限额后第一次发生hash碰撞,会导致扩容,容量增加一倍,建一个新的数组,原数组的数据迁移到新的数组上,再加上新的节点,删除原数组
*/
public V put(K k, V v) {
V oldValue=null;
if(entryUseSize>=length*load) //满足条件,则扩容
{
resize(2*length);
}
int i = hash(k)&(length-1); //得到Hash值,计算在数组中的位置。length是2的幂次,(length-1)可以得到全为1的二进制数。
if(table[i]==null) //该位置为空
{
table[i]=new Entry<K,V>(k,v,null);
entryUseSize++;
}
else //不为空,则逐个比对判断有没有重复,如果重复返回源节点
{
Entry<K,V> entry = table[i]; //获取第一个节点
do
{
if(entry.getKey().equals(k))//若找到相同的key,则更新value
{
oldValue=entry.getValue(); ///获取节点的原value
entry.value=v;
return oldValue;
}
entry=entry.next; //下一个节点
}
while(entry!=null); //下一个节点不为空,则继续判断
Entry<K, V> entry1 = new Entry<K,V>(k, v,table[i]); //新建一个节点指向原节点
table[i]=entry1;
entryUseSize++;
}
return oldValue;
}
/**
* 获取指定K值的数据
*/
public V get(K k) {
V value = null;
int i = hash(k)&(length-1);
if(table[i]!=null) //table不为null
{
Entry<K, V> entry = table[i]; //获取entry节点
do
{
if(entry.getKey().equals(k)||entry.getKey()==k)
{
return entry.value;
}
entry=entry.next;
}
while(entry!=null);
}
return value;
}
/**
* 获取指定key值的hash值(一种哈希函数)
* @param t
* @return
*/
private int hash(K k) {
int hashCode=k.hashCode();//获取HashCode
hashCode^=(hashCode>>>12)^(hashCode>>>20);
return hashCode^(hashCode>>>7)^(hashCode>>>4);
}
}
3、测试类(有两种键值对):
public class Test{
public static void main(String[] args)
{
System.out.println("存取int和String键值对:");
MyHashMap<Integer, String> mp = new MyHashMap();
mp.put(123, "asdasdad");
mp.put(346,"ddddd");
mp.put(3333, "sssss");
mp.put(4444, "xxxxx");
mp.put(789, "aaaaa");
System.out.println(mp.get(123));
System.out.println(mp.get(346));
System.out.println(mp.get(3333));
System.out.println(mp.get(4444));
System.out.println(mp.get(789));
System.out.println("存取String和String键值对:");
MyHashMap<String, String> mp1 = new MyHashMap();
mp1.put("ss", "cvcvv");
mp1.put("aaaa","uu");
mp1.put("dr", "yyy");
mp1.put("qwe", "rrrrr");
mp1.put("vv", "dsfsf");
System.out.println(mp1.get("ss"));
System.out.println(mp1.get("aaaa"));
System.out.println(mp1.get("dr"));
System.out.println(mp1.get("qwe"));
System.out.println(mp1.get("vv"));
}
}
4、输出结果测试:
以上测试结果表明HashMap的基本概念得到了实现。
关于HashMap一些问题的分析
1、一个key多个value如何实现?
key值是唯一的,但由于键值对是以节点的形式存储,那么在key值唯一的前提下,可以在节点存储value1,value2等数据。我们可以写获取节点的方法,然后读取不同value,或者重写get方法返回数组,都可以实现一个key获取多个value。
2、HashMap是否线程安全?
答案是否定的,HashTable是线程安全的,而HashMap不是,我们在HashMap的底层源码中找不到synchronize关键字,如果在多线程并发访问的时候是有可能出现错误的。那么如果避免呢?我们需要在程序层面人为地实现同步,确保同一时间只有一个线程访问HashMap。虽然HashMap不是线程安全的,但它也有优点,比如效率高,速度快等。
3、为什么数组的长度初值为2的指数次?
在进行按位与的时候可以显现出它的优点,(长度-1)转化为二进制可以得到的二进制各位都是1,不会造成存储空间的浪费。
4、为什么计算HashCode时使用31这个奇质数?
如果数值太小会使得最后得到的范围很小,容易发生哈希碰撞;如果数值超过100会使得到的数值超出int的范围;31+1得到的32是2的指数次,可以方便JVM进行移位运算,如:31 * i = (i << 5) - i