天天看点

集合框架(4):HashMap底层原理分析

本文会介绍Map底层如何实现以及原理。

如果对LinkedHashMap实现感兴趣的可以看这篇文章:LinkedHashMap、TreeMap实现原理

文章目录

    • 一、Map接口中常用的方法
    • 二、Map接口继承树
    • 三、Map底层源码分析
      • 1. Map:
      • 2. Map中key-value的理解:
      • 3. 面试题
    • 四、HashMap底层源码分析
      • 1. 在jdk7中
      • 2. 在jdk8中
      • 3. JDK1.8 之前扩容问题
      • 4. HashMap源码中的重要常量
      • 遍历方式
      • 面试题:负载因子值的大小,对HashMap 有什么影响?

一、Map接口中常用的方法

添加、删除、修改操作 :

  • Object put(Object key,Object value):将指定key-value添加到(或修改)当前map对象中
  • void putAll(Map m):将m中的所有key-value对存放到当前map中
  • Object remove(Object key):移除指定key的key-value对,并返回value
  • void clear():清空当前map中的所有数据

元素查询的操作:

  • Object get(Object key):获取指定key对应的value
  • boolean containsKey(Object key):是否包含指定的key
  • boolean containsValue(Object value):是否包含指定的value
  • int size():返回map中key-value对的个数
  • boolean isEmpty():判断当前map是否为空
  • boolean equals(Object obj):判断当前map和参数对象obj是否相等

元视图操作的方法:

  • Set keySet():返回所有key构成的Set集合
  • Collection values():返回所有value构成的Collection集合
  • Set entrySet():返回所有key-value对构成的Set集合

对应的代码实现:

  1. 添加、删除、修改操作
@Test
public void test2(){
	Map map = new HashMap();
	//添加
	map.put("AA",123);
	map.put("BB",456);
	map.put("CC",789);
	//修改
	map.put("AA",87);
	System.out.println(map);//{AA=87, BB=456, CC=789}
	
	Map map1 = new HashMap();
	map1.put("DD",123);
	map1.put("EE",123);
	//putAll(Map m):将m中的所有key-value对存放到当前map中
	map.putAll(map1);
	System.out.println(map);//{AA=87, BB=456, CC=789, DD=123, EE=123}
	
	//remove(Object key):移除指定key的key-value对,并返回value
	Object cc = map.remove("CC");
	System.out.println(cc);//789
	System.out.println(map);//{AA=87, BB=456, DD=123, EE=123}
	
	//void clear():清空当前map中的所有数据
	map.clear();
	System.out.println(map.size());//0
	System.out.println(map);//{}
}
           
  1. 元素查询的操作
@Test
public void test3() {
	Map map = new HashMap();
	map.put("AA", 123);
	map.put("BB", 456);
	map.put("CC", 132);
	//get(Object key):获取指定key对应的value
	System.out.println(map.get("AA"));//123
	
	//containsKey(Object key):是否包含指定的key
	boolean isExit = map.containsKey("BB");
	System.out.println(isExit);//true
	
	//containsValue(Object value):是否包含指定的value
	boolean b = map.containsValue(123);
	System.out.println(b);//true
	
	//size():返回map中key-value对的个数
	System.out.println(map.size());
	
	//isEmpty():判断当前map是否为空
	System.out.println(map.isEmpty());//false
	//equals(Object obj):判断当前map和参数对象obj是否相等
	//boolean aa = map.equals(map1);
}
           
  1. 元视图操作的方法
@Test
public void test4() {
  Map map = new HashMap();
  map.put("AA", 123);
  map.put("BB", 456);
  map.put("CC", 789);

  //Set keySet():返回所有key构成的Set集合
  Set set = map.keySet();
  Iterator iterator =set.iterator();
  while (iterator.hasNext()){
    System.out.println(iterator.next());
  }
  
  //Collection values():返回所有value构成的Collection集合
  Collection values = map.values();
  for (Object value : values) {
    System.out.println(value);
  }
  
  //遍历所有的key-value
  //方式一:
  //Set entrySet():返回所有key-value对构成的Set集合
  Set set1 = map.entrySet();
  Iterator iterator1 = set1.iterator();
  while (iterator1.hasNext()){
    Object obj = iterator1.next();
    Map.Entry entry = (Map.Entry) obj;
    System.out.println(entry.getKey() +"--->"+entry.getValue());
  }
  
  //方式二:
  Set KeySet = map.keySet();
  Iterator iterator2 =KeySet.iterator();
  while (iterator2.hasNext()){
    Object key = iterator2.next();
    Object value = map.get(key);
    System.out.println(key +"====>"+value);
  }
}
           

