Java集合——HashMap

1. HashMap概述

  HashMap 是一个存储键值对的集合类,其中的元素是无序的,且没有重复的 key 值;有点类似数学中的函数,x 对应一个 y 值。Java API 中对 HashMap 描述如下:

HashMap是基于哈希表的Map接口实现。此实现提供所有可选的映射操作,并允许空值和空键。 (HashMap类大致相当于Hashtable,除了它是不同步的并且允许空值。)这个类不保证Map的顺序;特别是,它不保证顺序会随着时间的推移保持不变。

  HashMap 底层是哈希表,元素是无序的,允许 key 和 value 为 null 的情况。

假设散列函数在桶之间正确地分散元素,该实现为基本操作(get和put)提供了恒定时间性能。对集合视图的迭代需要与HashMap实例的“容量”(桶的数量)加上其大小(键 - 值映射的数量)成比例的时间。因此,如果迭代性能很重要,则不要将初始容量设置得太高(或负载因子太低)非常重要。
HashMap的一个实例有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中的桶数,初始容量只是创建哈希表时的容量。加载因子是在自动增加容量之前允许哈希表获取的完整程度的度量。当哈希表中的条目数超过加载因子和当前容量的乘积时,哈希表将被重新哈希(即,重建内部数据结构),以便哈希表具有大约两倍的桶数。

  HashMap 的初始容量和负载因子会影响其性能,设置的太大迭代会花费跟多的时间,设置的太小,又可能会频繁扩容;当元素个数超过当前容量*负载因子时,需要扩容,大约为原来的两倍。

作为一般规则,默认加载因子(0.75)在时间和空间成本之间提供了良好的权衡。较高的值会减少空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括get和put)。在设置其初始容量时,应考虑映射中的预期条目数及其加载因子,以便最小化重新散列操作的数量。如果初始容量大于最大条目数除以加载因子,则不会发生重新加载操作。

  负载因子默认情况下是0.75,太大能减少空间开销,但是会增加查找成本。

如果要将多个映射存储在HashMap实例中,则使用足够大的容量创建映射将允许映射更有效地存储,而不是根据需要执行自动重新散列来扩展表。请注意,使用具有相同hashCode()的许多键是减慢任何哈希表性能的可靠方法。为了改善影响,当键是Comparable时,此类可以使用键之间的比较顺序来帮助打破关系。

  在知道容量的情况下,尽量初始化时设置足够的容量,避免扩容影响效率。
  另外,HashMap 不是线程安全的,多线程使用时需要注意;同时,迭代器也是fail-fast的,也就是使用迭代器迭代过程中,不能对其进行修改,否则直接抛出异常。

2. 成员变量

静态成员变量:

// 默认容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

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

/**
 * 负载因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 桶 使用树的阈值,节点超过8个时,有链表改为树结构
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 桶 恢复为链表的阈值
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 容器转化为树的阈值,超过该容量,将桶转化为树,否则继续扩容
 * 至少为 4 * TREEIFY_THRESHOLD,避免扩容和树形结构化之间的冲突
 */
static final int MIN_TREEIFY_CAPACITY = 64;

成员变量:

// 链表数组
transient Node<K,V>[] table;

// 缓存的键值对集合
transient Set<Map.Entry<K,V>> entrySet;

// 键值对元素个数
transient int size;

// 集合修改次数
transient int modCount;

// 扩容容量,capacity * load factor
int threshold;

// 加载因子
final float loadFactor;

3. 构造方法

// 传入初始化容量,加载因子
public HashMap(int initialCapacity, float loadFactor) {
    // 如果初始化容量 < 0 ,抛出异常
    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);
}

  上边的构造方法中,传入的初始化容量,会使用tableSizeFor方法处理,将初始化容量设置为2的幂,为了后边hash时,均匀分布。

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

  上边的方法,通过移位运算,通过不断的无符号右移和原值进行或运算,将二进制从最高位开始,每一位都置为1,也就是2N-1,所以,最后需要再加一,变为2N。上边的过程也很好理解,例如:传入的是9,n=8,转为二进制,000 000 0000 … 1000,只看后4位:

  • 第一次操作之后变为 1100,
  • 然后,右移两位0011,与1100进行或运算,变为1111;

  后边两步此时计算了也不会改变,因此最后结果是16;为什么最后只移动到16位呢?因为我们知道int是32位,即时n=231,经过这几步也会变为 232-1;

其他构造方法:

// 只传入初始化容量
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 无参构造器,均使用默认值
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 初始化传入一个Map
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

4. 主要方法

