详解Java HashMap实现原理

时间:2022-03-24 01:39:41

HashMap是基于哈希表的Map接口实现,提供了所有可选的映射操作,并允许使用null值和null建,不同步且不保证映射顺序。下面记录一下研究HashMap实现原理。

HashMap内部存储

在HashMap内部,通过维护一个 瞬时变量数组table (又称:桶) 来存储所有的键值对关系,桶 是个Entry对象数组,桶 的大小可以按需调整大小,长度必须是2的次幂。如下代码:

?
1
2
3
4
5
6
7
8
/**
  * 一个空的entry数组,桶 的默认值
  */
 static final Entry<?,?>[] EMPTY_TABLE = {};
 /**
  * 桶,按需调整大小,但必须是2的次幂
  */
 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

初始容量与负载因子

HashMap有两个参数影响性能,初始容量和负载因子。容量是哈希表中 桶 的数量,初始容量只是哈希表在创建时的容量,负载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中条目数超出了负载因子与当前容量的乘积时,则要对该Hash表进行rehash操作(即重建内部数据结构),重建时以当前容量的两倍数目新建。可以通过构造器设置初始容量与负载因子,默认初始容量是16个条目,最大容量是2^30次方个条目,默认负载因子是0.75

桶 就像一个存水的水桶,它默认的初始存水容量是16个单位的水,默认在灌水灌到16*0.75时,在下次添加数据时会先扩充容量,扩充到32单位。0.75就是负载因子,初始容量与负载因子可以通过创建水桶的时候进行设置。水桶最大的容量是2的30次方个单位的水。当初始容量设置的数量大于最大容量时,以最大容量为准。当扩展时如果大于等于最大容量时则直接返回。

如下为HashMap的部分源码,定义了默认初始容量、负载因子及其他一些常量:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
  * 默认初始化容量,必须为2的次幂The default initial capacity - MUST be a power of two.
  */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 /**
  * 最大容量,如果通过构造函数参数中传递初始化容量大于该最大容量了,也会使用该容量为初始化容量  * 必须是2的次幂且小于等于2的30次方  */
 static final int MAXIMUM_CAPACITY = 1 << 30;
 /**
  * 默认的负载因子,可以通过构造函数指定
  */
 static final float DEFAULT_LOAD_FACTOR = 0.75f;
 /**
  * 一个空的数组表,当 桶没有初始化的时候
  */
 static final Entry<?,?>[] EMPTY_TABLE = {};
 /**
  * 桶 , 存储所有的键值对条目,可以按需调整大小,长度大小必须为2的次幂
  */
 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
 /**
  * Map中键值对的数量,在每次新增或删除的时候都会对size进行+1或者-1操作.
  */
 transient int size;
 /**
  * 负载值,需要调整大小的临界值,为:(capacity * load factor).在每次调整大小后会使用新的容量计算一下
  * @serial
  */
 // If table == EMPTY_TABLE then this is the initial capacity at which the
 // table will be created when inflated.
 int threshold;
 /**
  * 负载因子,如果构造函数中没有指定,则采用默认的负载因子,
  *
  * @serial
  */
 final float loadFactor;
 /**
  * HashMap结构修改次数,结构修改时改变HashMap中的映射数量或修改其内部结构(例如,* rehash方法,重建内部数据结构),此字段用于在  * HashMap的集合视图上生成的迭代器都处理成快速失败的
  */
 transient int modCount;

初始容量与负载因子性能调整

通常,默认负载因子(0.75)在时间和空间成本上寻求一种折中。负载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数HashMap类的操作中,包括get和put操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其负载因子,以便最大限度的减少rehash操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生rehash操作。

如果很多映射关系要存储在HashMap实例中,则相对于按需执行自动的rehash操作以增大表的容量来说,使用足够大的初始容量创建它将使得映射关系能更有效的存储。

如下为重建HashMap数据结构的代码:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void resize(int newCapacity) {
  Entry[] oldTable = table;
  int oldCapacity = oldTable.length;
  if (oldCapacity == MAXIMUM_CAPACITY) { // 如果容量已达最大限制,则设置下负载值后直接返回
   threshold = Integer.MAX_VALUE;
   return;
  }
  // 创建新的table存储数据
  Entry[] newTable = new Entry[newCapacity];
  // 将旧table中的数据转存到新table中去,这一步会花费比较多的时间
  transfer(newTable, initHashSeedAsNeeded(newCapacity));
  table = newTable;
  // 最后设置下下次调整大小的负载值
  threshold = (int) Math.min(newCapacity * loadFactor,
    MAXIMUM_CAPACITY + 1);
}

HashMap构造方法

 详解Java HashMap实现原理

第四个构造方法是以已经存在的Map创建一个新的HashMap,稍后再说,前三个构造方法,其实最终调用的都是第三个带两个参数的方法,如果没有传递参数则使用默认的数值,代码如下:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
   * Constructs an empty <tt>HashMap</tt> with the default initial capacity
   * (16) and the default load factor (0.75).
   */
  public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
  }
  /**
   * Constructs an empty <tt>HashMap</tt> with the specified initial
   * capacity and the default load factor (0.75).
   *
   * @param initialCapacity the initial capacity.
   * @throws IllegalArgumentException if the initial capacity is negative.
   */
  public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
  }
  /**
   * Constructs an empty <tt>HashMap</tt> with the specified initial
   * capacity and load factor.
   *
   * @param initialCapacity the initial capacity
   * @param loadFactor   the load factor
   * @throws IllegalArgumentException if the initial capacity is negative
   *     or the load factor is nonpositive
   */
  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;
    threshold = initialCapacity;
    init();
  }

由上可以看出,在构造函数中,如果初始容量给的大于最大容量,则直接以最大容量代替。

put方法

接下来就看看HashMap中比较重要的部分

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
   * 在此映射中关联指定值与指定建。如果该映射以前包含了一个该键的映射关系,则旧值被替换
   *
   * @param 指定将要关联的键
   * @param 指定将要关联的值
   * @return 与key关联的旧值,如果key没有任何映射关系,则返回null(返回null还可能表示该映射之前将null与key关联)
   */
  public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
      inflateTable(threshold);
    }
    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;
  }
 

1. 首先put方法中,先判断 桶 是否为默认的未初始化状态,如果未初始化则调用 inflateTable 方法去初始化,然后判断参数key是否为null,如果为null,则调用putForNullKey专门进行放key为null的数据,putForNullKey方法与下面的第3步开始其实都是一样的,只不过key为null的数据默认存储位置就是第一个,即下标默认为0。

 2. 如果key不是null,则调用hash()方法获取key的hash值,可以根据hash值、桶的长度通过indexFor方法计算该key可以放到桶的位置。

 3. Entry对象中有一个属性next,可以形成一个单向链表,用来存储哈希值相同的元素。因此当计算出来key的hash值重复时,存储位置也会重复,只要判断一下存储位置的元素及该元素的next属性链表中是否与给定的key和key的hash值是否完全一致就可以了。如果有完全一致的,代表已经存在,则替换旧值,并把旧值做为返回值直接返回。

 4. 把结构修改次数自增1

 5. 调用addEntry方法将新的键值对增加到HashMap中。addEntity方法首先判断当前条目数据是否已经大于等于负载值(桶的容量*负载因子)且桶的指定位置不为null,如果已经大于且指定位置不为null,则调调整桶的容量为当前容量的2倍,调整桶的容量参照上面的初始容量与负载因子性能调整 目录。重新计算Hash值,计算存放位置。调用createEntry方法存放到 桶 中

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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++;
  }
  /**
  * Entry构造方法,创建一个新的Entry.
  */
  Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
  }

 6. 在 createEntry 方法中,首先获取指定位置的entry,然后新生成一个entry,在生成entry时把原有的entry存储到新生成的entry的next属性中(参考Entry的构造方法),并把指定位置的entry替换成新生成的。

因为新增条目的时候,需要计算hash值,长度不够时需要调整长度,当计算的存储位置已有元素的时候需要进行链表式的存储,所以使用HashMap新增操作的效率并不是太高。

get方法

首先看下get方法的源码:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
   * 返回指定键所映射的值;如果对于该键来说,此映射不包含任何映射关系,则返回null
   * 返回null值并不一定表明该映射不包含该键的映射,也可能改映射将该键显示的映射为null,可使用containsKey操作来区分这两种情况
   * @see #put(Object, Object)
   */
  public V get(Object key) {
    if (key == null)
      return getForNullKey();
    Entry<K,V> entry = getEntry(key);
    return null == entry ? null : entry.getValue();
  }
  final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
      return null;
    }
    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;
}

get方法实现较简单,以下是几个步骤:

  1. 首先判断key是否为null,如果为null,则调用 getForNullKey 方法来获取,如果不为null则调用 getEntry 方法来获取。getForNullKey方法与getEntity基本上一致,只不过少了一个步骤,就是默认的key为null的存储位置在第一个,即下标为0,没有去计算位置而已。
  2. getEntity方法根据key计算哈希值,然后用哈希值、桶的长度计算存储位置。
  3. getEntity以获取指定位置的entry作为遍历的开始,遍历entry的next单链表,如果entry的哈希值与计算的哈希值一致且entry的key与指定的相等则返回entry
  4. 根据getEntity返回的值,get方法返回对应的值。

通过查看get的源码可以发现,get方法通过key的哈希值与桶的长度计算存储位置,基本上一下就能定位到要找的元素,即使再遍历几个重复哈希值的key,也是很快速的,因为哈希值相对唯一,所以HashMap对于查找性能是非常快的。

自定义对象作为HashMap的键

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class User {
  // 身份证号码
  protected int idNumber;
  public User(int id){
    idNumber = id;
  }
}
public class TestUser{
  public static void main(String[] args) {
    Map<User, String> map = new HashMap<User, String>();
    for (int i=0; i<5; i++) {
      map.put(new User(i), "姓名: " + i);
    }
    System.out.println("User3 的姓名:" + map.get(new User(3)));
  }
}
输出:
User3 的姓名:null

如上代码,通过自定义的User类实例作为HashMap的对象时,在打印的时候是无法找到User3的姓名的,因为User类自动继承基类Object,所以这里会自动使用Object的hashCode方法生成哈希值,而它默认是使用对象的地址计算哈希值的。因此new User(3)生成的第一个实例的哈希值与生成的第二个实例的哈希值是不一样的。但是如果只需要简单的覆盖hashCode方法,也是无法正常运作的,除非同时覆盖equals方法,它也是Object的一部分。HashMap使用equals()判断当前的键是否与表中存在的键相同,可以参考上面的get或put方法。

正确equals()方法必须满足下列5个条件:---参考《Java编程思想》—489页

  1. 自反性。对任意x,x.equals(x)一定返回true
  2. 对称性。对任意x和y,如果有y.equals(x)返回true,则x.equals(y)也返回true
  3. 传递性。对任意x,y,z,如果有x.equals(y)返回true,y.equals(z)返回true,则x.equals(z)一定返回true
  4. 一致性,对任意x和y,如果对象中用于等价比较的信息没有改变,那么无论调用x.equals(y)多少次,返回的结果应该保持一致,要么一致是true,要么一致是false.
  5. 对任何不是null的x,x.equals(null)一定返回false

再次强调:默认的Object.equals()只是比较对象的地址,所以一个new User(3)并不等于另一个new User(3)。因此,如果要使用自己的类作为HashMap的键,必须同时重载hashCode()和equals().

如下代码可以正常运作:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class User {
  // 身份证号码
  protected int idNumber;
  public User(int id){
    idNumber = id;
  }
  @Override
  public int hashCode() {
    return idNumber;
  }
  @Override
  public boolean equals(Object obj) {
    return obj instanceof User && (idNumber==((User)obj).idNumber);
  }
}
public class TestUser{
  public static void main(String[] args) {
    Map<User, String> map = new HashMap<User, String>();
    for (int i=0; i<5; i++) {
      map.put(new User(i), "姓名: " + i);
    }
    System.out.println("User3 的姓名:" + map.get(new User(3)));
  }
}
输出:
User3 的姓名:姓名: 3

上面只是简单的在hashCode中返回了idNumber作为唯一的判别,用户也可以根据自己的业务实现自己的方法。在equals方法中,instanceof会悄悄的检查对象是否为null,如果instanceof左边的参数为null,则会返回false,如果equals()的参数不为null且类型正确,则基于每个对象中的实际的idNumber进行比较。从输出可以看出,现在的方式是正确的。

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,同时也希望多多支持服务器之家!