【2023】HashMapの詳細なソースコード分析と解釈

序文

HashMap を理解する前に、まず使用されるデータ構造を紹介します jdk1.8 以降、効率を最適化するために HashMap に赤黒ツリー データ構造が追加されました。

コンピューター サイエンスでは、ツリー抽象データ型(ADT)、またはこの抽象データ型を実装するデータ構造であり、ツリーのような構造プロパティを持つデータ コレクションをシミュレートするために使用されます。これは、n (n>0) 個の限定されたノードで構成される階層関係のセットです。根が上を向き、葉が下を向いている、逆さまの木に見えることから「ツリー」と呼ばれています。次のような特徴があります。

  • 各ノードには子ノードが制限されているか、子ノードがありません。
  • 親ノードのないノードはルート ノードと呼ばれます。
  • ルート以外の各ノードには親ノードが 1 つだけあります。
  • ルート ノードを除き、各子ノードは複数の互いに素なサブツリーに分割できます。
  • ツリーにはサイクルがありません
    ここに画像の説明を挿入します

分類には、二分木、二分探索木、赤黒木、B 木、B+ 木などが含まれます。

1.二分木

  • 各ノードには最大 2 つの子ノード、つまり左側の子ノードと右側の子ノードが含まれます。
  • 各ノードは 2 つの子ノードを持つ必要はなく、左側の子のみを持つノードもあれば、右側の子だけを持つノードもあります。
  • 二分木の各ノードの左サブツリーと右サブツリーも、最初の 2 つの定義をそれぞれ満たします。
    ここに画像の説明を挿入します

2. 二分探索木

  • ツリー内のどのノードでも、左側のサブツリーの各ノードの値はこのノードの値より小さくなければならず、右側のサブツリー ノードの値はこのノードの値より大きくなければなりません。
  • 等しいキー値を持つノードは存在しません。
  • 通常、二分木探索の時間計算量は O(log n) です。
    ここに画像の説明を挿入します
    回転できないため、左右のサブツリーのバランスが極端に崩れるという最悪のシナリオが発生します。
    ここに画像の説明を挿入します

3. 赤と黒の木

  • ノードは赤または黒のいずれかです
  • 黒いノードをたどってください
  • リーフノードはすべて黒い空のノードです
  • 赤黒ツリー内の赤いノードの子ノードはすべて黒です。
  • 任意のノードからリーフ ノードまでのすべてのパスには、同じ数の黒いノードが含まれます。
  • 追加または削除後、上記の 5 つの定義が満たされていない場合、回転調整操作が発生します。
  • 検索、削除、および追加操作の時間計算量は O(log n) です。
    ここに画像の説明を挿入します

ハッシュ表

ハッシュ テーブル (ハッシュ テーブルとも呼ばれます) は、キーに基づいてメモリの格納場所にある値 (値) に直接アクセスするデータ構造です。配列から発展し、配列の機能を使用してランダム アクセスをサポートします

キーを配列の添字にマッピングする関数は、 ハッシュ関数 と呼ばれますこれは次のように表現できます: hashValue = hash(key)
ハッシュ関数の基本要件:

  • hashValue は配列の添字として使用する必要があるため、ハッシュ関数によって計算されるハッシュ値は 0 以上の正の整数である必要があります。
  • key1 = key2 の場合、ハッシュ化後に取得されるハッシュ値も同じでなければなりません: hash(key1) = hash(key2)
  • key1 != key2 の場合、ハッシュ化後に取得されるハッシュ値も同じである必要があります: hash(key1) != hash(key2)

ハッシュ衝突

実際の状況では、異なるキーに対して異なるハッシュ値を計算できるハッシュ関数を見つけることはほとんど不可能です。これにより、ハッシュ演算により変換された複数のキーが同じ配列添字位置にマッピングされる現象が発生し、これをハッシュ競合(またはハッシュ競合、ハッシュ衝突)と呼びます。
ここに画像の説明を挿入します

