HashMap面试题

文章目录

综述

HashMap在实际开发过程中是使用频率最高的用于映射(键值对)处理的数据类型。在JDK1.8中对HashMap底层的底层实现进行了大幅度地优化,比如引入红黑树的数据结构和扩容的优化等等。
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现不是同步的,这意味着它不是线程安全的。Map中的key和value可以是任意类型的数据,其中的key不允许重复,也就是说同一个Map对象中的任何两个key,通过equals()方法比较后总是会返回false。而且Map中的key和value都可以是null,但只能有一个为null的key,可以有多个为null的value。
​ JDK1.8之前 HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决hash冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的("拉链法"解决冲突)。
​ JDK1.8以后在解决哈希冲突时有了较大的变化,当链表的长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

**补充:**将链表转换为红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表转换为红黑树。而是选择进行数组扩容。

​ 这样做的目的是因为数组比较小,尽量避免红黑树结构,这种情况下变为红黑树反而会降低效率,因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。所以综上所述为了提高性能减少搜索时间,==底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树。==具体可以惨开treeifyBin方法。
​ 当然虽然增加了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变的更高效。

总结:

存取无序
建和值位置都可以是null,但是键的位置只能是一个null
键位置是唯一的,底层的数据结构控制键的
jdk1.8前数据结构是:数组+链表 jdk1.8之后是:数组+链表+红黑树
阈值(边界值)>8 并且数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询。

HashMap

特点

HashMap的底层基于哈希表,继承自AbstractMap类,实现自Map接口。它是一个用于存储key-value键值对映射关系的集合,每一个键值对都叫做一个Entry条目,key-value可以是任意类型,且都允许为null。这些基本特征和Map是一样的。

HashMap中的这些键值对(Entry)会分散存储在一个数组当中,这个数组就是HashMap的主体。默认情况下,HashMap的这个数组中,每个元素的初始值都是null。但HashMap中最多只允许一条记录的key为null,允许多条记录的value为null。

我们一般是根据key的hashCode值来存取数据,大多数情况下都可以直接根据key来定位到它所对应的值,因而HashMap有较快的访问速度,但遍历顺序却不确定。

另外HashMap是非线程安全的,即任一时刻如果有多个线程同时对HashMap进行写操作,有可能会导致数据的不一致。如果需要满足线程的安全性要求,可以用 Collections.synchronizedMap()方法使得HashMap具有线程安全的能力,或者使用ConcurrentHashMap来替代。

HashMap的构造方法

HashMap(): 创建一个默认初始容量为16 和 默认负载因子为0.75 的空HashMap;
HashMap(int initialCapacity): 创建一个 带有指定初始容量和默认负载因子为0.75 的空HashMap;
HashMap(int initialCapacity, float loadFactor): 创建一个 带有指定初始容量和负载因子的 空HashMap;
HashMap(Map<? extends K, ? extends V> m):根据某个已有的Map创建一个HashMap对象。

在HashMap中,除了可以在构造方法中进行初始化之外,还会在put()方法中对HashMap进行一定的初始化操作。当我们调用put(k,v)方法添加添加时,其内部会先检查 table数组 是否为空,如果为空就先进行初始化,对table数组进行扩容。

if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
首次初始化分为无参初始化和有参初始化两种情况,无参时默认容量是16,也就是table数组的初始长度为16。有参初始化的时候,会使用tableSizeFor()方法来确保实际的容量是2的N次方,最后在resize()方法中new一个Node数组出来。

HashMap存储结构

这里需要区分一下,JDK1.7和 JDK1.8之后的 HashMap 存储结构。在JDK1.7及之前,是用数组加链表的方式存储的。

但是,众所周知,当链表的长度特别长的时候,查询效率将直线下降,查询的时间复杂度为 O(n)。因此,JDK1.8 把它设计为达到一个特定的阈值之后,就将链表转化为红黑树。

总结:在JDK 7及其以前版本的HashMap中,其底层的数据结构是 数组+链表;而从JDK 8开始则采用 数组+链表+红黑树 的数据结构,其中的数组是Entry类型或者说是Node类型数组。

数据结构概念

​ 数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
​ 在JDK1.8之前HashMap由 数组+链表 数据结构组成的。
​ 在JDK1.8之后HashMap由 数组+链表+红黑树 数据结构组成的。
ArrayList底层是数组数据结构,查询快增删慢。
LinkedList底层是双重链表数据结构,查询相对较慢,增删快

如果节点长度即链表长度大于阈值8并且数组长度大于64则进行将链表变成红黑树

注释:先比较hashCode,若哈希值相等,则发生了哈希碰撞;再去比较内容即equals()方法。

存储数据的过程

概述:当我们往HashMap中存储数据时,首先会利用hash(key)方法 计算出key的hash值,再利用该 hash值 与 HashMap数组的长度-1 进行 与运算,从而得到该key在数组中对应的 下标位置。如果该位置上目前还没有存储数据,则直接将该key-value键值对数据存入到数组中的这个位置;如果该位置目前已经有了数据,则把新的数据存入到一个链表中;当链表的长度超过阈值(JDK 8中该值为8)时,会将链表转换为红黑树(转换为红黑树还需要满足其他的条件,链表长度达到阈值只是其中的一个条件)。通过这样的机制来解决存值时可能产生的哈希冲突问题,并可以大大提高我们的查找效率。

  • 当创建HashMap集合对象的时候,HashMap的构造方法并没有创建数组,而是在第一次调用put方法时创建一个长度是16的数组Node[ ] node (jdk1.8 之前是 Entry[] table)用来存储键值对数据。

  • 假设向哈希表中存储 <柳岩,18> 数据,根据柳岩调用 String 类中重写之后的 hashCode() 方法计算出值,然后结合数组长度采用某种算法计算出向 Node 数组中存储数据的空间的索引值。如果计算出的索引空间没有数据,则直接将<柳岩,18>存储到数组中。(举例:计算出的索引是 3 )

  • 向哈希表中存储数据 <刘德华,40>,假设算出的 hashCode() 方法结合数祖长度计算出的索引值也是3,那么此时数组空间不是 null,此时底层会比较柳岩和刘德华的 hash 值是否一致,如果不一致,则在空间上划出一个结点来存储键值对数据对 <刘德华,40>,这种方式称为拉链法。

  • 假设向哈希表中存储数据 <柳岩,20>,那么首先根据柳岩调用 hashCode() 方法结合数组长度计算出索引肯定是 3,此时比较后存储的数据柳岩和已经存在的数据的 hash 值是否相等,如果 hash 值相等,此时发生哈希碰

相等:将后添加的数据的 value 覆盖之前的 value。

不相等:继续向下和其他的数据的 key 进行比较,如果都不相等,则划出一个结点存储数据,如果结点长度即链表长度大于阈值 8 并且数组长度大于 64 则将链表变为红黑树。

  • 在不断的添加数据的过程中,会涉及到扩容问题,当超出阈值(且要存放的位置非空)时,扩容。默认的扩容方式:扩容为原来容量的 2 倍,并将原有的数据复制过来。

  • 综上描述,当位于一个表中的元素较多,即 hash 值相等但是内容不相等的元素较多时,通过 key 值依次查找的效率较低。而 jdk1.8 中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阈值)超过8且当前数组的长度大于64时,将链表转换为红黑树,这样大大减少了查找时间。
    jdk1.8 中引入红黑树的进一步原因:

jdk1.8 以前 HashMap 的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。

针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

总结:
HashMap数据结构的主体是一个 数组 结构,数组中的每一个存储位置被称作一个 bin,我们把它叫做 位桶。假如我们定义一个容量为 8 的 HashMap,那么就可以说这是一个由 8个位桶 组成的数组,前提是该HashMap没有出现哈希冲突。当我们向这个数组中插入数据的时候,其实就是把key-value数据转换成一个个的 Node 节点类型。Node 是 HashMap中定义的静态内部类,它是Map.Entry<K,V>接口的子类。

但当我们调用put(k,v)方法往HashMap中插入数据的时候,这些数据并不是按顺序一个一个向后存储的,而是有一套专门的 索引选择算法,我们称之为 散列计算(哈希运算),HashMap会利用这个算法来决定数据到底该存储在哪个位置上。具体的计算过程是这样的,这个散列计算会先对key进行hash运算,得到一个对应的 hash值,然后利用该hash值对 HashMap中数组的长度减1 进行 与运算。这样根据计算最终就会得到一个随机的 索引值,这个索引值就决定了这个Node节点到底是存储在HashMap数组中的哪个位置上。

但散列算法对key进行计算时有可能会出现 冲突 的情况,这叫做 哈希碰撞。也就是两个不一样的 key,经过散列计算出来的hash值可能是一样的,当然这种概率比较低。如果出现了冲突,HashMap内部会采用 拉链法(链地址法) 进行扩展,把这些出现冲突的Node节点,组成一个单向的链表结构,如下图所示。这样一来,即使有多个不同的key产生了相同的hash值,也就是说即使它们产生了哈希冲突,也都可以落到相同的位桶中,但又保证不会覆盖之前节点的数据内容。
但随着插入的元素越多,发生碰撞的概率就越大,某个位桶中的链表可能就会越来越长,直到达到一个 阈值(默认为8),HashMap就受不了了,节点查找等操作的性能就会严重下降。所以为了提升节点操作的性能,从JDK 8开始,Java对HashMap的源码进行了优化。在HashMap内部,将超过阈值的链表转换成了另一种形态,也就是变成了 红黑树,这个过程我们称之为 树化。当单个位桶内链表的 节点数大于等于 8 ,且数组的长度 大于等于64 时,就会将链表变身为红黑树。而当红黑树中的节点 小于等于6时,红黑树结构又会重新还原成链表结构。

如果当前链表的长度虽然大于等于8,但数组的长度小于64,这时候只会发生扩容,并不会进行树化!

HashMap中一开始使用 数组+链表 的存储方式,主要是为了解决 哈希碰撞 的问题。换言之,链表中的每个key,都具有相同的哈希值。最极端的情况就是,当所有的元素都具有相同的哈希值,那么HashMap就会退化成一个单向链表,查找时间也从O(1)上升到O(N)。当N越来越大时,get(key)方法的开销也会越来越大。

因此,在JDK 8里面增加了一个 红黑树,当某个位桶内链表的节点数量(>=8)过大的话,HashMap会动态的创建一个专门的TreeNode来替换掉它。TreeNode的底层就是一棵红黑树,初始化的时候会有个root根节点,同时TreeNode不允许key为null,且红黑树中的每个 TreeNode 都是该红黑树的一个节点。

红黑树使用Node节点的哈希值作为树的分支变量。如果有两个Node节点都指向同一个位桶,但哈希值不相等,hash值较大的那个Node会插入到右子树里。如果哈希值相等,可以让key实现Comparable接口进行比较排序,这样它可以按照顺序进行插入。红黑树的查找性能会更好,是O(logn),而不是糟糕的O(n)。

Map对象(深拷贝与浅拷贝)

实现浅拷贝的方式有两种:=和Map.putAll();

实现深拷贝:HashMap.putAll()。

大多数情况下,我们需要实现的是深拷贝而不是浅拷贝;

使用Map调用putAll()方法,并不是真正意义上的拷贝,Map对象B只是对Map对象A进行了引用,当Map对象A中的内容发生变化时,Map对象B也会发生变化;

使用HashMap调用putAll()方法才是真正意义上的拷贝。

如果两个键的 hashCode 相同,如何存储键值对?

答:通过 equals 比较内容是否相同。相同:则新的 value 覆盖之前的 value。不相同:则将新的键值对添加到哈希表中。

什么是拉链法

在JDK 1.8 的 HashMap 和 ConcurrentHashMap 都有这样一个同样的特点,那就是最开始的Map是空的,因为里面没有任何元素,往里放元素时会计算 hash值,计算之后,第 1 个 value 会首先占用一个桶(也被称为槽点)位置,后续如果经过计算发现需要落到同一个桶中,那么会使用链表的形式往后延长,俗称“拉链法”,
在这里插入图片描述
有的桶是空的, 比如第 4 个;有的只有一个元素,比如 1、3、6;有的就是刚才说的拉链法,比如第 2 和第 5 个桶。

当链表长度大于或等于阈值(默认为 8)的时候,如果同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。
HashMap的结构示意图,如下图所示:

在这里插入图片描述
在图中可以看到,有一些槽点是空的、一些是拉链、一些是红黑树。

在面试中经常会被问到,为什么会链表会为红黑树以及红黑树有哪些特点,可是,为什么转化的这个阈值要默认设置为 8 呢?

要想知道为什么设置为 8,那首先我们就要知道为什么要转换,因为转换是第一步。

每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。红黑树有和链表不一样的查找性能,因为红黑树有自平衡的特点,可以防止不平衡情况的发生,所以说可以始终将查找的时间复杂度控制在 O(log(n))。

最初链表还不是很长,所以可能 O(n) 和 O(log(n))的区别也不大,但是如果链表变得越来越长,那么区别就会变的很明显,所以为了提升查找性能,需要把链表转化为红黑树的形式。

为什么不一开始选择红黑树呢?

Because TreeNodes are about twice the size of regular nodes,

use them only when bins contain enough nodes to warrant use

(see TREEIFY_THRESHOLD). And when they become too small (due 

removal or resizing) they are converted back to plain bins.


这段话翻译过来就是:因为单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESHOLD 的值决定的。而当桶中节点数由于移除或者 resize 变少后,又会变回普通的链表的形式,以便节省空间。

通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想,最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,需要用红黑树的形式来保证查询的效率。

对于何时应该从链表转化为红黑树,需要确定一个阈值,这个阈值默认为 8,并且在源码中也对选择 8 这个数字做了说明,原文如下:

In usages with well-distributed user hashCodes, tree bins

are rarely used. Ideally, under random hashCodes, the

frequency of nodes in bins follows a Poisson distribution

(http://en.wikipedia.org/wiki/Poisson_distribution) with a

parameter of about 0.5 on average for the default resizing

threshold of 0.75, although with a large variance because

of resizing granularity. Ignoring variance, the expected

occurrences of list size k are (exp(-0.5) * pow(0.5, k) /

factorial(k)). The first values are:

0: 0.60653066

1: 0.30326533

2: 0.07581633

3: 0.01263606

4: 0.00157952

5: 0.00015795

6: 0.00001316

7: 0.00000094

8: 0.00000006

more: less than 1 in ten million
上面这段话的意思是,如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。

但是,HashMap 决定某一个元素落到哪一个桶里,是和这个对象的 hashCode 有关的,JDK 并不能阻止我们用户实现自己的哈希算法,如果我们故意把哈希算法变得不均匀,例如:

@Override
public int hashCode() {
return 1;
}

这里 hashCode 计算出来的值始终为 1,那么就很容易导致 HashMap 里的链表变得很长。让我们来看下面这段代码:

public class HashMapDemo {
    public static void main(String[] args) {
        HashMap map = new HashMap<HashMapDemo,Integer>(1);
        for (int i = 0; i < 1000; i++) {
            HashMapDemo hashMapDemo1 = new HashMapDemo();
            map.put(hashMapDemo1, null);
        }
        System.out.println("运行结束了");
    }
    @Override
    public int hashCode() {
       return 1;
    }
}


在这个例子中,我们建了一个 HashMap,并且不停地往里放入值,所放入的 key 的对象,它的 hashCode 是被重写过得,并且始终返回 1。这段代码运行时,如果通过 debug 让程序暂停在 System.out.println(“运行结束”) 这行语句,我们观察 map 内的节点,可以发现已经变成了 TreeNode,而不是通常的 Node,这说明内部已经转为了红黑树。

事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值。

** 如果我们开发中发现 HashMap 或是 ConcurrentHashMap 内部出现了红黑树的结构,这个时候可能说明我们的哈希算法设计出了问题,我们则需要考虑改进 hashCode 方法,以便减少冲突。**

核心参数

//默认的初始化容量为16,是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//最大容量为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认的加载因子0.75,乘以数组容量得到的值,用来表示元素个数达到多少时,需要扩容。
//为什么设置 0.75 这个值呢,简单来说就是时间和空间的权衡。
//若小于0.75如0.5,则数组长度达到一半大小就需要扩容,空间使用率大大降低,
//若大于0.75如0.8,则会增大hash冲突的概率,影响查询效率。
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//刚才提到了当链表长度过长时,会有一个阈值,超过这个阈值8就会转化为红黑树
static final int TREEIFY_THRESHOLD = 8;

//当红黑树上的元素个数,减少到6个时,就退化为链表
static final int UNTREEIFY_THRESHOLD = 6;

//链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化。
//这是为了避免,数组扩容和树化阈值之间的冲突。
static final int MIN_TREEIFY_CAPACITY = 64;

//存放所有Node节点的数组
transient Node<K,V>[] table;

//存放所有的键值对
transient Set<Map.Entry<K,V>> entrySet;

//map中的实际键值对个数,即数组中元素个数
transient int size;

//每次结构改变时,都会自增,fail-fast机制,这是一种错误检测机制。
//当迭代集合的时候,如果结构发生改变,则会发生 fail-fast,抛出异常。
transient int modCount;

//数组扩容阈值
int threshold;

//加载因子
final float loadFactor;					

//普通单向链表节点类
static class Node<K,V> implements Map.Entry<K,V> {
	//key的hash值,put和get的时候都需要用到它来确定元素在数组中的位置
	final int hash;
	final K key;
	V value;
	//指向单链表的下一个节点
	Node<K,V> next;

	Node(int hash, K key, V value, Node<K,V> next) {
		this.hash = hash;
		this.key = key;
		this.value = value;
		this.next = next;
	}
}

//转化为红黑树的节点类
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	//当前节点的父节点
	TreeNode<K,V> parent;  
	//左孩子节点
	TreeNode<K,V> left;
	//右孩子节点
	TreeNode<K,V> right;
	//指向前一个节点
	TreeNode<K,V> prev;    // needed to unlink next upon deletion
	//当前节点是红色或者黑色的标识
	boolean red;
	TreeNode(int hash, K key, V val, Node<K,V> next) {
		super(hash, key, val, next);
	}
}	

table扩容阈值 = table容量(capacity) x 负载因子(load factor)。

假设当前 HashMap的容量是 16,默认的负载因子是 0.75,则当 HashMap的 size 达到 16 x 0.75= 12 的时候,就会触发HashMap的扩容,并不会等到HashMap达到16个元素的时候才进行扩容。

HashMap 构造函数

//默认无参构造,指定一个默认的加载因子
public HashMap() {
	this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

//可指定容量的有参构造,但是需要注意当前我们指定的容量并不一定就是实际的容量,下面会说
public HashMap(int initialCapacity) {
	//同样使用默认加载因子
	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//可指定容量和加载因子,但是笔者不建议自己手动指定非0.75的加载因子
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;
	//这里就是把我们指定的容量改为一个大于它的的最小的2次幂值,如传过来的容量是14,则返回16
	//注意这里,按理说返回的值应该赋值给 capacity,即保证数组容量总是2的n次幂,为什么这里赋值给了 threshold 呢?
	
	this.threshold = tableSizeFor(initialCapacity);
}

//可传入一个已有的map
public HashMap(Map<? extends K, ? extends V> m) {
	this.loadFactor = DEFAULT_LOAD_FACTOR;
	putMapEntries(m, false);
}

//把传入的map里边的元素都加载到当前map
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
	int s = m.size();
	if (s > 0) {
		if (table == null) { // pre-size
			float ft = ((float)s / loadFactor) + 1.0F;
			int t = ((ft < (float)MAXIMUM_CAPACITY) ?
					 (int)ft : MAXIMUM_CAPACITY);
			if (t > threshold)
				threshold = tableSizeFor(t);
		}
		else if (s > threshold)
			resize();
		for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
			K key = e.getKey();
			V value = e.getValue();
			//put方法的具体实现,后边讲
			putVal(hash(key), key, value, false, evict);
		}
	}
}

在8的时候,把链表转为红黑树呢?

当单个链表中节点的数量>=8时,链表会转为红黑树。8只是其中的一个条件!真要把链表转为红黑树,还得满足另一个条件才行!这个条件就是只有HashMap里面元素的总数量 >=64 时,链表才能进行树化 ,否则位桶只会进行扩容,而不是树化。所以链表转红黑树的条件有2个,如下:

单个链表中节点的数量>=8;
HashMap中元素的总数量>=64。
只有这2个条件同时都满足,才会发生树化!
再说为什么选择8树化,
一方面,从时间复杂度的角度来考虑,红黑树的平均查找长度是log(n),当节点数量为8时,查找长度为log(8)=3;而链表的平均查找长度为n/2,当节点数量为8时,平均查找长度为8/2=4。所以在节点数量为8时,红黑树的查找效率比链表要高,这就是链表需要转为红黑树的理论依据。

另外上面这一大段注释大致上是说,他们采用了数学上的泊松分布原理,HashMap同一位桶上单个链表中同时有8个节点的概率只有0.00000006,这几乎是不可能的事件!换句话说,阈值为8时,链表转红黑树的概率是最低的,也就是最佳时机,这是由大量统计得来的事实依据!

HashMap中虽然提供了链表转红黑树的机制,以此来提高链表中节点的查询性能。但是,链表转红黑树也是要额外消耗很多性能的,这个转换操作本身是要尽可能避免的。我们来算一下,如果节点数量为6,链表的查找长度就是6/2=3,这时还要加上链表转为红黑树的额外时间,整体性能就比较差了。所以经过大量数据的测试,发现阈值为8时,链表转红黑树发生的概率最低,进行额外树化操作的频率较少,查找长度也较低,这就意味着HashMap此时在性能和效率上达到了一个最佳的平衡状态!所以HashMap底层选择8作为链表转红黑树的阈值,而不是6,也不是其他的7、9等数字,这都是基于大量的数学计算和真实数据统计得来的结论!

泊松分布原理:

泊松分布主要是用于描述单位时间(或空间)内某个随机事件发生的次数

当单个链表中节点的数量<=6时会从红黑树转回链表。就比如某个红黑树中本来有9个节点,然后我们就对该红黑树中的节点删除、删除、再删除,此时红黑树的节点数量只有6个了,那此时HashMap就会把该棵红黑树重新转为链表。所以UNTREEIFY_THRESHOLD属性就是红黑树转回链表的阈值。当hashCode离散性很好的时候,数据节点均匀地分布在每个位桶中,几乎不会有单个位桶中的链表长度达到阈值,因此树化的概率极低。另外即使在随机hashCode的情况下,离散性会变差一点,有可能会导致数据分布不均匀。在数据分布不均的情况下,即使出现单个链表中的节点数量>=8,根据泊松分布原理,这种概率也只有0.00000006。也就是一亿条数据中有6个数据出现了冲突,这几乎不可能发生的事情都发生了,只能说明HashMap中保存了超级多的数据节点。

假如这种情况真的发生了,HashMap为了尽可能的提高性能(因为链表与红黑树之间的互转性能消耗是有点大的),这时候就设置一个7,作为两者转换的缓冲,可以有效防止链表和树频繁转换。否则,我们加一个节点链表就变成了红黑树,减一个节点红黑树就变成了链表,消耗性能。

modCount属性

该属性是HashMap基于Fail-Fast机制,用于记录HashMap修改次数的属性。在HashMap的put()、get()、remove()等方法中,都用到了该属性

HashMap不是线程安全的,当我们使用 迭代器 或 foreach 进行集合遍历时,如果在这个过程中有其他线程调用了add()、remove()等修改方法对集合进行并发操作,就可能会导致数据的前后不一致。此时为了保证遍历前后数据的一致性,HashMap中定义了这个属性来记录我们的操作。在集合遍历时一旦有add、remove等操作,modCount的值就会改变,然后这个modCount会被赋值传递给迭代器的 expectedModCount 属性。

迭代器的expectedModCount属性,默认情况下记录的是开始迭代之前的值。如果HashMap在遍历的过程中,内部的数据被另一个线程修改了,modCount的数值就会发生变化,这个时候expectedModCount和modCount就会不一致。这就说明此时有两个线程在同时操作该集合,这种操作是有风险的。为了保证结果的正确性,迭代器会遵循 快速失败策略,立即抛出一个ConcurrentModificationException()异常来终止我们对HashMap的修改。
fail-fast机制,这是集合(Collection)中的一种错误处理机制。当多个线程同时对同一个集合的数据进行修改时,就可能会产生 fail-fast事件。比如说,在线程A通过 Iterator 去访问集合时,如果有其他线程修改了该集合,那么这时A线程就会抛出 ConcurrentModificationException 异常,也就是产生一个 fail-fast 事件。

HashMap 扩容触发时机

HashMap会遵循2倍扩容的原则,每次扩容之后数组的大小都是扩容前的2倍。

①. 当HashMap中使用的位桶数量,达到 总容量*负载因子 的时候会触发扩容;

②. 当某个位桶中的链表长度达到8,即将进行链表转红黑树时,会检查总位桶的数量是否小于64,如果总数量小于64也会进行扩容;

③. 当创建一个HashMap对象之后,第一次往HashMap里面进行put操作时,也会先进行扩容;

④. 另外在HashMap的put方法中,当HashMap的size(实际键值对个数)达到 threshold(阈值)时,也会触发扩容操作。

resize()扩容方法源码

    final Node<K,V>[] resize() {
        //扩容前的Node数组,先保存 table 副本,接下来 copy 到新数组用
        Node<K,V>[] oldTab = table;
        //当前 table 的容量,是 length 而不是 size
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //当前桶的大小
        int oldThr = threshold;
        int newCap, newThr = 0;
        // 计算新的容量值和下一次要扩展的容量
        if (oldCap > 0) {
        //如果当前容量大于 0,也就是非第一次初始化的情况(扩容场景下)
            if (oldCap >= MAXIMUM_CAPACITY) {
            // 当超过最大值,则直接使用最大值作为扩容最大限度,以后就不再扩充了
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 没超过最大值,就扩充为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 计算新的resize上限
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        
        // 创建新的扩容后数组,然后将旧的元素复制过去
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            // 把每个bucket都移动到新的buckets中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //如果位置上没有元素,直接为null
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //如果只有一个元素,新的hash计算后放入新的数组中
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //如果是树状结构,使用红黑树保存
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //如果是链表形式
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //hash碰撞后高位为0,放入低Hash值的链表中
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //hash碰撞后高位为1,放入高Hash值的链表中
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 低hash值的链表放入数组的原始位置
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 高hash值的链表放入数组的原始位置 + 原始容量
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        
        return newTab;
}

在这里插入图片描述

扩容机制原理

HashMap会创建一个新的更大的数组,将旧数组中的数据拷贝过去,用这个新数组代替已有的旧数组。

①. 如果节点的 next属性 为null,则说明这是一个最正常的节点,不是桶内链表,也不是红黑树,这样的节点可以直接计算索引位置,然后插入。

②. 如果是一颗红黑树,会使用split方法进行处理,原理就是将红黑树拆分成两个TreeNode链表,然后判断每个链表的长度是否<=6,如果是,就将TreeNode转换成桶内链表,否则再转换成红黑树。

③. 如果是桶内链表,则将链表拷贝到新数组中,保证链表的顺序不变。

rehash操作简介

HashMap在数组扩容后,还需要进行一次新的rehash操作,以此来重新确定元素的存放位置。在rehash之后,元素的存放位置要么是在原位置,要么是在原位置的基础上 向下移动 之前容量个数 的位置。比如,上次容量是16,下次扩容后容量变成了16+16=32。如果一个元素原先在下标为7的位置上,那么扩容后,该元素要么还在7的位置上,要么就在7+16的位置上。

HashMap 扩容优化

在 JDK1.7 中,HashMap 整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。

而在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。

之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的,而 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。

实际应用中,我们设置初始容量,一般得是 2 的整数次幂。你知道原因吗?

tableSizeFor()

构造函数中,调用了 tableSizeFor 方法,这个方法是怎么实现的呢?

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;
}

我们以传入参数为14 来举例,计算这个过程。

首先,14传进去之后先减1,n此时为13。然后是一系列的无符号右移运算。

//13的二进制
0000 0000 0000 0000 0000 0000 0000 1101
//无右移1位,高位补0
0000 0000 0000 0000 0000 0000 0000 0110
//然后把它和原来的13做或运算得到,此时的n值
0000 0000 0000 0000 0000 0000 0000 1111
//再以上边的值,右移2位
0000 0000 0000 0000 0000 0000 0000 0011
//然后和第一次或运算之后的 n 值再做或运算,此时得到的n值
0000 0000 0000 0000 0000 0000 0000 1111

//我们会发现,再执行右移 4,8,16位,同样n的值不变
//当n小于0时,返回1,否则判断是否大于最大容量,是的话返回最大容量,否则返回 n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
//很明显我们这里返回的是 n+1 的值,
0000 0000 0000 0000 0000 0000 0000 1111

0000 0000 0000 0000 0000 0000 0001 0000

将它转为十进制,就是 2^4 = 16 。我们会发现一个规律,以上的右移运算,最终会把最低位的值都转化为 ****1111111 这样的结构,然后再加1,就是1 0000 这样的结构,它一定是 2的n次幂。因此,这个方法返回的就是大于当前传入值的最小(最接近当前值)的一个2的n次幂的值。

为什么这么设计呢?

1)通过将 Key 的 hash 值与 length-1 进行 & 运算,实现了当前 Key 的定位,2 的幂次方可以减少冲突(碰撞)的次数,提高 HashMap 查询效率;
2)如果 length 为 2 的次幂,则 length-1 转化为二进制必定是 11111…… 的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length-1 为 14,对应的二进制为 1110,在于 h 与操作,最后一位都为 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。

