Java中的HashMap详解

一、什么是HashMap

HashMap继承了AbstractMap,实现了Map接口,存储的是一个键值对对象。

二、HashMap数据结构解析

1、继承关系:HashMap继承了AbstractMap,实现了Map接口。

public abstract class AbstractMap<K,V> implements Map<K,V> {

2、常量及构造方法

 	//这两个是限定值 当节点数大于8时会转为红黑树存储
    static final int TREEIFY_THRESHOLD = 8;
    //当节点数小于6时会转为单向链表存储
    static final int UNTREEIFY_THRESHOLD = 6;
    //红黑树最小长度为 64
    static final int MIN_TREEIFY_CAPACITY = 64;
    //HashMap容量初始大小
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //HashMap容量极限
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //负载因子默认大小
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //Node是Map.Entry接口的实现类
    //在此存储数据的Node数组容量是2次幂
    //每一个Node本质都是一个单向链表
    transient Node<K,V>[] table;
    //HashMap大小,它代表HashMap保存的键值对的多少
    transient int size;
    //HashMap被改变的次数
    transient int modCount;
    //下一次HashMap扩容的大小
    int threshold;
    //存储负载因子的常量
    final float loadFactor;


   //默认的构造函数:造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap
   public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    //指定容量大小:构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
     //指定容量大小和负载因子大小:构造一个带指定初始容量和加载因子的空 HashMap
    public HashMap(int initialCapacity, float loadFactor) {
        //指定的容量大小不可以小于0,否则将抛出IllegalArgumentException异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
         //判定指定的容量大小是否大于HashMap的容量极限
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
         //指定的负载因子不可以小于0或为Null,若判定成立则抛出IllegalArgumentException异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
         
        this.loadFactor = loadFactor;
        // 设置“HashMap阈值”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。
        this.threshold = tableSizeFor(initialCapacity);
    }
    //传入一个Map集合,将Map集合中元素Map.Entry全部添加进HashMap实例中
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        //此构造方法主要实现了Map.putAll()
        putMapEntries(m, false);
    }

3、数据结构
HashMap在JDK1.8之前的实现方式是数组+链表,但是JDK1.8对HashMap进行了底层优化,改为由 数组+链表+红黑树实现,主要目的是提高查询效率
jdk1.7中HashMap数据结构图
jdk1.8中HashMap数据结构图
两张图分别是jdk1.7和1.8中HashMap的数据结构图,两张图中的左边数组中保存着每个链表的表头节点,数组的索引是根据key的hash值计算得到的,不同的hash值有可能产生一样的索引,这就是哈希冲突,此时可采用链地址法处理哈希冲突,即将所有索引一致的节点构成一个单链表

下边通过对比jdk1.7和jdk1.8来解析HashMap的数据结构

  • jdk1.7 中使用个 Entry 数组来存储数据,用key的 hashcode 取模来决定key会被放到数组里的位置,如果 hashcode 相同,或者 hashcode 取模后的结果相同( hash collision ),那么这些 key 会被定位到 Entry 数组的同一个格子里,这些 key 会形成一个链表。在 hashcode 特别差的情况下,比方说所有key的 hashcode 都相同,这个链表可能会很长,那么 put/get 操作都可能需要遍历这个链表,也就是说时间复杂度在最差情况下会退化到 O(n)
  • jdk1.8 中使用一个 Node 数组来存储数据,但这个 Node 可能是链表结构,也可能是红黑树结构,如果插入的 key 的 hashcode 相同,那么这些key也会被定位到 Node 数组的同个格子里。如果同一个格子里的key不超过8个(默认阀值),使用链表结构存储。如果超过了8个,那么会调用 treeifyBin 函数,将链表转换为红黑树。那么即使 hashcode 完全相同,由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销也就是说put/get的操作的时间复杂度最差只有 O(log n)
三、HashMap中hashCode的作用

因为需要它来对HashMap的数组位置来定位,如果HashMap里存一个数,单纯依次使用equals方法比较key是否相同来确定当前数据是否已存储,效率会很低,通过比较hashCode值,效率会大大提高。如图:
在这里插入图片描述

四、HashMap的数组长度为什么是2的n次幂

首先我们先看一下HashMap中的put方法

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

然后再点进putVal 方法,则会看到其中有下面的代码:

tab[i = (n - 1) & hash]

& 为二进制中的与运算 ,它的运算特点是,两个数进行& ,如果都为1,则运算结果为1,否则为0。

以数组长度分别为8和7 为例,看下边的情况:
在这里插入图片描述
这样得到的数,就会完整的得到原hashcode 值的低位值,不会受到与运算对数据的变化影响。
在这里插入图片描述
通过上边可以看到,当数组长度不为2的n次幂 的时候,hashCode 值与数组长度减一做与运算的时候,会出现重复的数据,因为不为2的n次幂 的话,对应的二进制数肯定有一位为0 ,这样,不管你的hashCode 值对应的该位,是0 还是1 ,最终得到的该位上的数肯定是0 ,这带来的问题就是HashMap 上的数组元素分布不均匀,而数组上的某些位置,永远也用不到。如下图所示:
在这里插入图片描述
这将带来的问题就是你的HashMap 数组的利用率太低,并且链表可能因为上边的(n - 1) & hash 运算结果碰撞率过高,导致链表太深。(当然jdk 1.8已经在链表数据超过8个以后转换成了红黑树的操作,但那样也很容易造成它们之间的转换时机的提前到来)。

