HashTable vs HashMap vs ConcurrentHashMap

HashMap

JDK7 中的HashMap底层实现

基础知识

HashMap继承AbstractMap,实现Map。不管JDK7,还是JDK8,HashMap的实现框架都是哈希表+链表的组合方式。

## 这个是JDK8 HashMap的代码
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 初始大小,必须为2的次幂
static final int MAXIMUM_CAPACITY = 1 << 30; //哈希表的最大长度
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子,如果没有指定,默认值
static final int TREEIFY_THRESHOLD = 8; //树形化的阈值,JDK8
static final int UNTREEIFY_THRESHOLD = 6; //不树形化的阈值 JDK8
static final int MIN_TREEIFY_CAPACITY = 64; //最小树形化的值 JDK8
transient Node<K,V>[] table; //哈希表,存储Entry对象的数组,在JDK7中数组的元素为Entry
transient Set<Map.Entry<K,V>> entrySet; 
transient int size; //map中存的KV对的个数
transient int modCount; //修改次数
int threshold; //阈值,当上面的size> threshold时,需对哈希表做扩容
final float loadFactor; //加载因子

modCount记录了Map新增/删除KV键值对,或者内部结构做了调整的次数,其主要作用,是对Map的iterator()操作做一致性校验,如果在iterator操作的过程中,
map的数值有修改,直接跑出ConcurrentModificationException异常。

threshold = table.length * loadFactor;
判断size > threshold是否成立,这是决定哈希table是否扩容的重要因素。

put方法

put方法是有返回值,场景区分如下:
场景1:若执行put操作前,key已经存在,那么在执行put操作时,会使用本次的新value值来覆盖前一次的旧value值,返回的是旧value值。
场景2:若key不存在,则返回null值。

特殊key值处理:

  • HashMap中,是允许key、value都为null的,且key为null只存一份,多次存储会将旧value值覆盖。
  • key为null的存储位置,都统一放在下标为0的bucket,即table[0]位置的链表;
  • 如果是第一次对key=null做put操作,将会在table[0]的位置新增一个Entry节点,使用头插法做链表插入。

扩容:

  • 扩容后大小是扩容前的2倍。
  • 数据搬迁,从旧table迁移到扩容后的新table。为了避免碰撞过多,先决策是否需要对每个entry链表节点重新hash,然后根据hash值计算得到bucket
    下标,然后使用头插法做节点迁移。

在计算hash值的时候,JDK7用了9次扰动处理=4次位运算+5次异或

如何计算bucket下标:

  • hash值的计算:首先得有key的hash值,计算方式使用了一种尽量减少碰撞的算式,使用key的hashCode作为算式的输入,得到了hash值。
  • 对于两个对象,若其hashCode相同,那么两个对象的hash值就一定相同。对于HashMap中key的类型,必须满足一下条件:如两个对象逻辑相等,
    hashCode一定相等,反之却不一定成立。
  • 取模的逻辑: hashcode & (length - 1),得到哈希table的bucket下标。

为什么非要将哈希table的大小控制为2的次幂数

  • 降低发生碰撞的概率,使散列更均匀。根据key的hash值计算bucket的下标位置时,使用与运算公式: hashCode & (length-1),
    当还希表长度为2的次幂时,等同于使用表长度对hash值取模,散列更均匀。
  • 表的长度为2的次幂,那么(length-1)的二进制最后一位一定是1,在对hash值做与运算时,最后一位就可能是1,也可能是0。如果(leng-1)为
    偶数,那么与元算后的值只能是0,奇数下标的bucket就永远散列不到,会浪费一半的空间。

在目标bucket中遍历entry节点

通过hash值计算出下标,找到对应的目标bucket,然后对链表做遍历操作,逐个比较。
查找key的条件:e.hash == hash && ((k = e.key) == key || key.equals(k))

GET 方法

通过key的hash值计算目标bucket的下标,然后遍历对应bucket上的链表,逐个对比,得到结果。

Map中的迭代器Iterator

Map遍历的集中方式
方式1.Iterator迭代器

Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
    Entry<String, Integer> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
}

方式2.entryset

for (Map.Entry<String, Integer> entry : testMap.entrySet()) {
    System.out.println(entry.getKey() + ":" + entry.getValue());
}

方式3.keyset

Map<String,Object> map = new HashMap<>();
for (String key:map.keySet()){
    System.out.println(key+":"+map.get(key));
}

方式4.foreach(不推荐使用,新增二次查询)

testMap.forEach((key, value) -> {
    System.out.println(key + ":" + value);
});

方式5.通过key的set集合遍历

Iterator<String> keyIterator = testMap.keySet().iterator();
while (keyIterator.hasNext()) {
    String key = keyIterator.next();
    System.out.println(key + ":" + testMap.get(key));
}

