Map&Set的理解

Set子接口

  • 特点:无序、无下标、元素不可重复。
  • 方法:全部继承自Collection中的方法。

Set实现类

  • HashSet:
    • 基于HashCode实现了不重复。
    • 当存入元素的哈希码相同时,会调用equals进行确认,如结果为true,则拒绝后者存入。
  • TreeSet:
    • 基于排列顺序实现元素不重复。
    • 实现了SortedSet接口,对集合元素自动排序。
    • 元素对象的类型必须实Comparable接口,指定排序规则。
    • 通过CompareTo方法确定是否为重复元素。

HashSet分析

  • 无序性
    • 不等于随机性.存储的数据在底层数组中并非按照数组索引的顺序添加,而是根据数据的哈希值存放
  • 不可重复性
    • 保证添加的元素按照equals()判断时,不能返回true.(即相同元素只能添加一个.)
# 添加元素的过程
	向HashSet添加元素a,首先调用元素a所在类的hashCode()方法,计算元素a的哈希值,
	此哈希值接着通过某种算法计算出在HashSet底层数组中的存放位置,判断数组此位置上是否已经有元素:
		如果此位置上没有其他元素,则元素a添加成功.  ---> 情况1
		如果此位置上有其他元素b(或以链表形式存在的多个元素),则比较元素a与元素b的hash值:
			如果hash值不相同,则元素a添加成功.  ---> 情况2
			如果hash值相同,进而需要调用元素a所在类的equals()方法:
				equals()返回true,元素a添加失败
				equals()返回false,则元素a添加成功.  --->情况2

-	对于添加成功的情况2和情况3而言:元素a与已存在指定索引位置上数据以链表的方式存储.
	jdk 7: 元素a放到数组中,指向原来的元素.
	jdk 8: 原来的元素在数组中,指向新放入的元素a

Map父接口

  • 特点:存储一对数据(Key-Value),无序,无下标,键不可重复,值可重复。
  • 方法:
    • V put(K key,V value) //将对象存入到集合中,关联键值。key重复则覆盖原值。
    • Object get(Object key) //根据键获取对应的值。
    • Set keySet() //返回所有key
    • Collection values() //返回包含所有值的Collection集合
    • Set<Map.Entry<K,V>> entrySet() //键值匹配的Set集合。

Map集合的实现类

  • HashMap:
    • JDK1.2版本,线程不安全,运行效率快;允许用null 作为key或是value。
  • Hashtable:
    • JDK1.0版本,线程安全,运行效率慢;不允许null作为key或是value。

Map实现的底层原理:

Map是个链表数组,数组中的每个元素都是一个Map.Entry对象,同时Entry对象是个链表的节点,在这个链表的节点上可以通过next指向他的下一个节点。

1.7的结构如下:

Map实现类的结构:

# 1 Map实现类的结构
- |---Map:存储键值对的数据  ---类似于高中的函数 y=f(x)
	|---HashMap:作为Map的主要实现类;线程不安全 但是效率高;存储null的k和v
		|---LinkedHashMap:保证在遍历map元素时,可以按照添加的顺序实现遍历.
			原因:在原有的HashMap底层结构基础上,添加了一对指针,指向前一个元素和后一个元素
	|---TreeMap:保证按照添加的k-v对进行排序,实现排序遍历.此时考虑key的自然排序或定制排序
		底层使用红黑树
	|---HashTable:作为古老的实现类:线程安全,效率低;不能存储null的key和value
		|---Properties:常用来处理配置文件.key和value都是String类型
		
* HashMap的底层: 数组+链表(jdk 7 之前)
			  数组+链表+红黑树 (jdk 8)
			  
# 2 Map结构的理解
	Map中的key : 无序的、不可重复的,使用Set存储所有的key
		---> 当使用hasnMap时key所在的类要重写equals()和hashCode()
	Map中的value:无序的、不可重复的、使用Collection存储所有的value
	一个键值对:key-value构成了一个Entry对象
	Map中的entry : 无序的、不可重复的,使用Set存储所有的entry
