Java集合之Map

465 阅读10分钟

HashMap

1、Hash表的结构

  • HashMap可以看作是【数组(Node[] table)和链表】结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;
  • 哈希值【相同的】键值对,则以链表形式存储
  • 如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),并且哈希表中的节点数量大于64(MIN_TREEIFY_CAPACITY = 64),链表就会被改造为【树结构】。

/**
 * The bin count threshold for using a tree rather than list for a
 * bin.  Bins are converted to trees when adding an element to a
 * bin with at least this many nodes. The value must be greater
 * than 2 and should be at least 8 to mesh with assumptions in
 * tree removal about conversion back to plain bins upon
 * shrinkage.
 */
static final int TREEIFY_THRESHOLD = 8;


/**
 * The smallest table capacity for which bins may be treeified.
 * (Otherwise the table is resized if too many nodes in a bin.)
 * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
 * between resizing and treeification thresholds.
 */
static final int MIN_TREEIFY_CAPACITY = 64;


/**
 * Replaces all linked nodes in bin at index for given hash unless
 * table is too small, in which case resizes instead.
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; 
    Node<K,V> e;
    // 1、初始化时,数组为空,需要resize();
    // 2、如果数组的长度 小于 64,则不进行[树化],而是对map进行resize()
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 省略代码
   }
}


final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        // 当一个哈希下标的链表数量大于等于7,转变为树
                        treeifyBin(tab, hash);
                    break;
                }
                // 省略部分代码
            }
        }
        // 省略部分代码
    }
    // 省略部分代码
    return null;

2、HashMap初始化

如果使用【默认无参构造函数】,只会对loadFactor赋默认值 【0.75】;

并没有对 Node<K,V>[] table 初始化,所以是【lazy-load形式】进行加载;

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;


/**
 * The load factor for the hash table.
 */
final float loadFactor;


/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;


/**
 * The next size value at which to resize (capacity * load factor).
 */
int threshold;


public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;    

3、put(K key, V value)源码分析

方法间的依赖关系

putVal方法处理流程

/**
 * Table值在首次使用才初始化,总是分配长度为2的幂数。
 */
transient Node<K,V>[] table;


/**
 * The bin count threshold for using a tree rather than list for a
 * bin.  Bins are converted to trees when adding an element to a
 * bin with at least this many nodes. The value must be greater
 * than 2 and should be at least 8 to mesh with assumptions in
 * tree removal about conversion back to plain bins upon
 * shrinkage.
 */
static final int TREEIFY_THRESHOLD = 8;


/**
 * 如果Key已存在,返回put前Key的Value,否则返回null
 */
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}


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


final V putVal(int hash, 
               K key, 
               V value, 
               boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 第一次调用putVal方法,table为空,此时调用resize方法初始化数组
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果hash值对应的数组下标存放的值为null,则newNode并给下标赋值
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // 用来记录key对应的节点是否存在
        Node<K,V> e; 
        K k;
        // 如果hash和key与数组下标对应的第一个节点相同,说明节点已存在
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果节点是TreeNode(红黑树)
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        // 下标对应的链表长度大于等于 7,树化
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key 
                          || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        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

源码分析:

  1. 调用hash(Object key)方法;

并没有直接使用 key 本身的 hashCode,而是来自于 HashMap 内部的另外一个 hash 方法。**取【高位运算】**因为有些数据计算出的【哈希值差异主要在高位】,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效【避免哈希碰撞】。

注意:这里有的面试官可能会问为什么取高位做哈希

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  1. 如果哈希表数组【Node<K, V>[] table】是 null,resize() 方法会负责初始化它,这从 【tab = resize()】 可以看出。

  2. resize 方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)。

  3. 在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。

    /**

    • threshold 默认为 0.75 * 16,最大值为 Integer.MAX_VALUE */ if (++size > threshold) resize();

