堆 堆排序 優先隊列 圖文詳解(Golang實現)

引入

在實際應用中,我們經常需要從一組對象中查找最大值最小值。當然我們可以每次都先排序,然後再進行查找,但是這種做法效率很低。哪么有沒有一種特殊的數據結構,可以高效率的實現我們的需求呢,答案就是堆(heap)

堆分為最小堆和最大堆,它們的性質相似,我們以最小堆為例子。

最小堆

舉例

如上圖所示,就為一個最小堆。

特性

  • 是一棵完全二叉樹

如果一顆二叉樹的任何結點,或者是樹恭弘=叶 恭弘,或者左右子樹均非空,則這棵二叉樹稱做滿二叉樹(full binary tree)

如果一顆二叉樹最多只有最下面的兩層結點度數可以小於2,並且最下面一層的結點都集中在該層最左邊的連續位置上,則此二叉樹稱做完全二叉樹(complete binary tree)

  • 局部有序

最小堆對應的完全二叉樹中所有結點的值均不大於其左右子結點的值,且一個結點與其兄弟之間沒有必然的聯繫

二叉搜索樹中,左子 < 父 < 右子

存儲結構

由於堆是一棵完全二叉樹,所以我們可以用順序結構來存儲它,只需要計算簡單的代數表達式,就能夠非常方便的查找某個結點的父結點和子節點,既避免了使用指針來保持結構,又能高效的執行相應操作。

結點i的左子結點為2xi+1,右子結點為2xi+2
結點i的父節點為(i-1)/2

數據結構

// 本例為最小堆
// 最大堆只需要修改less函數即可
type Heap []int

func (h Heap) swap(i, j int) {
    h[i], h[j] = h[j], h[i]
}

func (h Heap) less(i, j int) bool {
    return h[i] < h[j]
}

如上所示,我們使用slice來存儲我們的數據,為了後續方便我們在此定義了 swapless 函數,分別用來交換兩個結點和比較大小。

插入-Push

如上圖所示,首先,新添加的元素加入末尾。為了保持最小堆的性質,需要沿着其祖先的路徑,自下而上依次比較和交換該結點與父結點的位置,直到重新滿足堆的性質為止。

這樣會出現兩種情況,要麼新結點升到最小堆的頂端,要麼到某一位置時發現父結點比新插入的結點關鍵值小。

上面的流程代碼如下:

func (h Heap) up(i int) {
    for {
        f := (i - 1) / 2 // 父親結點
        if i == f || h.less(f, i) {
            break
        }
        h.swap(f, i)
        i = f
    }
}

實現了最核心的 up 操作后,我們的插入操作 push 便很簡單,代碼如下:

// 注意go中所有參數轉遞都是值傳遞
// 所以要讓h的變化在函數外也起作用,此處得傳指針
func (h *Heap) Push(x int) {
    *h = append(*h, x)
    h.up(len(*h) - 1)
}

刪除-Remove

如上圖所示,首先把最末端的結點填入要刪除節點的位置,然後刪除末端元素,同理,這樣做也可能導致破壞最小堆的堆序特性。

為了保持堆的特性,末端元素需要與被刪除位置的父結點做比較,如果小於父結點,就要up(詳細代碼看插入)如果大於父結點,就要再和被刪除位置的子結點做比較,即down,直到該結點下降到小於最小子結點為止。

上面down的流程代碼如下:

func (h Heap) down(i int) {
    for {
        l := 2*i + 1 // 左孩子
        if l >= len(h) {
            break // i已經是恭弘=叶 恭弘子結點了
        }
        j := l
        if r := l + 1; r < len(h) && h.less(r, l) {
            j = r // 右孩子
        }
        if h.less(i, j) {
            break // 如果父結點比孩子結點小,則不交換
        }
        h.swap(i, j) // 交換父結點和子結點
        i = j        //繼續向下比較
    }
}

實現了核心的 down 操作后,我們的 Remove 便很簡單,代碼如下:

// 刪除堆中位置為i的元素
// 返回被刪元素的值
func (h *Heap) Remove(i int) (int, bool) {
    if i < 0 || i > len(*h)-1 {
        return 0, false
    }
    n := len(*h) - 1
    h.swap(i, n) // 用最後的元素值替換被刪除元素
    // 刪除最後的元素
    x := (*h)[n]
    *h = (*h)[0:n]
    // 如果當前元素大於父結點,向下篩選
    if (*h)[i] > (*h)[(i-1)/2] {
        h.down(i)
    } else { // 當前元素小於父結點,向上篩選
        h.up(i)
    }
    return x, true
}

彈出-Pop

當i=0時,Remove 就是 Pop

// 彈出堆頂的元素,並返回其值
func (h *Heap) Pop() int {
    n := len(*h) - 1
    h.swap(0, n)
    x := (*h)[n]
    *h = (*h)[0:n]
    h.down(0)
    return x
}

初始化-Init

在我們講完了堆的核心操作 updown 后,我們來講如何根據一個數組構造一個最小堆。

其實我們可以寫個循環,然後將各個元素依次 push 進去,但是這次我們利用數學規律,直接由一個數組構造最小堆。

首先,將所有關鍵碼放到一維數組中,此時形成的完全二叉樹並不具備最小堆的特徵,但是僅包含恭弘=叶 恭弘子結點的子樹已經是堆。

即在有n個結點的完全二叉樹中,當 i>n/2-1 時,以i結點為根的子樹已經是堆。

func (h Heap) Init() {
    n := len(h)
    // i > n/2-1 的結點為恭弘=叶 恭弘子結點本身已經是堆了
    for i := n/2 - 1; i >= 0; i-- {
        h.down(i)
    }
}

測試

func main() {
    var h = heap.Heap{20, 7, 3, 10, 15, 25, 30, 17, 19}
    h.Init()
    fmt.Println(h) // [3 7 20 10 15 25 30 17 19]

    h.Push(6)
    fmt.Println(h) // [3 6 20 10 7 25 30 17 19 15]

    x, ok := h.Remove(5)
    fmt.Println(x, ok, h) // 25 true [3 6 15 10 7 20 30 17 19]

    y, ok := h.Remove(1)
    fmt.Println(y, ok, h) // 6 true [3 7 15 10 19 20 30 17]

    z := h.Pop()
    fmt.Println(z, h) // 3 [7 10 15 17 19 20 30]
}

完整代碼

堆排序

在講完堆的基礎知識后,我們再來看堆排序就變得非常簡單。利用最小堆的特性,我們每次都從堆頂彈出一個元素(這個元素就是當前堆中的最小值),即可實現升序排序。代碼如下:

// 堆排序
var res []int
for len(h) != 0 { 
    res = append(res, h.Pop())
}
fmt.Println(res)

優先隊列

優先隊列是0個或者多個元素的集合,每個元素都有一個關鍵碼,執行的操作有查找,插入和刪除等。

優先隊列的主要特點是支持從一個集合中快速地查找並移出具有最大值或最小值的元素。

堆是一種很好的優先隊列的實現方法。

參考資料

  • 《數據結構與算法》張銘 王騰蛟 趙海燕 編著
  • GO SDK 1.13.1 /src/container/heap

最後

本文是自己的學習筆記,在刷了幾道LeetCode中關於堆的題目后,感覺應該系統的學習和總結一下這一重要的數據結構了。

強烈建議看Go的源碼中關於heap的實現,仔細感受面向接口編程的思想,和他們的代碼風格以及質量。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

【集合系列】- 深入淺出的分析 Hashtable

一、摘要

在集合系列的第一章,咱們了解到,Map 的實現類有 HashMap、LinkedHashMap、TreeMap、IdentityHashMap、WeakHashMap、Hashtable、Properties 等等。

本文主要從數據結構和算法層面,探討 Hashtable 的實現,如果有理解不當之處,歡迎指正。

二、簡介

Hashtable 一個元老級的集合類,早在 JDK 1.0 就誕生了,而 HashMap 誕生於 JDK 1.2,在實現上,HashMap 吸收了很多 Hashtable 的思想,雖然二者的底層數據結構都是 數組 + 鏈表 結構,具有查詢、插入、刪除快的特點,但是二者又有很多的不同。

打開 Hashtable 的源碼可以看到,Hashtable 繼承自 Dictionary,而 HashMap 繼承自 AbstractMap。

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {
    .....
}

HashMap 繼承自 AbstractMap,HashMap 類的定義如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    .....
}

其中 Dictionary 類是一個已經被廢棄的類,翻譯過來的意思是這個類已經過時,新的實現應該實現 Map 接口而不是擴展此類,這一點我們可以從它代碼的註釋中可以看到:

/**
 * <strong>NOTE: This class is obsolete.  New implementations should
 * implement the Map interface, rather than extending this class.</strong>
 */
public abstract
class Dictionary<K,V> {
    ......
}

Hashtable 和 HashMap 的底層是以數組來存儲,同時,在存儲數據通過key計算數組下標的時候,是以哈希算法為主,因此可能會產生哈希衝突的可能性。

通俗的說呢,就是不同的key,在計算的時候,可能會產生相同的數組下標,這個時候,如何將兩個對象放入一個數組中呢?

而解決哈希衝突的辦法,有兩種,一種開放地址方式(當發生 hash 衝突時,就繼續以此繼續尋找,直到找到沒有衝突的hash值),另一種是拉鏈方式(將衝突的元素放入鏈表)。

Java Hashtable 採用的就是第二種方式,拉鏈法!

於是,當發生不同的key通過一系列的哈希算法計算獲取到相同的數組下標的時候,會將對象放入一個數組容器中,然後將對象以單向鏈表的形式存儲在同一個數組下標容器中,就像鏈子一樣,掛在某個節點上,如下圖:

與 HashMap 類似,Hashtable 也包括五個成員變量:

/**由Entry對象組成的數組*/
private transient Entry[] table;

/**Hashtable中Entry對象的個數*/
private transient int count;

/**Hashtable進行擴容的閾值*/
private int threshold;

/**負載因子,默認0.75*/
private float loadFactor;

/**記錄修改的次數*/
private transient int modCount = 0;

具體各個變量含義如下:

  • table:表示一個由 Entry 對象組成的鏈表數組,Entry 是一個單向鏈表,哈希表的key-value鍵值對都是存儲在 Entry 數組中的;
  • count:表示 Hashtable 的大小,用於記錄保存的鍵值對的數量;
  • threshold:表示 Hashtable 的閾值,用於判斷是否需要調整 Hashtable 的容量,threshold 等於容量 * 加載因子;
  • loadFactor:表示負載因子,默認為 0.75;
  • modCount:表示記錄 Hashtable 修改的次數,用來實現快速失敗拋異常處理;

接着來看看Entry這個內部類,Entry用於存儲鏈表數據,實現了Map.Entry接口,本質是就是一個映射(鍵值對),源碼如下:

 private static class Entry<K,V> implements Map.Entry<K,V> {
         /**hash值*/
        final int hash;
        /**key表示鍵*/
        final K key;
        /**value表示值*/
        V value;
        /**節點下一個元素*/
        Entry<K,V> next;
        ......
}

我們再接着來看看 Hashtable 初始化過程,核心源碼如下:

public Hashtable() {
    this(11, 0.75f);
}

this 調用了自己的構造方法,核心源碼如下:

public Hashtable(int initialCapacity, float loadFactor) {
        .....
        //默認的初始大小為 11
        //並且計算擴容的閾值
        this.loadFactor = loadFactor;
        table = new Entry<?,?>[initialCapacity];
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}

可以看到 HashTable 默認的初始大小為 11,如果在初始化給定容量大小,那麼 HashTable 會直接使用你給定的大小

擴容的閾值threshold等於initialCapacity * loadFactor,我們在來看看 HashTable 擴容,方法如下:

protected void rehash() {
        int oldCapacity = table.length;
        //將舊數組長度進行位運算,然後 +1
        //等同於每次擴容為原來的 2n+1
        int newCapacity = (oldCapacity << 1) + 1;
        
        //省略部分代碼......
        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
}

