本文内容来自深入理解HashMap从数据结构谈HashMapHashMap深度分析

先说使用要点。

1、不要在并发场景中使用HashMap
  HashMap是线程不安全的,如果被多个线程共享的操作,将会引发不可预知的问题。

2、如果数据大小是固定的,那么最好给HashMap设定一个合理的容量值

  HashMap的初始默认容量是16,默认加载因子是0.75,也就是说,如果采用HashMap的默认构造函数,当增加数据时,数据实际容量超过16*0.75=12时,HashMap就扩容,扩容带来一系列的运算,新建一个是原来容量2倍的数组,对原有元素全部重新哈希,这是很耗费资源的。

  好了,现在来对HashMap进行“深入分析”。

  1、hashmap的数据结构

  在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。

  从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。

  在HashMap里有这样的一句属性声明:

transient Entry[] table;

  Entry就是HashMap存储数据所用的类,它拥有的属性如下:

static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
final int hash;
Entry<K,V> next;
..........
}

  看到next了吗?它是一个指向下一个元素的引用,这就构成了链表。

  当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,将这个位置的原有Entry赋值给当前新加的 Entry的next属性,这样新加入的就放在了链头,而最先加入的就放在了链尾。

  从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

  从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服。哈希碰撞总是难免的,不过java里面使用了一个聪明的办法减少了哈希碰撞的几率,这个后面会讲到。

  2、HashMap的操作。

  现在来看看,当我们new、get、put的时候,HashMap到底都是怎么处理的。

  先看初始化

  与初始化有关的关键参数如下:

   /**
* 默认容量
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 等于 16 /**
* 最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30; //2的30次方 /**
* 默认加载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 当实际数据容量超过threshold时,HashMap会将容量扩容,threshold=容量*加载因子
*/
int threshold; /**
* 加载因子
*/
final float loadFactor;

  HashMap有4个构造方法,值得一提的是下面这个:

  public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor); this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}

  看这句: threshold = initialCapacity;

  也就是说,如果构造时写成new HashMap(9,0.75);那么当容量超过9时,HashMap才会扩容,而不是9*0.75时就扩容。这里的initialCapacity建议写成2的倍数,至于为什么后面会说到。

  然后是get。即如何获取我们要的元素。

  其实前面已经提过了,在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。然后通过key的equals方法在对应位置的链表中找到需要的元素。

  接下来是put 。

  我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

  所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的:

static int indexFor(int h, int length) {
  return h & (length-1);
}

  首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。

看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

  所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。 
      说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。 
  所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。

  接下来是resize,也就是扩容。

  当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 
      那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。

  最后说说HashMap死锁。才

  死锁是扩容操作与put或get方法并发引起的,看源码:

  void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;//切换线程1
e = next;
}
}
}
  final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
} int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];//切换线程2
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}

table初始状态:

假设执行getEntry的线程1在e = table[indexFor(hash, table.length)]后切换到线程2的put方法,而且进行了扩容。此时table和newTable的状态是:

线程2在newTable[i] = e后切换到线程1,在for循环里e.next==e,永远不为空,若

if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  

不满足,就会进入死循环。

ok,先说这么多,感谢各位同行的分享。

最新文章

  1. eclipseMARS2.0使用可视化设计界面——配置window builder
  2. StringBuffer类
  3. Java NIO入门
  4. java解析xml禁止校验dtd
  5. Redis 集合(Set)
  6. cocos2d-x 2.x版本中,场景切换各方法调用顺序
  7. Java write And read Demo
  8. NYOJ-745蚂蚁的难题(二)
  9. 初学VUE2.0
  10. Linux 使用tcpdump观察arp通信过程
  11. PHP如何搭建百度Ueditor富文本编辑器
  12. Markdown语法进阶
  13. CSS3笔记2
  14. Jenkins部署码云SpringBoot项目
  15. swiper,一个页面使用多个轮播
  16. 谷歌pixel手机解BL锁、刷机、破解电信(史上最详细的帖子)
  17. Spark实战记录
  18. VMware下ubuntu与Windows实现文件共享的方法 (转)
  19. Golang 函数function
  20. oracle 数据库 NLS_CHARACTERSET 字符集的修改

热门文章

  1. C--二分搜索
  2. Spring基础(一)
  3. 深度长文回顾web基础组件
  4. postman参数化
  5. Node.js新手必须知道的4个JavaScript概念
  6. SPDK发送和接收连接请求的处理
  7. CF991D Bishwock
  8. 在Mac平台用Sublime编辑器使用Git并连接github
  9. Helm神器,让管理Kubernetes像yum安装包一样简单
  10. JAVA aio简单使用