put()方法详解

//put方法,会先调用一个hash()方法,得到当前key的一个hash值,
//用于确定当前key应该存放在数组的哪个下标位置
//这里的 hash方法,我们姑且先认为是key.hashCode(),其实不是的,一会儿细讲
public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}

//把hash值和当前的key,value传入进来
//这里onlyIfAbsent如果为true,表明不能修改已经存在的值,因此我们传入false
//evict只有在方法 afterNodeInsertion(boolean evict) { }用到,可以看到它是一个空实现,因此不用关注这个参数
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
			   boolean evict) {
	Node<K,V>[] tab; Node<K,V> p; int n, i;
	//判断table是否为空,如果空的话,会先调用resize扩容
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;
	//根据当前key的hash值找到它在数组中的下标,判断当前下标位置是否已经存在元素,
	//若没有,则把key、value包装成Node节点,直接添加到此位置。
	// i = (n - 1) & hash 是计算下标位置的,为什么这样算,后边讲
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
	else { 
		//如果当前位置已经有元素了,分为三种情况。
		Node<K,V> e; K k;
		//1.当前位置元素的hash值等于传过来的hash,并且他们的key值也相等,
		//则把p赋值给e,跳转到①处,后续需要做值的覆盖处理
		if (p.hash == hash &&
			((k = p.key) == key || (key != null && key.equals(k))))
			e = p;
		//2.如果当前是红黑树结构,则把它加入到红黑树 
		else if (p instanceof TreeNode)
			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		else {
		//3.说明此位置已存在元素,并且是普通链表结构,则采用尾插法,把新节点加入到链表尾部
			for (int binCount = 0; ; ++binCount) {
				if ((e = p.next) == null) {
					//如果头结点的下一个节点为空,则插入新节点
					p.next = newNode(hash, key, value, null);
					//如果在插入的过程中,链表长度超过了8,则转化为红黑树
					if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
						treeifyBin(tab, hash);
					//插入成功之后,跳出循环,跳转到①处
					break;
				}
				//若在链表中找到了相同key的话,直接退出循环,跳转到①处
				if (e.hash == hash &&
					((k = e.key) == key || (key != null && key.equals(k))))
					break;
				p = e;
			}
		}
		//①
		//说明发生了碰撞,e代表的是旧值,因此节点位置不变,但是需要替换为新值
		if (e != null) { // existing mapping for key
			V oldValue = e.value;
			//用新值替换旧值,并返回旧值。
			if (!onlyIfAbsent || oldValue == null)
				e.value = value;
			//看方法名字即可知,这是在node被访问之后需要做的操作。其实此处是一个空实现,
			//只有在 LinkedHashMap才会实现,用于实现根据访问先后顺序对元素进行排序,hashmap不提供排序功能
			// Callbacks to allow LinkedHashMap post-actions
			//void afterNodeAccess(Node<K,V> p) { }
			afterNodeAccess(e);
			return oldValue;
		}
	}
	//fail-fast机制
	++modCount;
	//如果当前数组中的元素个数超过阈值,则扩容
	if (++size > threshold)
		resize();
	//同样的空实现
	afterNodeInsertion(evict);
	return null;
}

添加元素的时候先对key进行hash操作

在这里插入图片描述

  1. 根据待插入数据的key,计算出其对应的hash值,并根据该hash值确定元素的插入位置(即在动态数组中的位置);
  2. 将元素放入到数组的指定位置,如果该位置上之前没有元素,则直接放入;
  3. 放入该位置后,如果数组元素超过了扩容阈值,则对数组进行扩容;
  4. 放入该位置后,如果数组元素没超过扩容阈值,则写入结束;
  5. 如果该位置上之前已有元素,则直接覆盖掉旧元素;
  6. 如果元素之前组成了红黑树,则挂入到树的指定位置;
  7. 如果之前元素组成了链表,则先进行判断,看看链表长度是否超过了树化的阈值;
  8. 如果加入该元素后,链表长度超过8,则将链表转化为红黑树后插入;
  9. 如果加入该元素后,链表长度不超过8,则直接插入。

hash操作源码

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

①. 如果key==null,则直接判断当前key的hash值是0;
②. 如果key不为空,则进行h = key.hashCode()操作,得到key的hash值并赋值给变量h;
③. 最后将 h 和 h>>>16 进行异或运算,得到真正的hash值。

会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传 null 的。若非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并与原来的h进行异或处理。为什么要这样做,这样做有什么好处呢?

hashCode()方法继承自父类Object,它返回的是一个 int 类型的数值,可以保证同一个应用单次执行的每次调用,返回结果都是相同的(这个说明可以在hashCode源码上找到),这就保证了hash的确定性。在此基础上,再进行某些固定的运算,肯定结果也是可以确定的。

public static void main(String[] args) {
Object o = new Object();
int hash = o.hashCode();
System.out.println(hash);
System.out.println(Integer.toBinaryString(hash));

}
//1836019240
//1101101011011110110111000101000

然后,进行 (h = key.hashCode()) ^ (h >>> 16) 这一段运算。

//h原来的值
0110 1101 0110 1111 0110 1110 0010 1000
//无符号右移16位,其实相当于把低位16位舍去,只保留高16位
0000 0000 0000 0000 0110 1101 0110 1111
//然后高16位和原 h进行异或运算
0110 1101 0110 1111 0110 1110 0010 1000
^
0000 0000 0000 0000 0110 1101 0110 1111
=
0110 1101 0110 1111 0000 0011 0100 0111

可以看到,其实相当于,我们把高16位值和当前h的低16位进行了混合,这样可以尽量保留高16位的特征,从而降低哈希碰撞的概率。

思考一下,为什么这样做,就可以降低哈希碰撞的概率呢?先别着急,我们需要结合 i = (n - 1) & hash 这一段运算来理解。

//这是 put 方法中用来根据hash()值寻找在数组中的下标的逻辑, //n为数组长度, hash为调用 hash()方法混合处理之后的hash值。 i = (n - 1) & hash

如果给定某个数值,去找它在某个数组中的下标位置时,直接用模运算就可以了(假设数组值从0开始递增)。如,我找 14 在数组长度为16的数组中的下标,即为 14 % 16,等于14 。 18的位置即为 18%16,等于2。

(n - 1) & hash就是取模运算的位运算形式。以18%16为例

//18的二进制
0001 0010
//16 -1 即 15的二进制
0000 1111
//与运算之后的结果为
0000 0010
// 可以看到,上边的结果转化为十进制就是 2 。
//其实我们会发现一个规律,因为n是2的n次幂,因此它的二进制表现形式肯定是类似于
0001 0000
//这样的形式,只有一个位是1,其他位都是0。而它减 1 之后的形式就是类似于
0000 1111
//这样的形式,高位都是0,低位都是1,因此它和任意值进行与运算,结果值肯定在这个区间内
0000 0000 ~ 0000 1111
//也就是0到15之间,(以n为16为例)
//因此,这个运算就可以实现取模运算,而且位运算还有个好处,就是速度比较快。

为什么高低位异或运算可以减少哈希碰撞

我们想象一下,假如用 key 原来的hashCode值,直接和 (n-1) 进行与运算来求数组下标,而不进行高低位混合运算,会产生什么样的结果。

//例如我有另外一个h2,和原来的 h相比较,高16位有很大的不同,但是低16位相似度很高,甚至相同的话。
//原h值
0110 1101 0110 1111 0110 1110 0010 1000
//另外一个h2值
0100 0101 1110 1011 0110 0110 0010 1000
// n -1 ,即 15 的二进制
0000 0000 0000 0000 0000 0000 0000 1111
//可以发现 h2 和 h 的高位不相同,但是低位相似度非常高。
//他们分别和 n -1 进行与运算时,得到的结果却是相同的。(此处n假设为16)
//因为 n-1 的高16位都是0,不管 h 的高 16 位是什么,与运算之后,都不影响最终结果,高位一定全是 0
//因此,哈希碰撞的概率就大大增加了,并且 h 的高16 位特征全都丢失了。异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率当然就更小了。

以上,就是为什么要对一个hash值进行高低位混合,并且选择异或运算来混合的原因。

resize() 扩容机制