# 3 HashMap的底层实现原理
	在实例化以后,底层创建了长度是16的一维数组Entry[] table.
	...执行多次put后...
	map.put(key1,value1);
	1). 首先调用key1所在类的hashCode() 计算key哈希值,此哈希值经过某种算法计算以后,得到在Entry数组中的存放位置.
	2). 如果此位置上的数据为空,此时的key1-value1添加成功 ---> 情况一
		如果此位置上的数据不为空,(意味着此位置上存在一个或多个数据(以链表形式存在)),比较key1和已经存在的一个或多个数据的哈希值:
			3). 如果key1的哈希值与已经存在的数据的哈希值都不相同,此时key1-value1添加成功 ---> 情况2
				如果key1的哈希值和已经存在的某一个数据(key2-value2)的哈希值相同,继续比较,调用key1所在类的equals(key2)方法:
					如果equals()返回false:此时key1-value1添加成功 --->情况3
					如果equals()返回true:使用value1替换value2.并且return value2

- 补充:关于情况2和情况3:此时key1-value1和原来的数据以链表的方式存储.
- 在不断额添加过程中,会涉及到扩容问题,当超出临界值(平衡因子 * Entry[]的长度)并且将要放入的元素非空时扩容,扩容后需要重新计算hash,并重新摆放Entry的位置
	默认的扩容方式:扩容为原来的2倍,并将原有的数据复制过来.

# 4 jdk8 相较于jdk7在底层实现方面的不同:
	1. new HashMap():底层没有创建一个长度为16的数组(与ArrayList相似)
	2. jdk8底层的数组是:Node[],而非Entry[]
	3. 首次调用put() 方法时,底层创建长度为16的数组
	4. jdk7 底层结构只有:数组+链表.jdk8中底层结构:数组+链表+红黑树.
		当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64 时,
		此时索引位置上的所有数据改为使用红黑树存储.
> jdk7中createEntry的过程(往链表插入节点的方式)
	采用头插法,即将新元素放到数组头上,然后旧的链表连到新元素后面
> jdk8中createEntry的过程
>	采用尾插法,即将新元素放到数组列表的后面,因为引入红黑树之后,就需要判断单链表的节点个数(超过8个后要转成红黑树),所以干脆使用尾插法,正好遍历单链表,读取节点个数.也正是因为尾插法,使得HashMap在插入节点时,可以判断是否有重复节点.

put源码分析

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab;  //引用当前hashMap的散列表
        Node<K,V> p;  //表示当前散列表的元素
        int n, i;  //n: 当前散列表的长度  i: 寻址结果
        
        //延迟初始化逻辑,第一次调用putVAl时会初始化hashMap对象中的最耗费内存的散列表
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        
        //情况1: 寻址到的位置刚好是null,这个时候,直接put
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            //e: 不为null的话,找到了一个与当前要插入的key-value一致的key的元素  k: 临时的key
            Node<K,V> e; K k;
            
            //表示桶位中的该元素,与你当前插入的元素的key完全一致,表示后续需要进行替换操作
            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 {
                //链表的情况,而且链表的头元素与我们要插入的key不一致
                //binCount记录链表的长度
                for (int binCount = 0; ; ++binCount) {
                    //开始遍历链表,并且没找到一个与要插入的key一致的node
                    if ((e = p.next) == null) {
                        //链表尾插节点
                        p.next = newNode(hash, key, value, null);
                        //如果说链表长度超过了阈值,就变成红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    
                    //找到了相同的key的node元素,需要进行替换操作
                    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;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //modCount: 表示散列表结构被修改的次数,替换Node元素的value不算
        ++modCount;
        //size自增,并判断是不是需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

resize源码分析

final Node<K,V>[] resize() {
    //oldTab: 引用扩容前的哈希表
    Node<K,V>[] oldTab = table;
    //oldCap: 扩容前数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //oldThr: 扩容前的阈值
    int oldThr = threshold;
    //扩容之后的数组长度和阈值
    int newCap, newThr = 0;
    
    //如果散列表已经初始化过了,正常扩容
    if (oldCap > 0) {
        
        //如果已经达到了散列表的最大长度将不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            //将阈值设为最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        //新的最大值为旧的最大值翻倍
        //16 = 0000 1000  左移一位后  0001 0000 = 32
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)  //如果说你设置的初始大小小于了默认大小16,将不会进行扩大阈值
            newThr = oldThr << 1; // double threshold
    }
    
    //散列表未初始化的情况  oldCap == 0  说明hashMap中的散列表是null
    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);
    }
    
    //newThr为0时,计算出一个newThr
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    //根据之前计算的cap,创造出一个更大的数组
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //如果扩容之前有数据
    if (oldTab != null) {
        
        //遍历散列表
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;  //当前node节点
            
            //如果说这个桶位有数据(可能是单个节点,也可能是链表或者是红黑树)
            if ((e = oldTab[j]) != null) {
                //将旧的桶位置空,方便GC回收内存
                oldTab[j] = null;
                
                //1. 单个节点的情况
                if (e.next == null)
                    //重新计算hash值,并放入该位置
                    newTab[e.hash & (newCap - 1)] = e;
                
                //2. 红黑树的情况
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                
                //3. 链表的情况
                /**
                *	以hash值为15的桶为例,扩容前的hash值为: 1111
                *	扩容后的hash值可能为: 01111  或者  11111 两种情况
                */
                else { // preserve order
                    
                    //低位链表: 存放在扩容之后的数组的下标位置,与当前数组的下标位置相同
                    //例如 01111 存放在原位置
                    Node<K,V> loHead = null, loTail = null;
                    //高位链表: 存放在扩容之后的数组的下标位置为: 当前数组下标的位置 + oldCap
                    //  11111  存放在 31号桶
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //hash-> ....1 1111 & 1 0000 = 1
                        //hash-> ....0 1111 & 0 0000 = 0
                        if ((e.hash & oldCap) == 0) {
                            //低位链表初始化
                            if (loTail == null)
                                loHead = e;
                            //低位链表添加next节点
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            //高位链表初始化
                            if (hiTail == null)
                                hiHead = e;
                            //高位链表添加next节点
                            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;
                        //放到当前数组下标的位置 + oldCap
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

get源码分析

final Node<K,V> getNode(int hash, Object key) {
    //tab: 引用当前hashMap的散列表
    Node<K,V>[] tab;
    //first: 桶位中的头元素  e: 临时node节点
    Node<K,V> first, e;
    //n: 散列表长度
    int n; K k;
    
    //如果说当前散列表不为空,并且当前桶有数据
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        
        //1. 头节点为要查找的元素,则直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        
        //如果说当前桶位不止一个元素
        if ((e = first.next) != null) {
            //2. 如果说当前桶位是树的情况
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            
            //3. 如果说当前桶位形成了链表就遍历查找元素
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

remove源码分析

/**
 * Implements Map.remove and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to match if matchValue, else ignored
 * @param matchValue if true only remove if value is equal
 * @param movable if false do not move other nodes while removing
 * @return the node, or null if none
 */
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    //tab: 引用当前hashMap中的散列表
    //p: 当前node元素
    //index: 寻址结果
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    
    //当前散列表不为空,并且所要删除元素的桶位也不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        //node: 查找到的结果  e: 当前node的下一个元素
        Node<K,V> node = null, e; K k; V v;
        
        //1. 找到了匹配的元素
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        
        //如果说当前桶位不止一个元素
        else if ((e = p.next) != null) {
            //2. 桶位是树的情况
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            //3. 桶位是链表的情况
            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);
            }
        }
        
        //如果node不为空的话,说明按照key查找到需要删除的数据了
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            //如果是树的情况,执行树节点的移除
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            
            //当前桶位上的头元素为需要删除的节点,将头结点的下一个节点设为头元素
            else if (node == p)
                tab[index] = node.next;
            //链表的情况,链表删除该节点
            else
                p.next = node.next;
            
            ++modCount;
            --size;
            afterNodeRemoval(node);
            
            //返回被删除的元素
            return node;
        }
    }
    return null;
}

相关问题

Q0:HashMap是如何定位下标的?
A:先获取Key,然后对Key进行hash,获取一个hash值,然后用hash值对HashMap的容量进行取余(实际上不是真的取余,而是使用按位与操作,原因参考Q6),最后得到下标。

Q1:HashMap由什么组成?
A:数组+单链表,jdk1.8以后又加了红黑树当链表节点个数超过8个(m默认值)并且容量大于64以后,开始使用红黑树,使用红黑树一个综合取优的选择,相对于其他数据结构,红黑树的查询和插入效率都比较高。而当红黑树的节点个数小于6个(默认值)以后,又开始使用链表

这两个阈值为什么不相同呢?

主要是为了防止出现节点个数频繁在一个相同的数值来回切换,举个极端例子,现在单链表的节点个数是9,开始变成红黑树,然后红黑树节点个数又变成8,就又得变成单链表,然后节点个数又变成9,就又得变成红黑树,这样的情况消耗严重浪费,因此干脆错开两个阈值的大小,使得变成红黑树后“不那么容易”就需要变回单链表,同样,使得变成单链表后,“不那么容易”就需要变回红黑树。

Q2:Java的HashMap为什么不用取余的方式存储数据?
A:实际上HashMap的indexFor方法用的是跟HashMap的容量-1做按位与操作,而不是%求余。(这里有个硬性要求,容量必须是2的指数倍,原因参考Q6)

Q3:HashMap往链表里插入节点的方式?
A:jdk1.7以前是头插法,jdk1.8以后是尾插法,因为引入红黑树之后,就需要判断单链表的节点个数(超过8个后要转换成红黑树),所以干脆使用尾插法,正好遍历单链表,读取节点个数。也正是因为尾插法,使得HashMap在插入节点时,可以判断是否有重复节点。

Q4:HashMap默认容量和负载因子的大小是多少?
A:jdk1.7以前默认容量是16,负载因子是0.75。

Q5:HashMap初始化时,如果指定容量大小为10,那么实际大小是多少?
A:16,因为HashMap的初始化函数中规定容量大小要是2的指数倍,即2,4,8,16,所以当指定容量为10时,实际容量为16。

源码如下:

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

☆☆☆☆Q6:容量大小为什么要取2的指数倍?
A:两个原因:1,提升计算效率:因为2的指数倍的二进制都是只有一个1,而2的指数倍-1的二进制就都是左全0右全1。那么跟(2^n - 1)做按位与运算的话,得到的值就一定在【0,(2^n - 1)】区间内,这样的数就刚合适可以用来作为哈希表的容量大小,因为往哈希表里插入数据,就是要对其容量大小取余,从而得到下标。所以用2^n做为容量大小的话,就可以用按位与操作替代取余操作,提升计算效率。2.便于动态扩容后的重新计算哈希位置时能均匀分布元素:因为动态扩容仍然是按照2的指数倍,所以按位与操作的值的变化就是二进制高位+1,比如16扩容到32,二进制变化就是从0000 1111(即15)到0001 1111(即31),那么这种变化就会使得需要扩容的元素的哈希值重新按位与操作之后所得的下标值要么不变,要么+16(即挪动扩容后容量的一半的位置),这样就能使得原本在同一个链表上的元素均匀(相隔扩容后的容量的一半)分布到新的哈希表中。(注意:原因2(也可以理解成优点2),在jdk1.8之后才被发现并使用)

Q7:HashMap满足扩容条件的大小(即扩容阈值)怎么计算?
A:扩容阈值=min(容量负载因子,MAXIMUM_CAPACITY+1),MAXIMUM_CAPACITY非常大,所以一般都是取(容量负载因子)

Q8:HashMap是否支持元素为null?
A:支持。

☆☆☆Q9:HashMap的 hash(Obeject k)方法中为什么在调用 k.hashCode()方法获得hash值后,为什么不直接对这个hash进行取余,而是还要将hash值进行右移和异或运算?

源码如下:

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

A:如果HashMap容量比较小而hash值比较大的时候,哈希冲突就容易变多。基于HashMap的indexFor底层设计,假设容量为16,那么就要对二进制0000 1111(即15)进行按位与操作,那么hash值的二进制的高28位无论是多少,都没意义,因为都会被0&,变成0。所以哈希冲突容易变多。那么hash(Obeject key)方法中在调用 k.hashCode()方法获得hash值后,进行的一步运算:(h = key.hashCode()) ^ (h >>> 16)有什么用呢?

首先,(h = key.hashCode()) ^ (h >>> 16)是将h的二进制中高位右移变成低位。其次异或运算是利用了特性:同0异1原则,尽可能的使得key.hashCode()) ^ (h >>> 16)在将来做取余(按位与操作方式)时都参与到运算中去。综上,简单来说,通过key.hashCode()) ^ (h >>> 16);运算,可以使k.hashCode()方法获得的hash值的二进制中高位尽可能多地参与按位与操作,从而减少哈希冲突。

Q10:哈希值相同,对象一定相同吗?对象相同,哈希值一定相同吗?
A:不一定。一定。

Q11:HashMap的扩容与插入元素的顺序关系?
A:jdk1.7以前是先扩容再插入,jdk1.8以后是先插入再扩容。

Q12:HashMap扩容的原因?
A:提升HashMap的get、put等方法的效率,因为如果不扩容,链表就会越来越长,导致插入和查询效率都会变低。

Q13:jdk1.8引入红黑树后,如果单链表节点个数超过8个,是否一定会树化?
A:不一定,它会先去判断是否需要扩容(即判断当前节点个数是否大于扩容的阈值),如果满足扩容条件,直接扩容,不会树化,因为扩容不仅能增加容量,还能缩短单链表的节点数,一举两得。

posted @ 2021-02-22 18:36  longda666  阅读(147)  评论(0)    收藏  举报