Java集合类:HashMap(基于JDK1.8)

前言

以下内容是我在学习HashMap集合时候做的笔记总结,主要的学习视频为b站黑马程序员HashMap集合 p1-p18。如有错误请指正,谢谢。

HashMap

HashMap基于哈希表的Map接口实现,是以key-value存储形式存储,即主要用来存放键值对的集合。而且HashMap 的实现不是同步的,这意味着它是线程不安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的("拉链法"解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。
补充:将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。
在这里插入图片描述

哈希冲突

两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同。
理解哈希冲突要先理解哈希函数。数组作为HashMap的主体,而假如我们要在数组中新增或查找某个元素,先通过把当前元素的关键字通过某个函数映射到数组中的某个位置,通过数组下标定位就可完成操作。这个函数可以简单描述为:存储位置 = f(关键字) ,这个函数f一般称为哈希函数这个函数的设计好坏会直接影响到哈希表的优劣
如果两个不同的元素,通过哈希函数得出的实际存储地址相同,也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,这就是所谓的哈希冲突,也叫哈希碰撞。
哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。HashMap中的链表出现越少,性能才会越好。

数据结构

在JDK1.8 之前 HashMap 由 数组+链表 数据结构组成的;在JDK1.8 之后 HashMap 由 数组+链表 +红黑树数据结构组成的。

  • AbstractMap 父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。
  • Cloneable 空接口,表示可以克隆。 创建并返回HashMap对象的一个副本。
  • Serializable 序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

首先拿一个简单的案例来描述存储流程:
Demo.java

public class demo {
    public static void main(String[] args){
        //创建HashMap集合
        HashMap<String,Integer> hm = new HashMap<>();
        hm.put("aaa",18);
        hm.put("bbb",19);
        hm.put("ccc",20);
        hm.put("aaa",28);
        System.out.printf(String.valueOf(hm));
    }
}

创建HashMap对象后,在jdk8之前,构造方法中会创建一个长度为16的Entry[] table 用来存储键值对数据;但是在jdk8之后而是在首次调用put()方法时构造方法才会创建一个长度为16的Node[] table数组。
向哈希表中存储数据 aaa-18,根据“aaa“调用String类中的hashcode()方法计算出”aaa“的哈希码值,然后结合数组长度进行无符号右移动(>>>),按位异或(^)计算hash的值,再按位与(&)计算出索引的值。
向哈希表中存储数据bbb-19,如果“bbb”的hashcode结合数组长度计算出的索引值和“aaa”的相同,即此位置不为空,那么为去比较“aaa”和“bbb”的哈希值。如果哈希值不同的话,“bbb”会在此空间划出一个节点变为链表来存储。这种方式称为拉链法。
向哈希表中存储数据aaa-28,首先根据“aaa”调用hashcode方法结合数组长度计算出来的索引肯定与aaa-18相同,此时再去比较两者的hash值是否相等,如果相等此时发生哈希碰撞。那么底层就会调用“aaa”所属类String中的equals方法比较内容是否相等,如果相等则将后添加的数据value覆盖之前的value;如果不相等则继续向下和其他的数据的key进行比较,如果都不相等则划出一个节点来存储数据。
如果节点长度即链表的长度大于阈值8并且数据长度大于64则将链表变为红黑树。
所以控制台输出:
在这里插入图片描述

存储流程

在这里插入图片描述

成员变量

序列化版本号

private static final long serialVersionUID = 362498820763181265L;

默认初始化容量

//默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

2的n次幂

当HashMap (int initialCapacity) 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。当向HashMap中添加一个元素的时候,需要根据key的hashCode去确定其在数组中的具体位置。 HashMap为了存取高效,要尽量减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法。这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算所以源码中做了优化,使用 hash&(length-1),而实际上hash%length等于hash&(length-1)的前提就是length是2的n次幂(如下证明一)。而且创建HashMap对象时,输入的数组长度是10,不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字(如下证明二)。
证明一如下:
按位与运算:相同的二进制数位上,都是1的时候,结果为1,否则为零
按位或运算:运算规则:相同的二进制数位上,都是0的时候,结果为0,否则为1。
数组容量为9在这里插入图片描述
数组容量为8
在这里插入图片描述
证明二如下:
源码:

//创建HashMap集合
HashMap<String,Integer> hm = new HashMap<>(10);

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

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

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

分析算法:
首先,对cap做减1操作(int n = cap - 1)这是为了防止cap已经是2的幂。如果cap已经是2的幂, 又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。
当n经过cap-1操作后等于0,最后返回的capacity值依然是1;当n不等于0的时候,第一次无符号右移操作:
在这里插入图片描述
第二次无符号右移操作:
在这里插入图片描述
第三次无符号右移操作:
在这里插入图片描述
以此类推,最后n |= n >>> 16 ,最多也就32个1,容量最大也就是32bit的正数。

最大容量

//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

默认加载因子

//默认值是0.75 
static final float DEFAULT_LOAD_FACTOR = 0.75f;

loadFactor加载因子,是用来衡量 HashMap 满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率,计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。
loadFactor太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值
当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免。

树形阈值

//当链表的值超过8则会转红黑树(1.8新增)
static final int TREEIFY_THRESHOLD = 8;

为什么是8

源码:

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  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

翻译:因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布。第一个值的概率如下:
可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。说白了就是权衡,空间和时间的权衡

当链表的值小于6则会从红黑树转回链表

static final int UNTREEIFY_THRESHOLD = 6;

树形最小容量

static final int MIN_TREEIFY_CAPACITY = 64;

其它变量

//table用来初始化
transient Node<K,V>[] table;

//存放具体元素的集合用来缓存
transient Set<Map.Entry<K,V>> entrySet;

//存放元素的个数,注意这个不等于数组的长度。
transient int size;

// 每次扩容和更改map结构的计数器
transient int modCount;

// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;

// 加载因子
final float loadFactor;

构造方法

在这里插入图片描述

  1. 构造一个具有指定的初始容量和负载因子的 HashMap
  2. 构造一个具有指定的初始容量和默认负载因子(0.75) HashMap
  3. 构造一个空的 HashMap ,默认初始容量(16)和默认负载因子(0.75)
  4. 包含另一个“Map”的构造函数 ,最后调用了putMapEntries(m,false)方法

成员方法

增加方法 put

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

实现步骤大致如下:
1)先通过hash值计算出key映射到哪个桶;
2)如果桶上没有碰撞冲突,则直接插入;
3)如果出现碰撞冲突了,则需要处理冲突:如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
4)如果桶中存在重复的键,则为该键替换新值value;
5)如果size大于阈值threshold,则进行扩容。

