前言
HashMap,键值对的一种数据结构,给定唯一的key,获取value。之前2篇文章说过ArrayList在随机访问的时候效率特别高。LinkedList做随机插入删除时候效率高。而jdk中的HashMap集成了这2种优点。
话说现在jdk11出来了。但我们工作中大多用的是jdk7。本人写的这一篇HashMap是在看了jdk7与jdk8后写的。2者的思想都有参考。(参考了jdk思想。并不是对jdk源码的讲解,希望不要对你造成误会。)
题外话:另外,希望还在用jdk8以下的,新开的项目尽量使用jdk8吧,对java的优化是一个质的飞跃。众所周知,jdk5开始是一个质的飞跃(引入了泛型,foreach,自动装箱/拆箱,变长参数等等),但jdk8对锁与集合性能有大幅提升。并且加入stream的处理集合。还有lamdba表达式的加入,使你写函数接口(如Runable)再也不用写那么冗长的代码了。
本文所写的HashMap也只是实现的一种简易版键值对集合帮助大家理解。在命名方面有部分参考过jdk的命名规则!
关注点
不只是HashMap,在任何集合中,都应关注以下几点:
是否可以为空 | key允许有一个null,value你随意 |
是否有序(插入时候的顺序和读取时是否一致) | hash码具有随机性,所以无序 |
是否允许重复 | key肯定不行,value你随意 |
是否线程安全 | 线程不安全(jdk8以下会出现链表成环) |
特性 | 时间复杂度为常数级 |
原理
内部一个Entry(内部定义的类)数组,将新添加的元素计算hash值后与数组长度取余。然后放到数组对应的下标位置。如果计算出来的hash一样,此时对应的数组下标已有元素,怎么办??如LinkedList(此处为单向链表),以链表的方式追加到链表头部。如果你的数据就是这么巧,所有数据的下标都在同一个位置,那你就一直链下去吧。此时get(key)的时间复杂度则与链表相当,但这种情况不多(后文会详细分析hash算法)。但jdk8已对此处优化,如果链表的长度>=8,则转化为红黑树(红黑树的特性自行查找资料吧)。二叉查找树的平均时间复杂度是O(LogN)效率远高于链表的O(N)。(个人认为hash随机性很强,分布均匀,且数组扩容,所以链表长度>8的情况应该不多,而转化红黑树的代价也是有的,空间上的占用与链表转红黑树的性能开销)
实现
构造器:首先,定义我们的HashMap,内部存储我们定义Entry类。如上原理:我们默认数组的长度是16,有个LOAD_FACTOR负载因子,是扩容时候用的,默认为0.75,即当元素>16*0.75=12时,发生扩容。我们将这个12定义一个变量threshold阈值,每次扩容后修改这个值。如下实现:
1 /** 2 * @author HK 3 * jdk7中,喜欢命名为Entry,而jdk8中,喜欢命名为Node 4 */ 5 public class HkHashMap { 6 7 /** 8 * table的默认初始长度 9 */ 10 static final int DEFAULT_INITIAL_CAPACITY = 16; 11 12 /** 13 * 负载因子,当达到16*0.75时触发扩容 14 */ 15 static final float DEFAULT_LOAD_FACTOR = 0.75f; 16 17 Entry[] table; 18 19 int size; 20 21 /** 22 * 阈值,当达到此值时扩容。threshold=DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR 23 */ 24 int threshold; 25 26 27 /** 28 * 无参构造器,当然了,指定初始长度和负载因子更好一些。 29 */ 30 public HkHashMap() { 31 threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR); 32 table = new Entry[DEFAULT_INITIAL_CAPACITY]; 33 } 34 35 36 37 //map的存储单元 38 static class Entry{ 39 int hash; 40 Object key; 41 Object value; 42 Entry next; 43 public Entry(int hash, Object key, Object value, Entry next) { 44 this.hash = hash; 45 this.key = key; 46 this.value = value; 47 this.next = next; 48 } 49 50 51 52 53 } 54 55 }
重点看一下这个内部类Entry,通过key,计算出hash值,来定位存储位置,因为有hash碰撞,所以用单链表的方式实现,定义next节点。如果table数组上某下标只有一个元素,那么该元素的next则为null。
增put(key)
其实,修改一个key的值也是用的这个api。既HashMap中只有增删查,如果key相同,则覆盖旧的value。接下来,着重看下这个put(key)方法
首先拿到key,获取key的hashCode码(jdk的实现会重写hashCode与equals),hashCode是Object类的一个native方法(相同对象不同虚拟机的值可能会不同),返回一个int类型的值。这个int类型的值不确定性很强,长度也不固定。如下
public static void main(String[] args) { String str = "66"; Integer i = 66; System.out.println("String的HashCode:"+str.hashCode());//1728 System.out.println("Integer的HashCode:"+i.hashCode());//66 }
所以,我们需要一个hash算法,来算出一个具有很强的随机性和长度相对固定的数字。此处,我们参考jdk8中,将hashCode高16与本身做异或运算。如下:可以看到,如果key是null,固定放到table[0]的位置
1 /** 2 * HashCode做一次hash运算。以此更加保证散列性 3 */ 4 static final int hash(Object key) { 5 int h; 6 //将hashCode右移16位与hashCode做“异或”运算,即高16位^hashCode(参考jdk8)。如果key为null,固定放到table[0]的位置 7 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 8 }
ok,有了hash值后,如何确定在数组上的位置呢?而又得保证在数组上的位置比较均匀的分布。我们将这个hash值对数组的长度取余,余数 = hash%table.length。然后将这个key对于的Entry放到table[余数]的位置。
hashMap中扩容有一个规定,table会扩容为原来的2倍,且建议table.length为2的N次幂。为什么这样呢?我们来看以下,2的N次幂数,比如8,16,32,他们的2进制分别是1000,10000,100000,将它们减1则变为111,1111,11111。
用下图来做一个讲解:随便添加一个key,“我是key”的hash值算出来结果假如是 int hash = “555”
我们进行与运算后,结果就是hash值为555这个数的二进制后4位。而一个4位二进制数永远也不可能大于15。同理,当table的长度变为32位时,31的二进制就是11111。此时与运算后结果肯定是一个小于等于31的数。
因此,如果hashMap长度不是2的N次幂,那么做与运算将又糟糕的结果。假如上图红色位置有一个是0,那么&操作后,将有一位永远是0,此时,table数组上有位置永远不可能存放元素,或者说,如果红色位置0特别多的话,hashMap将只有一俩个table位置存放了数据,性能大打折扣!!!之前没注意源码中有这个的优化之处,假如人工将初始长度改为不是2的N次幂,则会自动调整为2的N次幂(这句红色是修改的文章,之前误人子弟不好意思)。
下面代码我们定义出通过hash找到再table数组位置的方法,即类似求余数的方法:
1 /** 2 * 任何一个key,都需要找到hash后对应的哈希桶位置 3 */ 4 static int findBucketIndex(int h, int length) { 5 //求余数的算法的结果是一样的。 6 return h & (length-1); 7 }
此时,我们再来写put方法
1 /** 2 * 添加元素,如果key已存在,则替换 3 */ 4 public Object put(Object key,Object value){ 5 if(null==key){ 6 return putNullKey(value); 7 } 8 int h=hash(key); 9 int i = findBucketIndex(h,table.length); 10 //如果已经有这个key,则替换 11 for (Entry e = table[i]; e != null; e = e.next) { 12 Object k; 13 if (e.hash == h && ((k = e.key) == key || key.equals(k))) { 14 Object oldValue = e.value; 15 e.value = value; 16 return oldValue; 17 } 18 } 19 addEntry(h, key, value, i); 20 return null; 21 } 22 23 void addEntry(int hash, Object key, Object value, int bucketIndex){ 24 Entry e = table[bucketIndex]; 25 table[bucketIndex] =new Entry(hash,key,value,e); 26 size++; 27 if(size>threshold){ 28 resize(table.length*2); 29 } 30 }
如上代码,其中,第5~7行,是对key位null的Entry做处理,我们将这个Entry固定放到table[0]的位置上,
第8行是通过key计算hash值,然后通过hash值与table的长度,计算这个key在hash桶中的位置。
11~18行是HashMap的修改操作,即如果这个key已存在,则进行替换操作。(定位到hash桶后,遍历这个位置的单向链表)。
jdk中的hashMap即put成功返回null,如果key是替换操作,则返回旧的值。
看一下23~30行的addEntry方法。
24行:将原hash桶的位置的Entry赋予e元素。显然如果原先是空位置,则这个e是null。
25行:新添加的这个元素放到table[i]的位置上,原先的元素链接到现在位置的下一个元素。
27~29为扩容相关。可见,当元素数量大于阈值时,扩容为原先的2倍。扩容详细分析,写最后边?(扩容在不同的jdk中实现也是大不相同)
1 /** 2 * hashMap允许键为空,对这个空键单独处理 3 */ 4 private Object putNullKey(Object value){ 5 //如果已经有这个Null的Key,则替换 6 for (Entry e = table[0]; e != null; e = e.next) { 7 if (e.key == null) { 8 Object oldValue = e.value; 9 e.value = value; 10 return oldValue; 11 } 12 } 13 addEntry(0, null, value, 0); 14 return null; 15 }
这个是对key为null的情况单独处理,不解释!
查找get(key)
有了对put方法的描述,get就相对简单许多了。核心思想就是:根据key,先找到hash桶的位置,然后遍历hash桶位置上的链表,如果找不到,返回null。不做过多解释。看如下代码即可!
1 /** 2 * 获取元素 3 */ 4 public Object get(Object key){ 5 int h = hash(key); 6 for(Entry e = table[findBucketIndex(h,table.length)];e!=null;e=e.next){ 7 if(e.hash==h&&(key==e.key||key.equals(e.key))){ 8 return e.value; 9 } 10 } 11 return null; 12 }
删除remove(key)
根据key删除一个元素,核心思想是定位这个key再table数组的位置,定位到后,遍历这个位置的链表,找到Entry e 后,切断e与前后链表的关系,将前后节点连接起来,与LinkedListd的核心思想一样
细节问题,代码注释已说明,不再阐述。如下:
1 public Object remove(Object key){ 2 return removeEntryForKey(key); 3 } 4 5 private Entry removeEntryForKey (Object key){ 6 int hash = hash(key.hashCode()); 7 int i = findBucketIndex(hash,table.length); 8 //先定义一个prev表示待删除的元素的上一个 9 Entry prev = table[i]; 10 Entry e=prev; 11 while(e!=null){ 12 //定位出e的下一个元素,当找到我们要删除的元素时,将链表切断,将prev和next连接即可。参考linkedList的删除即可 13 Entry next = e.next; 14 //在链表上定位到这个元素,先判断hash值是否相等,再判断key的equals是否相等!注意,这里如果==返回true则说明是一个引用,这样可省略equals的判断。 15 if(e.hash==hash&& (e.key==key||(key!=null&&key.equals(e.key))) ){ 16 size--;//此时已有元素被定位到,注意while外面是判断了hash值相等的情况。hash值相等时候equals不一定相等。 17 //这里,还得判断一个东西,即定位到的这个Entry是否是table[i],因为table[i]上的删除和链表上的删除有区别 18 if(prev==e){ 19 table[i]=next;//尽管则个next可能为空 20 }else{ 21 prev.next=next;//如果不是table位置的,则是链表后边的元素,此时,将prev的下一个置为e的next即可,此时,e已被切断,e的前后Entry被连接起来 22 } 23 return e; 24 } 25 //上边的if没进去,则执行下一次循环,指针向后移动一位! 26 prev=e; 27 e=next; 28 } 29 //直到while结束,如果没找到元素,return null 30 return null; 31 }
扩容
ok,接下来,我们说一下扩容,扩容再jdk8中与之前版本有较大区别,jdk8解决了链表成环的问题,且jdk8扩容中还有红黑树,所以肯定会有较大区别。我们主要讲以下jdk7中的扩容(头插入,即扩容后会将原链表倒置)。
1 /** 2 * 扩容,传入新容量 3 */ 4 void resize(int newCapacity){ 5 Entry[] newTable = new Entry[newCapacity]; 6 //将所有旧元素换到新table中start 7 Entry[] oldTable = table; 8 //遍历所有的位置 9 for (int j = 0; j < oldTable.length; j++){ 10 Entry e = oldTable[j]; 11 if(null!=e){ 12 oldTable[j]=null;//循环结束后,会将oldTable引用全部置空,GC回收 13 do{ 14 Entry next = e.next;//先记住e的链表下一个是谁 15 int i = findBucketIndex(e.hash,newTable.length);//找到新的哈希桶位置 16 e.next=newTable[i];//注意这里是循环,newTable[i]上是可能有元素的。将现在newTable[i]上的元素置为e的下一个, 17 newTable[i]=e;//赋值,所以,jdk7中的扩容是链表头插入。 18 e=next;//下一次循环使用 19 }while(null!=e); 20 } 21 } 22 table=newTable; 23 //将所有旧元素换到新table中end 24 threshold=(int)(newCapacity*DEFAULT_LOAD_FACTOR); 25 }
定义一个新的数组,newTable,扩容传入的参数一般是原数组的2倍长度
此时,我们需要从旧数组中挨个位置来遍历,从table[j]开始入手,对table[j]上有hash碰撞的Entry再进行单独计算hash桶,重新放到新数组的新位置。(因为原先长度在一个Hash桶的那些元素,现在不一定在一个hash桶中)
最后,将table引用到newTable
阈值重新计算,方便下一次扩容。
总结:
- 如果你对数据量有一个预估值,建议在创建Map的时候指定长度,以减少hashMap的扩容!扩容是一个很消耗性能的操作。最好自己手工指定为2的N次幂。
- HashMap线程不安全,例如链表成环问题,只有在多线程情况下出现。但这并不是jdk设计的缺陷。所以如果你的集合要在多线程下访问,请禁止使用HashMap。建议使用Juc包下的HashMap。有关ConcurrentHashMap的详细讲解,会在后续多线程并发模块给大家详细讲解。个人认为hashTable应该淘汰了!
疑问
- hashMap中modCount变量是干什么的?答:java中所有数据结果都有此变量,主要用在fail—fast快速失败。即遍历时并发修改集合的时候,通过此变量可判断在遍历时是否进行修改。
- jdk源码中table数组变量为什么是transient修饰的?我们知道,此修饰符指序列化时忽略此字段。HashMap为什么要进行此设置呢?答:因为hashMap严重依赖hashCode,然而不同虚拟机的hashCode并不相同!意思就是A机器上序列化后的集合,到B机器上反序列化后,HashMap极有可能已不能正常使用。因为hash算法已无法定位到table上的位置!
- 对于上一个疑问,hashMap的解决方式就是重写了自己序列号的方式writeObject方法来进行自己的序列号方式。仅将key和value写到了一个文件中,并不参与hash的存储!还请大家自行详细研究吧!
附上源码
package main.java.collection; /** * @author HK * jdk7中,喜欢命名为Entry,而jdk8中,喜欢命名为Node */ public class HkHashMap { /** * table的默认初始长度 */ static final int DEFAULT_INITIAL_CAPACITY = 16; /** * 负载因子,当达到16*0.75时触发扩容 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; Entry[] table; int size; /** * 阈值,当达到此值时扩容。threshold=DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR */ int threshold; /** * 无参构造器,当然了,指定初始长度和负载因子更好一些。 */ public HkHashMap() { threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR); table = new Entry[DEFAULT_INITIAL_CAPACITY]; } /** * 获取元素 */ public Object get(Object key){ int h = hash(key); for(Entry e = table[findBucketIndex(h,table.length)];e!=null;e=e.next){ if(e.hash==h&&(key==e.key||key.equals(e.key))){ return e.value; } } return null; } /** * 添加元素,如果key已存在,则替换 */ public Object put(Object key,Object value){ if(null==key){ return putNullKey(value); } int h=hash(key); int i = findBucketIndex(h,table.length); //如果已经有这个key,则替换 for (Entry e = table[i]; e != null; e = e.next) { Object k; if (e.hash == h && ((k = e.key) == key || key.equals(k))) { Object oldValue = e.value; e.value = value; return oldValue; } } addEntry(h, key, value, i); return null; } public Object remove(Object key){ return removeEntryForKey(key); } private Entry removeEntryForKey (Object key){ int hash = hash(key.hashCode()); int i = findBucketIndex(hash,table.length); //先定义一个prev表示待删除的元素的上一个 Entry prev = table[i]; Entry e=prev; while(e!=null){ //定位出e的下一个元素,当找到我们要删除的元素时,将链表切断,将prev和next连接即可。参考linkedList的删除即可 Entry next = e.next; //在链表上定位到这个元素,先判断hash值是否相等,再判断key的equals是否相等!注意,这里如果==返回true则说明是一个引用,这样可省略equals的判断。 if(e.hash==hash&& (e.key==key||(key!=null&&key.equals(e.key))) ){ size--;//此时已有元素被定位到,注意while外面是判断了hash值相等的情况。hash值相等时候equals不一定相等。 //这里,还得判断一个东西,即定位到的这个Entry是否是table[i],因为table[i]上的删除和链表上的删除有区别 if(prev==e){ table[i]=next;//尽管则个next可能为空 }else{ prev.next=next;//如果不是table位置的,则是链表后边的元素,此时,将prev的下一个置为e的next即可,此时,e已被切断,e的前后Entry被连接起来 } return e; } //上边的if没进去,则执行下一次循环,指针向后移动一位! prev=e; e=next; } //直到while结束,如果没找到元素,return null return null; } /** * hashMap允许键为空,对这个空键单独处理 */ private Object putNullKey(Object value){ //如果已经有这个Null的Key,则替换 for (Entry e = table[0]; e != null; e = e.next) { if (e.key == null) { Object oldValue = e.value; e.value = value; return oldValue; } } addEntry(0, null, value, 0); return null; } void addEntry(int hash, Object key, Object value, int bucketIndex){ Entry e = table[bucketIndex]; table[bucketIndex] =new Entry(hash,key,value,e); size++; if(size>threshold){ resize(table.length*2); } } /** * 扩容,传入新容量 */ void resize(int newCapacity){ Entry[] newTable = new Entry[newCapacity]; //将所有旧元素换到新table中start Entry[] oldTable = table; //遍历所有的位置 for (int j = 0; j < oldTable.length; j++){ Entry e = oldTable[j]; if(null!=e){ oldTable[j]=null;//循环结束后,会将oldTable引用全部置空,GC回收 do{ Entry next = e.next;//先记住e的链表下一个是谁 int i = findBucketIndex(e.hash,newTable.length);//找到新的哈希桶位置 e.next=newTable[i];//注意这里是循环,newTable[i]上是可能有元素的。将现在newTable[i]上的元素置为e的下一个, newTable[i]=e;//赋值,所以,jdk7中的扩容是链表头插入。 e=next;//下一次循环使用 }while(null!=e); } } table=newTable; //将所有旧元素换到新table中end threshold=(int)(newCapacity*DEFAULT_LOAD_FACTOR); } /** * HashCode做一次hash运算。以此更加保证散列性 */ static final int hash(Object key) { int h; //将hashCode右移16位与hashCode做“异或”运算,即高16位^16位(参考jdk8)。如果key为null,固定放到table[0]的位置 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } /** * 任何一个key,都需要找到hash后对应的哈希桶位置 */ static int findBucketIndex(int h, int length) { //求余数的算法的结果是一样的。位运算效率高(装逼一点) return h & (length-1); } //map的存储单元 static class Entry{ int hash; Object key; Object value; Entry next; public Entry(int hash, Object key, Object value, Entry next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } }