当前位置: 首页 > 编程笔记 >

剖析Java中HashMap数据结构的源码及其性能优化

钱志
2023-03-14
本文向大家介绍剖析Java中HashMap数据结构的源码及其性能优化,包括了剖析Java中HashMap数据结构的源码及其性能优化的使用技巧和注意事项,需要的朋友参考一下

存储结构
首先,HashMap是基于哈希表存储的。它内部有一个数组,当元素要存储的时候,先计算其key的哈希值,根据哈希值找到元素在数组中对应的下标。如果这个位置没有元素,就直接把当前元素放进去,如果有元素了(这里记为A),就把当前元素链接到元素A的前面,然后把当前元素放入数组中。所以在Hashmap中,数组其实保存的是链表的首节点。下面是百度百科的一张图:

如上图,每个元素是一个Entry对象,在其中保存了元素的key和value,还有一个指针可用于指向下一个对象。所有哈希值相同的key(也就是冲突了)用链表把它们串起来,这是拉链法。

内部变量

//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//哈希表
transient Entry<K,V>[] table;
//键值对的数量
transient int size;
//扩容的阈值
int threshold;
//哈希数组的装载因子
final float loadFactor;

在上面的变量中,capacity是指哈希表的长度,也就是table的大小,默认为16。装载因子loadFactor是哈希表的“装满程度”,JDK的文档是这样说的:

The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.
大体意思是:装载因子是哈希表在扩容之前能装多满的度量值。当哈希表中“键值对”的数量超过当前容量(capacity)和装载因子的乘积后,哈希表重新散列(也就是内部的数据结构重建了),并且哈希表的容量大约变为原来的两倍。

从上面的变量定义可以看出,默认的装载因子DEFAULT_LOAD_FACTOR 是0.75。这个值越大,空间利用率越高,但查询速度(包括get和put)操作会变慢。明白了装载因子之后,threshold也就能理解了,它其实等于容量*装载因子。

构造器

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

  // Find a power of 2 >= initialCapacity
  int capacity = 1;
  while (capacity < initialCapacity) //计算出大于指定容量的最小的2的幂
    capacity <<= 1;

  this.loadFactor = loadFactor;
  threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
  table = new Entry[capacity];  //给哈希表分配空间
  useAltHashing = sun.misc.VM.isBooted() &&
      (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
  init();
}

构造器有好几个,最终都会调用上面的这个。它接受两个参数,一个是初始容量,还有一个是装载因子。刚开始先判断值合不合法,有问题的话会抛出异常。重要的是下面的capacity的计算,它的逻辑是计算出大于initialCapacity的最小的2的幂。其实目的就是要让容量能大于等于指定的初始容量,但这个值还得是2的指数倍,这是一个关键的问题。这么做的原因主要是为了哈希值的映射。先来看一下HashMap中有关哈希的方法:

final int hash(Object k) {
  int h = 0;
  if (useAltHashing) {
    if (k instanceof String) {
      return sun.misc.Hashing.stringHash32((String) k);
    }
    h = hashSeed;
  }
  h ^= k.hashCode();
  // This function ensures that hashCodes that differ only by
  // constant multiples at each bit position have a bounded
  // number of collisions (approximately 8 at default load factor).
  h ^= (h >>> 20) ^ (h >>> 12);
  return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
  return h & (length-1);
}

hash()方法重新计算了key的哈希值,用了比较复杂的位运算,具体逻辑我也不清楚,反正肯定是比较好的方法,能减少冲突什么的。

下面的indexFor()是根据哈希值得到元素在哈希表中的下标。一般在哈希表中是用哈希值对表长取模得到。当length(也就是capacity)为2的幂时,h & (length-1)是同样的效果。并且,2的幂一定是偶数,那么减1之后就是奇数,二进制的最后一位一定是1。那么h & (length-1)的最后一位可能是1,也可能是0,可以均匀地散列。如果length是奇数,那么length-1就是偶数,最后一位是0。此时h & (length-1)的最后一位只可能是0,所有得到的下标都是偶数,那么哈希表就浪费了一半的空间。所以HashMap中的容量(capacity)一定要是2的幂。可以看到默认的DEFAULT_INITIAL_CAPACITY=16和MAXIMUM_CAPACITY=1<<30都是这样的。

Entry对象
HashMap中的键值对被封装成Entry对象,这是HashMap中的一个内部类,看一下它的实现:

static class Entry<K,V> implements Map.Entry<K,V> {
  final K key;
  V value;
  Entry<K,V> next;
  int hash;

  Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
  }

  public final K getKey() {
    return key;
  }

  public final V getValue() {
    return value;
  }

  public final V setValue(V newValue) {
    V oldValue = value;
    value = newValue;
    return oldValue;
  }

  public final boolean equals(Object o) {
    if (!(o instanceof Map.Entry))
      return false;
    Map.Entry e = (Map.Entry)o;
    Object k1 = getKey();
    Object k2 = e.getKey();
    if (k1 == k2 || (k1 != null && k1.equals(k2))) {
      Object v1 = getValue();
      Object v2 = e.getValue();
      if (v1 == v2 || (v1 != null && v1.equals(v2)))
        return true;
    }
    return false;
  }

  public final int hashCode() {
    return (key==null  ? 0 : key.hashCode()) ^
        (value==null ? 0 : value.hashCode());
  }

  public final String toString() {
    return getKey() + "=" + getValue();
  }
  void recordAccess(HashMap<K,V> m) {
  }
  void recordRemoval(HashMap<K,V> m) {
  }
}

这个类的实现还是简洁易懂的。提供了getKey()、getValue()等方法供调用,判断相等是要求key和value均相等。

put操作
先put了才能get,所以先看一下put()方法:

public V put(K key, V value) {
  if (key == null)
    return putForNullKey(value);
  int hash = hash(key);
  int i = indexFor(hash, table.length);
  for (Entry<K,V> e = table[i]; e != null; e = e.next) {
    Object k;
    if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
      V oldValue = e.value;
      e.value = value;
      e.recordAccess(this);
      return oldValue;
    }
  }

  modCount++;
  addEntry(hash, key, value, i);
  return null;
}

在这个方法中,先判断key是否为null,是的话调用putForNullKey()方法,这说明HashMap允许key为null(其实value可以)。如果不是null,计算哈希值并且得到在表中的下标。然后到对应的链表中查询是否已经存在相同的key,如果已经存在就直接更新值(value)。否则,调用addEntry()方法进行插入。

看一下putForNullKey()方法:

private V putForNullKey(V value) {
  for (Entry<K,V> e = table[0]; e != null; e = e.next) {
    if (e.key == null) {
      V oldValue = e.value;
      e.value = value;
      e.recordAccess(this);
      return oldValue;
    }
  }
  modCount++;
  addEntry(0, null, value, 0);
  return null;
}

可以看到,key为null时直接在下标0处插入,同样是存在就更新值,否则调用addEntry()插入。

下面是addEntry()方法的实现:

void addEntry(int hash, K key, V value, int bucketIndex) {
  if ((size >= threshold) && (null != table[bucketIndex])) {
    resize(2 * table.length);
    hash = (null != key) ? hash(key) : 0;
    bucketIndex = indexFor(hash, table.length);
  }

  createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
  Entry<K,V> e = table[bucketIndex];
  table[bucketIndex] = new Entry<>(hash, key, value, e);
  size++;
}

首先判断是否要扩容(扩容会重新计算下标值,并且复制元素),然后计算数组下标,最后在createEntry()中使用头插法插入元素。

get操作

