本文来谈谈我们平时使用最多的HashMap。


1. 简介

HashMap是我们在开发过程中用的最多的一个集合结构,没有之一。HashMap实现了Map接口,内部存放Key-Value键值对,支持泛型。在JDK1.8以前,HashMap内部是以数组加链表的结构维护键值对数据。在JDK1.8中,HashMap以数组、链表加红黑树的结构维护数据,当链表长度大于8以后会自动转为红黑树提升数据增删改查的效率。另外JDK还提供了很多Map接口的其他实现,比较常用的有LinkedHashMapTreeMap以及已经淘汰但是经常拿来和HashMap做对比的HashTable。他们的继承关系如下。

值得注意的是HashMap不是线程安全的,所以JDK又提供了ConcurrentHashMapSynchronizedMap等,可以在多线程环境下使用。

2. HashMap中一些常量介绍

    // Hash表的默认初始化长度,默认是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//Hash表的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子,负载因子的大小可以平衡时间和空间的关系,如果负载因子较大,比较节省空间,但是增加了Hash碰撞的几率
//如果负载因子较小,resize会发生的比较频繁,空间利用率不高,但是减少了Hash碰撞的概率。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当桶上面的链表长度大于8时,链表会转换成树结构
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64 transient Node<K,V>[] table; /**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet; /**
* The number of key-value mappings contained in this map.
*/
transient int size; /**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount; /**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold; /**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor;

2. 确定Hash桶位置分析

HashMap在将键值对放入Map中的第一步是找出这个键值对在Hash表中的位置。JDK1.8HashMap的做法是:

    //第一步:获得key的hashCode,并加入扰动函数,增加hash值的随机性,减少冲突。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
} //其中n是Hash表的长度,相当于hash%n
(n - 1) & hash

总的来说,HashMap在计算元素在桶中位置的算法很简单:就是根据key的Hashcode值,加入扰动函数之后再跟Hasn表的长度取余就得到这个键值对在Hash表中的位置。这边加入扰动函数的做法是:将key本身的Hashcode值右移16位,再跟自身进行异或。自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。

Peter Lawley在文章《An introduction to optimising a hashing strategy》里做了一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。见本文

3. put方法分析

确定好键值对(Entry)的位置后,HashMap进行put操作。下面以JDK1.8的代码做下分析。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//step1:判断Hash表是否为空,如果为空就创建一个长度为16的Hash表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//step2:如果这Hash桶位置上没数据,直接在这个位置上创建
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//step3:如果这个Key在HashMap中已经存在,直接覆盖这个key的值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//step4:以红黑树的方式加入该键值对
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//step5:这个桶上是链表,遍历链表,如果在遍历过程中发现这个key已经存在,直接覆盖这个key的值返回
// 如果发现这个key不存在,直接在链表尾部加入这个键值对,并判断链表长度是否大于8,如果长度大于8转为红黑树
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//step6:如果key之前在HashMap中存在,用新值覆盖旧值,并返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果size大于阈值就行进扩容。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

总结一下HashMap进行put元素的过程:

step1:会先判断Hash表是否创建,如果没被创建(也就是在创建HashMap时没指定任何参数),就创建一个长度为16的Hash表,并将阈值设置为12;

step2:根据key的hashCode值计算出在Hash表中的位置,如果这个位置上没任何元素,直接在这个位置上创建元素,然后将size++后就结束了。如果这个位置上有元素进入步骤3.

step3如果这个位置上有元素,判断这个元素是否和新加入元素的key相等(判断的标准是key的hash值相等,并且通过equals方法比较也相等),如果相等用新元素的value覆盖旧元素的value,并且返回旧元素的值,方法结束,否则进入步骤4;

step4:判断这个位置上是不是一个树形结构,如果是树形结构,按红黑树的形式加入元素,然后返回;否则进入步骤5;

step5:进入步骤5的话,那么这儿位置上一定是一个链表结构,遍历链表,如果在遍历过程中发现这个key已经存在,直接覆盖这个key的值返回,如果发现这个key不存在,直接在链表尾部加入这个键值对,并判断链表长度是否大于8,如果长度大于8转为红黑树。

至此,整个put过程结束。顺便提下,HashMap允许元素的键值为null,并且会存放在Hash表的第一个位置

4. get方法分析

HashMap的get方法比较简单,源代码如下:

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

如果hash表为空,或者hash值所在的桶位置上没有值则直接返回null。否则就通过比较key的hash值和equals方法,两者都相等就返回对应的value值。

5. 扩容机制分析

当元素向HashMap容器中添加元素的时候,会判断当前元素的个数,如果当前元素的个数大于等于阈值时,即当前数组table的长度加载因子就要进行自动扩容。

由于HashMap的底层数据结构是“链表散列”,即数组和链表的组合,而数组是无法自动扩容的,所以只能是换一个更大的数组去装填以前的元素和将要添加的新元素。

HashMap的扩容过程是这样的:首先会创建一个大小是原来一倍的数组(如果原来的数组大小已经达到的最大值,那么不会再创建新的数组,只是将阈值改成最大值然后返回原数组),然后将旧数组中的元素一个个复制到新数组中。从整体上看,扩容就是这么一个简单的过程,只不过HashMap在重新计算元素在新数组中位置的时候针对不同的元素采取了些优化措施。比如原来旧数组中每个位置上的值是null值就直接跳过了;如果原来位置上的值是单个值,先通过这个值的hashcode值取余新数组的长度,得出在新数组中的位置,然后再赋值过去;如果原来位置上是一个红黑树结构,就调用split()方法进行拆分放置(这块代码没仔细看过);如果是链表结构,那么元素在新数组中的位置要么和之前一样,要么就是现在的位置加上旧数组的长度。

6. 线程安全相关

大家都知道HashMap是线程非安全的。下面的情况会产生线程安全问题。

  1. put的时候导致的多线程数据不一致

    比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
  2. resize而引起死循环(JDK1.8已经不会出现该问题)

    这种情况发生在JDK1.7HashMap自动扩容时,当2个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK1.7中,会出现resize前后元素顺序倒置的情况)。接下来再想通过get()获取某一个元素,就会出现死循环。具体产生死循环的原因请看这篇博客

线程安全的Map

  • Hashtable
  • ConcurrentHashMap
  • Synchronized Map

ConcurrentHashMap这边先不介绍,后面会专门写文章介绍。SynchronizedMap是集合工具类生成的并发类,其实现线程安全的原理是在每个方法上加了synchronized

下面介绍下HashMapHashTable的区别。

1.HashTable的方法是同步的,在方法的前面都有synchronized来同步,HashMap未经同步,所以在多线程场合要手动同步.

2.HashTable不允许null值(key和value都不可以) ,HashMap允许null值(key和value都可以)。

3.HashTable有一个contains(Object value)功能和containsValue(Object value)功能一样。

4.HashTable使用Enumeration进行遍历,HashMap使用Iterator进行遍历。

5.HashTable中hash数组默认大小是11,增加的方式是 old*2+1。HashMap中hash数组的默认大小是16,而且一定是2的指数。

7. 其他Map实现

7.1 TreeMap

TreeMap是有序的Map结构。应用在需要根据key排序的场景下。TreeMap内部是通过红黑树实现有序的。这边就不进行深入研究了。感兴趣的大家可以自己研究下代码。

下面提供下TreeMap的使用示例:

//根据key的默认排序顺序排序
TreeMap<String, String> treeMap = new TreeMap<>();
treeMap.put("Java","obj");
treeMap.put("Python","obk");
treeMap.put("C##","oxx");
treeMap.forEach((key,value) ->{
System.out.println("["+key+"]"+":"+"["+value+"]");
}); //自定义key的排序顺序
//如果自定义了比较器,那么TreeMap比较两个key是否相等的规则就变成
//首先根据hashcode判断,然后通过key的compare方法判断,而不是通过equals方法判断了
TreeMap<String, Object> treeMap1 = new TreeMap<>(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.compareToIgnoreCase(o2);
}
});
treeMap1.put("Java","obj");
treeMap1.put("Python","obk");
treeMap1.put("java","oxx");
treeMap1.forEach((key,value) ->{
System.out.println("["+key+"]"+":"+"["+value+"]");
});

7.2 LinkedHashMap

LinkedHashMap继承于HashMapHashMap是无序的,当我们希望有顺序地去存储key-value时,就需要使用LinkedHashMap了。LinkedHashMap使用双向链表维护顺序。(Entry中维护了两个指针,分别指向前面的节点和后面的节点

LinkedHashMap的有序性分两种:插入顺序和访问顺序。

LinkedHashMap的构造函数的参数中有一个accessOrder的参数。这个accessOrder设置为false,表示以插入顺序访问Map中的值。这个也是默认值。

 LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>(16, 0.75f, true);
linkedHashMap.put("name1","Java");
linkedHashMap.put("name2","Python");
linkedHashMap.put("name3","C##"); linkedHashMap.forEach((key,value) ->{
System.out.println("["+key+"]"+":"+"["+value+"]");
});

输出

[name1]:[Java]
[name2]:[Python]
[name3]:[C##]

输出的顺序和我们put元素的顺序是一致的。

还有一种模式是访问顺序模式,也就是将accessOrder设置成true。我们来看下效果。

    LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>(16, 0.75f, true);
linkedHashMap.put("name1","Java");
linkedHashMap.put("name2","Python");
linkedHashMap.put("name3","C##");
linkedHashMap.get("name2");
linkedHashMap.get("name1");
linkedHashMap.forEach((key,value) ->{
System.out.println("["+key+"]"+":"+"["+value+"]");
});

输出

[name3]:[C##]
[name2]:[Python]
[name1]:[Java]

可以看出,在访问顺序模式下,通过get方法和put方法访问过的元素都会被放置到双向链表的尾部。

参考

公众号推荐

欢迎大家关注我的微信公众号「程序员自由之路」

谈谈集合.Map的更多相关文章

  1. Java集合Map接口与Map.Entry学习

    Java集合Map接口与Map.Entry学习 Map接口不是Collection接口的继承.Map接口用于维护键/值对(key/value pairs).该接口描述了从不重复的键到值的映射. (1) ...

  2. (10)集合之双列集合Map,HashMap,TreeMap

    Map中的元素是两个对象,一个对象作为键,一个对象作为值.键不可以重复,但是值可以重复. 看顶层共性方法找子类特有对象. Map与Collection在集合框架中属并列存在 Map存储的是键值对 Ma ...

  3. 【由浅入深理解java集合】(五)——集合 Map

    前面已经介绍完了Collection接口下的集合实现类,今天我们来介绍Map接口下的两个重要的集合实现类HashMap,TreeMap.关于Map的一些通用介绍,可以参考第一篇文章.由于Map与Lis ...

  4. (7)Java数据结构--集合map,set,list详解

    MAP,SET,LIST,等JAVA中集合解析(了解) - clam_clam的专栏 - CSDN博---有颜色, http://blog.csdn.net/clam_clam/article/det ...

  5. 双列集合Map

    1.双列集合Map,就是存储key-value的键值对. 2.hashMap中键必须唯一,值可以不唯一. 3.主要方法:put添加数据    getKey---通过key获取数据    keySet- ...

  6. 12:集合map、工具类

    一.map集合 Map:一次添加一对元素.Collection 一次添加一个元素. Map也称为双列集合,Collection集合称为单列集合. 其实map集合中存储的就是键值对(结婚证书), map ...

  7. Java集合—Map

    简介 Map用户保存具有映射关系的数据,因此Map集合里保存着两组数,一组值用户保存Map里的key,另一组值用户保存Map里的value,key和value都可以是任何引用类型的数据.Map的key ...

  8. 20_java之集合Map

    01Map集合概述 A:Map集合概述: 我们通过查看Map接口描述,发现Map接口下的集合与Collection接口下的集合,它们存储数据的形式不同  a:Collection中的集合,元素是孤立 ...

  9. Go语言【第十二篇】:Go数据结构之:切片(Slice)、范围(Range)、集合(Map)

    Go语言切片(Slice) Go语言切片是对数组的抽象,Go数组的长度不可改变,在特定场景中这样的集合就不太适用,Go中提供了一种灵活,功能强悍的内置类型切片("动态数组"),与数 ...

随机推荐

  1. VisualStudioAddin2016Setup.rar

    本工具是用于Visual Studio 2010 /2012 的外接程序. 功能不太多,常用代码,引用管理等. 动态图: 下载地址: VisualStudioAddin2016Setup.rar

  2. LiauidCrystal

    1.begin()函数语法: lcd.begin(cols,rows) cols:列数: rows:行数: 2.print()函数,语法: lcd.print(data) lcd.print(data ...

  3. 68)PHP,cookie的详细属性和有效期

    (1)cookie的有效期: 默认:会话周期结束(就是浏览器关闭),默认情况下,cookie会在浏览器关闭时失效,这种cookie是 临时cookie或者叫会话. 支持设置有效期,setcookie的 ...

  4. Redis实现分布式读写锁(Java基于Lua实现)

    https://blog.csdn.net/grandachn/article/details/89032815 https://blog.csdn.net/xingsilong/article/de ...

  5. 吴裕雄--天生自然 HADOOP大数据分布式处理:安装配置MYSQL数据库

    安装之前先安装基本环境:yum install -y perl perl-Module-Build net-tools autoconf libaio numactl-libs # 下载mysql源安 ...

  6. fscanf使用心得

    好久没碰C语言了.从现在开始,要开始刷题了. (1)int fscanf( FILE* stream, const char* format, ... ); https://www.programiz ...

  7. discount the possibility|pessimistic|bankrupt|

    Nor can we discount the possibility that some factor in the diet itself has harmful effects. ADJ-GRA ...

  8. MOOC(8)- 在excel中定义用例是否运行

    除了在配置文件中定义运行哪几条用例,还可以直接在excel中定义好是否运行用例,这样比起配置文件更加直观 在运行用例的时候判断一下是否运行这个字段即可

  9. MAVEN实现多环境搭建

    在实际的开发中,会遇到开发环境的不同(开发环境,测试环境,线上环境),会来回根据环境的不同修改配置文件,一不小心修改错误导致无法正常运行,故障排除导致开发效率低.使用maven可以根据环境的不同,自动 ...

  10. 吴裕雄--python编程:CGI编程

    什么是CGI CGI 目前由NCSA维护,NCSA定义CGI如下: CGI(Common Gateway Interface),通用网关接口,它是一段程序,运行在服务器上如:HTTP服务器,提供同客户 ...