二、Map接口继承树

集合框架(4):HashMap底层原理分析

三、Map底层源码分析

1. Map:

双列数据,用于存储具有键值对的数据,类似于函数的概念。

1). HashMap:作为map的主要实现类,线程不安全的,效率高。

可以存储null的key或value

2). LinkedHashMap:HashMap的子类,保证在遍历map元素时,可以按照添加的顺序进行遍历。

原因:在原有hashMap底层的基础结构上,添加了一对指针,指向前一个和后一个元素。

对于频繁的遍历操作,此类执行效率高于hashMap。

3). TreeMap:可以按照添加的键值对进行排序,实现便利排序。按照key来排序。

底层使用红黑树。

4). Hashtable:古老的实现类。jdk1.0,线程安全的,效率低,

不可以存储null的key或value

5). Properties:HashTable的子类。常用来处理配置文件。key和value都是String类型。

2. Map中key-value的理解:

1)key不可重复(无序),value可以重复。

2)实际上放入map集合的是entry,entry有两个属性key和value。

3)entry无序不可重复。

4)key所在的类要重写hashcode() 和equals() 方法,针对于HashMap

5)判断该元素存不存在,需要重写equals() 。

3. 面试题

1)HashMap的底层实现原理?

见下面的分析说明!!!

2)HashMap和HashTable的异同?

3)CurrentHashMap 与 Hashtable的异同?

四、HashMap底层源码分析

1. 在jdk7中

Java7 HashMap 结构图

集合框架(4):HashMap底层原理分析

基本组成:

jdk7 数组+链表

头插、线程不安全

基本操作:

  1. resize

    双倍扩容,遍历所有元素,搬运到新的数组中

  2. put

    计算hash值、计算位置坐标

    添加链表(如果需要扩容,添加前先resize扩容)

  3. get

    计算hash值、计算位置坐标

    遍历链表

    并发分析

    并发扩容问题:扩容操作没有并发控制,有可能多个线程同时扩容,其中一个线程完成扩容,另一个线程继续扩容,会导致第二个线程扩容时数组下的链表形成链表环,死循环

底层实现分析

HashMap<Object, Object> map = new HashMap<>();

在实例化以后,底层创建了长度为16的一维数组Entry[] table。

。。。可能已经执行过多次put操作。。。

map.put(key1,value1);

在添加操作时过程:

首先,调用key1所在类的hashCode()计算key1的哈希值,此哈希值经过某种算法计算以后,得到在Entry数组中的存放位置。

如果此位置的数据为空,此时的key1-value1 添加成功。 ---->情况1

如果此位置的数据不为空(意味着此位置存在一个或者多个数据(以链表形式存在)),比较key1和已经存在的一个或多个数据的哈希值:

如果key1的哈希值与已经存在的数据的哈希值都不相同,此时key1-value1添加成功。 ---->情况2

如果key1的哈希值与已经存在的某一个数据(key2,value2)的哈希值相同,继续比较:调用key1所在类的equals()方法,比较:

如果equals()返回false,此时key1-value1添加成功。---->情况3

如果equals()返回true:使用value1替换value2。

关于情况2和情况3:

此时key1-value1和原来的数以链表的方式存储。

在不断的添加过程中,涉及到扩容问题,

当超出临界值,且要存放的位置非空时,默认的扩容方式,扩容为原来容量的2倍,并将原有的数据复制过来。

2. 在jdk8中

Java8 HashMap 结构图

集合框架(4):HashMap底层原理分析

基本组成:

jdk8 数组+链表+红黑树

时间复杂度为 O(logN)

尾插、线程不安全

