HashMap源碼及原理詳解

来源:https://www.cnblogs.com/DehLiu/archive/2022/08/08/16560312.html
-Advertisement-
Play Games

HashMap概要 代碼如果沒有特定說明,為JDK 1.8 HashMap用來存放鍵值對,是Map介面的實現,是非線程安全的 可以存儲key和value為null的值,但key為null的節點只能有一個 哈希值的計算:在hashCode的基礎上添加擾動函數,使元素分佈更加隨機 哈希衝突:通過鏈表存儲 ...


目錄

HashMap概要

代碼如果沒有特定說明,為JDK 1.8

  • HashMap用來存放鍵值對,是Map介面的實現,是非線程安全
  • 可以存儲key和value為null的值,但key為null的節點只能有一個
  • 哈希值的計算:在hashCode的基礎上添加擾動函數,使元素分佈更加隨機
  • 哈希衝突:通過鏈表存儲具有相同索引的元素,JDK1.8引入紅黑樹解決鏈表過長查詢效率慢的問題
  • 容量:
    • 總是以2的冪次方作為哈希表大小,用於優化key的哈希值的計算過程,預設初始容量為16;
    • 2倍擴容,JDK 1.8優化了key的數組下標的計算過程;JDK 1.8使用尾插法代替頭插法,避免迴圈鏈表問題

JDK1.8 HashMap數據結構圖

image

基本屬性

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    // 序列號
    private static final long serialVersionUID = 362498820763181265L;
    // 預設的初始容量是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 預設的填充因數
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 當桶(bucket)上的結點數大於這個值時會轉成紅黑樹
    static final int TREEIFY_THRESHOLD = 8;
    // 當桶(bucket)上的結點數小於這個值時樹轉鏈表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 桶中結構轉化為紅黑樹對應的table的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 存儲元素的數組,總是2的冪次倍
    transient Node<k,v>[] table;
    // 存放具體元素的集
    transient Set<map.entry<k,v>> entrySet;
    // 存放元素的個數,註意這個不等於數組的長度。
    transient int size;
    // 每次擴容和更改map結構的計數器
    transient int modCount;
    // 臨界值(容量*填充因數) 當實際大小超過臨界值時,會進行擴容
    int threshold;
    // 載入因數
    final float loadFactor;
    //......
}
  • loadFactor:載入因數,用於控制數據存放的密度,預設值為0.75。如果哈希表的數據分佈過於密集,會導致查找效率下降
  • threshold:HashMap所能容納的最大數據量的Node(鍵值對)個數,當size>threshold(註意這裡是元素的個數,不是數組的長度),執行擴容(resize())的邏輯。threshold=capacity * loadFactor

Node節點源碼

// 繼承自 Map.Entry<K,V>
static class Node<K,V> implements Map.Entry<K,V> {
       final int hash;// 哈希值,存放元素到hashmap中時用來與其他元素hash值比較
       final K key;//鍵
       V value;//值
       // 指向下一個節點
       Node<K,V> next;
       Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
        // 重寫hashCode()方法
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        // 重寫 equals() 方法
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
}

TreeNode節點源碼

	static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // 父
        TreeNode<K,V> left;    // 左
        TreeNode<K,V> right;   // 右
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;           // 判斷顏色
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
        // 返回根節點
        final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
       		}
        }
    	//......
	}

容量初始化

HashMap會保證容量(數組長度)為2的冪次方,預設容量為16。如果創建時,指定了容量,會轉換成就近的、大於等於指定容量的2次冪的容量。如:傳入的容量為10會轉換成16(2^4)。

	static final int tableSizeFor(int cap) {
    	int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

容量為2的冪次方的好處

1. 計算key對應的數組下標更加高效

因為&運算比%運算計算的效率更高,源碼中並不是通過key.hashCode()%table.length計算key對應的下標的,而是通過&的方式。

key.hashCode() & (table.length-1)

之所以可以使用&代替%是因為將容量設置為2的冪次方,這樣table.length-1轉換成二進位就能得到高位全部是0,低位全部是1的二進位,用於&運算。如下是table.length=16計算示例:

  10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101    //高位全部歸零,只保留末四位

2. 擴容時更加高效

在哈希表做擴容時,需要重新計算每個節點的哈希值。通過容量為2的冪次方二倍擴容的機制,可以實現高效的計算哈希值的優化。請查看resize()部分。

哈希的計算

HashMap是通過hashCode去計算數組索引的,從而訪問元素,無論是get()、put()都需要先計算元素對應的哈希值。

另外,為了減少哈希衝突,使節點分佈的更加均勻,HashMap並不是直接使用key.hashCode()作為哈希值,而是添加了擾動函數,把(h = key.hashCode()) ^ (h >>> 16)的計算結果作為哈希值,執行key.hashCode的高16位和低16位的異或運算。

	static final int hash(Object key) {
    	int h;
    	// key.hashCode():返回散列值也就是hashcode
    	// ^ :按位異或
   		// >>>:無符號右移,忽略符號位,空位都以0補齊
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	}

哈希衝突

  • 當發生哈希衝突,使用鏈表把具有相同數組索引串在一起。

  • 如果鏈表長度大於閾值(預設為8),且哈希表的數組長度大於或等於64時,會轉換成紅黑樹,減少搜索時間;否則,執行resize()對數組擴容。

put - 添加元素

image

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table未初始化或者長度為0,進行擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash 確定元素存放在哪個桶中,桶為空,新生成結點放入桶中(此時,這個結點是放在數組中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已經存在元素
    else {
        Node<K,V> e; K k;
        // 比較桶中第一個元素(數組中的結點)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 將第一個元素賦值給e,用e來記錄
                e = p;
        // hash值不相等,即key不相等;為紅黑樹結點
        else if (p instanceof TreeNode)
            // 放入樹中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 為鏈表結點
        else {
            // 在鏈表最末插入結點
            for (int binCount = 0; ; ++binCount) {
                // 到達鏈表的尾部
                if ((e = p.next) == null) {
                    // 在尾部插入新結點
                    p.next = newNode(hash, key, value, null);
                    // 結點數量達到閾值(預設為 8 ),執行 treeifyBin 方法
                    // 這個方法會根據 HashMap 數組來決定是否轉換為紅黑樹。
                    // 只有當數組長度大於或者等於 64 的情況下,才會執行轉換紅黑樹操作,以減少搜索時間。否則,就是只是對數組擴容。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // 減1,是因為頭節點要計算在內,所以鏈表長度需要達到TREEIFY_THRESHOLD,才會調用treeifyBin
                        treeifyBin(tab, hash);
                    // 跳出迴圈
                    break;
                }
                // 判斷鏈表中結點的key值與插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出迴圈
                    break;
                // 用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表
                p = e;
            }
        }
        // 表示在桶中找到key值、hash值與插入元素相等的結點
        if (e != null) {
            // 記錄e的value
            V oldValue = e.value;
            // onlyIfAbsent為false或者舊值為null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替換舊值
                e.value = value;
            // 訪問後回調
            afterNodeAccess(e);
            // 返回舊值
            return oldValue;
        }
    }
    // 結構性修改
    ++modCount;
    // 實際大小大於閾值則擴容
    if (++size > threshold)
        resize();
    // 插入後回調
    afterNodeInsertion(evict);
    return null;
}

說明:

  • 在訪問元素的過程中,先比較key.hashCode,如果hashCode不相等,就直接跳過;否則,再調用key.equals()比較元素。這麼做的好處是hashCode的比較效率要高於調用equals()。
  • JDK 1.8在鏈表的基礎上添加了紅黑樹,當數組長度>=64,且鏈表長度>=8時,就會將鏈表轉換成紅黑樹。
  • 擴容的觸發條件:
    • 元素個數大於>threshold
    • 數組為空
    • treeifyBin()方法中,數組長度<64

get - 獲取元素

public V get(Object key) {
    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;
        // 桶中不止一個節點
        if ((e = first.next) != null) {
            // 在樹中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在鏈表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
  1. 通過hash計算數組下標

  2. 查找元素:

    2.1 頭節點命中,直接返回

    2.2 在紅黑樹中查找,返回查找結果

    2.3 在鏈表中查找,命中返回

命中:hash值相等,且key相等

resize - 數組擴容

resize()用於數組擴容,需要新建一個數組,然後遍歷原數組重新計算數組下標,遷移元素,非常耗時,應該儘量避免該操作

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超過整數最大值就不再擴充
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 沒超過最大值,就擴充為原來的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {
        // signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 計算新的resize上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    //創建新的數組
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    
    table = newTab;
    if (oldTab != null) {
        // 把每個bucket都移動到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //對不為空的bucket執行遷移
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null; 
                //bucket中僅有一個節點,直接遷移(這裡不會發生哈希衝突嗎?)
                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 {
                    //鏈表的節點遷移
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;//用於保存原數組中鏈表的下一個節點
                    do {
                        next = e.next;
                        // 新的索引=原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 新的索引=原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

可以看到擴容的方式,並不會改變節點原先在鏈表中的順序,即尾插法。

JDK 1.8優化了key的哈希計算過程

JDK 1.8的resize()優化了key的哈希計算過程。

優化的前提:

  1. 哈希表的長度為2的冪次方
  2. 哈希表的擴容為2倍擴容

HashMap擴容後,newCap=2*oldCap,那麼key.hash & (newCap -1)計算新的下標時,mask會在高位多個1。而key在這個位置的值不是0就是1,如果是0,那麼新的數組下標=原來數組下標;如果是1,那麼新的數組下標=原數組下標+oldCap

image
image

JDK 1.8的尾插的優化,解決環形鏈表問題

JDK 1.8擴容時,使用尾插代替JDK 1.7的頭插法,不會改變節點原有的順序,避免了多線程下環形鏈表的問題。

JDK 1.7頭插法擴容和環形鏈表問題

以下為JDK 1.7的擴容源碼

 1 void resize(int newCapacity) {   //傳入新的容量
 2     Entry[] oldTable = table;    //引用擴容前的Entry數組
 3     int oldCapacity = oldTable.length;         
 4     if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小如果已經達到最大(2^30)了
 5         threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
 6         return;
 7     }
 8  
 9     Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
10     transfer(newTable);                         //!!將數據轉移到新的Entry數組裡
11     table = newTable;                           //HashMap的table屬性引用新的Entry數組
12     threshold = (int)(newCapacity * loadFactor);//修改閾值
13 }
 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;                   //src引用了舊的Entry數組
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
 5         Entry<K,V> e = src[j];             //取得舊Entry數組的每個元素
 6         if (e != null) {
 7             src[j] = null;//釋放舊Entry數組的對象引用(for迴圈後,舊的Entry數組不再引用任何對象)
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //重新計算每個元素在數組中的位置
11                 e.next = newTable[i]; //這裡要插入的節點的下一個節點直接設置成了當前鏈表的頭節點
12                 newTable[i] = e;      //將元素放在數組上
13                 e = next;             //訪問下一個Entry鏈上的元素
14             } while (e != null);
15         }
16     }
17 }

transfer()的11和12行代碼展示了頭插的方式,即原鏈表在擴容之後,被反轉了。(可以打個斷點演示一下這個過程就清楚了。)

容量為2的HashMap擴容示例

image

可以看到鏈表反轉了,即原先是{5,9}的順序,擴容後,順序變為{9,5}

迴圈鏈表問題

多個線程同時擴容時,頭插法可能形成環形鏈表,造成死迴圈。具體實例可以參考Java 8系列之重新認識HashMap

參考:


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • MYSQL的Java操作器——JDBC 在學習了Mysql之後,我們就要把Mysql和我們之前所學習的Java所結合起來 而JDBC就是這樣一種工具:幫助我們使用Java語言來操作Mysql資料庫 JDBC簡介 首先我們先來瞭解一下JDBC JDBC概念: JDBC是使用Java語言操作關係資料庫的 ...
  • 面試官:我看你的簡歷上寫著精通MySQL,問你個簡單的問題,MySQL聯合索引有什麼特性? 我:MySQL聯合索引遵循最左首碼匹配原則,即最左優先,查詢的時候會優先匹配最左邊的索引。 例如當我們在(a,b,c)三個欄位上創建聯合索引時,實際上是創建了三個索引,分別是(a)、(a,b)、(a,b,c)... ...
  • 有趣的特性:CHECK約束 功能說明 在MySQL 8.0.16以前, CREATE TABLE允許從語法層面輸入下列CHECK約束,但實際沒有效果: CHECK (expr) 在 MySQL 8.0.16,CREATE TABLE添加了針對所有存儲引擎的表和列的CHECK約束的核心特性。CREAT ...
  • 在B/S系統開發中,前後端分離開發設計已成為一種標準,而VUE作為前端三大主流框架之一,越來越受到大家的青睞,Antdv是Antd在Vue中的實現。本系列文章主要通過Antdv和Asp.net WebApi開發學生信息管理系統,簡述前後端分離開發的主要相關內容,僅供學習分享使用,如有不足之處,還請指... ...
  • vue3-admin-template 項目地址:vue3-admin-template 能拿來直接開發項目,不需要考慮格式化配置、打包編譯優化等等,難道它不香嗎?~~ 此項目是集成vue3 + vite + Element-Plus + Pinia + vue-router的後臺管理系統的模板工程 ...
  • 1 配置全局組件 當一個組件使用頻率非常高的時候,可以考慮設置其為全局組件,方便其他地方調用。 案例 我這兒封裝一個Card組件想在任何地方去使用: <template> <div class="card"> <div class="card-header"> <div>主標題</div> <div ...
  • 前言 由於業務需求,需要有一個圖片標記功能,其實就是對圖片畫框畫線做標記,類似微信的圖片編輯 但是需要存下標記圖及其標記的具體數據,。功能其實很簡單,但剛開始的時候也是費了一些功夫的。我將原項目中該功能抽離出來單獨寫了一個demo,作為記錄,同時你們在開發過程中有類似需求的話也可以參考一下該思路,其 ...
  • 原文鏈接:20 多個好用的 Vue 組件庫 在本文中,將分享一些常見的 vue.js 組件。 Tables / Data Grids Vue Tables-2 地址:https://github.com/matfish2/vue-tables-2 Vue Tables 2 旨在為開發者提供一個功能齊 ...
一周排行
    -Advertisement-
    Play Games
  • 概述:在C#中,++i和i++都是自增運算符,其中++i先增加值再返回,而i++先返回值再增加。應用場景根據需求選擇,首碼適合先增後用,尾碼適合先用後增。詳細示例提供清晰的代碼演示這兩者的操作時機和實際應用。 在C#中,++i 和 i++ 都是自增運算符,但它們在操作上有細微的差異,主要體現在操作的 ...
  • 上次發佈了:Taurus.MVC 性能壓力測試(ap 壓測 和 linux 下wrk 壓測):.NET Core 版本,今天計劃準備壓測一下 .NET 版本,來測試並記錄一下 Taurus.MVC 框架在 .NET 版本的性能,以便後續持續優化改進。 為了方便對比,本文章的電腦環境和測試思路,儘量和... ...
  • .NET WebAPI作為一種構建RESTful服務的強大工具,為開發者提供了便捷的方式來定義、處理HTTP請求並返迴響應。在設計API介面時,正確地接收和解析客戶端發送的數據至關重要。.NET WebAPI提供了一系列特性,如[FromRoute]、[FromQuery]和[FromBody],用 ...
  • 原因:我之所以想做這個項目,是因為在之前查找關於C#/WPF相關資料時,我發現講解圖像濾鏡的資源非常稀缺。此外,我註意到許多現有的開源庫主要基於CPU進行圖像渲染。這種方式在處理大量圖像時,會導致CPU的渲染負擔過重。因此,我將在下文中介紹如何通過GPU渲染來有效實現圖像的各種濾鏡效果。 生成的效果 ...
  • 引言 上一章我們介紹了在xUnit單元測試中用xUnit.DependencyInject來使用依賴註入,上一章我們的Sample.Repository倉儲層有一個批量註入的介面沒有做單元測試,今天用這個示例來演示一下如何用Bogus創建模擬數據 ,和 EFCore 的種子數據生成 Bogus 的優 ...
  • 一、前言 在自己的項目中,涉及到實時心率曲線的繪製,項目上的曲線繪製,一般很難找到能直接用的第三方庫,而且有些還是定製化的功能,所以還是自己繪製比較方便。很多人一聽到自己畫就害怕,感覺很難,今天就分享一個完整的實時心率數據繪製心率曲線圖的例子;之前的博客也分享給DrawingVisual繪製曲線的方 ...
  • 如果你在自定義的 Main 方法中直接使用 App 類並啟動應用程式,但發現 App.xaml 中定義的資源沒有被正確載入,那麼問題可能在於如何正確配置 App.xaml 與你的 App 類的交互。 確保 App.xaml 文件中的 x:Class 屬性正確指向你的 App 類。這樣,當你創建 Ap ...
  • 一:背景 1. 講故事 上個月有個朋友在微信上找到我,說他們的軟體在客戶那邊隔幾天就要崩潰一次,一直都沒有找到原因,讓我幫忙看下怎麼回事,確實工控類的軟體環境複雜難搞,朋友手上有一個崩潰的dump,剛好丟給我來分析一下。 二:WinDbg分析 1. 程式為什麼會崩潰 windbg 有一個厲害之處在於 ...
  • 前言 .NET生態中有許多依賴註入容器。在大多數情況下,微軟提供的內置容器在易用性和性能方面都非常優秀。外加ASP.NET Core預設使用內置容器,使用很方便。 但是筆者在使用中一直有一個頭疼的問題:服務工廠無法提供請求的服務類型相關的信息。這在一般情況下並沒有影響,但是內置容器支持註冊開放泛型服 ...
  • 一、前言 在項目開發過程中,DataGrid是經常使用到的一個數據展示控制項,而通常表格的最後一列是作為操作列存在,比如會有編輯、刪除等功能按鈕。但WPF的原始DataGrid中,預設只支持固定左側列,這跟大家習慣性操作列放最後不符,今天就來介紹一種簡單的方式實現固定右側列。(這裡的實現方式參考的大佬 ...