Hash算法全以及對Hash的解釋

1. Hash是什麼,它的作用

先舉個例子。我們每個活在世上的人,爲了能夠參與各種社會活動,都需要一個用於識別自己的標誌。也許你覺得名字或是身份證就足以代表你這個人,但是這種代表性非常脆弱,因爲重名的人很多,身份證也可以僞造。最可靠的辦法是把一個人的所有基因序列記錄下來用來代表這個人,但顯然,這樣做並不實際。而指紋看上去是一種不錯的選擇,雖然一些專業組織仍然可以模擬某個人的指紋,但這種代價實在太高了。

而對於在互聯網世界裏傳送的文件來說,如何標誌一個文件的身份同樣重要。比如說我們下載一個文件,文件的下載過程中會經過很多網絡服務器、路由器的中轉,如何保證這個文件就是我們所需要的呢?我們不可能去一一檢測這個文件的每個字節,也不能簡單地利用文件名、文件大小這些極容易僞裝的信息,這時候,我們就需要一種指紋一樣的標誌來檢查文件的可靠性,這種指紋就是我們現在所用的Hash算法(也叫散列算法)。

散列算法(Hash Algorithm),又稱哈希算法,雜湊算法,是一種從任意文件中創造小的數字「指紋」的方法。與指紋一樣,散列算法就是一種以較短的信息來保證文件唯一性的標誌,這種標誌與文件的每一個字節都相關,而且難以找到逆向規律。因此,當原有文件發生改變時,其標誌值也會發生改變,從而告訴文件使用者當前的文件已經不是你所需求的文件。

這種標誌有何意義呢?之前文件下載過程就是一個很好的例子,事實上,現在大部分的網絡部署和版本控制工具都在使用散列算法來保證文件可靠性。而另一方面,我們在進行文件系統同步、備份等工具時,使用散列算法來標誌文件唯一性能幫助我們減少系統開銷,這一點在很多雲存儲服務器中都有應用。

以Git爲代表的衆多版本控制工具都在使用SHA1等散列函數檢查文件更新

當然,作爲一種指紋,散列算法最重要的用途在於給證書、文檔、密碼等高安全係數的內容添加加密保護。這一方面的用途主要是得益於散列算法的不可逆性,這種不可逆性體現在,你不僅不可能根據一段通過散列算法得到的指紋來獲得原有的文件,也不可能簡單地創造一個文件並讓它的指紋與一段目標指紋相一致。散列算法的這種不可逆性維持着很多安全框架的運營,而這也將是本文討論的重點。

2. Hash算法有什麼特點

一個優秀的 hash 算法,將能實現:

  • 正向快速:給定明文和 hash 算法,在有限時間和有限資源內能計算出 hash 值。
  • 逆向困難:給定(若干) hash 值,在有限時間內很難(基本不可能)逆推出明文。
  • 輸入敏感:原始輸入信息修改一點信息,產生的 hash 值看起來應該都有很大不同。
  • 衝突避免:很難找到兩段內容不同的明文,使得它們的 hash 值一致(發生衝突)。即對於任意兩個不同的數據塊,其hash值相同的可能性極小;對於一個給定的數據塊,找到和它hash值相同的數據塊極爲困難。

但在不同的使用場景中,如數據結構和安全領域裏,其中對某一些特點會有所側重。

2.1 Hash在管理數據結構中的應用

在用到hash進行管理的數據結構中,就對速度比較重視,對抗碰撞不太看中,只要保證hash均勻分佈就可以。比如hashmap,hash值(key)存在的目的是加速鍵值對的查找,key的作用是爲了將元素適當地放在各個桶裏,對於抗碰撞的要求沒有那麼高。換句話說,hash出來的key,只要保證value大致均勻的放在不同的桶裏就可以了。但整個算法的set性能,直接與hash值產生的速度有關,所以這時候的hash值的產生速度就尤爲重要,以JDK中的String.hashCode()方法爲例:

public int hashCode() {
    int h = hash;
    //hash default value : 0 
    if (h == 0 && value.length > 0) {
        //value : char storage
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

很簡潔的一個乘加迭代運算,在不少的hash算法中,使用的是異或+加法進行迭代,速度和前者差不多。

2.1 Hash在在密碼學中的應用

在密碼學中,hash算法的作用主要是用於消息摘要和簽名,換句話說,它主要用於對整個消息的完整性進行校驗。舉個例子,我們登陸知乎的時候都需要輸入密碼,那麼知乎如果明文保存這個密碼,那麼黑客就很容易竊取大家的密碼來登陸,特別不安全。那麼知乎就想到了一個方法,使用hash算法生成一個密碼的簽名,知乎後臺只保存這個簽名值。由於hash算法是不可逆的,那麼黑客即便得到這個簽名,也絲毫沒有用處;而如果你在網站登陸界面上輸入你的密碼,那麼知乎後臺就會重新計算一下這個hash值,與網站中儲存的原hash值進行比對,如果相同,證明你擁有這個賬戶的密碼,那麼就會允許你登陸。銀行也是如此,銀行是萬萬不敢保存用戶密碼的原文的,只會保存密碼的hash值而而已。在這些應用場景裏,對於抗碰撞和抗篡改能力要求極高,對速度的要求在其次。一個設計良好的hash算法,其抗碰撞能力是很高的。以MD5爲例,其輸出長度爲128位,設計預期碰撞概率爲,這是一個極小極小的數字——而即便是在MD5被王小云教授破解之後,其碰撞概率上限也高達,也就是說,至少需要找次纔能有1/2的概率來找到一個與目標文件相同的hash值。而對於兩個相似的字符串,MD5加密結果如下:

MD5("version1") = "966634ebf2fc135707d6753692bf4b1e";
MD5("version2") = "2e0e95285f08a07dea17e7ee111b21c8";

可以看到僅僅一個比特位的改變,二者的MD5值就天差地別了

ps : 其實把hash算法當成是一種加密算法,這是不準確的,我們知道加密總是相對於解密而言的,沒有解密何談加密呢,HASH的設計以無法解爲目的的。並且如果我們不附加一個隨機的salt值,HASH口令是很容易被字典攻擊入侵的。

3. Hash算法是如何實現的?

密碼學和信息安全發展到現在,各種加密算法和散列算法已經不是隻言片語所能解釋得了的。在這裏我們僅提供幾個簡單的概念供大家參考。

作爲散列算法,首要的功能就是要使用一種算法把原有的體積很大的文件信息用若干個字符來記錄,還要保證每一個字節都會對最終結果產生影響。那麼大家也許已經想到了,求模這種算法就能滿足我們的需要。

事實上,求模算法作爲一種不可逆的計算方法,已經成爲了整個現代密碼學的根基。只要是涉及到計算機安全和加密的領域,都會有模計算的身影。散列算法也並不例外,一種最原始的散列算法就是單純地選擇一個數進行模運算,比如以下程序。

#  構造散列函數
def hash(a):
    return a % 8

#  測試散列函數功能
print(hash(233))
print(hash(234))
print(hash(235))

# 輸出結果
- 1
- 2
- 3

很顯然,上述的程序完成了一個散列算法所應當實現的初級目標:用較少的文本量代表很長的內容(求模之後的數字肯定小於8)。但也許你已經注意到了,單純使用求模算法計算之後的結果帶有明顯的規律性,這種規律將導致算法將能難保證不可逆性。所以我們將使用另外一種手段,那就是異或。

再來看下面一段程序,我們在散列函數中加入一個異或過程。

#  構造散列函數
def hash(a):
    return (a % 8) ^ 5

#  測試散列函數功能
print(hash(233))
print(hash(234))
print(hash(235))

# 輸出結果
- 4
- 7
- 6

很明顯的,加入一層異或過程之後,計算之後的結果規律性就不是那麼明顯了。

當然,大家也許會覺得這樣的算法依舊很不安全,如果用戶使用連續變化的一系列文本與計算結果相比對,就很有可能找到算法所包含的規律。但是我們還有其他的辦法。比如在進行計算之前對原始文本進行修改,或是加入額外的運算過程(如移位),比如以下程序。

#  構造散列函數
def hash(a):
    return (a + 2 + (a << 1)) % 8 ^ 5

#  測試散列函數功能
print(hash(233))
print(hash(234))
print(hash(235))

# 輸出結果
- 0
- 5
- 6

這樣處理得到的散列算法就很難發現其內部規律,也就是說,我們並不能很輕易地給出一個數,讓它經過上述散列函數運算之後的結果等於4——除非我們去窮舉測試。

上面的算法是不是很簡單?事實上,下面我們即將介紹的常用算法MD5和SHA1,其本質算法就是這麼簡單,只不過會加入更多的循環和計算,來加強散列函數的可靠性。

4. Hash有哪些流行的算法

目前流行的 Hash 算法包括 MD5、SHA-1 和 SHA-2。

  • MD4(RFC 1320)是 MIT 的 Ronald L. Rivest 在 1990 年設計的,MD 是 Message Digest 的縮寫。其輸出爲 128 位。MD4 已證明不夠安全。

  • MD5(RFC 1321)是 Rivest 於1991年對 MD4 的改進版本。它對輸入仍以 512 位分組,其輸出是 128 位。MD5 比 MD4 複雜,並且計算速度要慢一點,更安全一些。MD5 已被證明不具備”強抗碰撞性”。

  • SHA (Secure Hash Algorithm)是一個 Hash 函數族,由 NIST(National Institute of Standards and Technology)於 1993 年發佈第一個算法。目前知名的 SHA-1 在 1995 年面世,它的輸出爲長度 160 位的 hash 值,因此抗窮舉性更好。SHA-1 設計時基於和 MD4 相同原理,並且模仿了該算法。SHA-1 已被證明不具”強抗碰撞性”。

  • 爲了提高安全性,NIST 還設計出了 SHA-224、SHA-256、SHA-384,和 SHA-512 算法(統稱爲 SHA-2),跟 SHA-1 算法原理類似。SHA-3 相關算法也已被提出。

可以看出,上面這幾種流行的算法,它們最重要的一點區別就是”強抗碰撞性”。

5. 那麼,何謂Hash算法的「碰撞」?

你可能已經發現了,在實現算法章節的第一個例子,我們嘗試的散列算法得到的值一定是一個不大於8的自然數,因此,如果我們隨便拿9個數去計算,肯定至少會得到兩個相同的值,我們把這種情況就叫做散列算法的「碰撞」(Collision)。

這很容易理解,因爲作爲一種可用的散列算法,其位數一定是有限的,也就是說它能記錄的文件是有限的——而文件數量是無限的,兩個文件指紋發生碰撞的概率永遠不會是零。

但這並不意味着散列算法就不能用了,因爲凡事都要考慮代價,買光所有彩票去中一次頭獎是毫無意義的。現代散列算法所存在的理由就是,它的不可逆性能在較大概率上得到實現,也就是說,發現碰撞的概率很小,這種碰撞能被利用的概率更小。

隨意找到一組碰撞是有可能的,只要窮舉就可以。散列算法得到的指紋位數是有限的,比如MD5算法指紋字長爲128位,意味着只要我們窮舉21282128次,就肯定能得到一組碰撞——當然,這個時間代價是難以想象的,而更重要的是,僅僅找到一組碰撞並沒有什麼實際意義。更有意義的是,如果我們已經有了一組指紋,能否找到一個原始文件,讓它的散列計算結果等於這組指紋。如果這一點被實現,我們就可以很容易地篡改和僞造網絡證書、密碼等關鍵信息。

你也許已經聽過MD5已經被破解的新聞——但事實上,即便是MD5這種已經過時的散列算法,也很難實現逆向運算。我們現在更多的還是依賴於海量字典來進行嘗試,也就是通過已經知道的大量的文件——指紋對應關係,搜索某個指紋所對應的文件是否在數據庫裏存在。

5.1 MD5的實際碰撞案例

下面讓我們來看看一個真實的碰撞案例。我們之所以說MD5過時,是因爲它在某些時候已經很難表現出散列算法的某些優勢——比如在應對文件的微小修改時,散列算法得到的指紋結果應當有顯著的不同,而下面的程序說明了MD5並不能實現這一點。

import hashlib

#  兩段HEX字節串,注意它們有細微差別
a = bytearray.fromhex("0e306561559aa787d00bc6f70bbdfe3404cf03659e704f8534c00ffb659c4c8740cc942feb2da115a3f4155cbb8607497386656d7d1f34a42059d78f5a8dd1ef")

b = bytearray.fromhex("0e306561559aa787d00bc6f70bbdfe3404cf03659e744f8534c00ffb659c4c8740cc942feb2da115a3f415dcbb8607497386656d7d1f34a42059d78f5a8dd1ef")

#  輸出MD5,它們的結果一致
print(hashlib.md5(a).hexdigest())
print(hashlib.md5(b).hexdigest())

### a和b輸出結果都爲:
cee9a457e790cf20d4bdaa6d69f01e41
cee9a457e790cf20d4bdaa6d69f01e41

而諸如此類的碰撞案例還有很多,上面只是原始文件相對較小的一個例子。事實上現在我們用智能手機只要數秒就能找到MD5的一個碰撞案例,因此,MD5在數年前就已經不被推薦作爲應用中的散列算法方案,取代它的是SHA家族算法,也就是安全散列算法(Secure Hash Algorithm,縮寫爲SHA)。

5.2 SHA家族算法以及SHA1碰撞

安全散列算法與MD5算法本質上的算法是類似的,但安全性要領先很多——這種領先型更多的表現在碰撞攻擊的時間開銷更大,當然相對應的計算時間也會慢一點。

SHA家族算法的種類很多,有SHA0、SHA1、SHA256、SHA384等等,它們的計算方式和計算速度都有差別。其中SHA1是現在用途最廣泛的一種算法。包括GitHub在內的衆多版本控制工具以及各種雲同步服務都是用SHA1來區別文件,很多安全證書或是簽名也使用SHA1來保證唯一性。長期以來,人們都認爲SHA1是十分安全的,至少大家還沒有找到一次碰撞案例。

但這一事實在2017年2月破滅了。CWI和Google的研究人員們成功找到了一例SHA1碰撞,而且很厲害的是,發生碰撞的是兩個真實的、可閱讀的PDF文件。這兩個PDF文件內容不相同,但SHA1值完全一樣。(對於這件事的影響範圍及討論,可參考知乎上的討論:如何評價 2 月 23 日谷歌宣佈實現了 SHA-1 碰撞?)

所以,對於一些大的商業機構來說, MD5 和 SHA1 已經不夠安全,推薦至少使用 SHA2-256 算法。

6. Hash在Java中的應用

6.1 HashMap的複雜度

在介紹HashMap的實現之前,先考慮一下,HashMap與ArrayList和LinkedList在數據複雜度上有什麼區別。下圖是他們的性能對比圖:

  獲取 查找 添加/刪除 空間
ArrayList O(1) O(1) O(N) O(N)
LinkedList O(N) O(N) O(1) O(N)
HashMap O(N/Bucket_size) O(N/Bucket_size) O(N/Bucket_size) O(N)

可以看出HashMap整體上性能都非常不錯,但是不穩定,爲O(N/Buckets),N就是以數組中沒有發生碰撞的元素,Buckets是因碰撞產生的鏈表。

注:發生碰撞實際上是非常稀少的,所以N/Bucket_size約等於1

HashMap是對Array與Link的折衷處理,Array與Link可以說是兩個速度方向的極端,Array注重於數據的獲取,而處理修改(添加/刪除)的效率非常低;Link由於是每個對象都保持着下一個對象的指針,查找某個數據需要遍歷之前所有的數據,所以效率比較低,而在修改操作中比較快。

6.2 HashMap的實現

本文以JDK8的API實現進行分析

6.2.1 對key進行Hash計算

在JDK8中,由於使用了紅黑樹來處理大的鏈表開銷,所以hash這邊可以更加省力了,只用計算hashCode並移動到低位就可以了。

static final int hash(Object key) {
    int h;
    //計算hashCode,並無符號移動到低位
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

舉個例子: 363771819^(363771819 >>> 16)

0001 0101 1010 1110 1011 0111 1010 1011(363771819)
0000 0000 0000 0000 0001 0101 1010 1110(5550) XOR
--------------------------------------- =
0001 0101 1010 1110 1010 0010 0000 0101(363766277)

這樣做可以實現了高地位更加均勻地混到一起。

下面給出在Java中幾個常用的哈希碼(hashCode)的算法。

  1. Object類的hashCode. 返回對象的經過處理後的內存地址,由於每個對象的內存地址都不一樣,所以哈希碼也不一樣。這個是native方法,取決於JVM的內部設計,一般是某種C地址的偏移。

  2. String類的hashCode. 根據String類包含的字符串的內容,根據一種特殊算法返回哈希碼,只要字符串的內容相同,返回的哈希碼也相同。

  3. Integer等包裝類,返回的哈希碼就是Integer對象裏所包含的那個整數的數值,例如Integer i1=new Integer(100), i1.hashCode的值就是100 。由此可見,2個一樣大小的Integer對象,返回的哈希碼也一樣。

  4. int,char這樣的基礎類,它們不需要hashCode,如果需要存儲時,將進行自動裝箱操作,計算方法同上。

6.2.2 獲取到數組的index的位置

計算了Hash,我們現在要把它插入數組中了

i = (tab.length - 1) & hash;

通過位運算,確定了當前的位置,因爲HashMap數組的大小總是2^n,所以實際的運算就是 (0xfff…ff) & hash ,這裏的tab.length-1相當於一個mask,濾掉了大於當前長度位的hash,使每個i都能插入到數組中。

6.2.3 生成包裝類

這個對象是一個包裝類,Node

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        //getter and setter .etc.
}

6.2.4 插入包裝類到數組

(1). 如果輸入當前的位置是空的,就插進去,如圖,左爲插入前,右爲插入後

    0           0
    |           |
    1 -> null   1 - > null
    |           |
    2 -> null   2 - > null
    |           | 
    ..-> null   ..- > null
    |           | 
    i -> null   i - > new node
    |           |
    n -> null   n - > null

(2). 如果當前位置已經有了node,且它們發生了碰撞,則新的放到前面,舊的放到後面,這叫做鏈地址法處理衝突。

    0           0
    |           |
    1 -> null   1 - > null
    |           |
    2 -> null   2 - > null
    |           | 
    ..-> null   ..- > null
    |           | 
    i -> old    i - > new - > old
    |           |
    n -> null   n - > null

我們可以發現,失敗的hashCode算法會導致HashMap的性能由數組下降爲鏈表,所以想要避免發生碰撞,就要提高hashCode結果的均勻性。

6.3 擴容

如果當表中的75%已經被佔用,即視爲需要擴容了

(threshold = capacity * load factor ) < size

它主要有兩個步驟:

6.3.1 容量加倍

左移1位,就是擴大到兩倍,用位運算取代了乘法運算

newCap = oldCap << 1;
newThr = oldThr << 1;

6.3.2 遍歷計算Hash

for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        //如果發現當前有Bucket
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            //如果這裏沒有碰撞
            if (e.next == null)
                //重新計算Hash,分配位置
                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 = e.next;
                    if ((e.hash & oldCap) == 0) {
                        if (loTail == null)
                            loHead = e;
                        else
                            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;
                }
            }
        }
    }

由此可以看出擴容需要遍歷並重新賦值,成本非常高,所以選擇一個好的初始容量非常重要。

6.4 擴容如何提升性能?

  • 解決擴容損失:如果知道大致需要的容量,把初始容量設置好以解決擴容損失; 
    比如我現在有1000個數據,需要 1000/0.75 = 1333 個坑位,又 1024 < 1333 < 2048,所以最好使用2048作爲初始容量。

  • 解決碰撞損失:使用高效的HashCode與loadFactor,這個…由於JDK8的高性能出現,這兒問題也不大了。

6.5 HashMap與HashTable的主要區別

在很多的Java基礎書上都已經說過了,他們的主要區別其實就是Table全局加了線程同步保護

  • HashTable線程更加安全,代價就是因爲它粗暴的添加了同步鎖,所以會有性能損失。
  • 其實有更好的concurrentHashMap可以替代HashTable,一個是方法級,一個是Class級。

6.6 在Android中使用SparseArray代替HashMap

官方推薦使用SparseArray([spɑ:s][ə’reɪ],稀疏的數組)或者LongSparseArray代替HashMap。官方總結有一下幾點好處:

  • SparseArray使用基本類型(Primitive)中的int作爲Key,不需要Pair

總結

「The Algorithm Design Manual」一書中提到,雅虎的 Chief Scientist ,Udi Manber 曾說過,在 yahoo 所應用的算法中,最重要的三個是:Hash,Hash 和 Hash。其實從上文中所舉的git用sha1判斷文件更改,密碼用MD5生成摘要後加鹽等等對Hash的應用可看出,Hash的在計算機世界扮演着多麼重要的角色。另書中還舉了一個很有趣的顯示中例子:

一場拍賣會中,物品是價高者得,如果每個人只有一次出價機會,同時提交自己的價格後,最後一起公佈,出價最高則勝出。這種形式存在作弊的可能,如果有出價者能 hack 進後臺,然後將自己的價格改爲最高價 +1,則能以最低的代價獲得勝利。如何杜絕這種作弊呢?

答案很簡單,參與者都提交自身出價的 hash 值就可以了,即使有人能黑進後臺也無法得知明文價格,等到公佈之時,再對比原出價與 hash 值是否對應即可。是不是很巧妙?

是的,上面的做法,與上文提到的網站上儲存密碼用MD5 值而非明文,是同一種思想,殊途同歸。

可以看到無論是密碼學、數據結構、現實生活中的應用,到處可以看到Hash的影子,通過這篇文章的介紹,相信你不僅知其名,也能懂其意。

發佈了31 篇原創文章 · 獲贊 23 · 訪問量 50萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章