final Node<K,V>[] resize() {
	//旧数组
	Node<K,V>[] oldTab = table;
	//旧数组的容量
	int oldCap = (oldTab == null) ? 0 : oldTab.length;
	//旧数组的扩容阈值,注意看,这里取的是当前对象的 threshold 值,下边的第2种情况会用到。
	int oldThr = threshold;
	//初始化新数组的容量和阈值,分三种情况讨论。
	int newCap, newThr = 0;
	//1.当旧数组的容量大于0时,说明在这之前肯定调用过 resize扩容过一次,才会导致旧容量不为0。
	//为什么这样说呢,之前我在 tableSizeFor 卖了个关子,需要注意的是,它返回的值是赋给了 threshold 而不是 capacity。
	//我们在这之前,压根就没有在任何地方看到过,它给 capacity 赋初始值。
	if (oldCap > 0) {
		//容量达到了最大值
		if (oldCap >= MAXIMUM_CAPACITY) {
			threshold = Integer.MAX_VALUE;
			return oldTab;
		}
		//新数组的容量和阈值都扩大原来的2倍
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
				 oldCap >= DEFAULT_INITIAL_CAPACITY)
			newThr = oldThr << 1; // double threshold
	}
	//2.到这里,说明 oldCap <= 0,并且 oldThr(threshold) > 0,这就是 map 初始化的时候,第一次调用 resize的情况
	//而 oldThr的值等于 threshold,此时的 threshold 是通过 tableSizeFor 方法得到的一个2的n次幂的值(我们以16为例)。
	//因此,需要把 oldThr 的值,也就是 threshold ,赋值给新数组的容量 newCap,以保证数组的容量是2的n次幂。
	//所以我们可以得出结论,当map第一次 put 元素的时候,就会走到这个分支,把数组的容量设置为正确的值(2的n次幂)
	//但是,此时 threshold 的值也是2的n次幂,这不对啊,它应该是数组的容量乘以加载因子才对。别着急,这个会在③处理。
	else if (oldThr > 0) // initial capacity was placed in threshold
		newCap = oldThr;
	//3.到这里,说明 oldCap 和 oldThr 都是小于等于0的。也说明我们的map是通过默认无参构造来创建的,
	//于是,数组的容量和阈值都取默认值就可以了,即 16 和 12。
	else {               // zero initial threshold signifies using defaults
		newCap = DEFAULT_INITIAL_CAPACITY;
		newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
	}
	//③ 这里就是处理第2种情况,因为只有这种情况 newThr 才为0,
	//因此计算 newThr(用 newCap即16 乘以加载因子 0.75,得到 12) ,并把它赋值给 threshold
	if (newThr == 0) {
		float ft = (float)newCap * loadFactor;
		newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
				  (int)ft : Integer.MAX_VALUE);
	}
	//赋予 threshold 正确的值,表示数组下次需要扩容的阈值(此时就把原来的 16 修正为了 12)。
	threshold = newThr;
	@SuppressWarnings({"rawtypes","unchecked"})
		//我们可以发现,在构造函数时,并没有创建数组,在第一次调用put方法,导致resize的时候,才会把数组创建出来。这是为了延迟加载,提高效率。
		Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
	table = newTab;
	//如果原来的数组不为空,那么我们就需要把原来数组中的元素重新分配到新的数组中
	//如果是第2种情况,由于是第一次调用resize,此时数组肯定是空的,因此也就不需要重新分配元素。
	if (oldTab != null) {
		//遍历旧数组
		for (int j = 0; j < oldCap; ++j) {
			Node<K,V> e;
			//取到当前下标的第一个元素,如果存在,则分三种情况重新分配位置
			if ((e = oldTab[j]) != null) {
				oldTab[j] = null;
				//1.如果当前元素的下一个元素为空,则说明此处只有一个元素
				//则直接用它的hash()值和新数组的容量取模就可以了,得到新的下标位置。
				if (e.next == null)
					newTab[e.hash & (newCap - 1)] = e;
				//2.如果是红黑树结构,则拆分红黑树,必要时有可能退化为链表
				else if (e instanceof TreeNode)
					((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
				//3.到这里说明,这是一个长度大于 1 的普通链表,则需要计算并
				//判断当前位置的链表是否需要移动到新的位置
				else { // preserve order
					// loHead 和 loTail 分别代表链表旧位置的头尾节点
					Node<K,V> loHead = null, loTail = null;
					// hiHead 和 hiTail 分别代表链表移动到新位置的头尾节点
					Node<K,V> hiHead = null, hiTail = null;
					Node<K,V> next;
					do {
						next = e.next;
						//如果当前元素的hash值和oldCap做与运算为0,则原位置不变
						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;
}


一个非常重要的运算,我们没有讲解。就是下边这个判断,它用于把原来的普通链表拆分为两条链表,位置不变或者放在新的位置。

if ((e.hash & oldCap) == 0) {} else {}

我们以原数组容量16为例,扩容之后容量为32。说明下为什么这样计算。

还是用之前的hash值举例。

//e.hash值
0110 1101 0110 1111 0110 1110 0010 1000
//oldCap值,即16
0000 0000 0000 0000 0000 0000 0001 0000
//做与运算,我们会发现结果不是0就是非0 oldCap,
//而且它取决于 e.hash 二进制位的倒数第五位是 0 还是 1,
//若倒数第五位为0,则结果为0,若倒数第五位为1,则结果为非0。
//那这个和新数组有什么关系呢?
//别着急,我们看下新数组的容量是32,如果求当前hash值在新数组中的下标,则为
// e.hash &( 32 - 1) 这样的运算 ,即 hash 与 31 进行与运算,
0110 1101 0110 1111 0110 1110 0010 1000
&
0000 0000 0000 0000 0000 0000 0001 1111

0000 0000 0000 0000 0000 0000 0000 1000
//接下来,我们对比原来的下标计算结果和新的下标结果,会发现一个规律:

若hash值的倒数第五位是0,则新下标与旧下标结果相同,都为 0000 1000

若hash值的倒数第五位是1,则新下标(0001 1000)与旧下标(0000 1000)结果值相差了 16 。

因此,我们就可以根据 (e.hash & oldCap == 0) 这个判断的真假来决定,当前元素应该在原来的位置不变,还是在新的位置(原位置 + 16)。

如果,上边的推理还是不明白的话,我再举个简单的例子。

18%16=2 18%32=18
34%16=2 34%32=2
50%16=2 50%32=18

get()方法

public V get(Object key) {
	Node<K,V> e;
	//如果节点为空,则返回null,否则返回节点的value。这也说明,hashMap是支持value为null的。
	//因此,我们就明白了,为什么hashMap支持Key和value都为null
	return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
	Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
	//首先要确保数组不能为空,然后取到当前hash值计算出来的下标位置的第一个元素
	if ((tab = table) != null && (n = tab.length) > 0 &&
		(first = tab[(n - 1) & hash]) != null) {
		//若hash值和key都相等,则说明我们要找的就是第一个元素,直接返回
		if (first.hash == hash && // always check first node
			((k = first.key) == key || (key != null && key.equals(k))))
			return first;
		//如果不是的话,就遍历当前链表(或红黑树)
		if ((e = first.next) != null) {
			//如果是红黑树结构,则找到当前key所在的节点位置
			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);
		}
	}
	//否则,说明没有找到,返回null
	return null;
}

get取值流程总结如下:

  1. 根据要取值的key,hash(key)计算出数组中对应的索引位置;
  2. 取出指定位置上的元素(这时key的hash值是一样的);
  3. 如果key完全一样,则直接返回该值,查找结束;
  4. 如果key不一样,判断其后面挂载的是红黑树还是链表;
  5. 如果是红黑树,则按照树的方式查找;
  6. 如果是链表,则按照链表的方式查找;
  7. 如果任何一步都没有发现结果,则说明key在map中不存在,直接返回null。

为什么HashMap链表会形成死循环

JDK1.7 的 HashMap 链表会有死循环的可能,因为JDK1.7是采用的头插法,在多线程环境下有可能会使链表形成环状,从而导致死循环。JDK1.8做了改进,用的是尾插法,不会产生死循环。

那么,链表是怎么形成环状的呢?

从 put()方法开始,最终找到线程不安全的那个方法。这里省略中间不重要的过程,我只把方法的跳转流程贴出来:

//添加元素方法 -> 添加新节点方法 -> 扩容方法 -> 把原数组元素重新分配到新数组中
put() --> addEntry() --> resize() --> transfer()

问题就发生在 transfer 这个方法中。

图1

我们假设,原数组容量只有2个,其中一条链表上有两个元素 A,B,如下图

img

现在,有两个线程都执行 transfer 方法。每个线程都会在它们自己的工作内存生成一个newTable 的数组,用于存储变化后的链表,它们互不影响(这里互不影响,指的是两个新数组本身互不影响)。但是,需要注意的是,它们操作的数据却是同一份。

因为,真正的数组中的内容在堆中存储,它们指向的是同一份数据内容。就相当于,有两个不同的引用 X,Y,但是它们都指向同一个对象 Z。这里 X、Y就是两个线程不同的新数组,Z就是堆中的A,B 等元素对象。

假设线程一执行到了上图1中所指的代码①处,恰好 CPU 时间片到了,线程被挂起,不能继续执行了。 记住此时,线程一中记录的 e = A , e.next = B。

然后线程二正常执行,扩容后的数组长度为 4, 假设 A,B两个元素又碰撞到了同一个桶中。然后,通过几次 while 循环后,采用头插法,最终呈现的结构如下:

img

此时,线程一解挂,继续往下执行。注意,此时线程一,记录的还是 e = A,e.next = B,因为它还未感知到最新的变化。

我们主要关注图1中标注的①②③④处的变量变化:

/**

  • next = e.next
  • e.next = newTable[i]
  • newTable[i] = e;
  • e = next;
    */

//第一次循环,(伪代码)
e=A;next=B;
e.next=null //此时线程一的新数组刚初始化完成,还没有元素
newTab[i] = A->null //把A节点头插到新数组中
e=B; //下次循环的e值

第一次循环结束后,线程一新数组的结构如下图:

img

然后,由于 e=B,不为空,进入第二次循环。

//第二次循环
e=B;next=A; //此时A,B的内容已经被线程二修改为 B->A->null,然后被线程一读到,所以B的下一个节点指向A
e.next=A->null // A->null 为第一次循环后线程一新数组的结构
newTab[i] = B->A->null //新节点B插入之后,线程一新数组的结构
e=A; //下次循环的 e 值

第二次循环结束后,线程一新数组的结构如下图:

img

此时,由于 e=A,不为空,继续循环。

//第三次循环
e=A;next=null; // A节点后边已经没有节点了
e.next= B->A->null // B->A->null 为第二次循环后线程一新数组的结构
//我们把A插入后,抽象的表达为 A->B->A->null,但是,A只能是一个,不能分身啊
//因此实际上是 e(A).next指向发生了变化,A的 next 由指向 null 改为指向了 B,
//而 B 本身又指向A,因此A和B互相指向,成环
newTab[i] = A->B 且 B->A
e=next=null; //e此时为空,结束循环

第三次循环结束后,看下图,A的指向由 null ,改为指向为 B,因此 A 和 B 之间成环。

img

这时,有的同学可能就会问了,就算他们成环了,又怎样,跟死循环有什么关系?

我们看下 get() 方法(最终调用 getEntry 方法),

img

可以看到查找元素时,只要 e 不为空,就会一直循环查找下去。若有某个元素 C 的 hash 值也落在了和 A,B元素同一个桶中,则会由于, A,B互相指向,e.next 永远不为空,就会形成死循环。

ConcurrentHashMap

ConcurrentHashMap在jdk1.7中的设计

在这里插入图片描述
每一个segment都是一个HashEntry<K,V>[] table, table中的每一个元素本质上都是一个HashEntry的单向队列。比如table[3]为首节点,table[3]->next为节点1,之后为节点2,依次类推。

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {

    // 将整个hashmap分成几个小的map,每个segment都是一个锁;与hashtable相比,这么设计的目的是对于put, remove等操作,可以减少并发冲突,对
    // 不属于同一个片段的节点可以并发操作,大大提高了性能
    final Segment<K,V>[] segments;

    // 本质上Segment类就是一个小的hashmap,里面table数组存储了各个节点的数据,继承了ReentrantLock, 可以作为互拆锁使用
    static final class Segment<K,V> extends ReentrantLock implements Serializable {
        transient volatile HashEntry<K,V>[] table;
        transient int count;
    }

    // 基本节点,存储Key, Value值
    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;
    }
}

在jdk1.8中实现

主要做了2方面的改进

改进一:取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。

JDK 7 与JDK 8 中的HashMap有什么不同

  1. 插入位置不同
    JDK 7 中HashMap用的是 头插法,而JDK 8及其之后使用的都是 尾插法。

那么为什么要这样做呢?

这是因为JDK 7中HashMap是 用单向链表进行的纵向延伸,采用头插法能够提高插入的效率,但是可能会出现一个逆序的环形链表,即死循环问题。而在JDK 8 之后因为加入了红黑树,则开始使用尾插法,能够避免出现逆序且死循环的链表。

  1. 扩容后数据存储位置的计算方式不同
    在JDK 7 中的HashMap采用 hash值和需要扩容的二进制数进行&运算,即hash & length-1这样的方法。这也是为什么在扩容的时候一定是2的n次幂的原因所在,因为只有在2的n次幂的时候,最后一位二进制数才一定是1,这样才能最大程度地减少hash碰撞。

而在JDK 8 中,HashMap是利用 扩容前的原始位置 + 原始容量,不再是JDK 7中的那种 异或 方法。JDK 8中的这种计算方式,相当于只需要判断Hash值中新增的参与运算的位是0还是1,就可以直接计算出扩容后的储存方式了。

  1. 数据结构不同
    JDK 7中的HashMap采用的是 数组+ 单向链表 的数据结构,而在JDK 8及之后,采用的是 数组+链表+红黑树 的数据结构。当链表的深度达到8的时候,就会自动扩容把链表转成红黑树,这样时间复杂度可以从O(N)变成O(logN),提高了查询效率。

  2. 性能不同
    我们知道,在最坏的情况下,所有Entry的key可能会形成一个单向链表。JDK 8中为了优化这个问题,在链表数目大于8时会转化为红黑树,但resize时又必需拆解和重建红黑树。

如果链表长度<=6,会去掉树结构的指针,重建成一个链表;如果链表长度>=8,则会重建成一棵红黑树。

总的来看,JDK 7中的resize时间复杂度为O(n),JDK 8中的复杂度为O(logn)。

HashMap与Hashtable的区别

  1. 相同点
    ①. 都是 基于哈希表 来实现的,并且里面存储的元素都是 key-value键值对结构。
    ②. 当产生哈希冲突时,内部都会通过 单链表 去解决冲突问题(当然JDK 8中HashMap中加入了红黑树)。
    ③. 内部容量不足时,都会 自动进行扩容。
    ④. 都实现了Map、Cloneable、Serializable接口,可以被克隆,支持序列化。
  2. 不同点
    2.1 继承的父类不同
    HashMap继承的是AbstractMap,而Hashtable继承的是Dictionary。

2.2 线程安全性不同
HashMap是线程不安全的,在源码中也可以看到,HashMap中的方法并没有添加synchronized修饰符。在多线程的环境下使用时,需要自己增加同步处理,建议使用Collections包下的synchronizedMap来把map包装起来。而Hashtable的方法用了synchronized修饰符,所以它是线程安全的。

2.3 提供的API方法不同
Hashtable提供了contains()、containsValue()和containsKey()3个方法。

而HashMap中则去掉了contains()方法,改用containsKey()方法和containsValue()方法,因为contains()方法容易引起误解。

2.4 key-value是否支持null值
在Hashtable中,key-value是不允许为null的,但是在使用put()方法时将一个null-null的键值对添加进Hashtable时,编译也会通过,只是在运行的时候会抛出NullPointerException异常。

而在HashMap中,是允许null的key出现的,并且只允许出现一个,null的key会放在table[0]的位置。

2.5 遍历方式
HashMap和Hashtable都使用了Iterator迭代器进行遍历,不同的是,Hashtable还有Enumeration等遍历方式。

2.6 数组初始化和扩容机制不同
在默认情况下,Hashtable的初始容量为11,而HashMap为16。

Hashtable不要求底层数组的容量一定是2的整数幂,而是将容量变为原来的2倍加1;而HashMap则要求一定是2的整数次幂。

ConcurrentHashMap jdk1.7、jdk1.8比较

public class CompareConcurrentHashMap {
    private static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>(40000);


    public static void putPerformance(int index, int num) {
        for (int i = index; i < (num + index) ; i++)
            map.put(String.valueOf(i), i);
    }
public static void getPerformance2() {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 400000; i++)
            map.get(String.valueOf(i));
        long end = System.currentTimeMillis();
        System.out.println("get: it costs " + (end - start) + " ms");
    }

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        final CountDownLatch cdLatch = new CountDownLatch(4);
        for (int i = 0; i < 4; i++) {
            final int finalI = i;
            new Thread(new Runnable() {
                public void run() {
                    CompareConcurrentHashMap.putPerformance(100000 * finalI, 100000);
                    cdLatch.countDown();
                }
            }).start();
        }
        cdLatch.await();
        long end = System.currentTimeMillis();
        System.out.println("put: it costs " + (end - start) + " ms");
        CompareConcurrentHashMap.getPerformance2();
    }
}

在这里插入图片描述

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值