本文目录哈希表的由来散列技术Map家族子类比较

  1. HashMap与HashTable的区别?

  2. ConcurrentHashMap和Hashtable的区别?

  3. 同步集合与并发集合?

HashMap存储结构HashMap源码大剖析

  1. 内部常量

  2. 内部属性

  3. 内部类

  4. 构造方法

  5. put方法流程

  6. 扩容原理

因为这篇文章内容有点多,所以我整了一个目录,文章中讲述了很多的细节知识点与重要的知识点,如果能够真正消化完,与面试官扯皮不在话下!好了,下面进入正题!哈希表的由来

对于数组,如果已知下标i,那么查询一个元素就很快。但是如果不知道下标i,只知道元素的值,那么需要从头开始遍历数组,直到找到这个元素位置。

而有序表的查找,比如二分查找或插值查找,虽然提高了效率,但还是需要经过多次>、

那么有没有一种方法,可以直接通过关键字key来找到它在内存中的存储位置呢(即解决数组无法快速通过关键字key找到索引i的不足)?

散列技术

首先我们引进一种新的技术,叫散列技术。散列技术是通过某个函数f,使得存储位置=f(关键字key)。

  • f称为散列函数(或哈希函数);

  • 通过散列函数而得到的一块连续存储空间称为散列表(或哈希表);

  • 关键字对应的存储位置称为散列地址;

散列技术既是一种存储方法,也是一种查找方法,存储和查找必须使用同一个散列函数。

散列表不适用的场景:

  • 不适合一对多的查找,如通过男性关键字查找班级所有的男生;

  • 不适合范围查找;

如何设计一个散列函数(均匀、简单、存储利用率高)?

  • 直接定址法

  • 数字分析法

  • 平方取中法

  • 折叠法

  • 除留余数法(这种用的多)

  • 随机数法

什么是冲突?

  • 冲突:key1≠key2,但f(key1)=f(key2)

即使散列函数设计得再好,也只能减少冲突,不能避免,如何处理冲突呢?

  • 开放定址法:当冲突时,找到一个空白位置进行插入。又分为:线性探测(i+1,i+2,i+3,...)、二次探测(i+1^2,i+2^2,i+3^2,...。解决线性探测聚集现象)等;

  • 再散列函数法:使用多种散列函数;

  • 链地址法:又称为拉链法,HashMap就用的这种;

  • 公共溢出区法:为所有冲突的关键字建立了个公共的溢出区来存放;

Map家族子类比较

java.util.Map的实现类HashMap、Hashtable、LinkedHashMap、TreeMap、ConcurrentHashMap之间的关系?

HashMap与HashTable的区别?

  • HashMap线程不安全,Hashtable线程安全;

  • HashMap允许K/V都为null;后者K/V都不允许为null;

  • HashMap继承自AbstractMap类;而Hashtable继承自Dictionary类;

ConcurrentHashMap和Hashtable的区别?

  • ConcurrentHashMap结合了HashMap和HashTable二者的优势;

  • HashMap 没有考虑同步,HashTable 考虑了同步的问题;

  • 但是HashTable 在每次同步执行时都要锁住整个结构,ConcurrentHashMap 锁的方式是稍微细粒度的;

同步集合与并发集合?

  • Collections.synchronizedMap()方法可以获取一个线程安全的map,称为同步集合,锁住整个方法(粗粒度);

  • ConcurrentHashMap 是并发集合,锁是细粒度的;

  • ConcurrentHashMap,它内部细分了若干个小的HashMap,称之为段(Segment)。默认情况下一个ConcurrentHashMap被进一步细分为16个段,既就是锁的并发度(分段锁);

  • 不管是同步集合还是并发集合,都能保证线程安全,但建议使用并发集合;

LinkedHashMap按插入顺序存储,TreeMap可对键排序

HashMap存储结构

大部分编程语言都有哈希表的实现,Java也不例外,HashMap就是典型代表。

Java7中HashMap的存储结构采用数组+单向链表,如图1所示;Java8中进一步优化,采用数组+单向链表+红黑树,如图2所示。

Entry数组也可以称它为哈希桶数组。

图1

图2

哈希桶数组越大越好吗?

  • 如果哈希桶很大,即使较差的Hash算法也会比较分散;

  • 如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞;

  • 所以就需要在空间成本和时间成本之间权衡,根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。

为什么引入红黑树?

  • 即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。

  • 于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删查的特点提高HashMap的性能。

HashMap源码大剖析注意:

  • 源码剖析使用jdk1.8版本;

  • 与红黑树相关的代码暂不剖析,如果讲红黑树就太复杂了,我后面单独写一篇文章作为专题来剖析;

内部常量

// 默认的初始化容量,16static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;// 默认的负载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;// 最大容量static final int MAXIMUM_CAPACITY = 1 << 30;// 链表长度达到8时转化为红黑树static final int TREEIFY_THRESHOLD = 8;// 结点个数小于等于6时,红黑树退化为单链表static final int UNTREEIFY_THRESHOLD = 6;// 树的最小容量static final int MIN_TREEIFY_CAPACITY = 64;

哈希桶数组的默认大小为16,默认负载因子为0.75,而扩容的阈值threshold=哈希桶数组大小*负载因子。

为什么哈希桶数组的大小一定要是2的n次方?

  • 因为HashMap计算索引的方式是hash & (length-1),如果length为2的n次方,那么length-1的二进制码低位全为1。比如当length=16,那么15的二进制码为00001111,低4位全为1,那么&运算的结果取决于hash;相反地,如果length不为2的n次方,那么低位必有0,作&运算会导致该位恒为0,如图3所示。由于&运算的结果会作为数组索引,而低3位的&运算结果却恒为0,这样会造成数组空间的浪费。

  • 扩容时,方便定位。假设扩容前容量为16,如果每次都扩容2倍的话,扩容后容量为32,假如现在一个hash值为50,那么扩容前的索引为2,扩容后的索引为18=16+2=原索引+原容量,之所以有这样一个公式得益于新容量是原容量的2倍(其实3的n次方也可以,但是2的n次方更加简单,大家可以细想一下为什么)。

图3

为什么最大容量是2的30次方?

  • 首先,哈希桶的容量一定要是2的n次方。整数最大值是2^31-1,那么2的31次方就超出范围了,所以是2的30次方。

为什么长度超过8转化为红黑树,而长度小于6时才退化为链表呢?

  • 选择8的原因是,红黑树平均时间复杂度为logn,链表为n/2,当长度为8时,链表查找时间为4=8/2,红黑树为3=log8,因此设为8比较合适;

  • 选择8和6的原因是,让其中间有个差值7,当我们的HashMap频繁地插入、删除元素,那么链表和红黑树两者可能就会频繁地转化,设一个差值7可以有效减少这种频繁的转化;

内部属性

// 哈希表transient Node[] table;// Entry集合transient Set> entrySet;// 实际存储kv对的个数transient int size;// HashMap内部结构发生变化的次数transient int modCount;// 初始化数组容量,若threshold为0,则初始化数组容量为16int threshold;// 负载因子final float loadFactor;

内部类

HashMap中有很多内部类,这里重点关注两个。分别是普通结点和红黑树结点,我只列举了它们的属性。

static class Node<K,V> implements Map.Entry<K,V> {        final int hash;        final K key;        V value;        Node next;}static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {        TreeNode parent;        TreeNode left;        TreeNode right;        TreeNode prev; boolean red;}

构造方法

HashMap一共有四个构造方法,如下图所示。

图4

从这些构造方法可知,主要有以下参数:

  • int initialCapacity:初始容量

  • float loadFactor:负载因子

在实际使用中,我们一般调用的是无参构造方法(图4中的第三个),如下所示。

public HashMap() {    this.loadFactor = DEFAULT_LOAD_FACTOR;}

无参构造方法采用默认负载因子0.75,而初始化数组容量采用默认容量16,数组是在put方法的时候初始化的,这个下面会讲到。

而带参构造方法如下所示。

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;  this.threshold = tableSizeFor(initialCapacity);}

public HashMap(int initialCapacity) {  this(initialCapacity, DEFAULT_LOAD_FACTOR);}

static final int tableSizeFor(int cap) {  int n = cap - 1;  n |= n >>> 1;  n |= n >>> 2;  n |= n >>> 4;  n |= n >>> 8;  n |= n >>> 16;  return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;}
  • 如果我们使用了有参构造方法(图4中的第一个和第二个),即显式指定了初始化容量,tableSizeFor方法还会做一层处理。

  • 假如我们设置初始化容量为18,即cap=18,那么tableSizeFor方法的返回值就是32,为什么呢?因为HashMap要求哈希桶数组大小必须为2的n次方,那么比18大的第一个2的n次方就是32。