4.1 添加元素 put(K key, V value)

  HashMap中最核心的就是添加元素方法,涉及到了扩容,Java8 中还有数据接口转换。

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

  添加元素,首先对 key 进行 hash 操作,key 在数组中的索引为(n - 1) & hash(key)

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

  上边的方法中,首先获取 key 的 hashCode 值,然后,高16位和低16位进行异或操作,得到hash值,最后计算索引时,又使用(n-1) & hash;而我们知道 n (HashMap容量)是2的N次方,相当于和长度取模操作,防止索引超过了容量。
  hash计算中,为什么要使用高16位和低16位异或呢?因为元素的 hashCode 低位很多都是相同的,这样在和容量进行取模运算时,可能造成同一个索引元素过多,发生碰撞。因此,使用高位进行异或运算之后,再取模,尽可能使元素均匀分布。
  回到 put 方法,其中主要调用了 putVal 方法:

/*
* onlyIfAbsent 如果为true, 添加的key如果存在,不改变原来的值
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // <1> 如果 table 为null,使用 resize()创建一个 哈希表
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 如果添加到数组中的索引处,节点为null, 直接在该索引位置创建一个新的节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
        
    else {
        Node<K,V> e; K k;
        // 如果索引处,桶的第一个节点 key 和插入节点key相同,获取该节点;后边判断是否需要替换
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        
        // 如果,第一个节点不匹配,并且已经是一个树形结构,添加树节点
        else if (p instanceof TreeNode)
        	// <2> 添加树节点
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        	// 如果是链表结构,遍历链表
            for (int binCount = 0; ; ++binCount) {
            	// 如果遍历到最后,都没有key相同的,在链表末尾添加节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    
                    // <3> 如果超过了树形结构阈值,转换为红黑树
                    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;
            }
        }
        // 如果,上边的过程中找到了key相同的节点
        if (e != null) { // existing mapping for key
            V oldValue = e.value;

			// onlyIfAbsent 为false,即允许替换;或者旧值为null,替换节点处的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
   	// 如果超过了容量,扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

  上边代码展示了HashMap添加元素的基本过程:

  1. 首先,如果哈希表为空,需要创建存储元素的哈希表;
  2. 然后,计算key对应的索引,如果哈希表中该索引位置还未添加元素,直接在哈希表中添加一个节点;
  3. 如果,该索引位置已经有值,需要判断该节点的key是否和插入的key相同,如果相同获取节点;
  4. 如果不同,且桶仍是链表结构,遍历链表,找到key相同的节点,如果没有找到,就在链表末尾添加元素;
  5. 如果该处桶已经是红黑树结构,想红黑树中添加元素。

流程图大致如下:
在这里插入图片描述
(图片来源:Java 8系列之重新认识HashMap)

4.2 扩容操作resize()

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果扩容前容量大于0
    if (oldCap > 0) {
    	// 超过了最大容量,直接返回
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果扩容前容量,超过默认容量16;新容量为原来的2倍;
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 如果扩容前容量大于0,
    // 并且扩容容量threshold > 0(初始化时,为初始化传入的容量计算和的值)
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 新容量等于 扩容容量,即初始化容量
        newCap = oldThr;
    
    // 如果初始化时,没有传入容量,设置为默认容量16
    else {               // zero initial threshold signifies using defaults
        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的值
    threshold = newThr;

	// 新建一个长度为newCap的Node数组
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    
    // table 指向新数组
    table = newTab;

	// 如果原哈希表不为空,将原来的数据复制过来
    if (oldTab != null) {
    	// 遍历原哈希表
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果当前索引位置有节点,需要将节点添加到新哈希表中
            // 首先,获取索引出节点
            if ((e = oldTab[j]) != null) {
            	// 将原哈希表中节点设置为null,方便GC
                oldTab[j] = null;
                // 如果原哈希表中该位置,只有一个节点,直接将该节点重新rehash之后,插入新哈希表
                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;
                        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;
}

  上边扩容的过程,相比 JDK1.7 变化很大, 首先引入了红黑树,扩容时,需要处理红黑树;对于链表的处理也不一样了,但是,只是处理方式不同,最终结果还是相同的。JDK 1.7 中resize() 方法中,计算节点新的索引位置是通过 has & (newCapacity-1) 的方式来计算的,这和我们最开始添加元素时一样,很好理解。
  在上边说过,每次扩容时扩容原来容量的两倍,因此上边操作过程,可以展示如下图:
在这里插入图片描述
  上图展示了容量从16扩容为32的 rehash 过程,索引位置从5变为5或者21(5+16);从图中也可以看出来,之和最高一位有关,如果和最高一位与运算结果为0,那么还是原来位置,如果为1,就是原来的索引加上扩容的长度,即原长度;因此,上边的Java8 中的代码直接使用(e.hash & oldCap) 运算,判断索引是在原来位置,还是需要移动原来的长度。另外上边的代码中没有打乱链表的顺序,避免了原来多线程下出现死循环的问题;但是HashMap 仍是线程不安全的,多线程下建议使用ConcurrentHashMap。

4.3 获取元素

public V get(Object key) {
    Node<K,V> e;
    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;

	// 如果,哈希表table为空,直接返回null;如果key对应的索引处为空,也直接返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        
        // 如果索引处第一个节点匹配,直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 否则遍历链表查询
        if ((e = first.next) != null) {
        	// 如果是树结构,查询树节点
            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);
        }
    }
    return null;
}

  上边的get(Key key) 方法,主要思路,在哈希表不为空的前提下,首先对 key 进行 hash 操作,然后根据 hash 值获取对应哈希表的索引,该过程和 put 方法中相同;找到哈希表中的索引后,先看第一个节点是否匹配,不匹配的话就开始遍历链表,需要注意链表已经转换为树的情况。

4.4 判断元素是否存在

判断key是否存在:

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}

  判断key是否存在,比较方便,直接通过key查询,如果查到元素就说明key是存在的。
判断value 是否存在:

public boolean containsValue(Object value) {
    Node<K,V>[] tab; V v;
    if ((tab = table) != null && size > 0) {
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next) {
                if ((v = e.value) == value ||
                    (value != null && value.equals(v)))
                    return true;
            }
        }
    }
    return false;
}

  判断是否包含 value 需要依次遍历哈希表的各个节点比对,相对而言比较麻烦,不过一般也很少使用。

4.5 遍历HashMap

  之前遍历HashMap我们都是使用EntrySet,Java8 中新增了一个foreach方法,可以直接使用该方法遍历HashMap。

public void forEach(BiConsumer<? super K, ? super V> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
        int mc = modCount;
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e.key, e.value);
        }
        // 遍历过程中修改,会抛出异常
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }
}

使用示例:

public static void main(String[] args) {
    HashMap<String, Object> hashMap = createHashMap();
    hashMap.forEach((key, value) -> {
        System.out.println(key+":"+value);
    });
}

总结

  HashMap 是一个存放键值对的集合,其中的元素是无序的,允许key 和 value 为null的情况,但是不存在重复的 key。HashMap 的容量都是2的次幂,为了是元素更加均匀的分布,另外,每次扩容时都是扩容为原来的2倍。Java8 中引入了红黑树数据结构,优化了 HashMap 的效率,解决了多线程扩容可能出现环的问题,但是 HashMap 仍然是线程不安全的,需要保证线程安全的情况下建议使用ConcurrentHashMap。

  HashMap 细节还有很多,暂时整理到这里,文中如有错误请大家指正。

参考:

美团技术网:Java 8系列之重新认识HashMap

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
VR(Virtual Reality)即虚拟现实,是一种可以创建和体验虚拟世界的计算机技术。它利用计算机生成一种模拟环境,是一种多源信息融合的、交互式的三维动态视景和实体行为的系统仿真,使用户沉浸到该环境中。VR技术通过模拟人的视觉、听觉、触觉等感觉器官功能,使人能够沉浸在计算机生成的虚拟境界中,并能够通过语言、手势等自然的方式与之进行实时交互,创建了一种适人化的多维信息空间。 VR技术具有以下主要特点: 沉浸感:用户感到作为主角存在于模拟环境中的真实程度。理想的模拟环境应该使用户难以分辨真假,使用户全身心地投入到计算机创建的三维虚拟环境中,该环境中的一切看上去是真的,听上去是真的,动起来是真的,甚至闻起来、尝起来等一切感觉都是真的,如同在现实世界中的感觉一样。 交互性:用户对模拟环境内物体的可操作程度和从环境得到反馈的自然程度(包括实时性)。例如,用户可以用手去直接抓取模拟环境中虚拟的物体,这时手有握着东西的感觉,并可以感觉物体的重量,视野中被抓的物体也能立刻随着手的移动而移动。 构想性:也称想象性,指用户沉浸在多维信息空间中,依靠自己的感知和认知能力获取知识,发挥主观能动性,寻求解答,形成新的概念。此概念不仅是指观念上或语言上的创意,而且可以是指对某些客观存在事物的创造性设想和安排。 VR技术可以应用于各个领域,如游戏、娱乐、教育、医疗、军事、房地产、工业仿真等。随着VR技术的不断发展,它正在改变人们的生活和工作方式,为人们带来全新的体验。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值