键值之道:深入学习Java中强大的HashMap(一)

简介: 键值之道:深入学习Java中强大的HashMap

HashMap 基于哈希表的 Map 接口实现,主要用来存放键值对数据HashMap 不是同步的,这意味着它不是线程安全的。如果需要满足线程安全,可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用ConcurrentHashMap。HashMap 最多允许一条数据的键为 null,允许多条数据的值为 null。此外,HashMap 中的映射不是有序的

JDK1.8 之前 HashMap 由数组 + 链表组成,数组是 HashMap 的主体,链表则是主要使用链地址法解决哈希冲突,即当两个对象调用的 hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用了。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大于 64 时,此时此索引位置上的所有数据改为使用红黑树存储。

HashMap 继承自 AbstractMap 抽象类,并实现了 Map、Cloneable、Serializable,可以用来存储键值对类型的数据,同时支持复制和序列化。

父类 AbstractMap

AbstractMap 提供了一套基于键值对访问的接口。通过继承此类,子类仅需实现部分代码即可拥有完整的一套访问键值对数据的接口。

**AbstractMap 只有一个抽象方法 entrySet(),其他大多方法都是调用 entrySet() 去处理数据。**所以子类继承可以把关注点放在 entrySet() 方法上,这个方法返回一个保存所有 key-value 映射的 Set。

public abstract Set<Entry<K,V>> entrySet();

如果想要实现一个可变的 Map,我们还需要重写 put() 方法,因为 AbstractMap 类中默认不支持 put 实现,子类必须重写该方法的实现,否则会抛出异常:

public V put(K key, V value) {
  throw new UnsupportedOperationException();
}

成员变量

初始化容量:16

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  1. 当我们根据 key 的 hash 确定其在数组的位置时,如果 n 为 2 的幂次方,可以保证数据的均匀插入,如果 n 不是 2 的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大 hash 冲突,降低 HashMap 的性能
  2. 一般我们可能会想通过 % 求余来确定位置,只不过性能不如 & 运算。所以 HashMap 使用后者来确定位置,而且当 n 是 2 的幂次方时:hash & (length - 1) == hash % length
  3. HashMap 容量为 2 次幂的原因为了数据的的均匀分布,减少冲突,毕竟 hash 冲突越大,代表数组中一个链的长度越大,这样的话会降低性能
  4. 如果创建 HashMap 对象时,输入的数组长度是 10(不是 2 的幂),HashMap 通过一系列位移运算和或运算得到离初始容量最近的 2 的幂次数

最大容量:2 的 30 次幂

static final int MAXIMUM_CAPACITY = 1 << 30;

HashMap 集合最大容量的上限是:2 的 30 次幂。

默认的负载因子: 0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

链表和树相互转换阈值

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;

JDK1.8 新增特性,当桶中的链表的元素个数超过 8 则会转红黑树。当桶上的结点数小于 6 时红黑树又会转换成链表。

首先我们要知道,无论是链表的结构还是红黑树的结构,都是设计者在寻找一种时间和空间的平衡。链表的查找效率为 O(n),而红黑树的查找效率为 O(logn),查找效率是变高了,但是插入效率变低了,如果从一开始就用红黑树并不合适。从概率学的角度选了一个合适的临界值为 8。

当元素数量为 8 的时候,红黑树的平均查找长度是 3。而链表的平均查找长度是 4,显然树要快一些,而当元素数量小于等于 6 的时候,链表的平均查找长度是 3,其实也很快,没有必要使用红黑树结构,因为转换的过程也需要一定的时间。

6 和 8 中间有个差值 7 还可以防止链表和树之间频繁的转换。

最小树形化阈值:64

static final int MIN_TREEIFY_CAPACITY = 64;

当 HashMap 桶的容量超过这个值时才能进行树形化 ,否则会先选择扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。

存储元素的数组

transient Node<K,V>[] table;

在 JDK1.8 中我们了解到 HashMap 是由数组加链表加红黑树来组成的结构,其中 table 就是 HashMap 中的数组,JDK1.8 之前数组类型是 Entry 类型。从 JDK1.8 之后是 Node 类型。只是换了个名字,都实现了一样的接口:Map.Entry。负责存储键值对数据的。

存放元素的个数

transient int size;

存放元素(键值对)的个数,注意这个不等于数组的长度。

统计修改次数

transient int modCount;

每次扩容和更改 map 结构的计数器。

扩容临界值用来调整大小

int threshold;

计算公式:capacity(数组长度默认16)* loadFactor(负载因子默认 0.75)。这个值是当前已占用数组长度的最大值。当 size >= threshold 的时候,就要考虑对数组进行扩容操作,临界值是衡量数组是否需要扩增的一个标准,扩容后的 HashMap 容量是之前容量的两倍。

哈希表的加载因子:0.75

final float loadFactor;

用来衡量 HashMap 满的程度,表示 HashMap 的疏密程度。loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。计算 HashMap 的实时加载因子:size / capacity。

当 HashMap 里面容纳的元素已经达到 HashMap 数组长度的 75% 时,表示 HashMap 太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可以通过创建 HashMap 集合对象时指定初始容量来尽量避免。

同时在 HashMap 的构造器中可以定制 loadFactor。

