首頁  >  文章  >  Java  >  詳解JavaHashMap實作原理

詳解JavaHashMap實作原理

高洛峰
高洛峰原創
2017-01-19 10:34:461261瀏覽

HashMap是基於哈希表的Map介面實現,提供了所有可選的映射操作,並允許使用null值和null建,不同步且不保證映射順序。以下記錄研究HashMap實作原理。

HashMap內部儲存

在HashMap內部,透過維護一個瞬時變數數組table (又稱:桶) 來儲存所有的鍵值對關係,桶是個Entry物件數組,桶的大小可以按需調整大小,長度必須可以按需調整大小,長度必須是2的次方。如下程式碼:

/**
  * 一个空的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的部分源碼,定義了預設初始容量、負載因子及其他一些常數:

/**
  * 默认初始化容量,必须为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資料結構的程式碼:

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建構法

詳解JavaHashMap實作原理

第四個構造方法是以已經存在的Map方法,其實最終呼叫的都是第三個帶兩個參數的方法,如果沒有傳遞參數則使用預設的數值,程式碼如下:

/**
   * 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中比較重要的部分

/**
   * 在此映射中关联指定值与指定建。如果该映射以前包含了一个该键的映射关系,则旧值被替换
   *
   * @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方法存放到 桶 中

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方法的源码:

/**
   * 返回指定键所映射的值;如果对于该键来说,此映射不包含任何映射关系,则返回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方法实现较简单,以下是几个步骤:

首先判断key是否为null,如果为null,则调用 getForNullKey 方法来获取,如果不为null则调用 getEntry 方法来获取。getForNullKey方法与getEntity基本上一致,只不过少了一个步骤,就是默认的key为null的存储位置在第一个,即下标为0,没有去计算位置而已。

getEntity方法根据key计算哈希值,然后用哈希值、桶的长度计算存储位置。

getEntity以获取指定位置的entry作为遍历的开始,遍历entry的next单链表,如果entry的哈希值与计算的哈希值一致且entry的key与指定的相等则返回entry

根据getEntity返回的值,get方法返回对应的值。

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

自定义对象作为HashMap的键

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页

自反性。对任意x,x.equals(x)一定返回true

对称性。对任意x和y,如果有y.equals(x)返回true,则x.equals(y)也返回true

传递性。对任意x,y,z,如果有x.equals(y)返回true,y.equals(z)返回true,则x.equals(z)一定返回true

一致性,对任意x和y,如果对象中用于等价比较的信息没有改变,那么无论调用x.equals(y)多少次,返回的结果应该保持一致,要么一致是true,要么一致是false.

对任何不是null的x,x.equals(null)一定返回false

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

如下代码可以正常运作:

 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进行比较。从输出可以看出,现在的方式是正确的。

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

更多详解JavaHashMap实现原理相关文章请关注PHP中文网!


陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn