`
qindongliang1922
  • 浏览: 2184450 次
  • 性别: Icon_minigender_1
  • 来自: 北京
博客专栏
7265517b-f87e-3137-b62c-5c6e30e26109
证道Lucene4
浏览量:117546
097be4a0-491e-39c0-89ff-3456fadf8262
证道Hadoop
浏览量:125934
41c37529-f6d8-32e4-8563-3b42b2712a50
证道shell编程
浏览量:59928
43832365-bc15-3f5d-b3cd-c9161722a70c
ELK修真
浏览量:71305
社区版块
存档分类
最新评论

JDK8中HashMap的工作原理剖析

    博客分类:
  • JAVA
jdk 
阅读更多

在Java语言里,HashMap无疑是使用频率非常高的一个类,了解它的内部实现将有助于更好的使用它。


在jdk8中的HashMap是由三种数据结构组成:数组 + ( 链表 or 红黑树 )

图示如下:






而在jdk8之前还只是数组+链表两种数据结构,在这里简单提下数组和链表的区别:


数组

优点:物理地址连续+按下标随机访问效率高O(1)

缺点:插入,删除效率低,

链表

优点:存储地址不连续,可灵活的扩展自己的长度,插入,删除效率高

缺点:访问效率低O(n)



而哈希表(Hash类数据结构)正是结合了两者的优点,而衍生出来的的一种高效的数据存储结构,本质上是采用空间换时间的方式,提高了读写的效率。

HashMap的继承结构如下:
`````
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
`````


这里我们能发现HashMap中K,V都是泛型的,所以可以支持任何类型作为key或者value,但实际开发中用的最多的都是以String类型的字符串作为key。


在这里泛型Key的hashCode和equals方法非常重要,因为它们会影响HashMap存储的数据分布和读写是否正确


上面说过HashMap可以看成是一个大的数组,然后每个数组元素的类型是Node类型,源码里定义如下:
````
    transient Node<K,V>[] table;
````
注意Node类还有两个子类:TreeNode和Entry
````
TreeNode<K,V> extends Entry<K,V> extends Node<K,V>
````


上图中的链表就是Node类,而红黑树正是TreeNode类


接着我们来看下HashMap的一些成员变量:

````
     //默认table数组buckets的数目,必须是2的平方,默认值是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

    //默认table最大的长度约10亿多(1073741824)最大的buckets数目
    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;

    //当数组的长度(注意不是map的size而是table.length)大于64的时候,
    //会对单个桶里大于8的链表进行树化
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    //用来实现遍历map的set,依次遍历table中所有桶中的node或者treeNode
    transient Set<Map.Entry<K,V>> entrySet;

    //当前存储的实际数据量=map.size而不是table.length
    transient int size;

    //修改次数,用来判断是否该map同时被多个线程操作,
    //多线程环境下会抛出异常ConcurrentModificationException
    transient int modCount;

    //当前数组中的阈值 table.length * loadFactor,如果超
    //过这个阈值,就要进行扩容 (resize)
    int threshold;

    //负载因子
    final float loadFactor;
    
    
````



成员变量主要两部分组成,一部分是处理化时候的常量,一部分是变量会在运行时改变,这里还需要注意的是HashMap本身不是线程安全的,所以尽量避免在多线程环境下使用,如果非要使用,就用线程安全的Map,如下:
````
 ` (1)Map m = Collections.synchronizedMap(new HashMap(...)) 
  (2)ConcurrentHashMap map=new ConcurrentHashMap();
````


此外,HashMap还有几个构造方法:

````
    `   //1
 `   public HashMap() { 
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
    }
    
    
    //2
     public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
     
     //3
        public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
    
    //4
        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;
        this.threshold = tableSizeFor(initialCapacity);
    }
    
````



(1)第一个是默认的构造方法,我们看到只对影响扩容负载因子做了初始化,默认是0.75

(2)第二个和第四个其实是一个方法逻辑,可以传入指定的table数组的大小,负载因子用默认的。

(3)第三个可以传入一个Map集合直接赋值给该map,里面用了putMapEntries方法,这个方法可以理解为迭代传入的Map然后将数据赋值给new的Map

(4)第四个是同时指定初始化table数组的大小和负载因子,中间有一些逻辑判断,这里需要提下tableSizeFor这个方法:

源码如下:

````
    /**
     * Returns a power of two size for the given target capacity.
     */
    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;
    }
````


这个方法,保证指定的设置的table数组的长度必须是2的n次方,比如你初始化传入的是5,但是实际运行后你会发现table数组的长度是8,这是因为2的n次方,对于数组的扩容和重新赋值有比较大的好处,所以如果你传入的不是2的n次方,那么经过这个方法出来的值一般都是大于你传入的参数最接近的2的n次方。



下面我们看下HashMap是如何存数据的?

源码中的put方法如下:

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



这里我们看到put方法里面调用了hash方法,来得到该key的hashCode,那么我们来看下其内部是如何实现的:
````
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
````
这里能看到hash方法并不是直接取的hashCode值,而是用hashCode()的高16位异或低16位实现的,这么做可以保证高低bit位都能参与到Hash的计算中,一句话就是为了减少hash冲突的几率。