可以看到,HashTable 每次擴充為原來的 2n+1

我們再來看看 HashMap,如果是執行默認構造方法,會在擴容那一步,進行初始化大小,核心源碼如下:

final Node<K,V>[] resize() {
    int newCap = 0;

    //部分代碼省略......
    newCap = DEFAULT_INITIAL_CAPACITY;//默認容量為 16
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
}

可以看出 HashMap 的默認初始化大小為 16,我們再來看看,HashMap 擴容方法,核心源碼如下:

final Node<K,V>[] resize() {
    //獲取舊數組的長度
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int newCap = 0;

    //部分代碼省略......
    //當進行擴容的時候,容量為 2 的倍數
    newCap = oldCap << 1;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
}

可以看出 HashMap 的擴容后的數組數量為原來的 2 倍

也就是說 HashTable 會盡量使用素數、奇數來做數組的容量,而 HashMap 則總是使用 2 的冪作為數組的容量。

我們知道當哈希表的大小為素數時,簡單的取模哈希的結果會更加均勻,所以單從這一點上看,HashTable 的哈希表大小選擇,似乎更高明些。

Hashtable 的 hash 算法,核心代碼如下:

//直接計算key.hashCode()
int hash = key.hashCode();

//通過除法取余計算數組存放下標
// 0x7FFFFFFF 是最大的 int 型數的二進製表示
int index = (hash & 0x7FFFFFFF) % tab.length;

從源碼部分可以看出,HashTable 的 key 不能為空,否則報空指針錯誤!

但另一方面我們又知道,在取模計算時,如果模數是 2 的冪,那麼我們可以直接使用位運算來得到結果,效率要大大高於做除法。所以在 hash 計算數組下標的效率上,HashMap 卻更勝一籌,但是這也會引入了哈希分佈不均勻的問題, HashMap 為解決這問題,又對 hash 算法做了一些改動,具體我們來看看。

HashMap 的 hash 算法,核心代碼如下:

/**獲取hash值方法*/
static final int hash(Object key) {
     int h;
     // h = key.hashCode() 為第一步 取hashCode值(jdk1.7)
     // h ^ (h >>> 16)  為第二步 高位參与運算(jdk1.7)
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//jdk1.8
}

/**獲取數組下標方法*/
static int indexFor(int h, int length) {
    //jdk1.7的源碼,jdk1.8沒有這個方法,但是實現原理一樣的
     return h & (length-1);  //第三步 取模運算
}

HashMap 由於使用了2的冪次方,所以在取模運算時不需要做除法,只需要位的與運算就可以了。但是由於引入的 hash 衝突加劇問題,HashMap 在調用了對象的 hashCode 方法之後,又做了一些高位運算,也就是第二步方法,來打散數據,讓哈希的結果更加均勻。

與此同時,在 jdk1.8 中 HashMap 還引進來紅黑樹實現,當衝突鏈表長度大於 8 的時候,會將鏈表結構改變成紅黑樹結構,讓查詢變得更快,具體實現可以參見《集合系列》中的 HashMap 分析

三、常用方法介紹

3.1、put方法

put 方法是將指定的 key, value 對添加到 map 里。

put 流程圖如下:

打開 HashTable 的 put 方法,源碼如下:

public synchronized V put(K key, V value) {
        //當 value 值為空的時候,拋異常!
        if (value == null) {
            throw new NullPointerException();
        }

        Entry<?,?> tab[] = table;

        //通過key 計算存儲下標
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        
        //循環遍曆數組鏈表
        //如果有相同的key並且hash相同,進行覆蓋處理
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }
        //加入數組鏈表中
        addEntry(hash, key, value, index);
        return null;
}

put 方法中的 addEntry 方法,源碼如下:

private void addEntry(int hash, K key, V value, int index) {
        //新增修改次數
        modCount++;

        Entry<?,?> tab[] = table;
        if (count >= threshold) {
           //數組容量大於擴容閥值,進行擴容
            rehash();
            
            tab = table;
            //重新計算對象存儲下標
            hash = key.hashCode();
            index = (hash & 0x7FFFFFFF) % tab.length;
        }

        //將對象存儲在數組中
        Entry<K,V> e = (Entry<K,V>) tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        count++;
}

addEntry 方法中的 rehash 方法,源碼如下:

protected void rehash() {
        int oldCapacity = table.length;
        Entry<?,?>[] oldMap = table;

        //每次擴容為原來的 2n+1
        int newCapacity = (oldCapacity << 1) + 1;
        if (newCapacity - MAX_ARRAY_SIZE > 0) {
            if (oldCapacity == MAX_ARRAY_SIZE)
                //大於最大閥值,不再擴容
                return;
            newCapacity = MAX_ARRAY_SIZE;
        }
        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];

        modCount++;
        //重新計算擴容閥值
        threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
        table = newMap;
        //將舊數組中的數據複製到新數組中
        for (int i = oldCapacity ; i-- > 0 ;) {
            for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
                Entry<K,V> e = old;
                old = old.next;

                int index = (e.hash & 0x7FFFFFFF) % newCapacity;
                e.next = (Entry<K,V>)newMap[index];
                newMap[index] = e;
            }
        }
}

總結流程如下:

  • 1、通過 key 計算對象存儲在數組中的下標;
  • 2、如果鏈表中有 key,直接進行新舊值覆蓋處理;
  • 3、如果鏈表中沒有 key,判斷是否需要擴容,如果需要擴容,先擴容,再插入數據;

有一個值得注意的地方是 put 方法加了synchronized關鍵字,所以,在同步操作的時候,是線程安全的。

3.2、get方法

get 方法根據指定的 key 值返回對應的 value。

get 流程圖如下:

打開 HashTable 的 get 方法,源碼如下:

public synchronized V get(Object key) {
        Entry<?,?> tab[] = table;
        //通過key計算節點存儲下標
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return (V)e.value;
            }
        }
        return null;
}

同樣,有一個值得注意的地方是 get 方法加了synchronized關鍵字,所以,在同步操作的時候,是線程安全的。

3.3、remove方法

remove 的作用是通過 key 刪除對應的元素。

remove 流程圖如下:

打開 HashTable 的 remove 方法,源碼如下:

public synchronized V remove(Object key) {
        Entry<?,?> tab[] = table;
        //通過key計算節點存儲下標
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        Entry<K,V> e = (Entry<K,V>)tab[index];
        //循環遍歷鏈表,通過hash和key判斷鍵是否存在
        //如果存在,直接將改節點設置為空,並從鏈表上移除
        for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                modCount++;
                if (prev != null) {
                    prev.next = e.next;
                } else {
                    tab[index] = e.next;
                }
                count--;
                V oldValue = e.value;
                e.value = null;
                return oldValue;
            }
        }
        return null;
}

同樣,有一個值得注意的地方是 remove 方法加了synchronized關鍵字,所以,在同步操作的時候,是線程安全的。

四、總結

總結一下 Hashtable 與 HashMap 的聯繫與區別,內容如下:

  • 1、雖然 HashMap 和 Hashtable 都實現了 Map 接口,但 Hashtable 繼承於 Dictionary 類,而 HashMap 是繼承於 AbstractMap;
  • 2、HashMap 可以允許存在一個為 null 的 key 和任意個為 null 的 value,但是 HashTable 中的 key 和 value 都不允許為 null;
  • 3、Hashtable 的方法是同步的,因為在方法上加了 synchronized 同步鎖,而 HashMap 是非線程安全的;

儘管,Hashtable 雖然是線程安全的,但是我們一般不推薦使用它,因為有比它更高效、更好的選擇 ConcurrentHashMap,在後面我們也會講到它。

最後,引入來自 HashTable 的註釋描述:

If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable. If a thread-safe highly-concurrent implementation is desired, then it is recommended to use java.util.concurrent.ConcurrentHashMap in place of Hashtable.

簡單來說就是,如果你不需要線程安全,那麼使用 HashMap,如果需要線程安全,那麼使用 ConcurrentHashMap。

HashTable 已經被淘汰了,不要在新的代碼中再使用它。

五、參考

1、JDK1.7&JDK1.8 源碼

2、

作者:炸雞可樂
出處:

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

智慧手機時代的來臨,RWD網頁設計已成為網頁設計推薦首選

想知道網站建置、網站改版該如何進行嗎?將由專業工程師為您規劃客製化網頁設計及後台網頁設計

帶您來看台北網站建置台北網頁設計,各種案例分享

廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

基於 HTML5 WebGL 和 VR 技術的 3D 機房數據中心可視化

 

前言

在 3D 機房數據中心可視化應用中,隨着視頻監控聯網系統的不斷普及和發展, 網絡攝像機更多的應用於監控系統中,尤其是高清時代的來臨,更加快了網絡攝像機的發展和應用。

在監控攝像機數量的不斷龐大的同時,在監控系統中面臨着嚴峻的現狀問題:海量視頻分散、孤立、視角不完整、位置不明確等問題,始終圍繞着使用者。因此,如何更直觀、更明確的管理攝像機和掌控視頻動態,已成為提升視頻應用價值的重要話題。所以當前項目正是從解決此現狀問題的角度,應運而生。圍繞如何提高、管理和有效利用前端設備採集的海量信息為公共安全服務,特別是在技術融合大趨勢下,如何結合當前先進的視頻融合,虛實融合、三維動態等技術,實現三維場景實時動態可視化監控,更有效的識別、分析、挖掘海量數據的有效信息服務公共應用,已成為視頻監控平台可視化發展的趨勢和方向。目前,在監控行業中,海康、大華等做監控行業領導者可基於這樣的方式規劃公共場所園區等的攝像頭規劃安放布局,可以通過海康、大華等攝像頭品牌的攝像頭參數,調整系統中攝像頭模型的可視範圍,監控方向等,更方便的讓人們直觀的了解攝像頭的監控區域,監控角度等。

以下是項目地址:

效果預覽

整體場景-攝像頭效果圖

局部場景-攝像頭效果圖

代碼生成
攝像頭模型及場景

項目中使用的攝像頭模型是通過 3dMax 建模生成的,該建模工具可以導出 obj 與 mtl 文件,在 HT 中可以通過解析 obj 與 mtl 文件來生成 3d 場景中的攝像頭模型。

項目中場景通過 HT 的 3d 編輯器進行搭建,場景中的模型有些是通過 HT 建模,有些通過 3dMax 建模,之後導入 HT 中,場景中的地面白色的燈光,是通過 HT 的 3d 編輯器進行地面貼圖呈現出來的效果。

錐體建模

3D 模型是由最基礎的三角形面拼接合成,例如 1 個矩形可以由 2 個三角形構成,1 個立方體由 6 個面即 12 個三角形構成, 以此類推更複雜的模型可以由許多的小三角形組合合成。因此 3D 模型定義即為對構造模型的所有三角形的描述, 而每個三角形由三個頂點 vertex 構成, 每個頂點 vertex 由 x, y, z 三維空間坐標決定,HT 採用右手螺旋定則來確定三個頂點構造三角形面的正面。

HT 中通過 ht.Default.setShape3dModel(name, model) 函數,可註冊自定義 3D 模型,攝像頭前方生成的錐體便是通過該方法生成。可以將該錐體看成由 5 個頂點,6 個三角形組成,具體圖如下:

ht.Default.setShape3dModel(name, model)

1. name 為模型名稱,如果名稱與預定義的一樣,則會替換預定義的模型 
2. model 為JSON類型對象,其中 vs 表示頂點坐標數組,is 表示索引數組,uv 表示貼圖坐標數組,如果想要單獨定義某個面,可以通過 bottom_vs,bottom_is,bottom_uv,top_vs,top_is, top_uv 等來定義,之後便可以通過 shape3d.top.*, shape3d.bottom.*  等單獨控制某個面

以下是我定義模型的代碼:

// camera 是當前的攝像頭圖元
// fovy 為攝像頭的張角的一半的 tan 值
var setRangeModel = function(camera, fovy) {
    var fovyVal = 0.5 * fovy; var pointArr = [0, 0, 0, -fovyVal, fovyVal, 0.5, fovyVal, fovyVal, 0.5, fovyVal, -fovyVal, 0.5, -fovyVal, -fovyVal, 0.5]; ht.Default.setShape3dModel(camera.getTag(), [{ vs: pointArr, is: [2, 1, 0, 4, 1, 0, 4, 3, 0, 3, 2, 0], from_vs: pointArr.slice(3, 15), from_is: [3, 1, 0, 3, 2, 1], from_uv: [0, 0, 1, 0, 1, 1, 0, 1] }]); }

我將當前攝像頭的 tag 標籤值作為模型的名稱,tag 標籤在 HT 中用於唯一標識一個圖元,用戶可以自定義 tag 的值。通過 pointArr 記錄當前五面體的五個頂點坐標信息,代碼中通過 from_vs, from_is, from_uv 單獨構建五面體底面,底面用於显示當前攝像頭呈現的圖像。

代碼中設置了錐體 style 對象的 wf.geometry 屬性,通過該屬性可以為錐體添加模型的線框,增強模型的立體效果,並且通過 wf.color,wf.width 等參數調節線框的顏色,粗細等。