/**
 * 构造一个带指定初始容量和加载因子的空 HashMap
 */
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);
}
为什么加载因子设置为 0.75,初始化临界值是 12?

loadFactor 越趋近于 1,那么数组中存放的数据也就越多,也就越密,会让链表的长度增加,降低查询效率,loadFactor 越趋近于 0,数组中存放的数据也就越少,也就越稀疏。既要兼顾数组利用率还要考虑链表元素不要太多,经过大量测试 0.75 是最佳方案。

threshold 表示扩容临界值,计算公式:capacity(数组长度默认16)* loadFactor(负载因子默认 0.75)。当 size >= threshold 的时候,就要考虑对数组进行扩容操作。所以最开始的初始化临界值是 12,临界值是衡量数组是否需要扩增的一个标准,扩容后的 HashMap 容量是之前容量的两倍。那么接下来的临界值就是 24、48、96 …

构造方法

HashMap 的构造方法主要是用于设置初始化容量和加载因子,也可以传入 Map 对数据初始化。

无参构造

public HashMap() {
  this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

使用无参构造的时候,加载因子 DEFAULT_LOAD_FACTOR 默认值为 0.75f,初始化容量 DEFAULT_INITIAL_CAPACITY 为 16。

指定初始化容量和加载因子

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

默认情况下 HashMap 的容量是 16,但是,如果用户通过构造函数指定了一个数字作为容量,那么会选择大于该数字的第一个 2 的幂作为容量(3->4、7->8、9->16),即 tableSizeFor 方法的作用。

指定初始化容量

public HashMap(int initialCapacity) {
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

本质上使用的还是上门的构造方法,只不过是指定初始化容量,使用默认的加载因子。

传入 Map 数据进行初始化

public HashMap(Map<? extends K, ? extends V> m) {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
  putMapEntries(m, false);
}

使用默认的加载因子,之后调用 putMapEntries 处理元素,循环原始 Map,不断调用 putVal 方法添加数据。

成员方法

新增元素 put

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

HashMap 暴露 put 方法用于添加数据,而 put 方法种实际上调用 putVal 实现添加逻辑。

首先 putVal 方法接收的第一个参数为 key 的哈希值,计算逻辑如下:

static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 如果 key 等于 null,那么返回的哈希值为 0
  • 如果 key 不等于 null,首先调用 key 的 hashCode 方法赋值给 h,然后和 h 无符号右移 16 位后的二进制进行按位异或得到最后的 hash 值

所以从 hash 方法中可以看出,HashMap 允许其中一条数据的键为空。按位异或运算(^):相同的二进制数位上,数字相同,结果为 0,不同为 1。


键值之道:深入学习Java中强大的HashMap(二)https://developer.aliyun.com/article/1480892

相关文章
|
2天前
|
设计模式 消息中间件 算法
【实习总结】Java学习最佳实践!
【实习总结】Java学习最佳实践!
22 3
|
2天前
|
数据采集 安全 Java
Java并发编程学习12-任务取消(上)
【5月更文挑战第6天】本篇介绍了取消策略、线程中断、中断策略 和 响应中断的内容
30 4
Java并发编程学习12-任务取消(上)
|
1天前
|
NoSQL 算法 Java
【redis源码学习】持久化机制,java程序员面试算法宝典pdf
【redis源码学习】持久化机制,java程序员面试算法宝典pdf
|
2天前
|
存储 算法 搜索推荐
滚雪球学Java(27):从零开始学习数组:定义和初始化
【5月更文挑战第2天】🏆本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
10 3
|
2天前
|
存储 安全 Java
Java一分钟之-Map接口与HashMap详解
【5月更文挑战第10天】Java集合框架中的`Map`接口用于存储唯一键值对,而`HashMap`是其快速实现,基于哈希表支持高效查找、添加和删除。本文介绍了`Map`的核心方法,如`put`、`get`和`remove`,以及`HashMap`的特性:快速访问、无序和非线程安全。讨论了键的唯一性、`equals()`和`hashCode()`的正确实现以及线程安全问题。通过示例展示了基本操作和自定义键的使用,强调理解这些概念对编写健壮代码的重要性。
10 0
|
2天前
|
缓存 Java 数据库
Java并发编程学习11-任务执行演示
【5月更文挑战第4天】本篇将结合任务执行和 Executor 框架的基础知识,演示一些不同版本的任务执行Demo,并且每个版本都实现了不同程度的并发性。
34 4
Java并发编程学习11-任务执行演示
|
2天前
|
存储 Java 索引
Java HashMap:设计思想与实现原理详解
Java HashMap:设计思想与实现原理详解
146 0
|
2天前
|
存储 算法 安全
认真学习Java集合之HashMap的实现原理
认真学习Java集合之HashMap的实现原理
31 0
认真学习Java集合之HashMap的实现原理
|
6月前
|
存储 Java
[java进阶]——HashMap的底层实现原理和源码分析,另附几个高频面试题
[java进阶]——HashMap的底层实现原理和源码分析,另附几个高频面试题
|
9月前
|
存储 算法 Java
【java常见的面试题】HashMap的实现原理?
Java基础的面试题HashMap的实现原理?
【java常见的面试题】HashMap的实现原理?