如果在使用的过程中,只需要key,使用方式5较为合适,如果只需要value,可以使用方式1。

Iterator的实现原理

Iterator是一个接口,拥有三个方法:

public interface Iterator<E> {
    boolean hasNext();
    E next();
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
}

在HashMap中,新增了一个内部抽象类HashIterator,在遍历的时候,以Entry节点进行遍历,EntryIterator(实现next方法)继承HashIterator。

fail-fast策略

fail-fast:尽可能早的发现问题,立即终止当前执行过程,有更高层级的系统来做处理。
在HashMap中,提交的modCount域变量,就是用于实现HashMap的fail-fast。
在Map做迭代的时候,会将modCount域变量赋值给expectedModCount局部变量。在迭代过程中,用于做内容修改次数的一致性校验。
若此时有其他线程或本线程的其他操作对此Map做了内容修改时,那么会导致modCount和expectedModCount不一致,立即抛出
ConcurrentModificationException。
在JDK7中,HashMap是一个并发不安全的问题,在迭代操作是采用的fail-fast机制;在并发添加操作中会出现丢失更新问题;因为采用头插法在并发扩容
时会产生环形链表的问题,导致CPU达到100%,甚至宕机。

解决并发问题可以采用:Collections.synchronizedMap()方法,返回一个线程安全的Map;CurrentHashMap(采用分段锁实现线程安全);HashTable(不推荐)

下面是删除元素的方法:

Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
    Entry<String, Integer> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
    if (next.getKey().equals("s2")) {
        iterator.remove();
    }
}

在单线程中,没有问题;如果在多个线程,就会出现问题。

JDK8的HashMap底层实现

HashMap在JDK8中对链表结构做了优化,在一定条件下将链表转为红黑树,提升查询效率。
JDK8之前采用数组(位桶)+链表的方式实现,通过链表处理冲突。

JDK8之后采用数组+链表+红黑树的方式实现,使用链表存储的优点是可以降低内存的使用率,而红黑树的优点是查询效率更高。

Put方法

在插入节点时,JDK7新增节点是使用头插法,JDK8中,在链表使用尾插法,将待新增的节点追加到链表末尾。

put方法执行过程:

  • 若哈希table为null,或长度为0,则做一次扩容操作。
  • 根据index找到目标bucket,若当前bucket上没有节点,那么直接新增一个节点,赋值给该bucket;
  • 若当前bucket上有链表,且头节点就匹配,那么直接做替换即可
  • 若当前bucket上的是树结构,则转为红黑树的插入操作
  • 如果上面的题哦件都不成立,则对链表做遍历操作:
    • 若链表中有节点匹配,则做value替换;
    • 若没有节点匹配,则在链表末尾追加。
      • 若链表长度大于TREEIFY_THRESHOLD,执行红黑树转换操作
      • 若第一个条件不成立,便直接扩容。
  • 如果上面的步骤都执行完之后,节点的数量超过阈值,再次扩容。

什么场景下会触发扩容?

  • 哈希table为null或长度为0;
  • Map中存储的KV数量超过threshold;
  • 链表中的长度超过TREEIFY_THRESHOLD,但表长度却小于MIN_TREEIFY_CAPACITY。

扩容的时候,JDK8只用了2次扰动处理=1次为运算+1次异或

GET方法

先根据key计算hash值,进一步计算得到哈希table的目标index,若此bucket上为红黑树,则再红黑树上查找,若不是红黑树,遍历链表。

loadFactor的作用

loadFactor表示HashMap的拥挤程度,影响hash操作到同一个数组位置的概率。

平时在使用HashMap时一般使用什么类型的元素作为key?

选择Integer,String这种不可变的类型。作为不可变类天生是线程安全的。

HashMap中hash函数怎么实现的,还有那些hash的实现方式?

1.对key的hashCode做hash操作(高16bit不变);
2.h & (length - 1) 通过位操作得到下标index。

还有数字分析法、平方取中法、分段叠加法、除留余数法

HashMap是浅拷贝,深拷贝与浅拷贝的区别

浅拷贝:被复制对象的所有变量都含有与原来对象相同的值,而所有对其他对象的应用仍然指向原来的对象。
换言之,浅拷贝仅仅复制所考虑的对象,而不复制它所引用的对象。
深拷贝:被复制对象的所有变量都含有与原来对象相同的值,除去那些引用其他对象的变量。

浅拷贝的时候,基本类型是拷贝出来的,引用类型是指向原来的对象,在修改引用对象时,原先对象的引用类型也会变化。
深拷贝的时候,就是生成一个新的对象。

HashMap的JDK8、JDK7的区别