然后在putVal方法中,实现数据的插入操作,注意数组的下标的计算方式是:
````
i = (table.length - 1) & hash
````
等价于使用转化后hash值对数组长度取模
````
h % table.length
````
但是位运算比模运算效率更高


在putVal插入数据的方法中,第一次会调用扩容方法,此外插入时还会判断该节点是链表还是红黑树,他们分别对应不同的赋值方法,并且如果单个bucket的节点数量大于8,还会将链表转化为红黑树,在插入完成后还会继续判断下一次是否需要扩容。

这里重点介绍下扩容方法:
````
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //上一次table的长度赋值
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //阈值等于当前成员变量的值
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //否则就将cap和thr进行*2扩容  
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; 
        }
        else if (oldThr > 0) //如果旧table没有初始化,就把阈值作为table的长度
            newCap = oldThr;
        else { //第一次没有传入任何构造函数时,table.length=16
            newCap = DEFAULT_INITIAL_CAPACITY;
            //阈值=16*0.75=12
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
        	//如果新的阈值等于0,那么会根据新的table.length和负载因子,重新计算
        	//并判断是否超过最大值,如果超过就取最大值
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //将新计算的阈值,重新赋值给成员变量
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        //用新的table的大小,构造一个数组
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;//赋值给成员变量
        if (oldTab != null) {
        	//将旧table中的数据给重新计算到新table数组中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    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位置,要么不变要么是table[j + oldCap]
                        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;
                        }
                    }
                }
            }
        }
         //返回扩容后的新table数组
        return newTab;
    }
````



注意,扩容后的table数组的长度,一定是2的n次方。


最后我们来看下get方法:

````
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
````


可以看到get方法也同样调用了hash方法获取hashCode,接着调用了getNode方法:


````
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //判断table不等于null,table.length必须大于0
        //然后同样通过位运算得到数组访问的下标接着从数组中取的第一个元素
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
        	//如果hash值相等,key相等,并且key不等于null,检索的key等于第一个元素的key,
        	//就直接返回该节点
            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);
            }
        }
        //如果最终还没有找到就返回null
        return null;
    }
````


HashMap读取的效率:

(1)如果在第一个节点命中,那就是O(1)

(2)如果在红黑树中查询,那就是O(logn)

(3)如果是在链表中查询,那就是O(n)

在这里,我们就会发现红黑树的结构的引入,其实是为了提升检索效率。

注意上面查询过程中还有一个小细节,就是判断key是否null,因为在HashMap中其key和value
都可以允许为null值,有时候你get一个null值出来,可能会有两种情况,那么value就是null,
要么是因为你的key传入的是null,而刚好这个null这个key,对应的value也是null。

演示的代码如下:
````
        HashMap<String, Integer> map=new HashMap<String, Integer>();
        map.put(null, null);//k和v都允许为null
        map.put("5", null);
       
       //判断是否存在null的key
        System.out.println(map.containsKey(null));
        
        System.out.println(map.get(null));//null
        System.out.println(map.get("5"));//null
````


这里可以通过containsKey方法判断是否有null的key




总结:

本文对JDK8中的HashMap的工作原理做了一个剖析,并介绍了一些核心的方法和注意事项,通过了解它的内部运行机制,以帮助我们更加合理的在实际开发中使用。



参考文章:

https://www.jianshu.com/p/aa017a3ddc40

https://www.geeksforgeeks.org/internal-working-of-hashmap-java/

https://www.cdn.geeksforgeeks.org/java-util-hashmap-in-java/

https://www.javacodegeeks.com/2017/11/java-hashmap-detail-explanation.html

http://blog.csdn.net/zxt0601/article/details/77413921

http://lingmeng.github.io/archives/

有什么问题可以扫码关注微信公众号:我是攻城师(woshigcs),在后台留言咨询。 技术债不能欠,健康债更不能欠, 求道之路,与君同行。

  • 大小: 18.8 KB
1
0
分享到:
评论