基本操作:

  1. put

    ①对 Key 求 Hash 值,然后再计算下

    ②如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的 Hash 值相同,需要放到同一个 bucket 中)

    ③如果碰撞了,以链表的方式链接到后面

    ④如果链表长度超过阀值(TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表

    ⑤如果节点已经存在就替换旧值

    ⑥如果桶满了(容量16 * 加载因子0.75),就需要 resize(扩容2倍后重排)

  2. get

    ①使用键对象的 hashcode 找到 bucket 位置

    ②调用 keys.equals() 方法去找到链表或红黑树中正确的节点,最终找到要找的值对象

底层实现分析

  • HashMap的内部存储结构其实是 数组+ 链表+ 红黑树 的结合。当实例化一个HashMap时,会初始化 initialCapacity 和 loadFactor,在put第一对映射关系时,系统会创建一个长度为initialCapacity的Node数组,这个长度在哈希表中被称为容量(Capacity),在这个数组中可以存放元素的位置我们称之为“桶”(bucket),每个bucket都有自己的索引,系统可以根据索引快速的查找bucket中的元素。
  • 每个bucket中存储一个元素,即一个Node对象,但每一个Node对象可以带一个引用变量next,用于指向下一个元素,因此,在一个桶中,就有可能生成一个Node链。也可能是一个一个TreeNode对象,每一个TreeNode对象可以有两个叶子结点left和right,因此,在一个桶中,就有可能生成一个TreeNode树。而新添加的元素作为链表的last,或树的叶子结点。

总结:

jdk8相比于jdk7在底层实现方面的不同:

1)new HashMap():底层没有创建一个长度为16的数组。

2)首次调用put方法时,底层创建长度为16的数组。

3)JDK8底层的数组是Node[],不再是Entry[]。

4)原来jdk7底层结构只有数组+链表。jdk8中底层结构:数组+链表+红黑树。

当数组某一个索引位置上的元素以链表形式存在的数据个数 >8 且当前数组长度 >64 时,此时此索引位置上的所有数据改为使用红黑树存储。

3. JDK1.8 之前扩容问题

  • HashMap 的扩容

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

  • 那么HashMap 什么时候进行扩容呢 ?

当HashMap中的元素个数超过数组大小(数组总大小length,不是数组中个数size) * loadFactor 时 , 就会进行数组扩容, loadFactor 的默认值(DEFAULT_LOAD_FACTOR)为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小(DEFAULT_INITIAL_CAPACITY)为16,那么当HashMap中元素个数超过16x0.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

当HashMap中的其中一个链的对象个数如果达到了8个,此时如果capacity没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链会变成树,结点类型由Node变成TreeNode类型。当然,如果当映射关系被移除后,下次resize方法时判断树的结点个数低于6个,也会把树再转为链表。

4. HashMap源码中的重要常量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;

int threshold;

final float loadFactor;

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);
}
           
  • DEFAULT_INITIAL_CAPACITY:初始化桶大小,因为底层是数组,所以这是数组默认的大小,默认容量:16
  • MAXIMUM_CAPACITY:最大支持容量,2^30
  • DEFAULT_LOAD_FACTOR:默认加载因子:0.75
  • MIN_TREEIFY_CAPACITY:桶中的Node被树化时最小的hash表容量:64(当桶中Node的数量大到需要变红黑树时,若hash表容量小于MIN_TREEIFY_CAPACITY时,此时应执行resize扩容操作这个MIN_TREEIFY_CAPACITY的值至少是TREEIFY_THRESHOLD的4倍。)
  • TREEIFY_THRESHOLD:Bucket中链表长度大于该默认值:8,转化为红黑树
  • UNTREEIFY_THRESHOLD:Bucket中红黑树存储的Node小于该默认值,转化为链表
  • threshold:扩容的临界值(阈值)= 容量(capacity) * 负载因子(loadFactor):16*0.75 = 12
  • loadFactor:负载因子 默认:0.75
  • capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍

遍历方式

还有一个值得注意的是 HashMap 的遍历方式,通常有以下几种:

Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();
while (entryIterator.hasNext()) {
	Map.Entry<String, Integer> next = entryIterator.next();
	System.out.println("key=" + next.getKey() + " value=" + next.getValue());
}

Iterator<String> iterator = map.keySet().iterator();
while (iterator.hasNext()){
	String key = iterator.next();
	System.out.println("key=" + key + " value=" + map.get(key));
}
           

强烈建议

使用第一种 EntrySet 进行遍历。

第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。

简单总结下 HashMap:无论是 1.7 还是 1.8 其实都能看出 JDK 没有对它做任何的同步操作,所以并发会出问题,甚至出现死循环导致系统不可用。

因此 JDK 推出了专项专用的 ConcurrentHashMap ,该类位于

java.util.concurrent

包下,专门用于解决并发问题。

坚持把 ConcurrentHashMap 这篇看完,打牢好基础,链接:https://blog.csdn.net/weixin_45606067/article/details/110926906

面试题:负载因子值的大小,对HashMap 有什么影响?

  • 负载因子的大小决定了HashMap的数据密度。
  • 负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
  • 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。
  • 按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。

如果有收获!!! 希望老铁们来个三连,点赞、收藏、转发

创作不易,别忘点个赞,可以让更多的人看到这篇文章,顺便鼓励我写出更好的博客