Java容器框架——Map接口及其实现类

646 阅读10分钟

Map接口

HashMap实现类

这里我们分析HashMap的源码,是Java8中的源码。

实际上我们这里分析HashMap实现类,在之前分析HashSet的时候其底层源码就已经有了一定程度的分析。

因为HashSet的底层使用的就是HashMap

**详见我的另一篇文章 Java容器框架——Set接口及其实现类HashSet

Map接口实现类的特点:

  1. Map中的key和value可以是任何应用类型的数据,用于保存具有映射关系的数据: Key-Value ,会封装到HashMap$Node对象中

  2. Map中的key不允许重复,原因和HashSet一样

    • Map中的所谓的key不允许重复,这里实际上要追溯到HashMap的源码中,涉及到两方面的内容。

      • 什么叫做key重复了,那就是这个key对应的hash值以及equals()方法 。 hash值相同并且equals()为true
  3. Map中的value可以重复

  4. Map中的key可以为null,value也可以为null。 但是key为null只能有一个,value为null可以有多个

  5. key和value之间存在单向一对一的关系,即通过指定的key总能找到对应的value

HashMap底层结构和源码

HashMap中底层核心成员变量

  • transient Node<K,V>[] table;

    • HashMap中使用一个 Node<K,V>[]类型的数组table来存储元素

    • 这里的Node是HashMap中的静态内部类,其成员变量部分源码为:

      • 里面记录了每个节点对应的hash值、对应的key、value以及next指向下一个节点的指针
static class Node<K,V> implements Map.Entry<K,V> {
                    final int hash;
                    final K key;
                    V value;
                    Node<K,V> next;
            }
  • transient int size;

    • 记录map中实际键值对的对数
  • transient Set<Map.Entry<K,V>> entrySet;

    • 保存缓存的entrySet()AbstractMap字段用于keySet()values()

HashMap的put方法

HashMap的put方法底层源码具体请查看HashSet底层源码分析。

但是特别需要注意的是,调用put方法,如果节点已经存在,那么就会用新的value值去替换旧的value值 。 这一点在HashSet中是无法体现的,因为HashSet底层虽然是HashMap,但是实际上它只用到了key,而value直接用了一个静态常量Object PRESENT 给偷鸡了

代码尝试:

可以通过下面的代码打印看到,key为“zhangsan”的,其value已经被替换成了新的value,即5

    package com.nylonmin.mapDemo;
    ​
    import java.util.HashMap;
    import java.util.Iterator;
    import java.util.Map;
    import java.util.Set;
    ​
    /**
     * 需求: 统计每个单词在文档中出现了多少次
     */
    public class MapDemo {
        public static void main(String[] args) {
            Map<String,Integer> map = new HashMap<>();
            map.put("zhangsan",1);
            map.put("lisi",2);
            map.put("wangwu",1);
            map.put("zhangsan",5);
            Set<Map.Entry<String,Integer>> entrySet = map.entrySet();
            Iterator<Map.Entry<String, Integer>> iterator = entrySet.iterator();
            while (iterator.hasNext()) {
                Map.Entry<String, Integer> node =  iterator.next();
                System.out.println(node);
            }
            /**
             * 输出结果:
             * lisi=2
             * zhangsan=5
             * wangwu=1
             */
        }
    }
    ​

这里实际上也就是在HashMapfinal V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) 方法中的某个if语句,具体如下:

 if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;
                }

HashMap的树化改造逻辑

这个树化方法的前提是某条链的长度超过了8

如果当前hashmap底层的数组长度小于 MIN_TREEIFY_CAPACITY(64) 那么就只会扩容,不会树化

如果hashmap底层的数组长度大于等于64 就会进行树化。

//进入这个方法的前提是某条链的长度超过了8
    final void treeifyBin(Node<K,V>[] tab, int hash) {
            int n, index; Node<K,V> e;
            //如果当前hashmap底层的数组长度小于 MIN_TREEIFY_CAPACITY(64) 那么就只会扩容,不会树化
            if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
                resize();
            //如果hashmap底层的数组长度大于等于64  就会进行树化。
            else if ((e = tab[index = (n - 1) & hash]) != null) {
                TreeNode<K,V> hd = null, tl = null;
                do {
                    TreeNode<K,V> p = replacementTreeNode(e, null);
                    if (tl == null)
                        hd = p;
                    else {
                        p.prev = tl;
                        tl.next = p;
                    }
                    tl = p;
                } while ((e = e.next) != null);
                if ((tab[index] = hd) != null)
                    hd.treeify(tab);
            }
        }

HashMap的hash方法的实现