转换红黑树 treeifBin

先进行判断如果当前数组为空或者数组的长度小于进行树形化的阈值(MIN_TREEIFY_CAPACITY = 64)就去扩容,而不是将节点变为红黑树。

扩容方法 resize

当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
而HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
证明如下:
在这里插入图片描述

删除方法 remove

删除的话就是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于6的时候要转链表。

查找方法 get

1)get方法实现的步骤:通过hash值获取该key映射到的桶,桶上的key就是要查找的key,则直接找到并返回;桶上的key不是要找的key,则查看后续的节点,如果后续节点是红黑树节点,通过调用红黑树的方法根据key获取value;如果后续节点是链表节点,则通过循环遍历链表根据key获取value。
2)上述红黑树节点调用的是getTreeNode方法通过树形节点的find方法进行查找。
3)查找红黑树,由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找,效率更高。
4)这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等就直接返回。不相等就从子树中递归查找。
5)若为树,则在树中通过key.equals(k)查找,时间复杂度为O(logn) ;若为链表,则在链表中通过key.equals(k)查找,时间复杂度为O(n)。

初始化容量设计

尽可能的避免hashmap的扩容操作以提高性能。

initialCapacity=(需要存储的元素个数/负载因子)+1
发布了4 篇原创文章 · 获赞 2 · 访问量 1419

猜你喜欢

转载自blog.csdn.net/weixin_45468390/article/details/104338620