ジッパー方式

ハッシュの競合を解決するには、一般にジッパー方式と呼ばれる方式が使用されます。
ハッシュテーブルでは、配列の各添字位置をバケットと呼ぶことができます。各バケットはリンクリストに対応します。同じハッシュ値を持つすべての要素は、同じスロットに対応するリンクリストに配置されます。この方法はジッパーメソッドと呼ばれます

  • 挿入操作では、対応するハッシュ スロットがハッシュ関数を通じて計算され、対応するリンク リストに挿入されます。挿入の時間計算量は O(1) です。
  • 要素を検索または削除するときは、ハッシュ関数を通じて対応するスロットも計算し、リンクされたリストを走査して要素を検索または削除します。
    • 平均すると、リンク リスト方式に基づいて競合を解決する場合のクエリの時間計算量は O(1) です。
    • ハッシュ テーブルはリンク リストに縮退する可能性があり、クエリの時間計算量は O(1) から O(n) に縮退します。
    • リンク リスト方式のリンク リストを赤黒ツリーなどの他の効率的な動的データ構造に変換すると、クエリの時間計算量は O(log n) になります。
      ここに画像の説明を挿入します
      ここに画像の説明を挿入します

また、赤黒ツリーを使用すると、DDos 攻撃を効果的に防ぐことができます。

1. はじめに

HashMapこれは Map の重要な実装クラスであり、ハッシュ テーブルであり、格納される内容はキーと値のペア (key=>value) のマッピングです。HashMap はスレッドセーフではありません。HashMap では null キーと値を保存でき、キーは一意です。

JDK1.8 より前は、HashMap基礎となるデータ構造は純粋な配列 + リンク リスト構造でした。配列は読み込みが速く、追加と削除が遅いという特徴があり、リンクされたリストは読み込みが遅く、追加と削除が速いという特徴があるため、HashMap は変更に同期ロックを使用せずに 2 つを組み合わせているため、パフォーマンスが優れています。配列がHashMap本体であり、ハッシュの競合を解決するためにリンクされたリストが導入されています。ここでハッシュの競合を解決する具体的な方法は次のとおりです。ジッパー メソッド

2. ソースコード分析

1. putメソッド

1.1. 共通の属性

拡張しきい値 = アレイ容量 * 負荷率

    //默认的初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  
    //默认的加载因子     
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //存储数据的数组
    transient Node<K,V>[] table;
    //容量
    transient int size;    
    

1.2. コンストラクター

    //默认无参构造
    public HashMap() {
    
    
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 指定加载因子为默认加载因子 0.75
    }
  • HashMap は配列を遅延的に作成し、オブジェクトの作成時に配列を初期化しません。
  • パラメーターなしのコンストラクターでは、デフォルトの負荷係数が設定されます