实际上hash方法的实现很简单,但是巧妙之处在于扩容之后,某条链表中部分node的下标改变方式.

  • 这里在分析HashSet的时候实际上也有提到过 。 实际上某个key对应的哈希值是通过两方面的道德

    1. 首先通过调用hashCode()方法,获取到一个哈希值h
    2. 然后将h 右移16位(就是先把h变为二进制,然后右移16位)
    3. 然后将1的结果和2的结果 进行异或操作,得到key对应的最终的哈希值
        static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }

计算出来了key对应的哈希值之后,这个key应该放到哪个下标呢? 仍然还是在putVal()方法中.

计算key应该放的下标的位置,其源码为:

       if ((p = tab[i = (n - 1) & hash]) == null)
          tab[i] = newNode(hash, key, value, null);

只用看这一句 。 n是hashMap底层数组table.length ,这里就是用n-1 和前面 求得到哈希值进行与操作

i = (n - 1) & hash]
扩容后节点索引会变?

好,我们继续拿一条链为例,那么我们为什么说!扩容之后!这条链上的某些节点的下标会变?

针对源码 i = (n - 1) & hash]

  • hash 这个值不会变,因为他就只是一个简单的h = key.hashCode()) ^ (h >>> 16 ,key的值又不会变,那么h就不会变
  • 扩容之后,n-1 变了。 记住,hashMap的扩容机制是扩容为原来的2倍

hashMap它很有一个很巧妙的点在于,根本不需要去再次通过上面这些繁琐的操作去求key对应的新索引,只需要做一个简单的加法。

hashmap扩容机制算下标.png

这意味着什么?!

  1. 我们始终可以发现,不管怎么样,每次都是扩容成2倍,为啥扩容成2倍,扩容2倍就相当于n-1的二进制就只是多了一个1

    • 这啥意思,意思就是只要我原来hash值对应的这一位原来是1,那么它新对应的下标就是 原下标+原数组容量 对应上面hash2的新下标 5+16 = 21

    • 那么又引出了一个新的问题,如果我一开始代码这么写的呢? HashMap<String,Integer> map2 = new HashMap<>(7); 那么第一次扩容是扩容成多少?

      • ▲ 这里要记住HashMap扩容的步骤

        1. 计算当前容量的两倍。
        2. 找到大于或等于这个值的最小 2 的幂。
      • 也就是说 一开始确确实实,容量被初始化为7,但是第一次扩容,会被扩为16

Hashtable实现类

Map接口的另一个实现类——Hashtable,基本介绍

  1. 存放的元素是键值对,即K-V

  2. Hashtable的键和值都不能为null,否则会抛出NullPointerException

  3. Hashtable使用方法基本上和HashMap一样

  4. Hashtable是线程安全的,HashMap是线程不安全的

    • Hashtable的底层相关方法,加上了synchronized关键字,所以说他是线程安全的
    public synchronized V put(K key, V value) {…………}

Hashtable底层扩容机制

首先直接说结论:

Map接口实现类扩容方法名称负载因子无参构造器初始化有参构造器初始化(指定长度)
第一次添加元素后容量后续扩容第一次添加元素后容量后续扩容
HashMapresize()0.7516原容量*2指定长度1. 计算当前容量的两倍。 2. 找到大于或等于这个值的最小 2 的幂。
Hashtablerehash()0.7511原容量*2+1指定长度原容量*2+1

Hashtable的底层扩容机制,其源码为:

最最重要的实际上就一行int newCapacity = (oldCapacity << 1) + 1; 就是前面所说的,将原容量扩充为2倍然后再+1 即得到新的容量。

   protected void rehash() {
            int oldCapacity = table.length;
            Entry<?,?>[] oldMap = table;
    ​
            // overflow-conscious code
            int newCapacity = (oldCapacity << 1) + 1;
        
            ……………………后面的源码就暂时省略
      }

Propertied实现类

Snipaste_2024-11-09_10-47-41.png

  1. Poroperties类继承自Hashtable类,并且实现了Map接口,也是使用一种键值对的形式来保存数据
  2. 使用特点和Hashtable类似,键不能存放null,值也不能存放null
  3. Properties还可以用于从xxx.properties文件中,加载数据到Properties类对象,并进行读取和修改

TreeSet与TreeMap

这类TreeMapTreeSet之所以放在一起进行说明,实际上在于这二者之间的关系就是同HashSetHashMap之间的关系。

TreeSet的底层实际上用的就是TreeMap

这里就直接以讲TreeSet为主,但是要知道,大多数时候,在讲解源码时实际上讲解的是TreeMap的源码

这里直接说明,无论是HashSet还是TreeSet,其底层代码都是直接使用的对应的Map接口的实现类。

所以HashSetTreeSet 都设置了一个静态常量private static final Object PRESENT = new Object();

这玩意儿PRESENT就是用来偷鸡的,因为Value总归要放东西

TreeSet构造方法

一般我们使用的构造方法有两种,一种是无参构造方法,另一种是有参构造方法(这里的有参构造方法中的参实际上是Comparator<? super E> comparator,这里我们姑且称之为比较器)

这里看底层源码就可以明显看到其底层调用的就是对应到TreeMap的构造器

    //无参构造器    
        public TreeSet() {
            this(new TreeMap<E,Object>());
        }
    //有参构造器
        public TreeSet(Comparator<? super E> comparator) {
            this(new TreeMap<>(comparator));
        }

添加元素

同构造方法,TreeSet的add()方法实际上使用的就是TreeMap的put()方法,所以我们直接分析put()方法的源码

▲ 这里首先要明确一个事儿,就是TreeMap的底层数据结构是红黑树,数据结构就不做具体介绍了,反正知道是棵树就完事儿了。

  public V put(K key, V value) {
            Entry<K,V> t = root;
            //如果树的头结点为空,那么就直接插入
            if (t == null) {
                compare(key, key); // type (and possibly null) check
    ​
                root = new Entry<>(key, value, null);
                size = 1;
                modCount++;
                return null;
            }
            int cmp;
            Entry<K,V> parent;
            // split comparator and comparable paths
            //获取比较器
            Comparator<? super K> cpr = comparator;
            //如果比较器不为空,这里说通俗一点就是我们构造器当时调用的是有参构造器
            //传入了Comparator参数
            if (cpr != null) {
                do {
                    parent = t;
                    //调用当时设定的compare方法
                    cmp = cpr.compare(key, t.key);
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else
                        //如果compare的结果是相等,那么用新的值来替换旧的值
                        //但是在TreeSet中,因为值都是PRESENT常量
                        //那么在形式上的体现就是——“新的键无法加入”
                        return t.setValue(value);
                } while (t != null);
            }
            // 如果没有提供比较器,使用键的Comparable接口
            // 下面的整个比较同上面的if中的语句    
            else {
                if (key == null)
                    throw new NullPointerException();
                @SuppressWarnings("unchecked")
                    Comparable<? super K> k = (Comparable<? super K>) key;
                do {
                    parent = t;
                    cmp = k.compareTo(t.key);
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else
                        return t.setValue(value);
                } while (t != null);
            }
            Entry<K,V> e = new Entry<>(key, value, parent);
            if (cmp < 0)
                parent.left = e;
            else
                parent.right = e;
            fixAfterInsertion(e);
            size++;
            modCount++;
            return null;
        }

再着重强调一下上面的源码,用通俗易懂的话来说明:

  1. 如果初始化时调用的是有参构造器,传入了Comparator参数,那么就用传入的这个compare方法

  2. 如果初始化时调用的是无参构造器,那么就会使用key的CompareTo方法 来进行比较

    • 不管使用哪个方法吧,总之,只要比较的结果是相同

      • 那么在TreeSet中体现的结果就是——新的键无法加入
      • TreeMap中体现的结果就是——新的键无法加入,旧的键对应的值更新
    • 实际上,上面的源码也写了,本质上就是更新值,只不过TreeSetvalue永远是常量PRESENT这么个东西。

写个小demo来验证一下就知道了

这里稍微多说一句,实际上这个有参构造器中的Comparator参数,它是一个函数式接口,所以下面的代码我就直接用Lambda表达式来简化了

通过下面代码的输出结果可以看到ffff这个键,根本没有进去

package com.nylonmin.setDemo;
    ​
    import java.util.TreeSet;
    ​
    @SuppressWarnings("all")
    public class TreeSetDemo {
        public static void main(String[] args) {
            TreeSet<String> set = new TreeSet<>((o1,o2)->{
                return o2.length()-o1.length();
            });
            set.add("safd");
            set.add("ad");
            set.add("adddd");
            set.add("ffff");
            for(String s :set){
                System.out.println(s);
            }
            //        输出的结果为
            //        adddd
            //        safd
            //        ad
        }
    }
    ​

再来个TreeMap的小demo。 通过下面的代码输出可以看到,键asdf的值更新成了222

package com.nylonmin.mapDemo;
    ​
    import java.util.Map;
    import java.util.TreeMap;
    ​
    public class TreeMapdemo {
    ​
        public static void main(String[] args) {
            TreeMap<String,Integer> map = new TreeMap<>((o1,o2)->{
                return o1.length() - o2.length();
            });
            map.put("asdf",123);
            map.put("ss",1111);
            map.put("sadss",11);
            map.put("ssss",222);
            for(Map.Entry<String,Integer> entry : map.entrySet()){
                System.out.println(entry.getKey()+"--"+entry.getValue());
            }
    //              输出结果为
    //        ss--1111
    //        asdf--222
    //        sadss--11
        }
    ​
    }
    ​