另外,前面说到,threshold=cap*loadFactor,为什么这里(这里指带两个参数构造方法的最后一行代码)直接是threshold=cap呢?

  • 放心,扩容的时候会将这个loadFactor给乘上的,下面你就会了解到。

put方法流程

想要剖析HashMap源码,那么put方法是最重要的切入点。从put方法切入,可以看到hash函数、扩容、解决冲突、链表转化为红黑树、红黑树退化为链表等重要功能的源码。

先来通过一张图,总览一下put的流程(注意:图是根据源码来的,所以还是以看源码为主,源码看懂了,心中自有图)。

put方法如下所示

public V put(K key, V value) {    return putVal(hash(key), key, value, false, true);}

hash方法如下所示

static final int hash(Object key) {    int h;    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}

hash方法就是jdk1.8 HashMap的哈希函数。注意有个细节,就是(h = key.hashCode()) ^ (h >>> 16)。这相当于如下代码。

int h = key.hashCode();h ^ (h >>> 16);

为什么hash方法不直接返回key.hashCode()?

  • HashMap计算数组下标的方式,不是传统的%求余,而是采用hash & (length-1)。而在大多数情况下,哈希桶数组的大小都小于2^16,因此length-1的二进制码的前16位都是0,如果直接用hashCode的话,hashCode的高16位恒为0(注意是&运算),如此一来hashCode的高16位就参与不到计算中来。

  • 因此将hashCode右移16位,再异或hashCode(&运算的结果偏向0、|运算的结果偏向1,^运算结果比较均匀),这样hashCode的低16位和高16位都能参与计算了。

下来看看putVal方法(大家看注释,我写的很详细了)。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {

  Node[] tab; // 哈希桶数组  Node p; // 结点int n; // 哈希桶数组长度int i; //索引// 如果哈希桶数组为null 或 长度为0if ((tab = table) == null || (n = tab.length) == 0)// 对哈希桶数组进行扩容    n = (tab = resize()).length;// 标记1:通过i = (n - 1) & hash计算,得到索引为i的槽位为空if ((p = tab[i = (n - 1) & hash]) == null)// 则直接new一个Node,将其放入空槽位    tab[i] = newNode(hash, key, value, null);else {    Node e;     K k;// 要put的key 与 标记1中获取的对象p地址相同(是==判断的),那么equals一定相等,从而hashcode也一定相等// 要put的key 与 标记1中获取的对象p equals相等if (p.hash == hash &&      ((k = p.key) == key || (key != null && key.equals(k))))// 满足上述两个条件之一,则表明要put的key 与 标记1中获取的对象p是同一个对象      e = p;// 表示该槽位是红黑树else if (p instanceof TreeNode)// 委托给putTreeVal方法,让它去处理红黑树结点的插入      e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);else {// 表示该槽位是单链表,则在该单链表上顺序查找for (int binCount = 0; ; ++binCount) {// 代表在该单链表上没有找到相同的keyif ((e = p.next) == null) {// 则直接在单链表的末尾插入新结点          p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1)// 超过了阈值,委托给treeifyBin方法将单链表转换为红黑树            treeifyBin(tab, hash);break;        }//代表在该单链表上找到了相同的keyif (e.hash == hash &&          ((k = e.key) == key || (key != null && key.equals(k))))break;        p = e;      }    }// 如果成功插入了,则e为null,如果要插入的key已存在,则e为已存在的结点if (e != null) { // existing mapping for key      V oldValue = e.value;if (!onlyIfAbsent || oldValue == null)        e.value = value;      afterNodeAccess(e);return oldValue;    }  }//成功插入新结点后,需要记录modCount,且判断是否有必要进行扩容  ++modCount;if (++size > threshold)// kv对个数超过阈值,则扩容    resize();  afterNodeInsertion(evict);return null;}

扩容原理

  • 数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组;

  • 扩容是一个特别耗性能的操作,因此在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容;

下面通过源码针对性地看看扩容机制。

final Node[] resize() {  Node[] oldTab = table; // 旧的哈希桶数组int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧的容量int oldThr = threshold; // 旧的阈值int newCap = 0; // 新的容量int newThr = 0; // 新的阈值// 旧容量不为0时,出现这种情况,表示哈希表已经初始化if (oldCap > 0) {if (oldCap >= MAXIMUM_CAPACITY) {// 如果旧容量已经达到最大容量,则将阈值设为int的最大值,且不进行扩容      threshold = Integer.MAX_VALUE;return oldTab;    }// 旧容量>=默认容量 且 扩容后的新容量 < 最大容量,则容量和阈值都扩大两倍else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&         oldCap >= DEFAULT_INITIAL_CAPACITY)      newThr = oldThr << 1;  }// 标志1:阈值大于0时,新容量=阈值。出现这种情况是因为调用了有参构造方法,且是第一次put元素(即哈希表还未初始化)else if (oldThr > 0)    newCap = oldThr;// 当旧容量和旧阈值都是0时,进入此分支。出现这种情况是因为调用了无惨构造方法else { // 新容量设为默认容量    newCap = DEFAULT_INITIAL_CAPACITY;// 新阈值就是默认容量*默认负载因子    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  }// 出现这种情况,是因为走了上面标志1的代码if (newThr == 0) {float ft = (float)newCap * loadFactor;    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?          (int)ft : Integer.MAX_VALUE);  }  threshold = newThr;  Node[] newTab = (Node[])new Node[newCap]; // new一个Node数组,长度为新容量大小  table = newTab; // 将引用指向新的哈希桶// 旧哈希表不为null时,需要对旧哈希表上的元素向新哈希表做迁移;否则,不需要迁移,直接返回上一步new好的新哈希表if (oldTab != null) {// 遍历旧哈希桶for (int j = 0; j < oldCap; ++j) {      Node e; // 旧哈希桶的元素// 只将不为null的槽位 搬到 新的哈希桶数组中来if ((e = oldTab[j]) != null) {        oldTab[j] = null;if (e.next == null)// 表示该槽位只有一个元素,则新位置为e.hash & (newCap - 1)// 新位置与旧位置的关系:如果hash/旧容量=偶数,那么新位置与旧位置相同,否则不同          newTab[e.hash & (newCap - 1)] = e;else if (e instanceof TreeNode)// 表示该槽位是红黑树结构          ((TreeNode)e).split(this, newTab, j, oldCap);else { // 表示该槽位有多个元素,且是单链表结构// java7在复制单链表时,是在while循环里面,单个计算好数组索引位置后,单个地插入新数组中,在多线程情况下,会有回环问题// java8是等链表整个while循环结束后,才给新数组赋值,所以多线程情况下,也不会成环// 老值表示,迁移后索引不变的元素;新值表示索引会变// 举个例子,扩容前容量为16,扩容后容量为31,有两个元素,hash值分别为33,50,33在迁移前后的索引都为1,是老值;50在迁移前索引为2,迁移后索引为18=16+2,是新值          Node loHead = null; // 老值头指针          Node loTail = null; // 老值尾指针          Node hiHead = null; // 新值头指针          Node hiTail = null; // 新增尾指针          Node next; // 槽位的下一个元素do {            next = e.next;// 通过该表达式可判断是老值还是新值,下面是老值if ((e.hash & oldCap) == 0) {if (loTail == null)                loHead = e;else                loTail.next = e;              loTail = e;            }// 下面是新值else {if (hiTail == null)                hiHead = e;else                hiTail.next = e;              hiTail = e;            }          } while ((e = next) != null);if (loTail != null) {// 老值,新索引=原索引            loTail.next = null;            newTab[j] = loHead;          }if (hiTail != null) {// 新值,新索引=原索引+原容量            hiTail.next = null;            newTab[j + oldCap] = hiHead;          }        }      }    }  }return newTab;}

什么是Java7的HashMap单链表复制时的成环问题?

  • 在Java7中,单链表的复制会出现成环问题,造成此问题的原因是java7在复制单链表时,是在while循环里面,单个计算好数组索引位置后,单个地插入新数组中,在多线程情况下,会有成环问题;

Java8如何解决成环问题?

  • Java8是等链表整个while循环结束后,才给新数组赋值。且新、老值的头指针都是局部变量,所以多线程情况下,也不会成环;

  • 关于更多Java7成环问题的形成原因请看这篇文章:https://www.cnblogs.com/wen-he/p/11496050.html;

在旧哈希表上遍历单链表老值过程如图5所示。遍历完成后,直接将新哈希表对应索引的值设为loHead指针即可。单链表新值的遍历复制同理。

图5

上面的所有内容就是关于我对HashMap的理解,HashMap是面试的一个高频问点,如果大家有什么疑问,可以在文章下面留言,觉得写的不错的点个赞或者在看哟!

hashmap是单向链表吗_HashMap源码大剖析相关推荐

  1. hashmap实现原理_Java中HashMap底层实现原理(JDK1.8)源码分析

    在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里.但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依 ...

  2. Java面试绕不开的问题: Java中HashMap底层实现原理(JDK1.8)源码分析

    这几天学习了HashMap的底层实现,但是发现好几个版本的,代码不一,而且看了Android包的HashMap和JDK中的HashMap的也不是一样,原来他们没有指定JDK版本,很多文章都是旧版本JD ...

  3. libevent源码深度剖析

    原文地址:http://blog.csdn.net/sparkliang/article/details/4957667 libevent源码深度剖析一 --序幕 张亮 1 前言 Libevent是一 ...

  4. libevent源码深度剖析八

    libevent源码深度剖析八 --集成信号处理 张亮 现在我们已经了解了libevent的基本框架:事件管理框架和事件主循环.上节提到了libevent中I/O事件和Signal以及Timer事件的 ...

  5. libevent源码深度剖析一

    libevent源码深度剖析一 --序幕 张亮 1 前言 Libevent是一个轻量级的开源高性能网络库,使用者众多,研究者更甚,相关文章也不少.写这一系列文章的用意在于,一则分享心得:二则对libe ...

  6. libevent源码深度剖析十二

    libevent源码深度剖析十二 --让libevent支持多线程 张亮 Libevent本身不是多线程安全的,在多核的时代,如何能充分利用CPU的能力呢,这一节来说说如何在多线程环境中使用libev ...

  7. libevent源码深度剖析九

    libevent源码深度剖析九 --集成定时器事件 张亮 现在再来详细分析libevent中I/O事件和Timer事件的集成,与Signal相比,Timer事件的集成会直观和简单很多.Libevent ...

  8. libevent源码深度剖析六

    libevent源码深度剖析六 --初见事件处理框架 张亮 前面已经对libevent的事件处理框架和event结构体做了描述,现在是时候剖析libevent对事件的详细处理流程了,本节将分析libe ...

  9. libevent源码深度剖析五

    libevent源码深度剖析五 --libevent的核心:事件event张亮 对事件处理流程有了高层的认识后,本节将详细介绍libevent的核心结构event,以及libevent对event的管 ...

最新文章

  1. python遍历任意层次字典_Python递归中 return 代码陷阱
  2. 《生活随笔》相关内容将转移到个人微信公众号,本博客专注技术内容。
  3. LeetCode题解之Copy List with Random Pointer
  4. chrome和safari字体粗细问题
  5. Natasha 4.0 探索之路系列(二) 「域」与插件
  6. java - 求素数
  7. 胡润百富:华为成为中国最值钱消费电子企业 小米排名第二
  8. WebLogic命令行远程部署
  9. Swift3.0P1 语法指南——枚举
  10. 巧妙检查WinXP系统漏洞
  11. ASP.NET基础教程-利用javascript将光标定位到文本框
  12. 动易 转 html5,动易dedecms数据转成dedecms的php程序
  13. iOS 让CoreData更简单些
  14. 桌面创建html文件夹路径,HTML5+ - DirectoryEntry(文件夹及文件操作)
  15. 华为内部转岗最好时间_华为博士类员工离职率21.8%:平均年薪110万,依然度日如年...
  16. PHP中级程序员常见面试题
  17. redhat 6.5安装oracle时出现java异常,redhat6.5 下安装 oracle11 报错
  18. 公司邮件登录发邮件,在outlook邮件撤回怎么操作?
  19. WordPress 配置七牛云 CDN 具体操作
  20. 常用http请求解析

热门文章

  1. vm虚拟机联网最简单的方式
  2. SpringBoot核心原理:自动配置、事件驱动、Condition
  3. 干掉Dubbo !这个后端开发框架就是王者!
  4. 玩转Java8的 Stream 之函数式接口
  5. dpkg: 处理归档 /var/cache/apt/archives/libpng12-0_1.2.54-1ubuntu1.1_amd64.deb (--unpack)时出错: 无法安装 /lib/
  6. SpringMVC框架----SpringMVC的入门程序
  7. 2019.08.26关于分页
  8. 将你的数据导入到json格式
  9. 由系统调用想起的。。。
  10. innodb doublewrite