HashMap深入源码

Java容器专栏: Java容器源码详细解析(面试知识点)

(一)HashMap底层数据结构和原理

数据结构是哈希桶/哈希表/散列表(即数组+链表),数组每一个位置对应一个桶。在JDK8还引入了红黑树。在某个桶的链表长度大于8且HashMap中元素的个数大于64的时候自动变成一棵红黑树。(如果链表长度大于8,但是HashMap元素个数小于64时,采用扩容来解决)

为什么要引入红黑树?

因为HashMap中存放数据,所以查询也是很频繁的,而链表查询的时间复杂度始终为O(N),当链表过长的时候,查询效率较低。所以使用红黑树来提高查询效率,在红黑树中,增删改查的时间复杂度都是O(log n)。

无序,允许使用null 键和null值,因为key不允许重复,所以只能有一个键为null。

 

(二)HashMap继承和实现关系

1、实现的接口:

  • Map接口:提供了Map相应的操作,内部还提供了entry实体接口。
  • Cloneable接口
  • Serializable接口

2、继承的类:

AbstractMap抽象类,最简单实现 Map 接口的骨架类。

 

(三)HashMap源码分析

1、成员变量与内部类

private static final long serialVersionUID = 362498820763181265L;

//默认容量(数组长度/桶数),左移一位扩大一倍,1向左移位4位 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量(数组长度/桶数),2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子,用于扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//若一个桶中链表的节点数 >=此值(8)时,则此链表转换成红黑树
static final int TREEIFY_THRESHOLD = 8;
//一棵红黑树的节点如果 <=此值(6)时 ,退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表若要转成红黑树,除了满足节点数>=8外,还要满足 总节点数>=此值(64)
static final int MIN_TREEIFY_CAPACITY = 64;
//存储元素的数组(是一个个桶)
transient Node[] table;
//将数据转换成set的另一种存储形式,主要用于迭代
//Entry数组来存储key-value对,每一个键值对组成了一个Entry实体
transient Set> entrySet;
//元素总数量
transient int size;
//统计修改次数
transient int modCount;
//临界值,也就是元素总数量size达到临界值时,会对数组进行扩容(增加桶个数)。
int threshold;
//加载因子,用于扩容,默认会设置为上面的DEFAULT_LOAD_FACTOR,也可通过参数设置
final float loadFactor; 

下面介绍一下HashMap两个和节点有关的内部类

//Node是HashMap中存放数据实体的静态内部类
//实现了Map接口中定义的Entry接口,可以对Node作为Entry进行操作。
static class Node implements Map.Entry {
    final int hash;
    final K key;
    V value;
    Node next;
}    

HashMap深入源码_第1张图片

//当链表转换成红黑树时,对应的Node也转换成TreeNode
static final class TreeNode extends LinkedHashMap.Entry {
    TreeNode parent;  // red-black tree links
    TreeNode left;
    TreeNode right;
    TreeNode prev;    // needed to unlink next upon deletion
    boolean red;
}    

HashMap还有其他内部类:KeySet、EntrySet、Value等等,这些可以用于遍历操作。

2、构造方法

//创建默认的HashMap(加载因子默认9.75f,容量默认16---在put的时候再初始化容量)
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

//创建自定义初始容量的HashMap(使用默认的加载因子)
public HashMap(int initialCapacity) {
    //调用下面的构造方法
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//创建自定义初始容量和加载因子的HashMap
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;
    //约束扩容的临界值threshold的大小应该为 2 的 n 次幂(<=initialCapacity)
    this.threshold = tableSizeFor(initialCapacity);
}

从上面构造器可知,HashMap中的属性大部分时候是懒加载的,也就是等到put添加元素的时候再加载。

HashMap的长度都是2的次幂,保证2的次幂可以提高效率

 

面试题(HashMap优化思路):

如果能够大致预期HashMap大小,那么我们应该初始化时就指定好 capacity ,减少扩容次数,提升 HashMap 的效率,也提高了安全性。(扩容是最耗性能的事,所以要尽量较少扩容,又要根据实际确定好hashMap的容量)

//最后一个构造方法:将Map转换成HashMap
public HashMap(Map m) {
    //加载因子默认值
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    //调用下面的方法:将Map中的元素设置到HashMap中
    //false表示在创建HashMap的时候就调用这个方法,反之表示在创建之后调用
    putMapEntries(m, false);
}