相关推荐

    02-JDK1.7中的HashMap底层实现分析.mp4

    02-JDK1.7中的HashMap底层实现分析.mp4

    03-JDK1.8中的HashMap底层实现分析.mp4

    03-JDK1.8中的HashMap底层实现分析.mp4

    hashmap_use_in_JDK7.zip

    通过对JDK7中HashMap源码的深入剖析,我们可以了解到HashMap的高效性和内部机制,这对于优化代码和理解数据结构有极大的帮助。同时,也需要注意在不同版本的JDK中,HashMap的实现可能会有所变化,例如JDK8引入了红黑...

    马士兵老师HashMap学习笔记

    马士兵老师的HashMap学习笔记深入剖析了这一核心组件的工作原理,旨在帮助开发者更深入地理解其内部机制。本文将结合马士兵老师的讲解,详细阐述HashMap在不同JDK版本中的put操作流程,以及可能遇到的死循环问题。 ...

    jdk17中文说明文档

    8. **新特性实验(JEPs):** JDK 17可能包含一些实验性的Java增强提案(JEPs),例如新的垃圾回收器、语言特性等。 通过这份"jdk-17中文api.CHM"文档,开发者可以全面了解和掌握JDK 17的各个方面,无论是初学者...

    计算机后端-Java-Java核心基础-第25章 集合02 12. HashMap在JDK8中的源码分析.avi

    计算机后端-Java-Java核心基础-第25章 集合02 12. HashMap在JDK8中的源码分析.avi

    java_jdk8源码

    - 如Runnable、Callable、Comparator等接口在JDK8中被标记为函数式接口,可以用于Lambda表达式。查看这些接口的源码有助于理解如何定义和使用函数式接口。 5. **Optional类** - Optional是为了解决空指针异常问题...

    HashMap 源码分析

    《HashMap 源码解析——JDK11版本》 HashMap是Java中广泛使用的非同步散列表,其设计和实现是高效且灵活的。在JDK1.8之前,HashMap的底层数据结构采用的是数组+链表的方式,这种方式称为“拉链法”来解决哈希冲突。...

    jdk8u-jdk-master

    3. Stream API:Stream API是Java 8中的一个重大改进,提供了对集合数据的高效、声明性处理方式。通过链式调用,可以实现过滤、映射、聚合等多种操作。 4. 默认方法:接口中可以定义带有实现的方法,这使得接口可以...

    尚硅谷-深入java8的集合3:HashMap的实现原理.pdf

    ·基于JDK 11,将Java8、Java9、Java10、Java11新特性一网打尽 ·课程中,Eclipse和IDEA这两种企业一线开发环境都使用到了 3.技术讲解更深入、更全面: ·课程共30天,715个知识视频小节,涉及主流Java使用的...

    java面试 集合中知识点 HashMap(JDK1.8)源码+底层数据结构分析 整理.pdf

    2. **JDK1.8之后**:在JDK1.8中,当链表长度超过8且数组长度超过64时,HashMap会将链表转换为红黑树。红黑树是一种自平衡二叉查找树,它的插入、删除和查找操作的时间复杂度可以达到O(logn),这显著提升了在高冲突率...

    JDK13 API 中文 文档.CHM

    **JDK13 API中文文档**是Java开发者的重要参考资料,它包含了JDK13版本中的所有公共类、接口、枚举、注解等编程元素的详细说明,为开发者提供了全面的API函数用法和功能解释。这篇文档是中文版,方便了中文使用者...

    jdk-8u181-windows-i586

    例如,集合框架在JDK 8中得到了进一步增强,添加了Optional类以避免空指针异常,以及更强大的Map接口实现,如TreeMap和HashMap的优化。I/O流支持文件操作和网络通信,多线程API则使得开发者能够方便地处理并发问题。...

    java面试 集合中知识点 HashMap(JDK1.8)源码+底层数据结构分析 整理.docx

    如果某个桶(bucket)中的链表长度超过8个,且HashMap的总容量超过64,那么这个链表会转换为红黑树,以提高查找、插入和删除操作的效率。红黑树是一种自平衡的二叉查找树,它的特性使得在平均情况下,这些操作的时间...

    HashMap-hash原理

    ### HashMap的Hash原理详解 #### 一、概述 在Java编程语言中,`HashMap`是实现`Map`接口的...通过以上分析,我们可以更加深入地理解`HashMap`内部的工作机制,这对于理解和优化基于`HashMap`的应用程序具有重要意义。

    面试必考之HashMap源码分析与实现

    本篇文章将深入探讨HashMap的内部工作原理、关键特性以及其在实际应用中的考量因素。 HashMap基于哈希表(也称为散列表)实现,它提供了快速的插入、删除和查找操作。其基本思想是通过键的哈希函数将键映射到数组的...

    jdk源代码src.zip

    《深入解析JDK8源代码:src.zip中的编程智慧》 在编程的世界里,Java Development Kit(JDK)是开发者的重要工具,它包含了Java运行环境和开发所需的各种库。JDK8作为Java的一个里程碑版本,引入了许多创新特性,如...

    jdk的部分源码(jdk)

    通过源码,我们可以学习这些类和接口的实现,例如ArrayList和HashMap的工作原理,线程同步的细节,以及Socket通信的底层实现。 4. **异常处理**: 源码中展示了Java如何处理异常,包括检查性异常和运行时异常。理解...

    计算机后端-Java-Java核心基础-第25章 集合02 11. HashMap在JDK7中的源码分析.avi

    计算机后端-Java-Java核心基础-第25章 集合02 11. HashMap在JDK7中的源码分析.avi

    通过分析 JDK 源代码研究 Hash 存储机制

    本文将深入探讨JDK源代码中的哈希存储机制,以帮助我们理解这些数据结构的工作原理。 首先,我们要了解的是哈希函数。哈希函数是哈希存储的核心,它的作用是将任意长度的输入(也叫做键或关键字)转化为固定长度的...

Global site tag (gtag.js) - Google Analytics