ホームページ  >  記事  >  Java  >  JavaにおけるHashMapの実装原理の解析

JavaにおけるHashMapの実装原理の解析

不言
不言オリジナル
2018-09-11 13:57:061546ブラウズ

この記事の内容は Java における HashMap の実装原理の分析に関するもので、必要な方は参考にしていただければ幸いです。

1. ハッシュマップの概要:
HashMap は、ハッシュ テーブルに基づく Map インターフェイスの非同期実装です。この実装では、すべてのオプションのマッピング操作が提供され、null 値と null キーが許可されます。このクラスはマッピングの順序を保証せず、特に順序が不変であることを保証しません。
2. HashMap データ構造:
Java プログラミング言語には、2 つの最も基本的な構造があり、1 つは配列、もう 1 つはシミュレートされたポインター (参照) であり、これら 2 つの基本構造を使用してすべてのデータ構造を構築できます。HashMap も例外ではありません。 HashMap は実際には、配列とリンク リストを組み合わせた「リンク リスト ハッシュ」データ構造です。
上の図からわかるように、HashMap の最下層は配列構造であり、配列内の各項目はリンクされたリストです。新しい HashMap が作成されると、配列が初期化されます。

/** 
 * The table, resized as necessary. Length MUST Always be a power of two. 
 */  transient Entry[] table;  

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

3. HashMap アクセスの実装:
1) ストレージ:

public V put(K key, V value) {  
    // HashMap允许存放null键和null值。  
    // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。  
    if (key == null)  
        return putForNullKey(value);  
    // 根据key的keyCode重新计算hash值。  
    int hash = hash(key.hashCode());  
    // 搜索指定hash值在对应table中的索引。  
    int i = indexFor(hash, table.length);  
    // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。  
    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;  
        }  
    }  
    // 如果i索引处的Entry为null,表明此处还没有Entry。  
    modCount++;  
    // 将key、value添加到i索引处。  
    addEntry(hash, key, value, i);  
    return null;  
}

上記のソース コードからわかるように、HashMap に要素を配置すると、まずキーの hashCode に基づいてハッシュ値を再計算し、配列内の要素の位置を取得します。ハッシュ値 (つまり添え字) に基づいて、配列内のこの位置に他の要素が格納されている場合、この位置の要素はリンク リストの形式で格納され、新しく追加された要素がリストの先頭に配置されます。チェーンと、チェーンの最後に配置される最初に追加された要素。配列内のその位置に要素がない場合、要素は配列内のその位置に直接配置されます。
addEntry(hash, key, value, i) メソッドは、計算されたハッシュ値に基づいて、配列テーブルの i インデックスにキーと値のペアを配置します。 addEntry は、HashMap によって提供されるパッケージ アクセス メソッドです。コードは次のとおりです。

void addEntry(int hash, K key, V value, int bucketIndex) {  
    // 获取指定 bucketIndex 索引处的 Entry   
    Entry<K,V> e = table[bucketIndex];  
    // 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry  
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
    // 如果 Map 中的 key-value 对的数量超过了极限  
    if (size++ >= threshold)  
    // 把 table 对象的长度扩充到原来的2倍。  
        resize(2 * table.length);  
}

システムが HashMap にキーと値のペアを格納することを決定するとき、エントリ内の値はまったく考慮されず、単に計算されます。キーに基づいて各エントリを決定します。 Map コレクションの値は、システムがキーの保存場所を決定した後、そこに保存できるようになり、キーの付属品として完全に考えることができます。

hash(int h) メソッドは、キーの hashCode に基づいてハッシュを再計算します。このアルゴリズムでは、上位ビットの計算が追加され、下位ビットが変更されず上位ビットが変更された場合に発生するハッシュの競合を防ぎます。

static int hash(int h) {  
    h ^= (h >>> 20) ^ (h >>> 12);  
    return h ^ (h >>> 7) ^ (h >>> 4);  
}

HashMap 内の要素を見つけるには、キーのハッシュ値に基づいて、対応する配列内の位置を見つける必要があることがわかります。この位置を計算する方法はハッシュ アルゴリズムです。前述したように、HashMap のデータ構造は配列とリンク リストの組み合わせであるため、当然のことながら、この HashMap 内の要素の位置ができるだけ均等に分散され、各位置に要素が 1 つだけ存在することが望まれます。ハッシュ アルゴリズムを使用してこの位置を取得すると、リンク リストをたどることなく、対応する位置にある要素が必要なものであることがすぐにわかり、クエリの効率が大幅に最適化されます。