4、resize()源码分析

  • threshold值等于(负载因子)x(容量),如果构建 HashMap 的时候没有指定它们,那么就是依据相应的默认常量值。

  • threshold值通常是【以倍数进行调整 (newThr = oldThr << 1)】,根据 putVal 中的逻辑,当元素个数超过门限大小时,则调整 Map 大小。

  • 扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。

    /**

    • The maximum capacity, used if a higher value is implicitly specified

    • by either of the constructors with arguments.

    • MUST be a power of two <= 1<<30. */ static final int MAXIMUM_CAPACITY = 1 << 30;

    /**

    • The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

    /**

    • The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.7

    /**

    • The table, initialized on first use, and resized as

    • necessary. When allocated, length is always a power of two.

    • 第一次使用初始化,必须调用resize(),当重新分配时,长度是2的幂数 */ transient Node<K,V>[] table;

    final Node<K,V>[] resize() { // 这里也会判断 table 是否为null Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // oldCap大于0,说明已经初始化过 if (oldCap > 0) { // 如果已经>=2的30次方,不做处理 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 扩容2倍后还小于[2的30次方],并且 oldCap >= 16, 进行2倍扩容 newThr = oldThr << 1; } else if (oldThr > 0) { newCap = oldThr; } else { // 初始化默认容量为 16,扩容的阈值为12 = 0.75 * 16 newCap = DEFAULT_INITIAL_CAPACITY; 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; // ...省略代码 return newTab

5、为什么loadFactor值默认是0.75

来自HashMap源码说明:

as a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

  • 通常,默认负载因子(.75)在【时间和空间成本】之间提供了很好的折衷。

  • 较高的值会减少空间开销,但会增加查找成本(在HashMap类的大多数操作中都得到体现,包括get和put)。

  • 设置其初始容量时,应考虑映射中的预期条目数及其负载因子,以最大程度地减少重新哈希操作的次数

  • 如果初始容量大于【最大条目数】除以【负载因子】,则将不会发生任何哈希操作。

6、为什么要进行树化

本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,链表查询是线性的,会严重影响存取的性能

而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击

7、如何让HashMap编程线程安全

可以调用 Collections 工具类提供的包装方法,来获取一个同步的包装容器(如 Collections.synchronizedMap)方法,对所有**【方法内部】synchronized mutex 互斥** 保证线程安全,【在高并发情况下】,性能比较低。

8、为什么需要加载因子?

HashMap的底层是哈希表,是存储键值对的结构类型,它需要通过一定的计算才可以确定数据在哈希表中的存储位置:

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


public int hashCode() {


    int h = 0;
    Iterator<Entry<K,V>> i = entrySet().iterator();
    while (i.hasNext())
        h += i.next().hashCode();
    return h;

HashMap是一个插入慢、查询快的数据结构。但这种数据结构容易产生两种问题:

① 如果空间利用率高,那么经过的哈希算法计算存储位置的时候,会发现很多存储位置已经有数据了(哈希冲突);

② 如果为了避免发生哈希冲突,增大数组容量,就会导致空间利用率不高。

而加载因子就是表示Hash表中元素的填满程度。

  • >加载因子 = 填入表中的元素个数 / 散列表的长度
  • 加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
  • 加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
  • 冲突的机会越大,说明需要查找的数据还需要通过另一个途径查找,这样查找的成本就越高。因此,必须在“冲突的机会”与“空间利用率”之间,寻找一种平衡与折衷。

散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快地定位。

实际工作中需视不同的情况采用不同的哈希函数,通常考虑的因素有:

· 计算哈希函数所需时间;

· 关键字的长度;

· 哈希表的大小;

· 关键字的分布情况;

· 记录的查找频率;

  1. 直接寻址法:取关键字或关键字的某个线性函数值为散列地址。即H(key)=key或H(key) = a·key + b,其中a和b为常数(这种散列函数叫做自身函数)。若其中H(key)中已经有值了,就往下一个找,直到H(key)中没有值了,就放进去。

  2. 数字分析法:分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。

  3. 平方取中法:当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。

HashMap、HashTable、TreeMap区别

Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以【键值对】的形式存储和操作数据的容器类型。

Hashtable 是早期 Java 类库提供的一个【哈希表】实现,本身是【同步的(方法使用synchronized)修饰】,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。

HashMap 是应用更加广泛的【哈希表】实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能。

TreeMap 则是【基于红黑树】的一种提供【顺序访问】的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(log(n))的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。

线程安全

Key能否为nll

Value能否为null

是否有序

时间复杂度

HashTable

不能

不能

O(1)

HashMap

O(1)

TreeMap

O(log(n))