相關模型 style 屬性的設置代碼如下:

 1 rangeNode.s({
 2     'shape3d': cameraName, 3 // 攝像頭模型名稱 4 'shape3d.color': 'rgba(52, 148, 252, 0.3)', 5 // 錐體模型顏色 6 'shape3d.reverse.flip': true, 7 // 錐體模型的反面是否显示正面的內容 8 'shape3d.light': false, 9 // 錐體模型是否受光線影響 10 'shape3d.transparent': true, 11 // 錐體模型是否透明 12 '3d.movable': false, 13 // 錐體模型是否可移動 14 'wf.geometry': true // 是否显示錐體模型線框 15 });

攝像頭圖像生成原理

透視投影

透視投影是為了獲得接近真實三維物體的視覺效果而在二維的紙或者畫布平面上繪圖或者渲染的一種方法,它也稱為透視圖。 透視使得遠的對象變小,近的對象變大,平行線會出現先交等更更接近人眼觀察的視覺效果。

如上圖所示,透視投影最終显示到屏幕上的內容只有截頭錐體( View Frustum )部分的內容, 因此 Graph3dView 提供了 eye, center, up, far,near,fovy 和 aspect 參數來控制截頭錐體的具體範圍。具體的透視投影可以參考 HT for Web 的  手冊。

根據上圖的描述,在本項目中可以在攝像頭初始化之後,緩存當前 3d 場景 eyes 眼睛的位置,以及 center 中心的位置,之後將 3d 場景 eyes 眼睛和 center 中心設置成攝像頭中心點的位置,然後在這個時刻獲取當前 3d 場景的截圖,該截圖即為當前攝像頭的監控圖像,之後再將 3d 場景的 center 與 eyes 設置成開始時緩存的 eyes 與 center 位置,通過該方法即可實現 3d 場景中任意位置的快照,從而實現攝像頭監控圖像實時生成。

相關偽代碼如下:

 1 function getFrontImg(camera, rangeNode) {
 2     var oldEye = g3d.getEye(); 3 var oldCenter = g3d.getCenter(); 4 var oldFovy = g3d.getFovy(); 5  g3d.setEye(攝像頭位置); 6  g3d.setCenter(攝像頭朝向); 7  g3d.setFovy(攝像頭張角); 8  g3d.setAspect(攝像頭寬高比); 9  g3d.validateImp(); 10  g3d.toDataURL(); 11  g3d.setEye(oldEye);; 12  g3d.setCenter(oldCenter); 13  g3d.setFovy(oldFovy); 14  g3d.setAspect(undefined); 15  g3d.validateImp(); 16 }

經過測試之後,通過該方法進行圖像的獲取會導致頁面有所卡頓,因為是獲取當前 3d 場景的整體截圖,由於當前3d場景是比較大的,所以 toDataURL 獲取圖像信息是非常慢的,因此我採取了離屏的方式來獲取圖像,具體方式如下:
   1. 創建一個新的 3d 場景,將當前場景的寬度與高度都設置為 200px 的大小,並且當前 3d 場景的內容與主屏的場景是一樣的,HT中通過 new ht.graph3d.Graph3dView(dataModel) 來新建場景,其中的 dataModel 為當前場景的所有圖元,所以主屏與離屏的 3d 場景都共用同一個 dataModel,保證了場景的一致。
   2. 將新創建的場景位置設置成屏幕看不到的地方,並且添加進 dom 中。
   3. 將之前對主屏獲取圖像的操作變成對離屏獲取圖像的操作,此時離屏圖像的大小相對之前主屏獲取圖像的大小小很多,並且離屏獲取不需要保存原來的眼睛 eyes 的位置以及 center 中心的位置,因為我們沒有改變主屏的 eyes 與 center 的位置, 所以也減少的切換帶來的開銷,大大提高了攝像頭獲取圖像的速度。

以下是該方法實現的代碼:

 1 function getFrontImg(camera, rangeNode) {
 2     // 截取當前圖像時將該攝像頭所屬的五面體隱藏
 3     rangeNode.s('shape3d.from.visible', false); 4 rangeNode.s('shape3d.visible', false); 5 rangeNode.s('wf.geometry', false); 6 var cameraP3 = camera.p3(); 7 var cameraR3 = camera.r3(); 8 var cameraS3 = camera.s3(); 9 var updateScreen = function() { 10  demoUtil.Canvas2dRender(camera, outScreenG3d.getCanvas()); 11  rangeNode.s({ 12 'shape3d.from.image': camera.a('canvas') 13  }); 14 rangeNode.s('shape3d.from.visible', true); 15 rangeNode.s('shape3d.visible', true); 16 rangeNode.s('wf.geometry', true); 17  }; 18 19 // 當前錐體起始位置 20 var realP3 = [cameraP3[0], cameraP3[1] + cameraS3[1] / 2, cameraP3[2] + cameraS3[2] / 2]; 21 // 將當前眼睛位置繞着攝像頭起始位置旋轉得到正確眼睛位置 22 var realEye = demoUtil.getCenter(cameraP3, realP3, cameraR3); 23 24  outScreenG3d.setEye(realEye); 25 outScreenG3d.setCenter(demoUtil.getCenter(realEye, [realEye[0], realEye[1], realEye[2] + 5], cameraR3)); 26 outScreenG3d.setFovy(camera.a('fovy')); 27  outScreenG3d.validate(); 28  updateScreen(); 29 }

 

上面代碼中有一個 getCenter 方法是用於獲取 3d 場景中點 A 繞着點 B 旋轉 angle 角度之後得到的點 A 在 3d 場景中的位置,方法中採用了 HT 封裝的 ht.Math 下面的方法,以下為代碼:

 1 // pointA 為 pointB 圍繞的旋轉點
 2 // pointB 為需要旋轉的點
 3 // r3 為旋轉的角度數組 [xAngle, yAngle, zAngle] 為繞着 x, y, z 軸分別旋轉的角度 
 4 var getCenter = function(pointA, pointB, r3) {
 5     var mtrx = new ht.Math.Matrix4(); 6 var euler = new ht.Math.Euler(); 7 var v1 = new ht.Math.Vector3(); 8 var v2 = new ht.Math.Vector3(); 9 10 mtrx.makeRotationFromEuler(euler.set(r3[0], r3[1], r3[2])); 11 12  v1.fromArray(pointB).sub(v2.fromArray(pointA)); 13  v2.copy(v1).applyMatrix4(mtrx); 14  v2.sub(v1); 15 16 return [pointB[0] + v2.x, pointB[1] + v2.y, pointB[2] + v2.z]; 17 };

這裏應用到向量的部分知識,具體如下:

OA + OB = OC

方法分為以下幾個步驟求解:

   1.  var mtrx = new ht.Math.Matrix4() 創建一個轉換矩陣,通過 mtrx.makeRotationFromEuler(euler.set(r3[0], r3[1], r3[2])) 獲取繞着 r3[0],r3[1],r3[2] 即 x 軸,y 軸,z 軸旋轉的旋轉矩陣。
   2. 通過 new ht.Math.Vector3() 創建 v1,v2 兩個向量。
   3. v1.fromArray(pointB) 為建立一個從原點到 pointB 的一個向量。
   4. v2.fromArray(pointA) 為建立一個從原點到 pointA 的一個向量。
   5. v1.fromArray(pointB).sub(v2.fromArray(pointA)) 即向量 OB – OA 此時得到的向量為 AB,此時 v1 變為向量 AB。
   6. v2.copy(v1) v2 向量拷貝 v1 向量,之後通過 v2.copy(v1).applyMatrix4(mtrx) 對 v2 向量應用旋轉矩陣,變換之後即為 v1向量繞着 pointA 旋轉之後的的向量 v2。
   7. 此時通過 v2.sub(v1) 就獲取了起始點為 pointB,終點為 pointB 旋轉之後點構成的向量,該向量此時即為 v2。
   8. 通過向量公式得到旋轉之後的點為 [pointB[0] + v2.x, pointB[1] + v2.y, pointB[2] + v2.z]。

項目中的 3D 場景例子其實是  最近貴州數博會,HT 上工業互聯網展台的 VR 示例,大眾對 VR/AR 的期待很高,但路還是得一步步走,即使融資了 23 億美金的 Magic Leap 的第一款產品也只能是 ,這話題以後再展開,這裏就上段當時現場的視頻照片:

2d 圖像貼到 3d 模型

通過上一步的介紹我們可以獲取當前攝像機位置的截屏圖像,那麼如何將當前圖像貼到前面所構建的五面體底部呢?前面通過 from_vs, from_is 來構建底部的長方形,所以在 HT 中可以通過將五面體的 style 中 shape3d.from.image 屬性設置成當前圖像,其中 from_uv 數組用來定義貼圖的位置,具體如下圖:

以下為定義貼圖位置 from_uv 的代碼:

 1 from_uv: [0, 0, 1, 0, 1, 1, 0, 1] 

from_uv 就是定義貼圖的位置數組,根據上圖的解釋,可以將 2d 圖像貼到 3d 模型的 from 面。

控制面板

HT 中通過 new ht.widget.Panel() 來生成如下圖的面板:

面板中每個攝像頭都有一個模塊來呈現當前監控圖像,其實這個地方也是一個 canvas,該 canvas 與場景中錐體前面的監控圖像是同一個 canvas,每一個攝像頭都有一個自己的 canvas 用來保存當前攝像頭的實時監控畫面,這樣就可以將該 canvas 貼到任何地方,將該 canvas 添加進面板的代碼如下:

 formPane.addRow([{ 2 element: camera.a(‘canvas’) 3 }], 240, 240); 

代碼中將 canvas 節點存儲在攝像頭圖元的 attr 屬性下面,之後便可以通過 camera.a(‘canvas’) 來獲取當前攝像頭的畫面。

在面板中的每一個控制節點都是通過 formPane.addRow 來進行添加,具體可參考 HT for Web 的。之後通過 ht.widget.Panel 將表單面板 formPane 添加進 panel 面板中,具體可參考 HT for Web 的。

部分控制代碼如下:

 1 formPane.addRow(['rotateY', {
 2  slider: { 3 min: -Math.PI, 4  max: Math.PI, 5 value: r3[1], 6 onValueChanged: function() { 7 var cameraR3 = camera.r3(); 8 camera.r3([cameraR3[0], this.getValue(), cameraR3[2]]); 9 rangeNode.r3([cameraR3[0], this.getValue(), cameraR3[2]]); 10  getFrontImg(camera, rangeNode); 11  } 12  } 13 }], [0.1, 0.15]);

控制面板通過 addRow 來添加控制元素,以上代碼為添加攝像頭繞着 y 軸進行旋轉的控制,onValueChanged 在 slider 的數值改變的時候調用,此時通過 camera.r3() 獲取當前攝像頭的旋轉參數, 由於是繞着 y 軸旋轉所以 x 軸與 z 軸的角度是不變的,變的是 y 軸的旋轉角度,所以通過 camera.r3([cameraR3[0], this.getValue(), cameraR3[2]]) 來調整攝像頭的旋轉角度以及通過 rangeNode.r3([cameraR3[0], this.getValue(), cameraR3[2]]) 來設置攝像頭前方錐體的旋轉角度,然後調用之前封裝好的 getFrontImg 函數來獲取此時旋轉角度下面的實時圖像信息。

項目中通過 Panel 面板的配置參數 titleBackground: rgba(230, 230, 230, 0.4) 即可將標題背景設置為具有透明度的背景,其它類似的 titleColor, titleHeight 等標題參數都可以配置,通過 separatorColor,separatorWidth 等分割參數可以設置內部面板之間分割線的顏色,寬度等。最後面板通過 panel.setPositionRelativeTo(‘rightTop’) 將面板的位置設置成右上角,並且通過 document.body.appendChild(panel.getView()) 將面板最外層的 div 添加進頁面中, panel.getView() 用來獲取面板的最外層 dom 節點。

具體初始化面板代碼如下:

 1 function initPanel() {
 2     var panel = new ht.widget.Panel(); 3 var config = { 4 title: "攝像頭控制面板", 5 titleBackground: 'rgba(230, 230, 230, 0.4)', 6 titleColor: 'rgb(0, 0, 0)', 7 titleHeight: 30, 8 separatorColor: 'rgb(67, 175, 241)', 9 separatorWidth: 1, 10 exclusive: true, 11  items: [] 12  }; 13 cameraArr.forEach(function(data, num) { 14 var camera = data['camera']; 15 var rangeNode = data['rangeNode']; 16 var formPane = new ht.widget.FormPane(); 17  initFormPane(formPane, camera, rangeNode); 18  config.items.push({ 19 title: "攝像頭" + (num + 1), 20 titleBackground: 'rgba(230, 230, 230, 0.4)', 21 titleColor: 'rgb(0, 0, 0)', 22 titleHeight: 30, 23 separatorColor: 'rgb(67, 175, 241)', 24 separatorWidth: 1, 25  content: formPane, 26 flowLayout: true, 27 contentHeight: 400, 28 width: 250, 29 expanded: num === 0 30  }); 31  }); 32  panel.setConfig(config); 33 panel.setPositionRelativeTo('rightTop'); 34  document.body.appendChild(panel.getView()); 35 window.addEventListener("resize", 36 function() { 37  panel.invalidate(); 38  }); 39 }

在控制面板中可以調整攝像頭的方向,攝像頭監控的輻射範圍,攝像頭前方錐體的長度等等,並且攝像頭的圖像是實時生成,以下為運行截圖:

以下是本項目採用的 3D 場景結合 HT for Web 的 VR 技術實現的操作:

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

想要讓你的商品在網路上成為最夯、最多人討論的話題?

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

不管是台北網頁設計公司台中網頁設計公司,全省皆有專員為您服務

想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師"嚨底家"!!

預訓練語言模型整理(ELMo/GPT/BERT…)

目錄

簡介

2018年ELMo/GPT/BERT的相繼提出,不斷刷新了各大NLP任務排行榜,自此,NLP終於找到了一種方法,可以像計算機視覺那樣進行遷移學習,被譽為NLP新時代的開端。
與計算機視覺領域預訓練模型不同的是,其通過採用自監督學習的方法,將大量的無監督文本送入到模型中進行學習,即可得到通用的預訓練模型,而NLP領域中無監督文本數據要多少有多少,2019年發布的後續研究工作(GPT2、Roberta、T5等)表明,採用更大的數據、更強大的煉丹爐可以不斷提高模型性能表現,至少目前看來還沒有達到上限。同時,如何縮減模型參數也成為了另一個研究熱點,並有相應的論文在今年發表(ALBERT、ELECTRA)。這一類工作為NLP研發者趟通並指明了一條光明大道:就是通過自監督學習,把大量非監督的文本充分利用起來,並將其中的語言知識編碼,對各種下游NLP任務產生巨大的积極作用。
為何預訓練語言模型能夠達到如此好的效果?主要有如下幾點:

  • word2vec等詞向量模型訓練出來的都是靜態的詞向量,即同一個詞,在任何的上下文當中,其向量表徵是相同的,顯然,這樣的一種詞向量是無法體現一個詞在不同語境中的不同含義的。
  • 我們採用預訓練模型來代替詞向量的關鍵在於,其能夠更具上下文的不同,對上下文中的詞提取符合其語境的詞表徵,該詞表徵向量為一個動態向量,即不同上下文輸入預訓練模型后,同一個詞的詞表徵向量在兩個上下文中的詞表徵是不同的。
    本文將對一下幾個模型進行簡單的總結,主要關注點在於各大模型的主要結構,預訓練任務,以及創新點:
  • ELMo
  • GPT
  • BERT
  • BERT-wwm
  • ERNIE_1.0
  • XLNET
  • ERNIE_2.0
  • RoBERTa
  • (ALBERT/ELECTRA)

預訓練任務簡介

總的來說,預訓練模型包括兩大類:自回歸語言模型與自編碼語言模型

自回歸語言模型

通過給定文本的上文,對當前字進行預測,訓練過程要求對數似然函數最大化,即:
\[max_{\theta} \ logp_{\theta}(x) = \sum_{t=1}^{T}log \ p_{\theta}(x_t|x_{<t})\]

代表模型:ELMo/GPT1.0/GPT2.0/XLNet
優點:該模型對文本序列聯合概率的密度估計進行建模,使得該模型更適用於一些生成類的NLP任務,因為這些任務在生成內容的時候就是從左到右的,這和自回歸的模式天然匹配。
缺點:聯合概率是按照文本序列從左至右進行計算的,因此無法得到包含上下文信息的雙向特徵表徵;

自編碼語言模型

BERT系列的模型為自編碼語言模型,其通過隨機mask掉一些單詞,在訓練過程中根據上下文對這些單詞進行預測,使預測概率最大化,即
\[max_{\theta} \ logp_{\theta}(\bar{x}|\hat{x}) \approx \sum_{t=1}^{T}log \ m_tp_{\theta}(x_t|\hat{x}) = \sum_{t=1}^{T}log \ m_tlog\frac{exp(H_{\theta}(\hat{x})_t^Te(x_t))}{\sum_{x’}exp(H_{\theta}(\hat{x})_t^Te(x’))}\]

其本質為去噪自編碼模型,加入的 [MASK] 即為噪聲,模型對 [MASK] 進行預測即為去噪。
優點:能夠利用上下文信息得到雙向特徵表示
缺點:其引入了獨立性假設,即每個 [MASK] 之間是相互獨立的,這使得該模型是對語言模型的聯合概率的有偏估計;另外,由於預訓練中 [MASK] 的存在,使得模型預訓練階段的數據與微調階段的不匹配,使其難以直接用於生成任務。

預訓練模型的簡介與對比

ELMo

原文鏈接:

ELMo為一個典型的自回歸預訓練模型,其包括兩個獨立的單向LSTM實現的單向語言模型進行自回歸預訓練,不使用雙向的LSTM進行編碼的原因正是因為在預訓練任務中,雙向模型將提前看到上下文表徵而對預測結果造成影響。因此,ELMo在本質上還是屬於一個單向的語言模型,因為其只在一個方向上進行編碼錶征,只是將其拼接了而已

細節

  • 引入雙向語言模型,其實是2個單向語言模型(前向和後向)的集成,這樣做的原因在上一節已經解釋過了,用共享詞向量來進行預訓練;
  • 通過保存預訓練好的2層biLSTM,提取每層的詞表徵用於下游任務;

ELMo的下游使用

  • 對於每一個字符,其每一層的ELMo表徵均為輸入詞向量與該層的雙向編碼錶征拼接而成,即:
    \[R_k = \{x^{LM}_k, \overrightarrow{h}^{LM}_{k,j}, \overleftarrow{h}^{LM}_{k,j} | j = 1, …, L\} = \{h^{LM}_{k,j}|j = 0, …, L\}\]

  • 對於下游任務而言,我們需要把所有層的ELMo表徵整合為一個單獨的向量,最簡單的方式是只用最上層的表徵,而更一般的,我們採用對所有層的ELMo表徵採取加權和的方式進行處理,即:
    \[ELMo^{task}_k = E(R_k; \theta ^{task}) = \gamma ^{task}\sum_{j=0}^L s^{task}h^{LM}_{k,j}\]

其中\(s^{task}\)可以作為學習參數,為一個歸一化的權重因子,用於表示每一層的詞向量在整體的重要性。\(\gamma ^{task}\)為縮放參數,允許具體的task模型去放縮 ELMo 的大小,因為ELMo的表徵分佈與具體任務的表徵分佈不一定是一樣的,可以將其作為一個輔助特徵參數。

  • 得到ELMo表徵之後,則需要將其用於下游任務中去,注意,ELMo的微調過程中,並不是嚴格意義上的微調,預訓練模型部分通常是固定的,不參与到後續訓練當中。具體的,有以下幾種操作方法:
    • 方法一:直接將ELMo表徵與詞向量拼接,輸入到下游任務當中去;
    • 方法二:直接將ELMo表徵與下游模型的輸出層拼接
    • 另外,還可以在ELMo模型中加入dropout, 以及採用 L2 loss的方法來提升模型。

GPT/GPT2

GPT:
GPT2:

GPT

GPT是“Generative Pre-Training”的簡稱,從名字上就可以看出其是一個生成式的預訓練模型,即與ELMo類似,是一個自回歸語言模型。與ELMo不同的是,其採用多層Transformer Decoder作為特徵抽取器,多項研究也表明,Transformer的特徵抽取能力是強於LSTM的。

細節

  • 由於GPT仍然是一個生成式的語言模型,因此需要採用Mask Multi-Head Attention的方式來避免預測當前詞的時候會看見之後的詞,因此將其稱為單向Transformer,這也是首次將Transformer應用於預訓練模型,預測的方式就是將position-wise的前向反饋網絡的輸出直接送入分類器進行預測
  • 此外整個GPT的訓練包括預訓練和微調兩個部分,或者說,對於具體的下游任務,其模型結構也必須採用與預訓練相同的結構,區別僅在於數據需要進行不同的處理

微調

對於帶有標籤\(y\)的監督數據\([x_1, …, x_m]\),我們直接將其輸入到已經完成預訓練的模型中,然後利用最後一個位置的輸出對標籤進行預測,即
\[P(y|x^1, …, x^m) = softmax(h_l^mW_y)\]

其中,\(W_y\)為分類器的參數,\(h_l^m\)為最後一層最後一個位置的輸出。則最大化優化目標即為:
\[ L_2(C) = \sum_{(x, y)}^{T}log \ P(y|x^1, …, x^m)\]

具體的,對於不同的微調任務,我們需要對數據進行如下處理:

GPT2

GPT2 與 GPT 的大致模型框架和預訓練目標是一致的,而區別主要在於以下幾個方面:

  • 其使用了更大的模型
  • 使用了數量更大、質量更高、涵蓋範圍更廣的預訓練數據
  • 採用了無監督多任務聯合訓練的方式,即對於輸入樣本,給予一個該樣本所屬的類別作為引導字符串,這使得該模型能夠同時對多項任務進行聯合訓練,並增強模型的泛化能力

其他的就不深究了

優缺點

BERT

原文鏈接:

BERT 的特徵抽取結構為雙向的 Transformer,簡單來說,就直接套用了 Attention is all you need 中的 Transformer Encoder Block 結構,雖然相比於GPT,僅僅是從單向的變為雙向的,但這也意味着 BERT 無法適用於自回歸語言模型的預訓練方式,因此,BERT提出了兩種預訓練任務來對其模型進行預訓練。

BERT的預訓練

Task 1: MLM

由於BERT需要通過上下文信息,來預測中心詞的信息,同時又不希望模型提前看見中心詞的信息,因此提出了一種 Masked Language Model 的預訓練方式,即隨機從輸入預料上 mask 掉一些單詞,然後通過的上下文預測該單詞,類似於一個完形填空任務。

在預訓練任務中,15%的 Word Piece 會被mask,這15%的 Word Piece 中,80%的時候會直接替換為 [Mask] ,10%的時候將其替換為其它任意單詞,10%的時候會保留原始Token

  • 沒有100%mask的原因
    • 如果句子中的某個Token100%都會被mask掉,那麼在fine-tuning的時候模型就會有一些沒有見過的單詞
  • 加入10%隨機token的原因
    • Transformer要保持對每個輸入token的分佈式表徵,否則模型就會記住這個[mask]是token ’hairy‘
    • 另外編碼器不知道哪些詞需要預測的,哪些詞是錯誤的,因此被迫需要學習每一個token的表示向量
  • 另外,每個batchsize只有15%的單詞被mask的原因,是因為性能開銷的問題,雙向編碼器比單項編碼器訓練要更慢

Task 2: NSP

僅僅一個MLM任務是不足以讓 BERT 解決閱讀理解等句子關係判斷任務的,因此添加了額外的一個預訓練任務,即 Next Sequence Prediction。

具體任務即為一個句子關係判斷任務,即判斷句子B是否是句子A的下文,如果是的話輸出’IsNext‘,否則輸出’NotNext‘。

訓練數據的生成方式是從平行語料中隨機抽取的連續兩句話,其中50%保留抽取的兩句話,它們符合IsNext關係,另外50%的第二句話是隨機從預料中提取的,它們的關係是NotNext的。這個關係保存在圖4中的[CLS]符號中

輸入表徵

BERT的輸入表徵由三種Embedding求和而成:

  • Token Embeddings:即傳統的詞向量層,每個輸入樣本的首字符需要設置為[CLS],可以用於之后的分類任務,若有兩個不同的句子,需要用[SEP]分隔,且最後一個字符需要用[SEP]表示終止
  • Segment Embeddings:為\([0, 1]\)序列,用來在NSP任務中區別兩個句子,便於做句子關係判斷任務
  • Position Embeddings:與Transformer中的位置向量不同,BERT中的位置向量是直接訓練出來的

Fine-tunninng

對於不同的下游任務,我們僅需要對BERT不同位置的輸出進行處理即可,或者直接將BERT不同位置的輸出直接輸入到下游模型當中。具體的如下所示:

  • 對於情感分析等單句分類任務,可以直接輸入單個句子(不需要[SEP]分隔雙句),將[CLS]的輸出直接輸入到分類器進行分類
  • 對於句子對任務(句子關係判斷任務),需要用[SEP]分隔兩個句子輸入到模型中,然後同樣僅須將[CLS]的輸出送到分類器進行分類
  • 對於問答任務,將問題與答案拼接輸入到BERT模型中,然後將答案位置的輸出向量進行二分類並在句子方向上進行softmax(只需預測開始和結束位置即可)
  • 對於命名實體識別任務,對每個位置的輸出進行分類即可,如果將每個位置的輸出作為特徵輸入到CRF將取得更好的效果。

缺點

  • BERT的預訓練任務MLM使得能夠藉助上下文對序列進行編碼,但同時也使得其預訓練過程與中的數據與微調的數據不匹配,難以適應生成式任務
  • 另外,BERT沒有考慮預測[MASK]之間的相關性,是對語言模型聯合概率的有偏估計
  • 由於最大輸入長度的限制,適合句子和段落級別的任務,不適用於文檔級別的任務(如長文本分類);
  • 適合處理自然語義理解類任務(NLU),而不適合自然語言生成類任務(NLG)

ELMo/GPT/BERT對比,其優缺點

ELMo/GPT/BERT 均為在2018年提出的三個模型,且性能是依次提高的,這裏將其放在一起對比,來看看這三者之間的主要區別有哪些

  • ELMo 的特徵提取器為LSTM,特徵抽取能力明顯較Transformer更弱,且并行能力較差
  • ELMo/GPT 均為單向語言模型,即自回歸語言模型,天生適合用於處理生成式任務,但這種特性也決定了無法提取上下文信息用於序列編碼
  • BERT採用雙向Transformer作為特徵抽取結構,能夠有效提取上下文信息用於序列編碼

BERT-wwm

原文鏈接:
Github鏈接:

Whole Word Masking (wwm),暫翻譯為全詞Mask或整詞Mask,是哈工大訊飛聯合實驗室提出的BERT中文預訓練模型的升級版本,主要更改了原預訓練階段的訓練樣本生成策略。 簡單來說,原有基於WordPiece的分詞方式會把一個完整的詞切分成若干個子詞,在生成訓練樣本時,這些被分開的子詞會隨機被mask。

在全詞Mask中,如果一個完整的詞的部分WordPiece子詞被mask,則同屬該詞的其他部分也會被mask,即全詞Mask。這樣的做法強制模型預測整個的詞,而不是詞的一部分,即對同一個詞不同字符的預測將使得其具有相同的上下文,這將加強同一個詞不同字符之間的相關性,或者說引入了先驗知識,使得BERT的獨立性假設在同一個詞的預測上被打破,但又保證了不同的詞之間的獨立性。

作者將全詞Mask的方法應用在了中文中,使用了中文維基百科(包括簡體和繁體)進行訓練,並且使用了哈工大LTP作為分詞工具,即對組成同一個詞的漢字全部進行Mask。這樣一個簡單的改進,使得同樣規模的模型,在中文數據上的表現獲得了全方位的提升

RoBERTa

從模型結構上看,RoBERTa基本沒有什麼太大創新,最主要的區別有如下幾點:

  • 移除了NSP這個預訓練任務,效果變得更好
  • 動態改變mask策略,把數據複製10份,然後統一進行隨機mask;

  • 其他的區別就在於學習率/數據量/batch_size 等

ERNIE(艾尼) 1.0

作者認為BERT在中文文本中的MLM預訓練模型很容易使得模型提取到字搭配這種低層次的語義信息,而對於短語以及實體層次的語義信息抽取能力是較弱的。因此將外部知識引入大規模預訓練語言模型中,提高在知識驅動任務上的性能。具體有如下三個層次的預訓練任務:

  • Basic-Level Masking: 跟bert一樣對單字進行mask,很難學習到高層次的語義信息;
  • Phrase-Level Masking: 輸入仍然是單字級別的,mask連續短語;
  • Entity-Level Masking: 首先進行實體識別,然後將識別出的實體進行mask。

ERNIE 2.0

ERNIE 2.0相比於 1.0 來說,主要的改進在於採取 Multi-task learning(多任務同時學習,同時學習的任務數量逐漸增多)以及 Continue-Learning(不同任務組合輪番學習)的機制。其訓練任務包括了三個級別的任務:

  • 詞級別:
    • Knowledge Masking(短語Masking)
    • Capitalization Prediction(大寫預測)
    • Token-Document Relation Prediction(詞是否會出現在文檔其他地方)
  • 結構級別
    • Sentence Reordering(句子排序分類)
    • Sentence Distance(句子距離分類)
  • 語義級別:
    • Discourse Relation(句子語義關係)
    • IR Relevance(句子檢索相關性)

XLNet

XLNet針對自回歸語言模型單向編碼以及BERT類自編碼語言模型的有偏估計的缺點,提出了一種廣義自回歸語言預訓練方法。

提出背景

  • 傳統的語言模型(自回歸語言模型AR天然適合處理生成任務,但是無法對雙向上下文進行表徵;
  • 而自編碼語言模型(AE)雖然可以實現雙向上下文進行表徵,但是:
    • BERT系列模型引入獨立性假設,沒有考慮預測[MASK]之間的相關性;
    • MLM預訓練目標的設置造成預訓練過程和生成過程不一致;
    • 預訓練時的[MASK]噪聲在finetune階段不會出現,造成兩階段不匹配問題;
  • XLNet提出了一種排列語言模型(PLM),它綜合了自回歸模型和自編碼模型的優點,同時避免他們的缺點

排列語言模型(Permutation Language Model,PLM)

排列語言模型的思想就是在自回歸和自編碼的方式中間額外添加一個步驟,即可將兩者完美統一起來,具體的就是希望語言模型從左往右預測下一個字符的時候,不僅要包含上文信息,同時也要能夠提取到對應字符的下文信息,且不需要引入Mask符號。即在保證位置編碼不變的情況下,將輸入序列的順序打亂,然後預測的順序還是按照原始的位置編碼順序來預測的,但是相應的上下文就是按照打亂順序的上下文來看了,這樣以來,預測對象詞的時候,可以隨機的看到上文信息和下文信息。另外,假設序列長度為\(T\),則我們如果遍歷\(T!\)種分解方法,並且模型參數是共享的,PLM就一定可以學習到預測詞的所有上下文信息。但顯然,遍歷\(T!\)種上下文計算量是十分大的,XLNet採用的是一個部分預測的方法(Partial Prediction),為了減少計算量,作者只對隨機排列后的末尾幾個詞進行預測,並使得如下期望最大化:
\[max_{\theta} \ E_{Z \sim Z_T}[\sum_{t = 1}^{T}logp_{\theta}(x_{z_t}|x_{z < t})]\]

Two-Stream Self-Attention

直接用標準的Transformer來建模PLM,會出現沒有目標(target)位置信息的問題。即在打亂順序之後,我們並不知道下一個要預測的詞是一個什麼詞,這將導致用相同上文預測不同目標的概率是相同的。

XLNet引入了雙流自注意力機制(Two-Stream Self-Attention)來解決這個問題。Two-Stream Self-Attention表明了其有兩個分離的Self-Attention信息流:

  • Query Stream 就為了找到需要預測的當前詞,這個信息流的Self-Attention的Query輸入是僅包含預測詞的位置信息,而Key和Value為上下文中包含內容信息和位置信息的輸入,表明我們無法看見預測詞的內容信息,該信息是需要我們去預測的;
  • Content Stream 主要為 Query Stream 提供其它詞的內容向量,其Query輸入為包含預測詞的內容信息和位置信息,Value和Key的輸入為選中上下文的位置信息和內容信息;

兩個信息流的輸出同樣又作為對應的下一層的雙信息流的輸入。而隨機排列機制實際上是在內部用Mask Attention的機制實現的。

Transformer-XL

Transformer-XL是 XLNet 的特徵抽取結構,其相比於傳統的Transformer能捕獲更長距離的單詞依賴關係。

原始的Transformer的主要缺點在於,其在語言建模中會受到固定長度上下文的限制,從而無法捕捉到更長遠的信息。

Transformer-XL採用片段級遞歸機制(segment-level recurrence mechanism)和相對位置編碼機制(relative positional encoding scheme)來對Transformer進行改進。

  • 片段級遞歸機制:指的是當前時刻的隱藏信息在計算過程中,將通過循環遞歸的方式利用上一時刻較淺層的隱藏狀態,這使得每次的計算將利用更大長度的上下文信息,大大增加了捕獲長距離信息的能力。

  • 相對位置編碼:Transformer本身引入了三角函數向量作為位置編碼向量。而Transformer-XL復用了上文的信息,這就導致位置編碼出現重疊,因此採用了訓練的方式得到相對位置編碼向量。

ALBERT

未完待續…

參考鏈接

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

Golang 入門系列(十六)鎖的使用場景主要涉及到哪些?讀寫鎖為什麼會比普通鎖快

前面已經講過很多Golang系列知識,感興趣的可以看看以前的文章,,

接下來要說的是golang的鎖的使用場景主要涉及到哪些?讀寫鎖為什麼會比普通鎖快。

 

一、什麼場景下需要用到鎖

當程序中就一個線程的時候,是不需要加鎖的,但是通常實際的代碼不會只是單線程,有可能是多個線程同時訪問公共資源,所以這個時候就需要用到鎖了,那麼關於鎖的使用場景主要涉及到哪些呢?

1. 多個線程在讀相同的數據時
2. 多個線程在寫相同的數據時
3. 同一個資源,有讀又有寫時

 

二、Go中鎖分為兩種:

  • 互斥鎖 (sync.Mutex)
  • 讀寫鎖 (sync.RWMutex 底層依賴Mutex實現  )

互斥鎖是併發程序對公共資源訪問限制最常見的方式。在Go中,sync.Mutex 提供了互斥鎖的實現。

當一個goroutine獲得了Mutex后,其他goroutine只能等待,除非該goroutine釋放這個Mutex。

互斥鎖結構:

type Mutex struct {
    state int32
    sema  uint32
}

1. 鎖定狀態值為1,未鎖定狀態鎖未0 。

2. Lock()加鎖、Unlock解鎖。

 

讀寫鎖則是對讀寫操作進行加鎖。需要注意的是多個讀操作之間不存在互斥關係,這樣提高了對共享資源的訪問效率。

Go中讀寫鎖由 sync.RWMutex 提供,RWMutex在讀鎖佔用的情況下,會阻止寫,但不阻止讀。RWMutex在寫鎖佔用情況下,會阻止任何其他goroutine(無論讀和寫)進來,整個鎖相當於由該goroutine獨佔。

讀寫鎖結構:

type RWMutex struct {
    w           Mutex  // held if there are pending writers
    writerSem   uint32 // semaphore for writers to wait for completing readers
    readerSem   uint32 // semaphore for readers to wait for completing writers
    readerCount int32  // number of pending readers
    readerWait  int32  // number of departing readers
}

1. RWMutex是單寫多讀鎖,該鎖可以加多個讀鎖或者一個寫鎖。

2. 讀鎖佔用的情況會阻止寫,不會阻止讀,多個goroutine可以同時獲取讀鎖。

3. 寫鎖會阻止其他gorotine不論讀或者寫進來,整個鎖由寫鎖goroutine佔用 與第一條共用示範代碼

4. 適用於讀多寫少的場景

三、如何使用互斥鎖

Mutex為互斥鎖,Lock() 加鎖,Unlock() 解鎖,使用Lock() 加鎖后,便不能再次對其進行加鎖,直到利用Unlock()解鎖對其解鎖后,才能再次加鎖.適用於讀寫不確定場景,即讀寫次數沒有明顯的區別,並且只允許只有一個讀或者寫的場景,所以該鎖恭弘=叶 恭弘叫做全局鎖。

互斥鎖只能鎖定一次,當在解鎖之前再次進行加鎖,便會無法加鎖。如果在加鎖前解鎖,便會報錯”panic: sync: unlock of unlocked mutex”。 

package main
import ("fmt"
    "sync"
)

var (
    count int
    lock sync.Mutex
)

func main() {
    for i := 0; i < 2; i++ {
        go func() {
            for i := 1000000; i > 0; i-- {
                lock.Lock()
                count ++
                lock.Unlock()
            }
            fmt.Println(count)
        }()
    }

    fmt.Scanf("\n") //等待子線程全部結束
}

運行結果:
1952533
2000000 //最後的線程打印輸出

對於上面的程序,a作為一個公共的資源,所以對a的改變、讀寫等操作都需要加鎖。

 

需要注意的問題:

  1. 不要重複鎖定互斥鎖
  2. 不要忘記解鎖互斥鎖,必要時使用 defer 語句
  3. 不要在多個函數之間直接傳遞互斥鎖

 

四、如何使用讀寫鎖

讀寫鎖的場景主要是在多線程的安全操作下,並且讀的情況多於寫的情況,也就是說既滿足多線程操作的安全性,也要確保性能不能太差,這時候,我們可以考慮使用讀寫鎖。當然你也可以簡單暴力直接使用互斥鎖(Mutex)。

Lock() 寫鎖,如果在添加寫鎖之前已經有其他的讀鎖和寫鎖,則lock就會阻塞直到該鎖可用,為確保該鎖最終可用,已阻塞的 Lock 調用會從獲得的鎖中排除新的讀取器,即寫鎖權限高於讀鎖,有寫鎖時優先進行寫鎖定。

Unlock() 寫鎖解鎖,如果沒有進行寫鎖定,則就會引起一個運行時錯誤。

RLock() 讀鎖,當有寫鎖時,無法加載讀鎖,當只有讀鎖或者沒有鎖時,可以加載讀鎖,讀鎖可以加載多個,所以適用於"讀多寫少"的場景。

RUnlock() 讀鎖解鎖,RUnlock 撤銷單次RLock 調用,它對於其它同時存在的讀取器則沒有效果。若 rw 並沒有為讀取而鎖定,調用 RUnlock 就會引發一個運行時錯誤。

package main
import ("fmt"
    "sync"
)

var (
    count int
    rwLock sync.RWMutex
)

func main() {
    for i := 0; i < 2; i++ {
        go func() {
            for i := 1000000; i > 0; i-- {
                rwLock.Lock()
                count ++
                rwLock.Unlock()
            }
            fmt.Println(count)
        }()
    }

    fmt.Scanf("\n") //等待子線程全部結束
}

運行結果:
1968637
2000000 

看着挺複雜的,其實簡單來說就是:

  1. 讀鎖不能阻塞讀鎖

  2. 讀鎖需要阻塞寫鎖,直到所有讀鎖都釋放

  3. 寫鎖需要阻塞讀鎖,直到所有寫鎖都釋放

  4. 寫鎖需要阻塞寫鎖

 

五、最後

以上,就把golang中各種鎖的使用場景及怎麼使用互斥鎖和讀寫鎖等相關內容介紹完了,希望能對大家有所幫助。

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

智慧手機時代的來臨,RWD網頁設計已成為網頁設計推薦首選

想知道網站建置、網站改版該如何進行嗎?將由專業工程師為您規劃客製化網頁設計及後台網頁設計

帶您來看台北網站建置台北網頁設計,各種案例分享

廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

如何回答性能優化的問題?

日常工作中,我們多少都會遇到應用的性能問題。在阿裏面試中,性能優化也是常被問到的題目,用來考察是否有實際的線上問題處理經驗。面對這類問題,阿里工程師齊光給出了詳細流程。來阿裏面試前,先看看這篇文章哦。

性能問題和Bug不同,後者的分析和解決思路更清晰,很多時候從應用日誌(文中的應用指分佈式服務下的單個節點)即可直接找到問題根源,而性能問題,其排查思路更為複雜一些。

對應用進行性能優化,是一個系統性的工程,對工程師的技術廣度和技術深度都有所要求。一個簡單的應用,它不僅包含了應用代碼本身,還和容器(虛擬機)、操作系統、存儲、網絡、文件系統等緊密相關,線上應用一旦出現了性能問題,需要我們從多方面去考慮。

與此同時,除了一些低級的代碼邏輯引發的性能問題外,很多性能問題隱藏的較深,排查起來會比較困難,需要我們對應用的各個子模塊、應用所使用的框架和組件的原理有所了解,同時掌握一定的性能優化工具和經驗。

本文總結了我們在進行性能優化時常用的一些工具及技巧,目的是希望通過一個全面的視角,去感知性能優化的整體脈絡。本文主要分為下面三個部分:

  1. 第一部分會介紹性能優化的一些背景知識。
  2. 第二部分會介紹性能優化的通用流程以及常見的一些誤區。
  3. 第三部分會從系統層和業務層的角度,介紹高效的性能問題定位工具和高頻性能瓶頸點分佈。

本文中提到的線程、堆、垃圾回收等名詞,如無特別說明,指的是 Java 應用中的相關概念。

 

1.性能優化的背景

前面提到過,應用出現性能問題和應用存在缺陷是不一樣的,後者大多數是由於代碼的質量問題導致,會導致應用功能性的缺失或出現風險,一經發現,會被及時修復。而性能問題,可能是由多方面的因素共同作用的結果:代碼質量一般、業務發展太快、應用架構設計不合理等,這些問題處理起來一般耗時較長、分析鏈路複雜,大家都不願意干,因此可能會被一些臨時性的補救手段所掩蓋,如:系統水位高或者單機的線程池隊列爆炸,那就集群擴容增加機器;內存佔用高/高峰時段 OOM,那就重啟分分鐘解決……

臨時性的補救措施只是在給應用埋雷,同時也只能解決部分問題。譬如,在很多場景下,加機器也並不能解決應用的性能問題,如對時延比較敏感的一些應用必須把單機的性能優化到極致,與此同時,加機器這種方式也造成了資源的浪費,長期來看是得不償失的。對應用進行合理的性能優化,可在應用穩定性、成本核算獲得很大的收益。

上面我們闡述了進行性能優化的必要性。假設現在我們的應用已經有了性能問題(eg. CPU 水位比較高),準備開始進行優化工作了,在這個過程中,潛在的痛點會有哪些呢?下面列出一些較為常見的:

  1. 對性能優化的流程不是很清晰。初步定為一個疑似瓶頸點后,就興高采烈地吭哧吭哧開始干,最終解決的問題其實只是一個淺層次的性能瓶頸,真實的問題的根源並未觸達;
  2. 對性能瓶頸點的分析思路不是很清晰。CPU、網絡、內存……這麼多的性能指標,我到底該關注什麼,應該從哪一塊兒開始入手?
  3. 對性能優化的工具不了解。遇到問題后,不清楚該用哪個工具,不知道通過工具得到的指標代表什麼。

    2.性能優化的流程

    在性能優化這個領域,並沒有一個嚴格的流程定義,但是對於絕大多數的優化場景,我們可以將其過程抽象為下面四個步驟。

    1. 準備階段:主要工作是是通過性能測試,了解應用的概況、瓶頸的大概方向,明確優化目標;
    2. 分析階段:通過各種工具或手段,初步定位性能瓶頸點;
    3. 調優階段:根據定位到的瓶頸點,進行應用性能調優;
    4. 測試階段:讓調優過的應用進行性能測試,與準備階段的各項指標進行對比,觀測其是否符合預期,如果瓶頸點沒有消除或者性能指標不符合預期,則重複步驟2和3。
    5. 下圖即為上述四個階段的簡要流程。

    2.1 通用流程詳解

    在上述通用流程的四個步驟當中,步驟2和3我們會在接下來兩個部分重點進行介紹。首先我們來看一下,在準備階段和測試階段,我們需要做一些什麼。

    | 2.1.1 準備階段

    準備階段是非常關鍵的一步,不能省略。

    首先,需要對我們進行調優的對象進行詳盡的了解,所謂知己知彼,百戰不殆。

    1. 對性能問題進行粗略評估,過濾一些因為低級的業務邏輯導致的性能問題。譬如,線上應用日誌級別不合理,可能會在大流量時導致 CPU 和磁盤的負載飆高,這種情況調整日誌級別即可;
    2. 了解應用的的總體架構,比如應用的外部依賴和核心接口有哪些,使用了哪些組件和框架,哪些接口、模塊的使用率較高,上下游的數據鏈路是怎麼樣的等;
    3. 了解應用對應的服務器信息,如服務器所在的集群信息、服務器的 CPU/內存信息、安裝的 Linux 版本信息、服務器是容器還是虛擬機、所在宿主機混部后是否對當前應用有干擾等;

    其次,我們需要獲取基準數據,然後結合基準數據和當前的一些業務指標,確定此次性能優化的最終目標。

    1. 使用基準測試工具獲取系統細粒度指標。可以使用若干 Linux 基準測試工具(eg. jmeter、ab、loadrunnerwrk、wrk等),得到文件系統、磁盤 I/O、網絡等的性能報告。除此之外,類似 GC、Web 服務器、網卡流量等信息,如有必要也是需要了解記錄的;
    2. 通過壓測工具或者壓測平台(如果有的話),對應用進行壓力測試,獲取當前應用的宏觀業務指標,譬如:響應時間、吞吐量、TPS、QPS、消費速率(對於有 MQ 的應用)等。壓力測試也可以省略,可以結合當前的實際業務和過往的監控數據,去統計當前的一些核心業務指標,如午高峰的服務 TPS。

    | 2.1.2 測試階段

    進入到這一階段,說明我們已經初步確定了應用性能瓶頸的所在,而且已經進行初步的調優了。檢測我們調優是否有效的方式,就是在仿真的條件下,對應用進行壓力測試。注意:由於 Java 有 JIT(just-in-time compilation)過程,因此壓力測試時可能需要進行前期預熱。

    如果壓力測試的結果符合了預期的調優目標,或者與基準數據相比,有很大的改善,則我們可以繼續通過工具定位下一個瓶頸點,否則,則需要暫時排除這個瓶頸點,繼續尋找下一個變量。

    2.2 注意事項

    在進行性能優化時,了解下面這些注意事項可以讓我們少走一些彎路。

    1. 性能瓶頸點通常呈現 2/8 分佈,即80%的性能問題通常是由20%的性能瓶頸點導致的,2/8 原則也意味着並不是所有的性能問題都值得去優化;
    2. 性能優化是一個漸進、迭代的過程,需要逐步、動態地進行。記錄基準后,每次改變一個變量,引入多個變量會給我們的觀測、優化過程造成干擾;
    3. 不要過度追求應用的單機性能,如果單機表現良好,則應該從系統架構的角度去思考; 不要過度追求單一維度上的極致優化,如過度追求 CPU 的性能而忽略了內存方面的瓶頸;
    4. 選擇合適的性能優化工具,可以使得性能優化取得事半功倍的效果;
    5. 整個應用的優化,應該與線上系統隔離,新的代碼上線應該有降級方案。

    3.瓶頸點分析工具箱

    性能優化其實就是找出應用存在性能瓶頸點,然後設法通過一些調優手段去緩解。性能瓶頸點的定位是較困難的,快速、直接地定位到瓶頸點,需要具備下面兩個條件:

    1. 恰到好處的工具;
    2. 一定的性能優化經驗。

    工欲善其事,必先利其器,我們該如何選擇合適的工具呢?不同的優化場景下,又該選擇那些工具呢?

    首選,我們來看一下大名鼎鼎的「性能工具(Linux Performance Tools-full)圖」,想必很多工程師都知道,它出自系統性能專家 Brendan Gregg。該圖從 Linux 內核的各個子系統出發,列出了我們在對各個子系統進行性能分析時,可使用的工具,涵蓋了監測、分析、調優等性能優化的方方面面。除了這張全景圖之外,Brendan Gregg 還單獨提供了基準測試工具(Linux Performance Benchmark Tools)圖、性能監測工具(Linux Performance Observability Tools)圖等,更詳細的內容請參考 Brendan Gregg 的網站說明。

    圖片來源:

    上面這張圖非常經典,是我們做性能優化時非常好的參考資料,但事實上,我們在實際運用的時候,會發現可能它並不是最合適的,原因主要有下面兩點:

    1)對分析經驗要求較高。上面這張圖其實是從 Linux 系統資源的角度去觀測性能指標的,這要求我們對 Linux 各個子系統的功能、原理要有所了解。舉例:遇到性能問題了,我們不會拿每個子系統下的工具都去試一遍,大多數情況是:我們懷疑某個子系統有問題,然後根據這張圖上列舉的工具,去觀測或者驗證我們的猜想,這無疑拔高了對性能優化經驗的要求;

    2)適用性和完整性不是很好。我們在分析性能問題時,從系統底層自底向上地分析是較低效的,大多數時候,從應用層面去分析會更加有效。性能工具(Linux Performance Tools-full)圖只是從系統層一個角度給出了工具集,如果從應用層開始分析,我們可以使用哪些工具?哪些點是我們首先需要關注的?

    鑒於上面若干痛點,下面給出了一張更為實用的「性能優化工具圖譜」,該圖分別從系統層、應用層(含組件層)的角度出發,列舉了我們在分析性能問題時首先需要關注的各項指標(其中?標註的是最需要關注的),這些點是最有可能出現性能瓶頸的地方。需要注意的是,一些低頻的指標或工具,在圖中並沒有列出來,如 CPU 中斷、索引節點使用、I/O事件跟蹤等,這些低頻點的排查思路較複雜,一般遇到的機會也不多,在這裏我們聚焦最常見的一些就可以了。

    對比上面的性能工具(Linux Performance Tools-full)圖,下圖的優勢在於:把具體的工具同性能指標結合了起來,同時從不同的層次去描述了性能瓶頸點的分佈,實用性和可操作性更強一些。系統層的工具分為CPU、內存、磁盤(含文件系統)、網絡四個部分,工具集同性能工具(Linux Performance Tools-full)圖中的工具基本一致。組件層和應用層中的工具構成為:JDK 提供的一些工具 + Trace 工具 + dump 分析工具 + Profiling 工具等。

    這裏就不具體介紹這些工具的具體用法了,我們可以使用 man 命令得到工具詳盡的使用說明,除此之外,還有另外一個查詢命令手冊的方法:info。info 可以理解為 man 的詳細版本,如果 man 的輸出不太好理解,可以去參考 info 文檔,命令太多,記不住也沒必要記住。

    上面這張圖該如何使用?

    首先,雖然從系統、組件、應用兩個三個角度去描述瓶頸點的分佈,但在實際運行時,這三者往往是相輔相成、相互影響的。系統是為應用提供了運行時環境,性能問題的本質就是系統資源達到了使用的上限,反映在應用層,就是應用/組件的各項指標開始下降;而應用/組件的不合理使用和設計,也會加速系統資源的耗盡。因此,分析瓶頸點時,需要我們結合從不同角度分析出的結果,抽出共性,得到最終的結論。

    其次,建議先從應用層入手,分析圖中標註的高頻指標,抓出最重要的、最可疑的、最有可能導致性能的點,得到初步的結論后,再去系統層進行驗證。這樣做的好處是:很多性能瓶頸點體現在系統層,會是多變量呈現的,譬如,應用層的垃圾回收(GC)指標出現了異常,通過 JDK 自帶的工具很容易觀測到,但是體現在系統層上,會發現系統當前的 CPU 利用率、內存指標都不太正常,這就給我們的分析思路帶來了困擾。

    最後,如果瓶頸點在應用層和系統層均呈現出多變量分佈,建議此時使用 ZProfiler、JProfiler 等工具對應用進行 Profiling,獲取應用的綜合性能信息(注:Profiling 指的是在應用運行時,通過事件(Event-based)、統計抽樣(Sampling Statistical)或植入附加指令(Byte-Code instrumentation)等方法,收集應用運行時的信息,來研究應用行為的動態分析方法)。譬如,可以對 CPU 進行抽樣統計,結合各種符號表信息,得到一段時間內應用內的代碼熱點。

    下面介紹在不同的分析層次,我們需要關注的核心性能指標,同時,也會介紹如何初步根據這些指標,判斷系統或應用是否存在性能瓶頸點,至於瓶頸點的確認、瓶頸點的成因、調優手段,將會在下一部分展開。

    3.1 CPU&&線程

    和 CPU 相關的指標主要有以下幾個。常用的工具有 top、 ps、uptime、 vmstat、 pidstat等。

    1. CPU利用率(CPU Utilization)
    2. CPU 平均負載(Load Average)
    3. 上下文切換次數(Context Switch)

    top – 12:20:57 up 25 days, 20:49, 2 users, load average: 0.93, 0.97, 0.79

    Tasks: 51 total, 1 running, 50 sleeping, 0 stopped, 0 zombie
    %Cpu(s): 1.6 us, 1.8 sy, 0.0 ni, 89.1 id, 0.1 wa, 0.0 hi, 0.1 si, 7.3 st
    KiB Mem : 8388608 total, 476436 free, 5903224 used, 2008948 buff/cache
    KiB Swap: 0 total, 0 free, 0 used. 0 avail Mem

    PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND

    119680 admin 20 0 600908 72332 5768 S 2.3 0.9 52:32.61 obproxy
    65877 root 20 0 93528 4936 2328 S 1.3 0.1 449:03.61 alisentry_cli

    第一行显示的內容:當前時間、系統運行時間以及正在登錄用戶數。load average 后的三個数字,依次表示過去 1 分鐘、5 分鐘、15 分鐘的平均負載(Load Average)。平均負載是指單位時間內,系統處於可運行狀態(正在使用 CPU 或者正在等待 CPU 的進程,R 狀態)和不可中斷狀態(D 狀態)的平均進程數,也就是平均活躍進程數,CPU 平均負載和 CPU 使用率並沒有直接關係。

    第三行的內容表示 CPU 利用率,每一列的含義可以使用 man 查看。CPU 使用率體現了單位時間內 CPU 使用情況的統計,以百分比的方式展示。計算方式為:CPU 利用率 = 1 – (CPU 空閑時間)/ CPU 總的時間。需要注意的是,通過性能分析工具得到的 CPU 的利用率其實是某個採樣時間內的 CPU 平均值。注:top 工具显示的的 CPU 利用率是把所有 CPU 核的數值加起來的,即 8 核 CPU 的利用率最大可以到達800%(可以用 htop 等更新一些的工具代替 top)。

    使用 vmstat 命令,可以查看到「上下文切換次數」這個指標,如下錶所示,每隔1秒輸出1組數據:

    $ vmstat 1

    procs ———–memory———- —swap– —–io—- -system– ——cpu—–
    r b swpd free buff cache si so bi bo in cs us sy id wa st
    0 0 0 504804 0 1967508 0 0 644 33377 0 1 2 2 88 0 9

    上表的 cs(context switch) 就是每秒上下文切換的次數,按照不同場景,CPU 上下文切換還可以分為中斷上下文切換、線程上下文切換和進程上下文切換三種,但是無論是哪一種,過多的上下文切換,都會把 CPU 時間消耗在寄存器、內核棧以及虛擬內存等數據的保存和恢復上,從而縮短進程真正運行的時間,導致系統的整體性能大幅下降。vmstat 的輸出中 us、sy 分別用戶態和內核態的 CPU 利用率,這兩個值也非常具有參考意義。

    vmstat 的輸只給出了系統總體的上下文切換情況,要想查看每個進程的上下文切換詳情(如自願和非自願切換),需要使用 pidstat,該命令還可以查看某個進程用戶態和內核態的 CPU 利用率。

    CPU 相關指標異常的分析思路是什麼?

    1)CPU 利用率:如果我們觀察某段時間系統或應用進程的 CPU利用率一直很高(單個 core 超過80%),那麼就值得我們警惕了。我們可以多次使用 jstack 命令 dump 應用線程棧查看熱點代碼,非 Java 應用可以直接使用 perf 進行 CPU 采採樣,離線分析採樣數據后得到 CPU 執行熱點(Java 應用需要符號表進行堆棧信息映射,不能直接使用 perf得到結果)。

    2)CPU 平均負載:平均負載高於 CPU 數量 70%,意味着系統存在瓶頸點,造成負載升高的原因有很多,在這裏就不展開了。需要注意的是,通過監控系統監測平均負載的變化趨勢,更容易定位問題,有時候大文件的加載等,也會導致平均負載瞬時升高。如果 1 分鐘/5 分鐘/15 分鐘的三個值相差不大,那說明系統負載很平穩,則不用關注,如果這三個值逐漸降低,說明負載在漸漸升高,需要關注整體性能;

    3)CPU 上下文切換:上下文切換這個指標,並沒有經驗值可推薦(幾十到幾萬都有可能),這個指標值取決於系統本身的 CPU 性能,以及當前應用工作的情況。但是,如果系統或者應用的上下文切換次數出現數量級的增長,就有很大概率說明存在性能問題,如非自願上下切換大幅度上升,說明有太多的線程在競爭 CPU。

    上面這三個指標是密切相關的,如頻繁的 CPU 上下文切換,可能會導致平均負載升高。如何根據這三者之間的關係進行應用調優,將在下一部分介紹。

    CPU 上的的一些異動,通常也可以從線程上觀測到,但需要注意的是,線程問題並不完全和 CPU 相關。與線程相關的指標,主要有下面幾個(均都可以通過 JDK 自帶的 jstack 工具直接或間接得到):

    1. 應用中的總的線程數;
    2. 應用中各個線程狀態的分佈;
    3. 線程鎖的使用情況,如死鎖、鎖分佈等;

    關於線程,可關注的異常有:

    1)線程總數是否過多。過多的線程,體現在 CPU 上就是導致頻繁的上下文切換,同時線程過多也會消耗內存,線程總數大小和應用本身和機器配置相關;

    2)線程的狀態是否異常。觀察 WAITING/BLOCKED 線程是否過多(線程數設置過多或鎖競爭劇烈),結合應用內部鎖使用的情況綜合分析;

    3)結合 CPU 利用率,觀察是否存在大量消耗 CPU 的線程。

    3.2 內存&&堆

    和內存相關的指標主要有以下幾個,常用的分析工具有:top、free、vmstat、pidstat 以及 JDK 自帶的一些工具。

    1. 系統內存的使用情況,包括剩餘內存、已用內存、可用內存、緩存/緩衝區;
    2. 進程(含 Java 進程)的虛擬內存、常駐內存、共享內存;
    3. 進程的缺頁異常數,包含主缺頁異常和次缺頁異常;
    4. Swap 換入和換出的內存大小、Swap 參數配置;
    5. JVM 堆的分配,JVM 啟動參數;
    6. JVM 堆的回收,GC 情況。

    使用 free 可以查看系統內存的使用情況和 Swap 分區的使用情況,top 工具可以具體到每個進程,如我們可以用使用 top 工具查看 Java 進程的常駐內存大小(RES),這兩個工具結合起來,可用覆蓋大多數內存指標。下面是使用 free命令的輸出:

    $free -h

              total        used        free shared buff/cache available

    Mem: 125G 6.8G 54G 2.5M 64G 118G

    Swap: 2.0G 305M 1.7G

    上述輸出各列的具體含義在這裏不在贅述,也比較容易理解。重點介紹下 swap 和 buff/cache 這兩個指標。

    Swap 的作用是把一個本地文件或者一塊磁盤空間作為內存來使用,包括換出和換入兩個過程。Swap 需要讀寫磁盤,所以性能不是很高,事實上,包括 ElasticSearch 、Hadoop 在內絕大部分 Java 應用都建議關掉 Swap,這是因為內存的成本一直在降低,同時這也和 JVM 的垃圾回收過程有關:JVM在 GC 的時候會遍歷所有用到的堆的內存,如果這部分內存被 Swap 出去了,遍歷的時候就會有磁盤 I/O 產生。Swap 分區的升高一般和磁盤的使用強相關,具體分析時,需要結合緩存使用情況、swappiness 閾值以及匿名頁和文件頁的活躍情況綜合分析。

    buff/cache 是緩存和緩衝區的大小。緩存(cache):是從磁盤讀取的文件的或者向磁盤寫文件時的臨時存儲數據,面向文件。使用 cachestat 可以查看整個系統緩存的讀寫命中情況,使用 cachetop 可以觀察每個進程緩存的讀寫命中情況。緩衝區(buffer)是寫入磁盤數據或從磁盤直接讀取的數據的臨時存儲,面向塊設備。free 命令的輸出中,這兩個指標是加在一起的,使用 vmstat 命令可以區分緩存和緩衝區,還可以看到 Swap 分區換入和換出的內存大小。

    了解到常見的內存指標后,常見的內存問題又有哪些?總結如下:

    1. 系統剩餘內存/可用不足(某個進程佔用太多、系統本身內存不足),內存溢出;
    2. 內存回收異常:內存泄漏(進程在一段時間內內存使用持續走高)、GC 頻率異常;
    3. 緩存使用過大(大文件讀取或寫入)、緩存命中率不高;
    4. 缺頁異常過多(頻繁的 I/O 讀);
    5. Swap 分區使用異常(使用過大);

    內存相關指標異常后,分析思路是怎麼樣的?

    1. 使用 free/top 查看內存的全局使用情況,如系統內存的使用、Swap 分區內存使用、緩存/緩衝區佔用情況等,初步判斷內存問題存在的方向:進程內存、緩存/緩衝區、Swap 分區;
    2. 觀察一段時間內存的使用趨勢。如通過 vmstat 觀察內存使用是否一直在增長;通過 jmap 定時統計對象內存分佈情況,判斷是否存在內存泄漏,通過 cachetop 命令,定位緩衝區升高的根源等;
    3. 根據內存問題的類型,結合應用本身,進行詳細分析。

    舉例:使用 free 發現緩存/緩衝區佔用不大,排除緩存/緩衝區對內存的影響后 -> 使用 vmstat 或者 sar 觀察一下各個進程內存使用變化趨勢 -> 發現某個進程的內存時候用持續走高 -> 如果是 Java 應用,可以使用 jmap / VisualVM / heap dump 分析等工具觀察對象內存的分配,或者通過 jstat 觀察 GC 后的應用內存變化 -> 結合業務場景,定位為內存泄漏/GC參數配置不合理/業務代碼異常等。

    3.3 磁盤&&文件

    在分析和磁盤相關的問題時,通常是將其和文件系統同時考慮的,下面不再區分。和磁盤/文件系統相關的指標主要有以下幾個,常用的觀測工具為 iostat和 pidstat,前者適用於整個系統,後者可觀察具體進程的 I/O。

    1. 磁盤 I/O 利用率:是指磁盤處理 I/O 的時間百分比;
    2. 磁盤吞吐量:是指每秒的 I/O 請求大小,單位為 KB;
    3. I/O 響應時間,是指 I/O 請求從發出到收到響應的間隔,包含在隊列中的等待時間和實際處理時間;
    4. IOPS(Input/Output Per Second):每秒的 I/O 請求數;
    5. I/O 等待隊列大小,指的是平均 I/O 隊列長度,隊列長度越短越好;

    使用 iostat 的輸出界面如下:

    $iostat -dx

    Linux 3.10.0-327.ali2010.alios7.x86_64 (loginhost2.alipay.em14) 10/20/2019 x86_64 (32 CPU)

    Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
    sda 0.01 15.49 0.05 8.21 3.10 240.49 58.92 0.04 4.38 2.39 4.39 0.09 0.07

    上圖中 %util ,即為磁盤 I/O 利用率,同 CPU 利用率一樣,這個值也可能超過 100%(存在并行 I/O);rkB/s 和 wkB/s分別表示每秒從磁盤讀取和寫入的數據量,即吞吐量,單位為 KB;磁盤 I/O處理時間的指標為 r_await 和 w_await 分別表示讀/寫請求處理完成的響應時間,svctm 表示處理 I/O 所需要的平均時間,該指標已被廢棄,無實際意義。r/s + w/s 為 IOPS 指標,分別表示每秒發送給磁盤的讀請求數和寫請求數;aqu-sz 表示等待隊列的長度。

    pidstat 的輸出大部分和 iostat 類似,區別在於它可以實時查看每個進程的 I/O 情況。

    如何判斷磁盤的指標出現了異常?

      1. 當磁盤 I/O 利用率長時間超過 80%,或者響應時間過大(對於 SSD,從 0.0x 毫秒到 1.x 毫秒不等,机械磁盤一般為5ms~10ms),通常意味着磁盤 I/O 存在性能瓶頸;
      2. 如果 %util 很大,而 rkB/s 和 wkB/s 很小,一般是因為存在較多的磁盤隨機讀寫,最好把隨機讀寫優化成順序讀寫,(可以通過 strace 或者 blktrace 觀察 I/O 是否連續判斷是否是順序的讀寫行為,隨機讀寫應可關注 IOPS 指標,順序讀寫可關注吞吐量指標);
      3. 如果 avgqu-sz 比較大,說明有很多 I/O 請求在隊列中等待。一般來說,如果單塊磁盤的隊列長度持續超過2,一般認為該磁盤存在 I/O 性能問題。

    3.4 網絡

    網絡這個概念涵蓋的範圍較廣,在應用層、傳輸層、網絡層、網絡接口層都有不同的指標去衡量。這裏我們討論的「網絡」,特指應用層的網絡,通常使用的指標如下:

    1. 網絡帶寬:表示鏈路的最大傳輸速率;
    2. 網絡吞吐:表示單位時間內成功傳輸的數據量大小;
    3. 網絡延時:表示從網絡請求發出后直到收到遠端響應,所需要的時間;
    4. 網絡連接數和錯誤數;

    一般來說,應用層的網絡瓶頸有如下幾類:

    1. 集群或機器所在的機房的網絡帶寬飽和,影響應用 QPS/TPS 的提升;
    2. 網絡吞吐出現異常,如接口存在大量的數據傳輸,造成帶寬佔用過高;
    3. 網絡連接出現異常或錯誤;
    4. 網絡出現分區。

    帶寬和網絡吞吐這兩個指標,一般我們會關注整個應用的,通過監控系統可直接得到,如果一段時間內出現了明顯的指標上升,說明存在網絡性能瓶頸。對於單機,可以使用 sar 得到網絡接口、進程的網絡吞吐。

    使用 ping 或者 hping3 可以得到是否出現網絡分區、網絡具體時延。對於應用,我們更關注整個鏈路的時延,可以通過中間件埋點后輸出的 trace 日誌得到鏈路上各個環節的時延信息。

    使用 netstat、ss 和 sar 可以獲取網絡連接數或網絡錯誤數。過多網絡鏈接造成的開銷是很大的,一是會佔用文件描述符,二是會佔用緩存,因此系統可以支撐的網絡鏈接數是有限的。

    3.5 工具總結

    可以看到的是,在分析 CPU、內存、磁盤等的性能指標時,有幾種工具是高頻出現的,如 top、vmstat、pidstat,這裏稍微總結一下:

    1. CPU:top、vmstat、pidstat、sar、perf、jstack、jstat;
    2. 內存:top、free、vmstat、cachetop、cachestat、sar、jmap;
    3. 磁盤:top、iostat、vmstat、pidstat、du/df;
    4. 網絡:netstat、sar、dstat、tcpdump;
    5. 應用:profiler、dump分析。

    上述的很多工具,大部分是用於查看系統層指標的,在應用層,除了有 JDK 提供的一系列工具,一些商用的產品如 gceasy.io(分析 GC 日誌)、fastthread.io(分析線程 dump 日誌)也是不錯的。

    排查 Java 應用的線上異常或者分析應用代碼瓶頸,可以使用阿里開源的 Arthas ,這個工具非常強大,下面簡單介紹下。

    Arthas 主要面向線上應用實時診斷,解決的是類似「線上應用異常了,需要在線進行分析和定位」的問題,當然,Arthas 提供的一些方法調用追蹤工具,對我們排查諸如「慢查詢」等問題,也是非常有幫助的。Arthas 提供的主要功能有:

    1. 獲取線程統計,如線程持有的鎖統計、CPU 利用率統計等;
    2. 類加載信息、動態類加載、方法加載信息;
    3. 調用棧追蹤,調用耗時統計;
    4. 方法調用參數、結果檢測;
    5. 系統配置、應用配置信息;
    6. 反編譯加載類;
    7. ….

    需要注意的是,性能工具只是解決性能問題的手段,我們了解常用工具的一般用法即可,不要在工具學習上投入過多精力。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

想要讓你的商品在網路上成為最夯、最多人討論的話題?

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

不管是台北網頁設計公司台中網頁設計公司,全省皆有專員為您服務

想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師"嚨底家"!!