1.3. putメソッド

  • フローチャート
    ここに画像の説明を挿入します
  • 特定のソースコード
    
	public V put(K key, V value) {
    
    
        return putVal(hash(key), key, value, false, true);
    }
    
	/** 
	*  计算hash值的方法
	*/
		static final int hash(Object key) {
    
    
	        int h;
	        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	    }

	/** 
	*  具体执行put添加方法
	*/
	final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    
    
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判断数组是否初始化(数组初始化是在第一次put的时候)
        if ((tab = table) == null || (n = tab.length) == 0)
        	//如果未初始化,调用resize()进行初始化
            n = (tab = resize()).length;
        //通过 & 运算符计算求出该数据(key)的数组下标并且判断该下标位置是否有数据
        if ((p = tab[i = (n - 1) & hash]) == null)
        	//如果没有,直接将数据放在该下标位置
            tab[i] = newNode(hash, key, value, null);
        else {
    
      //该下标位置有数据的情况
            Node<K,V> e; K k;
            //判断该下标位置的数据是否和当前新put的数据一样
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
				//如果一样,则直接覆盖value
                e = p;
            //判断是不是红黑树
            else if (p instanceof TreeNode)  
            	//如果是红黑树的话,进行红黑树的具体添加操作
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //如果都不是代表是链表
            else {
    
      
            	//遍历链表
                for (int binCount = 0; ; ++binCount) {
    
    
                	//判断next节点是否为null,是null代表遍历到链表尾部了
                    if ((e = p.next) == null) {
    
    
                    	//把新值插入到尾部
                        p.next = newNode(hash, key, value, null);
                        //插入数据后,判断链表长度有大于等于8了没
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        	//如果是则进行红黑树转换
                            treeifyBin(tab, hash);
                        break; //退出
                    }
                    //如果在链表中找到相同数据的值,则进行修改操作
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //把下一个节点赋值为当前节点
                    p = e;
                }
            }
            //判断e是否为null(e值为前面修改操作存放原数据的变量)
            if (e != null) {
    
     // existing mapping for key
            	//不为null的话证明是修改操作,取出老值
                V oldValue = e.value;
               
                if (!onlyIfAbsent || oldValue == null)
                	//把新值赋值给当前节点
                    e.value = value;
                afterNodeAccess(e);
                //返回老值
                return oldValue;
            }
        }
        //计算当前节点的修改次数
        ++modCount;
        //判断当前数组中的数据量是否大于扩容阈值
        if (++size > threshold)
        	//进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }
  • 具体的なプロセス
  1. キーと値のペア配列テーブルがullかどうかを判断し、複雑な実行size()を展開(初期化)する
  2. キーと値のペアのキーに従ってハッシュ値を計算し、配列のインデックスを取得します
  3. table[i]==ハッシュ値を判定して配列インデックスを取得
  4. taale[i] == null の場合は条件成立、直接新規ノードを作成し
    i を追加 table[i] の先頭要素が key と同じか判定 同じであれば値
    ii を直接上書き判定table[i] は、treeNode、つまりテーブルです [i] は赤黒ツリーですか? 赤黒ツリーの場合は、キーと値のペアを番号 iii に直接挿入します table[i] を走査し、挿入し
    ますリンク リストの最後にあるデータを参照し、リンク リストの長さが 8 より大きいかどうかを判断します。そうである場合は、リンク リストを変換します。これは赤と黒のツリー操作です。キーがキーが既に存在していることが判明した場合は、走査プロセスでは、値は上書きされます。