如果看到这你还不了解的话继续看下一个例子:
下图中左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8(二进制后为1111)和9(二进制后为1110),但是很明显,当它们和1110 “&”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
在这里插入图片描述
所以说,当数组长度为2的n次幂的时候,数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了

五、HashMap何时扩容以及它的扩容机制?

首先要了解HashMap的扩容过程,我们就得了解一些HashMap中的变量:

	//链表节点
 	Node<K,V> 

   //每一个Node本质都是一个单向链表
    transient Node<K,V>[] table;

    //HashMap大小,它代表HashMap保存的键值对的多少
    transient int size;

    //下一次HashMap扩容的大小
    int threshold;

    //存储负载因子的常量
    final float loadFactor;

何时进行扩容?
HashMap使用的是懒加载,构造完HashMap对象后,进行put 方法插入元素之前,HashMap并不会去初始化或者扩容table。当首次调用put方法时,HashMap会发现table为空然后调用resize方法进行初始化, 当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

**具体是什么时候呢: **
当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16 * 0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

六、HashMap的key一般用字符串,能用其他对象吗?

HashMap的是key-value(键值对)组成的,这个key既可以是基本数据类型对象,如Integer,Float,同时也可以是自己编写的对象。
为什么key一般用字符串呢?
在《Java 编程思想》中有这么一句话:设计 hashCode() 时最重要的因素就是对同一个对象调用 hashCode() 都应该产生相同的值。String 类型的对象对这个条件有着很好的支持,因为 String 重写了 hashCode()方法,它的 hashCode() 值是根据 String 对象的内容计算的,并不是根据对象的地址计算, 所以内容相同的 String 对象会产生相同的散列码

总结:
HashMap的key可以用其他对象,但是相较String对象而言所需要的条件较为苛刻。即使自定义对象内部重写了hashCode()方法和equals()方法,也没有String对象高效,因为String是不可变对象,其中有个hash变量,它可以缓存hashCode,避免重复计算hashCode,这样查找更快

七、HashMap的key和value都能为null么?如果key能为null,那么它是怎么样查找值的?

HashMap的key和value都允许为null

 public static void main(String[] args) {
        HashMap<String, String> map = new HashMap<>();

        // key 和 value 都可以是 null
        map.put(null, null);
        System.out.println( map.get(null) );  // 输出 null

        // key 是 null,value 不是 null
        map.put(null, "v1");
        System.out.println( map.get(null) );  // 输出 v1

        // key 是 null,value 不是 null
        map.put("k2", null);
        System.out.println( map.get("k2") );  // 输出 null

        // key 不存在时,get 方法返回 null
        System.out.println( map.get("k3") );  // 输出 null
    }

Hashtable的key和value都不支持null值

 public static void main(String[] args) {
     Map map = new Hashtable();
        // key 和 value是 null
        map.put(null, null);
        System.out.println( map.get(null) );  // 空指针

        // key 是 null,value 不是 null
        map.put("k2", null);
        System.out.println( map.get("k2") );  // 空指针

        // key,value 都不是 null
        map.put("k2", "张三");
        System.out.println( map.get("k2") );  // 张三
 }
八、HashMap是线程安全的吗?如何实现线程安全?

HashMap不是线程安全的, 如果多个线程同时检测到元素的个数超过阀值(数组大小*负载因子),多个进程会同时对Node数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,其他线程的都会丢失,并且各自线程put的数据也丢失。
实现线程安全的方式:

  1. 使用HashTable:HashTable使用synchronized来保证线程安全,但是所有线程竞争同一把锁,效率低
  2. ConcurrentHashMap:使用锁分段技术,将数据分段存储,给每一段数据配一把锁,效率高。 Java8中使用CAS算法(了解CAS算法请参考 Java并发编程之原子变量和CAS算法
  3. SynchronizedMap: 调用synchronizedMap()方法后会返回一个SynchronizedMa类的对象,而在SynchronizedMap类中使用了synchronized同步关键字来保证对Map的操作是安全的。
九、HashMap的实现原理及它与HashTable、 ConcurrentHashMap的区别

HashMap
* 底层结构 数组+链表+红黑树,可以存储null键null值,线程不安全
* 初始size为16,扩容:newSize = oldSize * 2,size一定为2的n次幂(元素分配更均匀)
* 计算index方法:index = hash & (tab.length – 1)

HashTable
* 底层结构 数组+链表,无论是key还是value都不能为null,线程安全。实现线程安全的方式是在修改数据时锁住整个hashTable,效率低,ConcurrentHashMap做了相关优化。
* 初始size为11,扩容:newSize = oldSize * 2 + 1
* 实现原理和HashMap类似

ConcurrentHashMap
* 底层结构 数组+链表,线程安全,效率高
* 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。
* Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
* 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
* 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容

拓展:
锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。

ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。

以上是我的一些总结,有不对的地方请指教。

参考:
https://blog.csdn.net/woshimaxiao1/article/details/83661464

https://blog.csdn.net/wohaqiyi/article/details/81161735

https://blog.csdn.net/codejas/article/details/78837830

发布了30 篇原创文章 · 获赞 12 · 访问量 3448

猜你喜欢

转载自blog.csdn.net/zx1293406/article/details/103926429