3、添加操作

//添加元素
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

//计算哈希值的方法
static final int hash(Object key) {
    int h;
     //如果key不为null,值为 key的hashCode 异或 key的hashCode无符号右移16位
    //科学研究表明,这样可以减少哈希冲突
    /*
    hashCode值若高位变化很大,低位变化很小,或者没有变化,那么如果直接用hashCode
    和数组长度进行&运算时,很容易造成结果一致,导致哈希冲突
    */
    
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

//添加通过hash值元素
//oonlyIfAbsent如果为false,则可以替换已经存在了的旧值(put方法传入false)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //哈希数组               
    Node[] tab; 
    //哈希桶首节点
    Node p; 
    //n为HashMap的容量,i为哈希数组下标
    int n, i;
    //如果HashMap刚初始化,则对HashMap进行容量的初始化并获取容量
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    //通过key的hash值计算得到对应的哈希桶下标i ,并且将此哈希桶的首节点赋值给p       
    //如果对应的哈希桶没有元素(即没有发生哈希冲突)
    if ((p = tab[i = (n - 1) & hash]) == null)
        //直接插入
        tab[i] = newNode(hash, key, value, null);
    //若冲突,有下面几种情况
    else {
        //临时节点
        Node e; 
        //存放首节点p的key
        K k;
        //1、插入节点的key与首节点的相等(多重判断),则临时节点赋值为首节点p
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //2、若插入的节点不与首节点等价,且首节点是红黑树的节点    
        else if (p instanceof TreeNode)
            //添加相应的红黑树节点
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
        //3、插入节点不与首节点等价,且首节点是普通的链表节点    
        else {
            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;
            }
        }
        
        //这里对插入情况出现重复节点(即key重复)进行的统一处理:覆盖后返回旧值
        if (e != null) { 
            //获取到此节点的旧值
            V oldValue = e.value;
            //将新节点的新值覆盖旧值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    
    //判断插入后是否需要扩容(覆盖旧值的情况不会进这个判断)
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

总结一下,调用put方法存储键值对时:

  • 先调用hash方法,传入key,得到此key经特定哈希运算后的值
  • 调用putVal方法,传入包括上面得到的hash、key、value等参数
  • 判断HashMap是否初始化,如果还没初始化则需要扩容resize()
  • 过key的hash值计算得到对应的哈希桶下标i,即得到key在数组中相应的索引值
    • (length - 1) & hash的效果等价于 hash%length,但是&运算更高效
  • 如果此索引位置没有元素,就直接存储
  • 如果有元素,则判断是否是红黑树
    • 若是,则调用putTreeVal添加节点
    • 若不是,则依次调用索引位置的链表中元素的key值的equals方法与新的key进行比较,如果遍历中途遇到了等价的节点,就跳出循环,否则遍历到链尾,并将新节点插入到链表最后,再判断是否需要转换成红黑树,最后判断插入后是否还需要扩容。
  • 如果此节点已经存在了,就进行新值覆盖旧值的操作。
//扩容方法(返回的是扩容后的table)
final Node[] resize() {
    //先获取没插入之前的旧tbale
    Node[] oldTab = table;
    //旧桶数(数组长度)
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧临界值
    int oldThr = threshold;
    //新桶数和临界值
    int newCap, newThr = 0;
    
    //oldCap > 0,说明不是首次初始化
    if (oldCap > 0) {
        //如果桶不能再增加了,即数组长度到了最大长度(1 << 30)
        if (oldCap >= MAXIMUM_CAPACITY) {
            //将临界值设置为int类型的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //如果旧数组长度>=16且扩容 两倍 后也没超过最大长度
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //则newCap赋值为oldCap的 两倍 ,同时newThr也是oldThr的两倍     
            newThr = oldThr << 1; 
    }
    //oldCap为0,即数组为空,但是oldThr>0,说明是已经初始化过,是经过删除后导致的
    else if (oldThr > 0) 
        //则新桶数为旧临界值
        newCap = oldThr;
    //oldCap和oldThr均为0,说明还没初始化,则进行首次初始化,赋予默认值     
    else {               
        // 新容量/数组长度/桶数设为16
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 新临界值设为16*0.75 = 12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    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[] newTab = (Node[])new Node[newCap];
    
    //新建一个扩容后的空table    
    table = newTab;
    
    //将旧table的所有元素复制到新table中
    if (oldTab != null) {
        ……
    }
    return newTab;
}

总结一下

怎么扩容

默认负载因子是0.75,即当map中的元素个数(全部桶元素的总和)超过容量的75%时,就会进行扩容。且扩容是增加到旧数组的两倍。

(即按照默认情况下数组长度为16为例,当hashMap中的元素超过12个的时候,就会进行扩容:创建一个长度为32的数组,然后再重新计算各个元素在新数组中对应的位置,注意这里的位置确定在JDK8中不需要像JDK7一样重新计算Hash值,再去求余,而是只需要看原来的hash值新增的bit【假设扩容是N位,扩容后就是N+1位了】是0还是1,0则放在原位置,1则放在“原位置+旧容量”处。将旧table的所有元素按照计算得到的下标,复制到新table中)

 

JDK7扩容时可能出现的线程安全的问题

多线程resize时可能形成环形链表,导致下一次读取数据的时候可能出现死循环。

(假设两个线程同时扩容,对一个桶中的链表节点重新计算存放位置,那么肯定就会链表节点指针的转移或断开。假设一条链表有2个节点,第一个节点被第一个线程计算后移动到一个新位置上,还没来得即断开和第二个节点之间的指针此线程就挂起了,第二个线程就把第二个节点同样移动到这个位置上,采用的是头插法插入,这样子第二个节点的next指针指向了第一个节点,第一个节点的next指针指向了第二个节点,形成循环)

4、获取操作

//通过key获取元素值
public V get(Object key) {
    Node e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

//通过key和key的hash值匹配节点
final Node getNode(int hash, Object key) {
    Node[] tab; Node first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //总是先判断第一个节点(通过hash就可以定位到节点所存在的桶了)
        //如果第一个节点就是所要找的节点,直接返回first即可
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //否则,分普通节点和红黑树节点查找匹配
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode)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;
}

使用get方法获取key对应的value时:

  • 同样通过计算获取到key在数组中的索引值
  • 如果此索引值上没有元素,就返回null
  • 如果此索引值上有元素,那么就拿此key的equals方法与此位置的链表上的元素的key依次进行比较。
  • 如果第一个节点不是所要找的,则在判断下一个节点是普通节点还是红黑树节点,不同方法但是原理相似。都是不断比较下去直到最后一个。若都是false,就返回null;若中途返回true,就返回此位置元素对应的value。

5、删除操作

//通过key删除元素(节点),若节点存在返回元素值,不存在返回null
public V remove(Object key) {
    Node e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node[] tab; Node p; int n, index;
    //需要先找到key对应的节点,和上面getNode思路类似
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //如果找到了这个节点,根据普通链表节点和红黑树节点的不同操作删除此节点
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode)node).removeTreeNode(this, tab, movable);
            //如果要删的节点是链表头节点    
            else if (node == p)
                //直接将头节点设置为下一个节点
                tab[index] = node.next;
            else
                //否则,则设置上一个节点的next指针指向被删节点的下一个节点
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

(四)线程安全性

线程不安全

获取线程安全的Map:
Collections.synchronizedMap();

(五)遍历方式

参考:https://www.jianshu.com/p/0a70ce2d3b67

//方法一:通过 Map.keySet 遍历 key(可以获取到value)
for (String key : hashMap.keySet()) {
    System.out.println("Key: " + key + " Value: " + hashMap.get(key));
}

//方法二:通过 Map.values() 遍历value(不能获取到key)
for (String v : hashMap.values()) {
    System.out.println("value:" + v);
}

// 方法三:通过 entrySet 进行遍历,直接遍历出key和value。
//对于 size 比较大的情况下,又需要全部遍历的时候,效率是最高的。
for (Map.Entry entry : entries) {
    System.out.println("testHashMap: key = " + entry.getKey() + ";value = " + entry.getValue());
}

//方法四:通过 Map.entrySet 使用 iterator ,满足一边遍历一边删除的场景。
Iterator iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry entry = (Map.Entry) iterator.next();
    // 可以删除
    iterator.remove();
}

 

 

 

 

 

你可能感兴趣的:(Java容器源码详细解析,hashmap,java)