1.4 リサイズ方法(容量拡張)

  • フローチャート
    ここに画像の説明を挿入します
  • 特定のソースコード
    final Node<K,V>[] resize() {
    
    
        Node<K,V>[] oldTab = table;
        //如果当前数组为null的时候,把oldCap 老数组容量设置为0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //老的扩容阈值
        int oldThr = threshold;
        int newCap, newThr = 0;
        //判断数组容量是否大于0,大于0说明数组已经初始化
        if (oldCap > 0) {
    
    
        	//判断当前数组长度是否大于最大数组长度
            if (oldCap >= MAXIMUM_CAPACITY) {
    
    
            	//如果是,将扩容阈值直接设置为int类型的最大数值并且直接返回
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果在最大长度访问内,则需要扩容oldCap << 1 == oldCap * 2
            //并且判断是否大于16,
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold  等价于 oldCap * 2
        }
      
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //数组初始化的情况,将阈值和扩容因子设置为默认值
        else {
    
                   // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //初始化容量小于16的时候,扩容阈值没用阈值的
        if (newThr == 0) {
    
    
        	//创建阈值
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //计算出来的阈值赋值
        threshold = newThr;
        @SuppressWarnings({
    
    "rawtypes","unchecked"})
        //根据上边计算得出的容量 创建新的数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //扩容操作,判断不为null证明不是初始化数组
        if (oldTab != null) {
    
    
        //	遍历数组
            for (int j = 0; j < oldCap; ++j) {
    
    
                Node<K,V> e;
                //判断当前下标为j的数组如果不为null的话赋值给e
                if ((e = oldTab[j]) != null) {
    
    
                	//将数组的位置设置为null
                    oldTab[j] = null;
                    //判断是否有下一个节点
                    if (e.next == null)
                    	//如果没有,就查询计算在新数组中的下标并放进去
                        newTab[e.hash & (newCap - 1)] = e;
                    //有下个节点的情况,并且判断是否已经树化
                    else if (e instanceof TreeNode)
                    	//进行红黑树的操作
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                   //有下个节点的情况,并且判还没有树化  
                    else {
    
     // preserve order
                        Node<K,V> loHead = null, loTail = null;  //低位数组
                        Node<K,V> hiHead = null, hiTail = null;  //高位数组
                        Node<K,V> next;
                        遍历循环
                        do {
    
    
                        	//取出next节点
                            next = e.next;
                            //通过 & 操作计算出结果为0
                            if ((e.hash & oldCap) == 0) {
    
    
                            	//如果低位为null,则把e值放入低位2头
                                if (loTail == null)
                                    loHead = e;
                                //低位尾不是null,
                                else
                                	//将数据放入next节点
                                    loTail.next = e;
                                loTail = e;
                            }
                            
                            else {
    
    
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //低位如果记录的有数据,是链表
                        if (loTail != null) {
    
    
                        //将下一个元素置空
                            loTail.next = null;
                            //将低位头放入新数组的
                            newTab[j] = loHead;
                        }
                        //高位尾如果记录有数据,是链表
                        if (hiTail != null) {
    
    
                        	//将下个元素置空
                            hiTail.next = null;
                            //将高位头放入新数组的(原下标+原数组容量)位置
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
  • 実行原理
  1. 要素を追加するとき、または初期化するときは、拡張のためにサイズ変更メソッドを呼び出す必要があります。初めてデータを追加するとき、初期配列の長さは 16 です。その後の各拡張は、拡張しきい値 (配列長 * 0.75) に達します。
  2. 容量を拡張するたびに、拡張前の容量の 2 倍になります。
  3. 拡張後、新しい配列が作成され、古い配列のデータを新しい配列
    i に移動する必要があります。ハッシュの競合がないノードの場合は、e.hash&(newCap-1) を直接使用して、配列のインデックス位置を計算します。新しい配列
    ii. 赤黒ツリーの場合、赤黒ツリーの追加
    iii. リンク リストの場合、リンク リストを走査する必要があり、決定するにはリンク リストを分割する必要がある場合があります。 (e.hash&oldCap) が 0 かどうか。要素の位置は元の位置に留まるか、元の位置 + 増加した配列サイズに移動します。

展開時に配列内の要素の位置を再決定する方法は、 if ((e.hash & oldCap) == 0) によって決定されることがわかります。

hash HEX(97)  = 0110 0001‬ 
n    HEX(16)  = 0001 0000
--------------------------
         结果  = 0000 0000
# e.hash & oldCap = 0 计算得到位置还是扩容前位置
     hash HEX(17)  = 0001 0001‬ 
     n    HEX(16)  = 0001 0000
--------------------------
         结果  = 0001 0000
#  e.hash & oldCap != 0 计算得到位置是扩容前位置+扩容前容量

メソッドを取得する

public V get(Object key) {
    
    
    // 定义一个Node结点
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    
    
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
    
    
        // 数组中元素相等的情况
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // bucket中不止一个结点
        if ((e = first.next) != null) {
    
    
            //判断是否为TreeNode树结点
            if (first instanceof TreeNode)
                //通过树的方法获取结点
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
    
    
                //通过链表遍历获取结点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 找不到就返回null
    return null;
}

よくある問題

1. 指数の計算方法は? hashCode を作成したのに、なぜ hash() メソッドを使用する必要があるのでしょうか? アレイ容量が 2 の n 乗に等しいのはなぜですか?

  • まずキーの hashCode() を計算し、次にHash()メソッドを呼び出し、XOR メソッドを使用して 2 次ハッシュの操作を撹乱し、最後に(n - 1) & hashAND 操作を通じてインデックスを取得します。(このメソッドの使用は、hash% n モジュロ演算と同等です)

  • 2 番目の hash() は、バイナリの上位データを合成し、ハッシュ分布をより均一にし、ハッシュ衝突の確率を減らすためのもので、計算式は次のとおりです。(h = key.hashCode()) ^ (h >>> 16)

  • インデックスを計算するには、2 の n 乗の場合、モジュロの代わりにビットごとの AND 演算を使用でき、より効率的です。また、容量が拡張された場合、ハッシュ & lodCap == 0 の要素は元のままになります。 1の場合は展開位置に戻ります 新しい位置、新しい位置 = 古い位置 + lodCap

    • 计算方式:hash & length二次ハッシュ値と元の容量を使用して演算を実行し、結果が 0 の場合は位置を変更せず、0 でない場合は新しい位置に移動します。
    • 位置を計算する新しい方法:原始数组容量+原始下标=新的位置
  • 2 の n 乗を使用するのは、主に最適化効率と連携して添字の分布をより均一にするためです。

2. HashMap の put メソッドの処理における 1.7 と 1.8 の違いは何ですか?

  1. HashMap は配列を遅延的に作成します。配列は最初の使用後にのみ作成されます。
  2. インデックスの計算 (バケットの添字)
    1. まず、キーのハッシュ値を取得し、hash() メソッドで 2 次ハッシュ値を計算します **計算方法は次のとおりです ** ハッシュ値を(h = key.hashCode()) ^ (h >>> 16)unsigned で右にシフトし、次の XOR 計算を実行します元のハッシュ値。この関数は主に、実際に計算に関与する下位 16 ビットを中断することで、操作を効果的に中断し、ハッシュ衝突の確率を減らすことができます。
    2. 次に、セカンダリ ハッシュ値全体と配列の容量を使用して除算し、剰余メソッドを残します。得られた剰余は、最終的なバケットの添字です。計算方法は次のとおりです: 配列の長さ -1 を取得(n-1)&hashハッシュ値。これは、ハッシュ値を使用して、配列の長さ n の余りを % %にすることと同じです。操作、および操作の実行は、主に操作の効率を効果的に向上させることを目的としています (1.7 にはこの最適化がありません)。
  3. バケットの添字が誰も占有していない場合は、Node ノードを作成して戻ります
  4. バケットの添字が既に占有されている場合: 各ノードと 1 つずつ比較され、ハッシュ値と equals() が相対的であるかどうかが確認され、等しい場合は同じキーを意味し、上書きして変更されます。同じではないので追加します。
    1. TreeNode が既に赤黒ツリーである場合のロジックの追加または更新
    2. 通常のノードの場合は、リンク リストの追加または更新ロジックを使用します。リンク リストの長さがツリーしきい値の 8 を超える場合は、ツリー ロジックを使用してツリー操作を実行します (前提条件は、配列の長さが 64 に達します)
  5. 戻る前に、容量が拡張しきい値 (アレイ長/負荷率) を超えているかどうかもチェックされ、超えた場合は容量が拡張されます。
  6. 違う:
    1. 連結リストに挿入する場合、1.7 では先頭挿入方式(連結リストの先頭から挿入)、1.8 では末尾挿入方式(連結リストの末尾から挿入)となります。
      1. 1.7 は、しきい値以上でスペースがない場合の拡張を意味します (スペースがある場合、拡張されず、計算されたバケット インデックスに配置され続けます)。1.8 は、しきい値以上の場合の拡張を意味します。しきい値以上。
      2. 1.8 は、計算ノードノードを拡張するときに最適化されます。

おすすめ

転載: blog.csdn.net/weixin_52315708/article/details/131918897