版本 JDK8 JDK7
扩容 前置条件:插入键值对时,发现容量不足;尾插法;扩容结束:2倍的哈希表 & 转移旧数据的到新table 前置条件:插入键值对时,发现容量不足;头插法;扩容结束:2倍的哈希表 & 转移旧数据的到新table;后置条件:重新计算hash、存储位置
区别:扩容后存储位置的计算方式 按照扩容后的规律计算(扩容后的位置=原位置 or 原位置 + 旧容量) 全部按照原来方法进行计算(hashCode->扰动处理->(h&length-1)
区别:转移数据方式 尾插法(直接插入到链表尾部/红黑树,不会出现逆序&环形链表死循环问题) 头插法(先将原位置的数据移到后1位,在插入数据到该位置;会出现逆序&环形链表死循环问题)
区别:需要插入数据的时机&位置 扩容前插入、转移数据时统一计算 扩容后插入、单独计算

JDK7用的是头插法,而JDK8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,
当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

扩容后数据存储位置的计算方式也不一样:1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(
这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)
(hash值 & length-1)

JDK7的时候使用的是数组+单链表的数据结构。但是在JDK8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,
也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)。

1.为什么在JDK7的时候是先进行扩容后进行插入,而JDK8是先插入后进行扩容?

  • JDK7中,先进行扩容后进行插入,先判断是否插入的桶是否为空,如果不为空说明存在值就发生了hash冲突,就必须的扩容,如果不发生Hash冲突的话,
    说明当前桶是空的,那就等到下一次发生Hash的冲突的时候在进行扩容,如果以后都没有发生hash冲突产生,就不会进行扩容了,减少了一次无用扩容,
    也减少了内存的使用。
  • JDK8对HashMap中,主要是因为对链表转为红黑树进行优化,因为插入节点的时候有可能是普通链表节点,也有可能是红黑树节点。

2.为什么在JDK8中进行HashMap优化的时候,把链表转换为红黑树的阈值为8,而不是7或者不是20?

  • Map中桶的元素初始化是链表保存的,其查询性能是O(n),而树结构能将查找性能提升到O(logN)。当链表长度很小的时候,即使遍历,速度也非常快,当链表
    长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
  • TreeNodes占用空间是普通Nodes的两倍,只有当bin包含足够多的节点时才会转成TreeNodes,而是足够多就是有TREEIFY_THRESHOLD的值决定的。
    当bin中节点树变少时,又会转成普通的bin。链表长度达到8就转成红黑树,当长度降到6就转成普通bin。当hashCode离散型很好的时候,树型bin用到的概率
    非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度达到阈值。hashCode算法下所有bin节点的分布频率会遵循泊松分布,可以看到,一个bin
    中连读长度达到8个元素的概率为0.00000006,几乎是不可能事件。

HashMap、HashTable是什么关系

共同点与异同点

共同点: 底层都是使用哈希表+链表的实现方式。

区别:

  • 从层级结构上看,HashMap、HashTable有一个公用的Map接口,另外,HashTable还单独继承了一个抽象类Dictionary;
  • HashTable诞生自JDK1.0,HashMap从JDK1.2之后才有;
  • HashTable线程安全,HashMap线程不安全;
  • 初始值和扩容方式不同。HashTable的初始值为11,扩容为原先大小的2*d+1。容量大小都采用奇数为素数,且采用取模法,这种方式散列更均匀。
    但有个缺点就是对素数取模的性能较低,而HashTable的长度都是2的次幂,这种方式的取模都是直接做为运算,性能较好。
  • HashMap的key、value都可为null,且value可多次为null,key多次为null时会覆盖。当HashTable的Key、value都不可为null,
    否则直接NullPointException。

HashTable计算索引为:index = (hash &0x7FFFFFFF) % tab.length;

HashMap的线程安全

HashTable中的put操作、get操作、remove操作、equals操作,都使用了synchronized关键字修饰。
但是,这样会导致效率低下。
因为synchronized的特性,对于多线程共享的临界资源,同一时刻只能有一个线程占用,其他线程必须原地等待,大量线程必须排队一个个处理。
get方法问什么必须添加synchronized:
如果A线程在做put或者remove操作时,B线程或者C线程此时都可以同时执行get操作,可能哈希table已经被被A线程改变,也会带来问题,因此不加不行。

HashMap线程不安全在那

线程覆盖问题

两个线程执行put、删除、修改操作时,可能导致数据覆盖。
在JDK7中,A,B两个线程同时执行put操作,且两个ke都指向同一个bucket,那么此时两个节点,都会做头插法。
当A线程和B线程都获取到了bucket的头节点后,若此时A线程的时间片用完,B线程将其新数据完成了头插法操作,
此时轮到A线程操作,但这时A线程所具有的旧头节点已经过时了,线程A再做头插法操作,就会抹掉B刚刚新增的节点,
导致数据丢失。