public V get(Object key) {
  if (key == null)
    return getForNullKey();
  Entry<K,V> entry = getEntry(key);

  return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
  for (Entry<K,V> e = table[0]; e != null; e = e.next) {
    if (e.key == null)
      return e.value;
  }
  return null;
}
final Entry<K,V> getEntry(Object key) {
  int hash = (key == null) ? 0 : hash(key);
  for (Entry<K,V> e = table[indexFor(hash, table.length)];
     e != null;
     e = e.next) {
    Object k;
    if (e.hash == hash &&
      ((k = e.key) == key || (key != null && key.equals(k))))
      return e;
  }
  return null;
}

这个比put()简单一些,同样要判断key是不是null,然后就是链表的遍历查询。

性能优化
HashMap是一个高效通用的数据结构,它在每一个Java程序中都随处可见。先来介绍些基础知识。你可能也知 道,HashMap使用key的hashCode()和equals()方法来将值划分到不同的桶里。桶的数量通常要比map中的记录的数量要稍大,这样 每个桶包括的值会比较少(最好是一个)。当通过key进行查找时,我们可以在常数时间内迅速定位到某个桶(使用hashCode()对桶的数量进行取模) 以及要找的对象。

这些东西你应该都已经知道了。你可能还知道哈希碰撞会对hashMap的性能带来灾难性的影响。如果多个hashCode()的值落到同一个桶内的 时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到 O(n)。我们先来测试下正常情况下hashmap在Java 7和Java 8中的表现。为了能完成控制hashCode()方法的行为,我们定义了如下的一个Key类:

class Key implements Comparable<Key> {
private final int value;
Key(int value) {
this.value = value;
}
@Override
public int compareTo(Key o) {
return Integer.compare(this.value, o.value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass())
return false;
Key key = (Key) o;
return value == key.value;
}
@Override
public int hashCode() {
return value;
}
}

Key类的实现中规中矩:它重写了equals()方法并且提供了一个还算过得去的hashCode()方法。为了避免过度的GC,我将不可变的Key对象缓存了起来,而不是每次都重新开始创建一遍:

class Key implements Comparable<Key> {
public class Keys {
public static final int MAX_KEY = 10_000_000;
private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
static {
for (int i = 0; i < MAX_KEY; ++i) {
KEYS_CACHE[i] = new Key(i);
}
}
public static Key of(int value) {
return KEYS_CACHE[value];
}

现在我们可以开始进行测试了。我们的基准测试使用连续的Key值来创建了不同的大小的HashMap(10的乘方,从1到1百万)。在测试中我们还会使用key来进行查找,并测量不同大小的HashMap所花费的时间:

import com.google.caliper.Param;
import com.google.caliper.Runner;
import com.google.caliper.SimpleBenchmark;
public class MapBenchmark extends SimpleBenchmark {
private HashMap<Key, Integer> map;
@Param
private int mapSize;
@Override
protected void setUp() throws Exception {
map = new HashMap<>(mapSize);
for (int i = 0; i < mapSize; ++i) {
map.put(Keys.of(i), i);
}
}
public void timeMapGet(int reps) {
for (int i = 0; i < reps; i++) {
map.get(Keys.of(i % mapSize));
}
}
}

有意思的是这个简单的HashMap.get()里面,Java 8比Java 7要快20%。整体的性能也相当不错:尽管HashMap里有一百万条记录,单个查询也只花了不到10纳秒,也就是大概我机器上的大概20个CPU周期。 相当令人震撼!不过这并不是我们想要测量的目标。

假设有一个很差劲的key,他总是返回同一个值。这是最糟糕的场景了,这种情况完全就不应该使用HashMap:

class Key implements Comparable<Key> {
//...
@Override
public int hashCode() {
return 0;
}
}

Java 7的结果是预料中的。随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。因此从图上可以看到,它的时间复杂度是O(n)。

不过Java 8的表现要好许多!它是一个log的曲线,因此它的性能要好上好几个数量级。尽管有严重的哈希碰撞,已是最坏的情况了,但这个同样的基准测试在JDK8中的时间复杂度是O(logn)。单独来看JDK 8的曲线的话会更清楚,这是一个对数线性分布:

为什么会有这么大的性能提升,尽管这里用的是大O符号(大O描述的是渐近上界)?其实这个优化在JEP-180中已经提到了。如果某个桶中的记录过 大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。它是如何工作 的?前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升 级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希 望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最 好。如果没有实现这个接口,在出现严重的哈希碰撞的时候,你就并别指望能获得性能提升了。

这个性能提升有什么用处?比方说恶意的程序,如果它知道我们用的是哈希算法,它可能会发送大量的请求,导致产生严重的哈希碰撞。然后不停的访问这些 key就能显著的影响服务器的性能,这样就形成了一次拒绝服务攻击(DoS)。JDK 8中从O(n)到O(logn)的飞跃,可以有效地防止类似的攻击,同时也让HashMap性能的可预测性稍微增强了一些。我希望这个提升能最终说服你的 老大同意升级到JDK 8来。

测试使用的环境是:Intel Core i7-3635QM @ 2.4 GHz,8GB内存,SSD硬盘,使用默认的JVM参数,运行在64位的Windows 8.1系统 上。

总结
HashMap的基本实现就如上面所分析的,最后做下总结:

  • HashMap内部用Entry对象保存键值对,基于哈希表存储,用拉链法解决冲突。
  • HashMap的默认容量大小为16,默认装载因子为0.75。可以指定容量大小,容量最终一定会被设置为2的幂,这是为了均匀地散列。
  • HashMap的key和value都可以是null,当然只能有一个key是null,value可以有多个。
  • HashMap的键值对数量超过容量*装载因子时会扩容,扩容后的容量大约是原来的两倍。扩容会重新散列,所以元素的位置可能发生会变化,而且这是一个耗时操作。
  • HashMap是线程不安全的。
 类似资料:
  • 本文向大家介绍深入理解Java之HashMap源码剖析,包括了深入理解Java之HashMap源码剖析的使用技巧和注意事项,需要的朋友参考一下 一、HashMap概述 HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺

  • Haskell 是一门高级编程语言,一门真正的高级编程语言。 我们可以一直使用抽象概念、 幺半群、函子、以及多态进行编程,而不必与任何特定的硬件模型打交道。 Haskell 在语言规范方面下了很大的功夫,力求语言可以不受制于某个特定的求值模型。 这几层抽象使得我们可以把 Haskell 作为计算本身的记号, 让编程人员关心他们问题的关键点,而不用操心低层次的实现细节, 使得人们可以心无旁骛地进行编

  • 1. HashMap简介 HashMap是基于哈希表实现的,每一个元素都是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阈值)时,同样会自动增长。 HashMap是非线程安全的,只是用于单线程环境下,多线程环境下可以采用concurrent并发包下的concurrentHashMap。 HashMap实现了Serializable接口,因此它支持序列化,实现了Clonea

  • 问题内容: 我想创建一个大型HashMap,但性能不够好。有任何想法吗? 欢迎其他数据结构建议,但我需要Java Map的查找功能: 就我而言,我想创建一个包含2600万个条目的地图。使用标准的Java HashMap,插入2到3百万次后,放置速度会变得异常缓慢。 另外,有人知道对密钥使用不同的哈希码分布是否有帮助? 我的哈希码方法: 我正在使用adding的关联属性来确保相等的对象具有相同的哈希

  • 包括有以下 type: config _id 为 kibana5 的 version。内容主要是 defaultIndex,设置默认的 index_pattern. search _id 为 discover 上保存的搜索名称。内容主要是 title,column,sort,version,description,hits 和 kibanaSavedObjectMeta。kibanaSavedOb

  • 本文向大家介绍sqlserver数据库优化解析(图文剖析),包括了sqlserver数据库优化解析(图文剖析)的使用技巧和注意事项,需要的朋友参考一下 下面通过图文并茂的方式展示如下: 一、SQL Profiler  事件类 Stored Procedures\RPC:Completed TSQL\SQL:BatchCompleted 事件关键字段 EventSequence、EventClass