どのオブジェクトでも、その hashCode() 戻り値が同じである限り、 hash(int h) メソッドを呼び出すプログラムによって計算されるハッシュ コード値は常に同じです。最初に考えられるのは、要素の分布が比較的均一になるように、配列の長さを法としてハッシュ値を取得することです。ただし、「モジュロ」演算の消費量は依然として比較的多く、これは HashMap で行われます。indexFor(int h, int length) メソッドを呼び出して、オブジェクトを保存するテーブル配列のインデックスを計算します。 IndexFor(int h, int length) メソッドのコードは次のとおりです:

static int indexFor(int h, int length) {  
    return h & (length-1);  
}

このメソッドは、h & (table.length -1) を使用してオブジェクトのストレージ ビットと長さを取得します。 HashMap の基礎となる配列は常に 2 の n 乗です。これが HashMap の速度の最適化です。 HashMap コンストラクターには次のコードがあります:

int capacity = 1;  
    while (capacity < initialCapacity)  
        capacity <<= 1;

このコードは、初期化中に HashMap の容量が常に 2 の n 乗であること、つまり、基になる配列の長さが常に 2 の n 乗であることを保証します。
長さが常に 2 の n 乗である場合、h& (長さ-1) 演算は長さを剰余する、つまり h%length を取得するのと同等ですが、& は % より効率的です。
配列の長さが 2 の n 乗である場合、異なるキーによって同じインデックスが計算される確率は小さいため、データは配列上に均等に分散され、衝突の確率が小さいことを意味します。対照的に、クエリの場合は、リンクされたリストの特定の位置をたどる必要がないため、クエリの効率が高くなります。

上記の put メソッドのソース コードによると、プログラムがキーと値のペアを HashMap に配置しようとすると、プログラムはまずキーの hashCode() 戻り値に基づいてエントリの保存場所を決定します。 2 つのエントリの hashCode() の戻り値のキーが同じであれば、それらの格納場所は同じです。これら 2 つのエントリのキーが等価比較によって true を返した場合、新しく追加されたエントリの値はコレクション内の元のエントリの値を上書きしますが、キーは上書きされません。これら 2 つのエントリのキーが等価比較によって false を返した場合、新しく追加されたエントリはコレクション内の元のエントリとエントリ チェーンを形成し、新しく追加されたエントリはエントリ チェーンの先頭に配置されます - 引き続き説明を参照してください。特定の命令の addEntry() メソッドの。
(2)

を読む
public V get(Object key) {  
    if (key == null)  
        return getForNullKey();  
    int hash = hash(key.hashCode());  
    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.equals(k)))  
            return e.value;  
    }  
    return null;  
}

有了上面存储时的hash算法作为基础,理解起来这段代码就很容易了。从上面的源代码中可以看出:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

3) 归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。
4.    HashMap的resize(rehash):
当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
5.    HashMap的性能参数:
  HashMap 包含如下几个构造器:
  HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。
  HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。
  HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。
  HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。
  initialCapacity:HashMap的最大容量,即为底层数组的长度。
  loadFactor:负载因子loadFactor定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。
  负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。
  HashMap的实现中,通过threshold字段来判断HashMap的最大容量:

threshold = (int)(capacity * loadFactor);

结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下允许的最大元素数目,超过这个数目就重新resize,以降低实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍:

if (size++ >= threshold)     
    resize(2 * table.length);

6.    Fail-Fast机制:
我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。

这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

HashIterator() {  
    expectedModCount = modCount;  
    if (size > 0) { // advance to first entry  
    Entry[] t = table;  
    while (index < t.length && (next = t[index++]) == null)  
        ;  
    }  
}

在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map:
  注意到modCount声明为volatile,保证线程之间修改的可见性。

final Entry<K,V> nextEntry() {     
    if (modCount != expectedModCount)     
        throw new ConcurrentModificationException();

在HashMap的API中指出:

すべての HashMap クラスの「コレクション ビュー メソッド」によって返されるイテレータはフェイルファストです。イテレータの作成後、マップの構造が変更された場合、イテレータ自体の Remove メソッドを使用した場合を除き、それ以外の場合はメソッドの変更が行われます。イテレータは ConcurrentModificationException をスローします。したがって、同時変更に直面した場合、反復子は、将来の不確定な時点で任意の非決定的な動作を危険にさらすことなく、すぐに完全に失敗します。

イテレーターのフェイルファスト動作は保証されていないことに注意してください。また、一般に、同期されていない同時変更がある場合には、確実な保証を行うことは不可能です。フェイルファスト反復子は、ベストエフォートベースで ConcurrentModificationException をスローします。したがって、この例外に依存するプログラムを作成するのは間違いであり、イテレータのフェイルファスト動作はプログラム エラーを検出するためだけに使用するのが正しいアプローチです。

関連する推奨事項:

Java の HashMap の実装原理を深く理解する (写真)

Java ロックフリー ハッシュマップの原理と実装を詳細に説明する

以上がJavaにおけるHashMapの実装原理の解析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。