JDK7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且形成链表死循环问题。
JDK8之后,加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

扩容时导致死循环

在JDK7之前的版本会存在死循环现象;JDK8中,resize()方式已经做了调整,使用两队链表,且都是使用的尾插法,
及时多线程下,也顶多是从头节点在做一次尾插法,不会造成死循环。而JDK7能造成死循环,就是因为resize()时
使用了头插法,将原先的顺序做了反转,才留下了死循环的机会。

小结

多线程环境下使用HashMap,会引起各类问题,分为三类:

  • 死循环
  • 数据重复
  • 数据丢失

JDK5之前,多线程环境往往使用HashTable,JDK5之后,出现了ConcurrentHashMap类,采用分段锁来实现了线程安全,
相对HashTable有更多的性能,推荐使用

如何规避HashMap的线程不安全

将Map转为包装类

Map<String, Integer> testMap = new HashMap<>();
...
// 转为线程安全的map,在对象中添加对象锁
Map<String, Integer> map = Collections.synchronizedMap(testMap);

使用ConcurrentHashMap

JDK7版本以及以前,ConcurrentHashMap类使用了分段锁的技术,JDK8使用了synchronized修饰符。

SynchronizedMap

  • 会同步整个对象
  • 每一次的读写操作都需要加锁
  • 对整个对象加锁会极大降低性能
  • 相当于只允许同一时间内至多一个线程操作整个Map,而其他线程必须等待
  • 有可能会造成资源冲突
  • SynchronizedMap会返回Iterator,当遍历时进行修改会抛出异常

ConcurrentHashMap

整体架构

  • JDK7:Segment(继承ReentrantLock) + HashEntry + Unsafe
  • JDK8:移除Segment,使锁的粒度更小,Synchronized + CAS + Node + Unsafe

Put()

  • JDK7:先定位Segment,在定位桶,put全程加锁,没有获取锁的线程提前找桶的位置,并最多自旋64次获取锁,超过则挂起。
  • JDK8:由于移除Segment,类似HashMap,可以直接定位到桶,拿到first节点后进行判断,1.为空则CAS插入;2.为-1则说明在扩容,则跟着一起扩容;
    3.else则加锁put。

Get()

由于value声明为volatile,保证了修改的不可见性,因此不需要加锁。

resize()

  • JDK7:跟HashMap步骤一样,只不过是搬到单线程中执行,避免了HashMap在JDK7中扩容时死循环的问题,保证线程安全。
  • JDK8:支持并发扩容,HashMap扩容在JDK8中有头插法改为尾插法(为了避免死循环问题),ConcurrentHashMap也是,迁移也是从尾部开始,
    扩容前在桶的头部放置一个hash值为-1的节点,这样别的线程访问就能判断是否该桶已经被其他线程处理过了。

size()

  • JDK7:计算两次,如果不变则返回计算结果,若不一致,则锁住所有的Segment求和。
  • JDK8:用baseCount来存储当前的节点个数。这就设计到baseCount并发环境下修改的问题。

其他问题

Collections.synchronizedMap与HashTable的不同

Collections.synchronizedMap会调用内部类SynchronizedMap的构造器SynchronizedMap(Map<K,V> m),synchronized关键字作用的对象
mutex被设置为this(即类实例)。
静态内部类SynchronizedMap的另一个构造方法SynchronizedMap(Map<K,V> m,Object mutex)是传入一个Object作为mutex,可以将此Object
作为synchronized修饰的对象。

默认的HashTable和synchronizedMao都是锁类实例,synchronizedMap可以选择锁其他的Object(mutex)。
HashTable的synchronized是方法级别的;synchronizedMap的synchronized的代码块级别的。
两者性能相近,但是synchronizedMap可以用null作为key和value。

参考文献

HashMap面试题,看这一篇就够了!
HashMap面试题,看这一篇就够了!
【Java 容器面试题】谈谈你对HashMap 的理解
(1)美团面试题:Hashmap的结构,1.7和1.8有哪些区别,史上最深入的分析
(5)美团面试题:HashMap是如何形成死循环的?(最完整的配图讲解)
阿里面试题:为什么Map桶中个数超过8才转为红黑树
为什么Map桶中个数超过8才转为红黑树
hashMap源码分析以及原理
HashMap常见面试题整理
【java集合】HashMap常见面试题
天下无难试之HashMap面试刁难大全
透过面试题掌握HashMap
Java 并发容器 —— Hashtable 与 Collections.synchronizedMap(HashMap) 的区别
HashMap、ConcurrentHashMap和SynchronizedMap – 哈希表在Java中的同步处理
集合16-Hashtable与Collections.synchronizedMap
这几道Java集合框架面试题在面试中几乎必问
HashMap 源码分析
面试题:ConcurrentHashMap 1.7和1.8的区别

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342