Java併發編程實戰總結 (一)

前提

首先該場景是一個酒店開房的業務。為了朋友們閱讀簡單,我把業務都簡化了。
業務:開房後會添加一條賬單,添加一條房間排期記錄,房間排期主要是為了房間使用的時間不衝突。如:賬單A,使用房間1,使用時間段為2020-06-01 12:00 – 2020-06-02 12:00 ,那麼還需要使用房間1開房的時間段則不能與賬單A的時間段衝突。

業務類

為了簡單起見,我把幾個實體類都簡化了。

賬單類

public class Bill {
    // 賬單號
    private String serial;

    // 房間排期id
    private Integer room_schedule_id;
    // ...get set
}

房間類

// 房間類
public class Room {
    private Integer id;

    // 房間名
    private String name;
    // get set...
}

房間排期類

import java.sql.Timestamp;

public class RoomSchedule {
    private Integer id;
    
    // 房間id
    private Integer roomId;

    // 開始時間
    private Timestamp startTime;

    // 結束時間
    private Timestamp endTime;
    // ...get set
}

實戰

併發實戰當然少不了Jmeter壓測工具,傳送門: https://jmeter.apache.org/download_jmeter.cgi
為了避免有些小夥伴訪問不到官網,我上傳到了百度雲:鏈接:https://pan.baidu.com/s/1c9l3Ri0KzkdIkef8qtKZeA
提取碼:kjh6

初次實戰(sychronized)

第一次進行併發實戰,我是首先想到sychronized關鍵字的。沒辦法,基礎差。代碼如下:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;

import java.sql.Timestamp;

/**
 * 開房業務類
 */
@Service
public class OpenRoomService {
    @Autowired
    DataSourceTransactionManager dataSourceTransactionManager;
    @Autowired
    TransactionDefinition transactionDefinition;

    public void openRoom(Integer roomId, Timestamp startTime, Timestamp endTime) {
        // 開啟事務
        TransactionStatus transaction = dataSourceTransactionManager.getTransaction(transactionDefinition);
        try {
            synchronized (RoomSchedule.class) {
                if (isConflict(roomId, startTime, endTime)) {
                    // throw exception
                }
                // 添加房間排期...
                // 添加賬單

                // 提交事務
                dataSourceTransactionManager.commit(transaction);
            }
        } catch (Exception e) {
            // 回滾事務
            dataSourceTransactionManager.rollback(transaction);
            throw e;
        }
    }

    public boolean isConflict(Integer roomId, Timestamp startTime, Timestamp endTime) {
        // 判斷房間排期是否有衝突...
    }
}
  1. sychronized(RoomSchedule.class),相當於的開房業務都是串行的。不管開房間1還是房間2。都需要等待上一個線程執行完開房業務,後續才能執行。這並不好哦。
  2. 事務必須在同步代碼塊sychronized中提交,這是必須的。否則當線程A使用房間1開房,同步代碼塊執行完,事務還未提交,線程B發現房間1的房間排期沒有衝突,那麼此時是有問題的。

錯誤點: 有些朋友可能會想到都是串行執行了,為什麼不把synchronized關鍵字寫到方法上?
首先openRoom方法是非靜態方法,那麼synchronized鎖定的就是this對象。而Spring中的@Service註解類是多例的,所以並不能把synchronized關鍵字添加到方法上。

二次改進(等待-通知機制)

因為上面的例子當中,開房操作都是串行的。而實際情況使用房間1開房和房間2開房應該是可以并行才對。如果我們使用synchronized(Room實例)可以嗎?答案是不行的。
在第三章 解決原子性問題當中,我講到了使用鎖必須是不可變對象,若把可變對象作為鎖,當可變對象被修改時相當於換鎖,這裏的鎖講的就是synchronized鎖定的對象,也就是Room實例。因為Room實例是可變對象(set方法修改實例的屬性值,說明為可變對象),所以不能使用synchronized(Room實例)。
在這次改進當中,我使用了第五章 等待-通知機制,我添加了RoomAllocator房間資源分配器,當開房的時候需要在RoomAllocator當中獲取鎖資源,獲取失敗則線程進入wait()等待狀態。當線程釋放鎖資源則notiryAll()喚醒所有等待中的線程。
RoomAllocator房間資源分配器代碼如下:

import java.util.ArrayList;
import java.util.List;

/**
 * 房間資源分配器(單例類)
 */
public class RoomAllocator {
    private final static RoomAllocator instance = new RoomAllocator();

    private final List<Integer> lock = new ArrayList<>();

    private RoomAllocator() {}

    /**
     * 獲取鎖資源
     */
    public synchronized void lock(Integer roomId) throws InterruptedException {
        // 是否有線程已佔用該房間資源
        while (lock.contains(roomId)) {
            // 線程等待
            wait();
        }

        lock.add(roomId);
    }

    /**
     * 釋放鎖資源
     */
    public synchronized void unlock(Integer roomId) {
        lock.remove(roomId);
        // 喚醒所有線程
        notifyAll();
    }

    public static RoomAllocator getInstance() {
        return instance;
    }
}

開房業務只需要修改openRoom的方法,修改如下:

    public void openRoom(Integer roomId, Timestamp startTime, Timestamp endTime) throws InterruptedException {
        RoomAllocator roomAllocator = RoomAllocator.getInstance();
        // 開啟事務
        TransactionStatus transaction = dataSourceTransactionManager.getTransaction(transactionDefinition);
        try {
            roomAllocator.lock(roomId);
            if (isConflict(roomId, startTime, endTime)) {
                // throw exception
            }
            // 添加房間排期...
            // 添加賬單

            // 提交事務
            dataSourceTransactionManager.commit(transaction);
        } catch (Exception e) {
            // 回滾事務
            dataSourceTransactionManager.rollback(transaction);
            throw e;
        } finally {
            roomAllocator.unlock(roomId);
        }
    }

那麼此次修改后,使用房間1開房和房間2開房就可以并行執行了。

總結

上面的例子可能會有其他更好的方法去解決,但是我的實力不允許我這麼做….。這個例子也是我自己在項目中搞事情搞出來的。畢竟沒有實戰經驗,只有理論,不足以學好併發。希望大家也可以在項目中搞事情[壞笑],當然不能瞎搞。
後續如果在其他場景用到了併發,也會繼續寫併發實戰的文章哦~

個人博客網址: https://colablog.cn/

如果我的文章幫助到您,可以關注我的微信公眾號,第一時間分享文章給您

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

【其他文章推薦】

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

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

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

※南投搬家公司費用需注意的眉眉角角,別等搬了再說!

※教你寫出一流的銷售文案?

※回頭車貨運收費標準

※別再煩惱如何寫文案,掌握八大原則!

Python機器學習筆記:SVM(2)——SVM核函數,Python機器學習筆記:SVM(1)——SVM概述,Python機器學習筆記:SVM(2)——SVM核函數,Python機器學習筆記:SVM(3)——證明SVM,Python機器學習筆記:SVM(4)——sklearn實現

  上一節我學習了完整的SVM過程,下面繼續對核函數進行詳細學習,具體的參考鏈接都在上一篇文章中,SVM四篇筆記鏈接為:

Python機器學習筆記:SVM(1)——SVM概述

Python機器學習筆記:SVM(2)——SVM核函數

Python機器學習筆記:SVM(3)——證明SVM

Python機器學習筆記:SVM(4)——sklearn實現

  熱身實例

  我在上一節有完整的學習了SVM算法,為了不讓自己這麼快就忘了,這裏先學習一個實例,回顧一下,並引出核函數的概念。

  數據是這樣的:有三個點,其中正例 x1(3,  3),x2(4,3),負例 x3(1,1)

   求解:

   約束條件為:

  這個約束條件是通過這個得到的(為什麼這裏強調一下呢,因為我們這個例子本身說的就是SVM的求解過程):

   我們可以知道  y1 = +1, y2 = +1 , y3 = -1,同時,代入 α ,則得到:

  α1 +  α2 – α3=0

  下面通過SVM求解實例。

  我們將數據代入原式中:

   由於 α1 + α2 = α3 化簡可得:

   然後分別對  α1 和 α2 求偏導,偏導等於 0 可得: α1 = 1.5   α2 = -1 ,但是我們發現這兩個解並不滿足約束條件  αi >= 0,i=1,2,3,所以解應該在邊界上(正常情況下,我們需要對上式求偏導,進而算出 w,b)。

  首先令  α1 = 0,得出 α2 = -2/13  ,α3 = -2/13   (不滿足約束)

  再令 α2 = 0,得出 α1 = 0.25  ,α3 = 0.25  (滿足約束)

  所以最小值在(0.25, 0,0.25)處取得。

  我們將 α 的結果代入求解:

   所以我們代入 w  b ,求得最終的平面方程為:

  熱身完畢,下面學習核函數,為了方便理解我們下面要說的核函數,我在知乎找了一個簡單易懂的故事,讓我們了解支持向量機,更是明白支持向量機的核函數是個什麼鬼,下面看故事。

1,故事分析:支持向量機(SVM)是什麼?

  下面故事來源於此(這是源作者鏈接):點擊我即可

  在很久以前的情人節,有一個大俠要去救他的愛人,但是魔鬼和他玩了一個遊戲。

  魔鬼在桌面上似乎有規律放了兩種顏色的球,說:“你用一根棍分開他們?要求:盡量在放更多球之後,仍然使用”

  於是大俠這樣做,幹得不錯吧:

  然後魔鬼又在桌上放了更多的球,似乎有一個球站錯了陣營。

  SVM就是試圖把棍放在最佳位置,好讓在棍的兩邊有盡可能大的間隙。

  現在即使魔鬼放了更多的球,棍仍然是一個好的分界線。

  然後,在SVM工具箱中有另一個更加重要的trick 。魔鬼看到大俠已經學會了一個trick,於是魔鬼給了大俠一個新的挑戰。

  現在,大俠沒有棍可以很好地幫他分開這兩種球了,現在怎麼辦,當然像所有武俠片中一樣大俠桌子一拍,球飛到空中。然後憑藉著大俠的輕功,大俠抓起一張紙,插到了兩種球的中間。

  現在,從魔鬼的角度看這些球,這些球好像是被一條曲線分開了。

  在之後,無聊的大人們,把這些球叫做 「data」,把棍子 叫做 「classifier」, 最大間隙trick 叫做「optimization」, 拍桌子叫做「kernelling」, 那張紙叫做「hyperplane」。

  所以說,Support Vector Machine,一個普通的SVM就是一條直線罷了,用來完美劃分linearly separable的兩類。但是這又不是一條普通的直線,這是無數條可以分類的直線當中最完美的,因為它恰好在兩個類的中間,距離兩個類的點都一樣遠。而所謂的Support vector就是這些離分界線最近的點,如果去掉這些點,直線多半是要改變位置的。如果是高維的點,SVM的分界線就是平面或者超平面。其實沒有差,都是一刀切兩塊,我們這裏統一叫做直線。

  再理解一下,當一個分類問題,數據是線性可分的,也就是用一根棍就可以將兩種小球分開的時候,我們只要將棍的位置放在讓小球距離棍的距離最大化的位置即可。尋找這個最大間隔的過程,就叫做最優化。但是,显示往往是殘酷的,一般的數據是線性不可分的。也就是找不到一個棍將兩種小球很好的分類,這時候我們就需要像大俠一樣,將小球排起,用一張紙代替小棍將兩種小球進行分類,想讓數據飛起,我們需要的東西就是核函數(kernel),用於切分小球的紙,就是超平面。

2,核函數的概念

  上面故事說明了SVM可以處理線性可分的情況,也可以處理非線性可分的情況。而處理非線性可分的情況是選擇了核函數(kernel),通過將數據映射到高位空間,來解決在原始空間中線性不可分的問題。

  我們希望樣本在特徵空間中線性可分,因此特徵空間的好壞對支持向量機的性能至關重要,但是在不知道特徵映射的情況下,我們是不知道什麼樣的核函數是適合的,而核函數也只是隱式的定義了特徵空間,所以,核函數的選擇對於一個支持向量機而言就顯得至關重要,若選擇了一個不合適的核函數,則數據將映射到不合適的樣本空間,從而支持向量機的性能將大大折扣。

  所以構造出一個具有良好性能的SVM,核函數的選擇是關鍵。而核函數的選擇包含兩部分工作:一是核函數類型的選擇,二是確定核函數類型后相關參數的選擇。

  我們知道,核函數的精妙之處在於不用真的對特徵向量做核映射,而是直接對特徵向量的內積進行變換,而這種變換卻等價於先對特徵向量做核映射然後做內積。

  SVM主要是在用對偶理論求解一個二次凸優化問題,其中對偶問題如下:

   求得最終結果:

  當然這是線性可分的情況,那麼如果問題本身是線性不可分的情況呢?那就是先擴維后再計算。具體來說,在線性不可分的情況下,支持向量機首先在低維空間中完成計算,然後通過核函數將輸入空間映射到高維特徵空間,最終在高維特徵空間中構造出最優分離超平面,從而把平面上本身不好分的非線性數據分開。如下圖所示,一堆數據在二維空間中無法劃分,從而映射到三維空間中劃分:

   而在我們遇到核函數之前,如果用原始的方法,那麼在用線性學習器學習一個非線性關係,需要選擇一個非線性特徵集,並且將數據寫成新的表達形式,這等價於應用一個固定的非線性映射,將數據映射到特徵空間,在特徵空間中使用線性學習器。因此,考慮的假設集是這種類型的函數:

   這裏 Φ :X -> F 是從輸入空間到某個特徵空間的映射,這意味着建立非線性學習器分為兩步:

  • 1,使用一個非線性映射將數據變換到一個特徵空間 F
  • 2,在特徵空間使用線性學習器分類

  而由於對偶形式就是線性學習器的一個重要性質,這意味着假設可以表達為訓練點的線性組合,因此決策規則可以用測試點和訓練點的內積來表示:

  為向量加上核映射后,要求解的最優化問題變為:

  根據核函數滿足的等式條件,它等價於下面的問題:

  其線性不可分情況的對偶形式如下:

   其中 Φ(xi) 表示原來的樣本擴維后的坐標。

  最後得到的分類判別函數為:

   和不用核映射相比,只是求解的目標函數,最後的判定函數對特徵向量的內積做了核函數變換。如果K是一個非線性函數,上面的決策函數則是非線性函數,此時SVM是非線性模型。當訓練樣本很多,支持向量的個數很大的時候,預測時的速度是一個問題,因此很多時候我們會使用線性支持向量機。

3,舉例說明核函數的巧妙之處

  下面先從一個小例子來闡述問題。假設我們有兩個數據, x = (x1,  x2,  x3)  y = (y1,  y2,  y3)。此時在3D空間已經不能對其進行線性劃分了,那麼我們通過一個函數將數據映射到更高維的空間,比如9維的話,那麼 f(x) = (x1x1, x1x2, x1x3, x2x1, x2x2, x2x3, x3x1, x3x2, x3x3),由於需要計算內積,所以在新的數據在 9 維空間,需要計算  <f(x),  f(y)> 的內積,需要花費 O(n^2)。

  再具體點,令 x = (1, 2, 3), y = (4, 5, 6),那麼 f(x)  = (1, 2, 3, 2, 4, 6, 3, 6, 9),f(y) = (16, 20, 24, 20, 25, 36, 24, 30, 36)

  此時: <f(x),  f(y)>  = 16 + 40 + 72 +40 +100 + 180 + 72 +180 +324 = 1024

  對於3D空間這兩個數據,似乎還能計算,但是如果將維數擴大到一個非常大數的時候,計算起來可就不是這麼一點點問題了。

  然而,我們發現  K(x, y) = (<x, y>)^2   ,代入上式: K(x, y) = (4 + 10 + 18)^2 = 32^2 = 1024

  也就是說 : K(x, y) = (<x, y>)^2  = <f(x),  f(y)> 。

  但是 K(x, y) 計算起來卻比 <f(x), f(y)> 簡單的多,也就是說只要用 K(x, y)來計算,效果與 <f(x), f(y)> 是一樣的,但是計算效率卻大幅度提高了,如  K(x, y) 是 O(n),而  <f(x), f(y)> 是 O(n^2),所以使用核函數的好處就是,可以在一個低維空間去完成一個高緯度(或者無限維度)樣本內積的計算,比如上面例子中 K(x, y)的3D空間對比 <f(x), f(y)> 的9D空間。

  下面再舉個例子來證明一下上面的問題,為了簡單起見,假設所有樣本點都是二維點,其值分別為(x,  y),分類函數為:

   它對應的映射方式為:

   可以驗證:任意兩個擴維后的樣本點在3維空間的內積等於原樣本點在二維空間的函數輸出:

   有了這個核函數,以後的高維內積都可以轉換為低維的函數運算了,這裏也就是說只需要計算低維的內積,然後再平方。明顯問題得到解決且複雜度降低極大。總而言之:核函數它本質上隱含了從低維到高維的映射,從而避免直接計算高維的內積。

  當然上述例子是多項式核函數的一個特例,其實核函數的種類還有很多,後文會一一介紹。

4,核函數的計算原理

  通過上面的例子,我們大概可以知道核函數的巧妙應用了,下面學習一下核函數的計算原理。

  如果有一種方法可以在特徵空間中直接計算內積  <Φ(xi , Φ(x)> ,就像在原始輸入點的函數中一樣,就有可能將兩個步驟融合到一起建立一個非線性的學習器,這樣直接計算的方法稱為核函數方法。

  設 x 是輸入空間(歐式空間或者離散集合),H為特徵空間(希爾伯特空間),如果存在一個從 x 到 H 的映射:

  核是一個函數 K,對於所有 x, z ∈ χ, 則滿足:

   則稱Κ(x,z)為核函數,φ(x)為映射函數,φ(x)∙φ(z)為x,z映射到特徵空間上的內積。

  參考網友的理解:任意兩個樣本點在擴維后的空間的內積,如果等於這兩個樣本點在原來空間經過一個函數后的輸出,那麼這個函數就叫核函數。

  由於映射函數十分複雜難以計算,在實際中,通常都是使用核函數來求解內積,計算複雜度並沒有增加,映射函數僅僅作為一種邏輯映射,表徵着輸入空間到特徵空間的映射關係。至於為什麼需要映射后的特徵而不是最初的特徵來參与計算,為了更好地擬合是其中一個原因,另外的一個重要原因是樣例可能存在線性不可分的情況,而將特徵映射到高維空間后,往往就可分了。

  下面將核函數形式化定義。如果原始特徵內積是 <X ,  Z>,映射 <Φ(xi • Φ(x)>,那麼定義核函數(Kernel)為:

  到這裏,我們可以得出結論,如果要實現該節開頭的效果,只需要計算 Φ(x) ,然後計算 Φ(x)TΦ(x)即可,然而這種計算方式是非常低效的。比如最初的特徵是n維的,我們將其映射到 n2 維,然後再計算,這樣需要O(n2 ) 的時間,那麼我們能不能想辦法減少計算時間呢?

  先說結論,當然是可以的,畢竟我們上面例子,活生生的說明了一個將需要 O(n2 ) 的時間 轉換為 需要O(n ) 的時間。

  先看一個例子,假設x和z都是n維度的,

  展開后,得到:

  這個時候發現我們可以只計算原始特徵 x 和 z 內積的平方(時間複雜度為O(n)),就等價於計算映射后特徵的內積。也就是說我們不需要花O(n2 ) 的時間了。

  現在看一下映射函數(n = 3),根據上面的公式,得到:

  也就是說核函數  Κ(x,z) = (xTz)2  只能選擇這樣的 φ 作為映射函數時才能夠等價於映射后特徵的內積。

  再看另外一個核函數,高斯核函數:

  這時,如果 x 和 z 很相近 (||x – z || 約等於 0),那麼核函數值為1,如果 x 和 z 相差很大(||x – z ||  >> 0),那麼核函數值約等於0.由於這個函數類似於高斯分佈,因此稱為高斯核函數,也叫做徑向基函數(Radial Basis Function 簡稱為RBF)。它能夠把原始特徵映射到無窮維。

  下面有張圖說明在低維線性不可分時,映射到高維后就可分了,使用高斯核函數。

  注意,使用核函數后,怎麼分類新來的樣本呢?線性的時候我們使用SVM學習出w和b,新來樣本x的話,我們使用 wTx + b 來判斷,如果值大於等於1,那麼是正類,小於等於是負類。在兩者之間,認為無法確定。如果使用了核函數后,wTx + b 就變成了 wTΦ(x) + b,是否先要找到 Φ(x) ,然後再預測?答案肯定不是了,找 Φ(x) 很麻煩,回想我們之前說過的。

  只需將 <(x(i) , x> 替換成  (x(i) , x),然後值的判斷同上。

4.1  核函數有效性的判定

  問題:給定一個函數K,我們能否使用K來替代計算 Φ(x)TΦ(x),也就說,是否能夠找出一個 Φ,使得對於所有的x和z,都有 K(x, z) = Φ(x)TΦ(x),即比如給出了 K(x, z) = (xTz)2,是否能夠認為K是一個有效的核函數。

  下面來解決這個問題,給定m個訓練樣本(x(1),x(2), ….,x(m)),每一個x(i) 對應一個特徵向量。那麼,我們可以將任意兩個 x(i) 和 x(j) 帶入K中,計算得到Kij = K(x(i), x(j))。i 可以從1到m,j 可以從1到m,這樣可以計算出m*m的核函數矩陣(Kernel Matrix)。為了方便,我們將核函數矩陣和 K(x, z) 都使用 K 來表示。如果假設 K 是有效地核函數,那麼根據核函數定義:

  可見,矩陣K應該是個對稱陣。讓我們得出一個更強的結論,首先使用符號ΦK(x)來表示映射函數 Φ(x) 的第 k 維屬性值。那麼對於任意向量 z,得:

  最後一步和前面計算 K(x, z) = (xTz)2 時類似。從這個公式我們可以看出,如果K是個有效的核函數(即 K(x, z)  和 Φ(x)TΦ(z)等價),那麼,在訓練集上得到的核函數矩陣K應該是半正定的(K>=0)。這樣我們得到一個核函數的必要條件:K是有效的核函數 ==> 核函數矩陣K是對稱半正定的。

  Mercer定理表明為了證明K是有效的核函數,那麼我們不用去尋找 Φ ,而只需要在訓練集上求出各個 Kij,然後判斷矩陣K是否是半正定(使用左上角主子式大於等於零等方法)即可。

 

5,核函數:如何處理非線性數據

  來看個核函數的例子。如下圖所示的兩類數據,分別分佈為兩個圓圈的形狀,這樣的數據本身就是線性不可分的,此時我們該如何把這兩類數據分開呢?

   事實上,上圖所示的這個數據集,是用兩個半徑不同的圓圈加上了少量的噪音生成得到的,所以,一個理想的分界應該是“圓圈” 而不是“一條線”(超平面)。如果用 X1 和 X2 來表示這個二維平面的兩個坐標的話,我們知道一條二次曲線(圓圈是二次曲線的一種特殊情況)的方程可以寫作這樣的形式:

   注意上面的形式,如果我們構造另外一個五維的空間,其中五個坐標的值分別為:

   那麼顯然,上面的方程在新的坐標系下可以寫做:

   關於新的坐標 Z,這正是一個 hyper plane 的方程!也就是說,如果我們做一個映射:

   將X按照上面的規則映射為 Z,那麼在新的空間中原來的數據將變成線性可分的,從而使用之前我們推導的線性分類算法就可以進行處理了。這正是Kernel方法處理非線性問題的基本思想。

  再進一步描述 Kernel 的細節之前,不妨再來看看上述例子在映射過後的直觀形態。當然,我們無法將五維空間畫出來,不過由於我這裏生成數據的時候用了特殊的情形,所以這裏的超平面實際的方程是這個樣子的(圓心在X2軸上的一 個正圓):

   因此我只需要把它映射到下面這樣一個三維空間中即可:

   下圖即是映射之後的結果,將坐標軸經過適當的旋轉,就可以很明顯的看出,數據是可以通過一個平面來分開的

  核函數相當於把原來的分類函數:

   映射成:

   而其中的 α 可以通過求解如下 dual 問題而得到的:

   這樣一來問題就解決了嗎?似乎是的:拿到非線性數據,就找一個映射(Φ(•),然後一股腦把原來的數據映射到新空間中,再做線性SVM即可。不過事實上問題好像沒有這麼簡單)。

  細想一下,剛才的方法是不是有問題:

  在最初的例子里,我們對一個二維空間做映射,選擇的新空間是原始空間的所有一階和二階的組合,得到了五個維度;

  如果原始空間是三維(一階,二階和三階的組合),那麼我們會得到:3(一次)+3(二次交叉)+3(平方)+3(立方)+1(x1 * x2 * x3) + 2*3(交叉,一個一次一個二次,類似 x1*x2^2)=19 維的新空間,這個數目是呈指數級爆炸性增長的,從而勢必這給 Φ(•) 的計算帶來非常大的困難,而且如果遇到無窮維的情況,就根本無從計算了。

  這個時候,可能就需要Kernel出馬了。

  不妨還是從最開始的簡單例子觸發,設兩個向量為:

   而 Φ(•) 即是前面說的五維空間的映射,因此映射過後的內積為:

   (公式說明:上面的這兩個推導過程中,所說的前面的五維空間的映射,這裏說的便是前面的映射方式,回顧下之前的映射規則,再看看那個長的推導式,其實就是計算x1,x2各自的內積,然後相乘相加即可,第二個推導則是直接平方,去掉括號,也很容易推出來)

  另外,我們又注意到:

   二者有很多相似的地方,實際上,我們只要把某幾個維度線性縮放一下,然後再加上一個常數維度,具體來說,上面這個式子的計算結果實際上和映射

   之後的內積  <Φ(xi • Φ(x)>  的結果是相等的,那麼區別在什麼地方呢?

  • 1,一個是映射到高維空間中,然後再根據內積的公式進行計算
  • 2,另一個則直接在原來的低維空間中進行計算,而不需要顯式地寫出映射后的結果

  (公式說明:上面之中,最後的兩個式子,第一個算式,是帶內積的完全平方式,可以拆開,然後,再通過湊一個得到,第二個算式,也是根據第一個算式湊出來的)

  回想剛才提到的映射的維度爆炸,在前一種方法已經無法計算的情況下,后一種方法卻依舊能從容處理,甚至是無窮維度的情況也沒有問題。

  我們把這裏的計算兩個向量在隱式映射過後的空間中的內積的函數叫做核函數(kernel Function),例如,在剛才的例子中,我們的核函數為:

   核函數能簡化映射空間中的內積運算——剛好“碰巧”的是,在我們的SVM里需要計算的地方數據向量總是以內積的形式出現的。對比剛才我們上面寫出來的式子,現在我們的分類函數為:

   其中 α 由如下 dual 問題計算而得:

  這樣一來計算的問題就算解決了,避免了直接在高維空間中進行計算,而結果卻是等價的!當然,因為我們這裏的例子非常簡單,所以可以手工構造出對應於 Φ(•) 的核函數出來,如果對於任意一個映射,想要構造出對應的核函數就非常困難了。

6,核函數的本質

  下面概況一下核函數的意思:

  • 1,實際上,我們會經常遇到線性不可分的樣例,此時,我們的常用做法是把樣例特徵映射到高位空間中去(比如之前有個例子,映射到高維空間后,相關特徵便被分開了,也就達到了分類的目的)
  • 2,進一步,如果凡是遇到線性不可分的樣例,一律映射到高維空間,那麼這個維度大小是會高到可怕的(甚至是無窮維),所以核函數就隆重出場了,核函數的價值在於它雖然也是將特徵進行從低維到高維的轉換,但核函數絕就絕在它事先在低維上進行計算,而將實質上的分類效果表現在了高維上,也就是上文所說的避免了直接在高維空間中的複雜計算。

  下面引用這個例子距離下核函數解決非線性問題的直觀效果。

  假設現在你是一個農場主,圈養了一批羊群,但為了預防狼群襲擊羊群,你需要搭建一個籬笆來把羊群圈起來。但是籬笆應該建在哪裡呢?你很可能需要依據羊群和狼群的位置搭建一個“分類器”,比如下圖這幾種不同的分類器,我們可以看到SVM完成了一個很完美的解決方案。

   這個例子側面簡單說明了SVM使用非線性分類器的優勢,而邏輯模式以及決策樹模式都是使用了直線方法。

7,幾種常見的核函數

  核函數有嚴格的數學要求,所以設計一個核函數是非常困難的,科學家們經過很多很多嘗試也就只嘗試出來幾個核函數,所以我們就不要在這方面下無用功了,直接拿這常見的幾個核函數使用就OK。

  下面來分別學習一下這幾個常見的核函數。

7.1  線性核(Linear Kernel )

  基本原理:實際上就是原始空間中的內積

   這個核存在的主要目的是使得“映射后空間中的問題” 和 “映射前空間中的問題” 兩者在形式上統一起來了(意思是說:我們有的時候,寫代碼或者寫公式的時候,只要寫個模板或者通用表達式,然後再代入不同的核,便可以了。於此,便在形式上統一了起來,不用再找一個線性的和一個非線性的)

     線性核,主要用於線性可分的情況,我們可以看到特徵空間到輸入空間的維度是一樣的。在原始空間中尋找最優線性分類器,具有參數少速度快的優勢。對於線性可分數據,其分類效果很理想。因此我們通常首先嘗試用線性核函數來做分類,看看效果如何,如果不行再換別的。

優點:

  • 方案首選,奧多姆剃刀定理
  • 簡單,可以快速解決一個QP問題
  • 可解釋性強:可以輕易知道哪些feature是重要的

限制:

  • 只能解決線性可分問題

7.2 多項式核(Polynomial Kernel)

  基本原理:依靠升維使得原本線性不可分的數據線性可分。

  多項式核函數可以實現將低維的輸入空間映射到高維的特徵空間。多項式核適合於正交歸一化(向量正交且模為1)數據,屬於全局核函數,允許相距很遠的數據點對核函數的值有影響。參數d越大,映射的維度越高,計算量就會越大。

優點:

  • 可解決非線性問題
  • 可通過主觀設置Q來實現總結的預判

缺點:

  • 多項式核函數的參數多,當多項式的階數d比較高的是,由於學習複雜性也會過高,易出現“過擬合”現象,核矩陣的元素值將趨於無窮大或者無窮小,計算複雜度會大道無法計算。

 

7.3  高斯核(Gaussian Kernel)/ 徑向基核函數(Radial Basis Function)

  徑向基核函數是SVM中常用的一個核函數。徑向基函數是一個採用向量作為自變量的函數,能夠基於向量距離運算輸出一個標量。

   也可以寫成如下格式:

  徑向基函數是指取值僅僅依賴於特定點距離的實值函數,也就是:

  任意一個滿足上式特性的函數 Φ 都叫徑向量函數,標準的一般使用歐氏距離,儘管其他距離函數也是可以的。所以另外兩個比較常用的核函數,冪指數核,拉普拉斯核也屬於徑向基核函數。此外不太常用的徑向基核還有ANOVA核,二次有理核,多元二次核,逆多元二次核。

  高斯徑向基函數是一種局部性強的核函數,其可以將一個樣本映射到一個更高維的空間內,該核函數是應用最廣的一個,無論大樣本還是小樣本都有比較好的性能,而且其相對於多項式核函數參數要少,因此大多數情況下在不知道用什麼樣的核函數的時候優先使用高斯核函數。

  徑向基核函數屬於局部核函數,當數據點距離中心點變遠時,取值會變小。高斯徑向基核對數據中存在的噪聲有着較好的抗干擾能力,由於其很強的局部性,其參數決定了函數作用範圍,隨着參數 σ 的增大而減弱。

優點:

  • 可以映射到無線維
  • 決策邊界更為多樣
  • 只有一個參數,相比多項式核容易選擇

缺點:

  • 可解釋性差(無限多維的轉換,無法算出W)
  • 計算速度比較慢(當解決一個對偶問題)
  • 容易過擬合(參數選不好時容易overfitting)

上述所講的徑向核函數表達式:

  冪指數核(Exponential Kernel)

  拉普拉斯核(LaplacIan Kernel)

 

   ANOVA 核(ANOVA Kernel)

  二次有理核(Rational Quadratic Kernel)

  多元二次核(Multiquadric Kernel)

  逆多元二次核(Inverse Multiquadric Kernel)

7.4  Sigmoid核

   Sigmoid核函數來源於神經網絡,被廣泛用於深度學習和機器學習中

  採用Sigmoid函數作為核函數時,支持向量機實現的就是一種多層感知器神經網絡,應用SVM方法,隱含層節點數目(它確定神經網絡的結構),隱含層節點對輸入節點的權重都是在設計(訓練)的過程中自動確定的。而且支持向量機的理論基礎決定了它最終求得的是全局最優值而不是局部最優值,也保證了它對未知樣本的良好泛化能力而不會出現過學習線性。

8,核函數的選擇

8.1,先驗知識

  利用專家的先驗知識預先選定核函數

8.2,交叉驗證

  採取Cross-Validation方法,即在進行核函數選取時,分別試用不同的核函數,歸納誤差最小的核函數就是最好的核函數。如針對傅里恭弘=叶 恭弘核,RBF核,結合信號處理問題中的函數回歸問題,通過仿真實驗,對比分析了在相同數據條件下,採用傅里恭弘=叶 恭弘核的SVM要比採用RBF核的SVM誤差小很多。

8.3,混合核函數

  採用由Smits等人提出的混合核函數方法,該方法較之前兩者是目前選取核函數的主流方法,也是關於如何構建核函數的又一開創性的工作,將不同的核函數結合起來後有更好的特性,這是混合核函數方法的基本思想。

8.4,經驗

  當樣本特徵很多時,特徵的維度很高,這是往往樣本線性可分,可考慮用線性核函數的SVM或者LR(如何不考慮核函數,LR和SVM都是線性分類算法,也就是說他們的分類決策面都是線性的)

  當樣本的數量很多,但特徵較少時,可以手動添加一些特徵,使樣本線性可分,再考慮用線性核函數的SVM或者LR

  當樣本特徵維度不高時,樣本數量也不多時,考慮使用高斯核函數(RBF核函數的一種,指數核函數和拉普拉斯核函數也屬於RBF核函數)

8.5,吳恩達給出的選擇核函數的方法

   如果特徵的數量大道和樣本數量差不多,則選用LR或者線性核的SVM

  如果特徵的數量小,樣本的數量正常,則選用SVM+ 高斯核函數

  如果特徵的數量小,而樣本的數量很大,則需要手工添加一些特徵從而變成第一種情況

8.6  核函數選擇的例子

  這裏簡單說一下核函數與其他參數的作用(後面會詳細學習關於使用Sklearn學習SVM):

  • kernel=’linear’ 時,C越大分類效果越好,但有可能會過擬合(default C=1)
  • kernel=’rbf’時,為高斯核,gamma值越小,分類界面越連續;gamma值越大,分類界面越“散”,分類效果越好,但有可能會過擬合。

  我們來看一個簡單的例子,數據為[-5.0 , 9.0] 的隨機數組,函數如下 :

  下面分別使用三種核SVR:兩種乘法係數高斯核rbf和一種多項式核poly。代碼如下:

from sklearn import svm
import numpy as np
from matplotlib import pyplot as plt
import warnings

warnings.filterwarnings('ignore')

X = np.arange(-5.0 , 9.0 , 0.1)
# print(X)
X = np.random.permutation(X)
# print('1X:',X)
X_ = [[i] for i in X]
b = 0.5
y = 0.5 * X ** 2.0 + 3.0 * X + b + np.random.random(X.shape) * 10.0
y_ = [i for i in y]

# degree = 2 , gamma=, coef0 =
rbf1 = svm.SVR(kernel='rbf',C=1,)
rbf2 = svm.SVR(kernel='rbf',C=20,)
poly = svm.SVR(kernel='poly',C=1,degree=2)

rbf1.fit(X_ , y_)
rbf2.fit(X_ , y_)
poly.fit(X_ , y_)


result1 = rbf1.predict(X_)
result2 = rbf2.predict(X_)
result3 = poly.predict(X_)


plt.plot(X,y,'bo',fillstyle='none')
plt.plot(X,result1,'r.')
plt.plot(X,result2,'g.')
plt.plot(X,result3,'b.')
plt.show()

  結構圖如下:

  藍色是poly,紅色是c=1的rbf,綠色c=20的rbf。其中效果最好的是C=20的rbf。如果我們知道函數的表達式,線性規劃的效果更好,但是大部分情況下我們不知道數據的函數表達式,因此只能慢慢實驗,SVM的作用就在這裏了。

9,總結

  支持向量機是一種分類器。之所以稱為“機”是因為它會產生一個二值決策結果,即它是一種決策“機”。支持向量機的泛化錯誤率較低,也就是說它具有良好的學習能力,且學到的結果具有很好的推廣性。這些優點使得支持向量機十分流行,有些人認為它是監督學習中最好的定式算法。

  支持向量機視圖通過求解一個二次優化問題來最大化分類間隔。在過去,訓練支持向量機常採用非常複雜並且低效的二次規劃求解方法。John Platt 引入了SMO算法,此算法可以通過每次只優化2個 α 值來加快SVM的訓練速度。

  核方法或者說核技巧會將數據(有時候是非線性數據)從一個低維空間映射到一個高維空間,可以將一個在低維空間中的非線性問題轉化為高維空間下的線性問題來求解。核方法不止在SVM中適用,還可以用於其他算法。而其中的徑向基函數是一個常用的度量兩個向量距離的核函數。

 

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

【其他文章推薦】

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

※網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※想知道最厲害的網頁設計公司"嚨底家"!

※別再煩惱如何寫文案,掌握八大原則!

※產品缺大量曝光嗎?你需要的是一流包裝設計!

※回頭車貨運收費標準

※台中搬家公司費用怎麼算?

美研究早預言武漢肺炎 點名「野味市場」風險最高

摘錄自2020年4月8日自由時報報導

根據《CNN》報導,美國加州大學(University of California)與澳洲墨爾本大學(University of Melbourne)研究人員數年前進行的研究中發現,在人類持續開發破壞自然棲地時,原宿主是動植物的病毒一旦得以傳染人類,將構成相當高程度的風險。

研究作者,美國加州大學流行病學教授強森博士(Dr. Christine Kreuder Johnson)當時便曾警告,未來很可能出現具有威脅人類生存能力的動物傳人傳染病。

強森博士表示,當人類把野生哺乳類動物從自然環境捕捉並運送至市場上出售時,對這些活體野生動物造成極大的壓力,導致更多潛在存於動物體內的病毒有更高的潛力散播至人群中。她說,與野外接觸相比,處於被捕獲狀態下的野生動物具有更高的排泄、噴沫等衝動,將導致病毒被大量排出,暴露在大量人群密集分布的環境,如中國惡名昭彰的野味市場。

生活環境
國際新聞
美國
野味
武漢肺炎
蝙蝠與新興傳染病
食品安全

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

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

※別再煩惱如何寫文案,掌握八大原則!

※教你寫出一流的銷售文案?

※超省錢租車方案

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

※產品缺大量曝光嗎?你需要的是一流包裝設計!

※回頭車貨運收費標準

研究:人類接觸野生動物恐招致病毒入侵 三種類別動物風險尤高

環境資訊中心綜合外電;姜唯 編譯;林大利 審校

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※別再煩惱如何寫文案,掌握八大原則!

※網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※超省錢租車方案

※教你寫出一流的銷售文案?

※網頁設計最專業,超強功能平台可客製化

※產品缺大量曝光嗎?你需要的是一流包裝設計!

※台中搬家遵守搬運三大原則,讓您的家具不再被破壞!

疫情之後還有氣候戰役 歐盟十國環境部長公開信:復甦方案要綠色政綱

環境資訊中心綜合外電;黃鈺婷、鄒敏惠 編譯;趙家緯 審校

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※超省錢租車方案

※別再煩惱如何寫文案,掌握八大原則!

※回頭車貨運收費標準

※教你寫出一流的銷售文案?

※產品缺大量曝光嗎?你需要的是一流包裝設計!

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

※網頁設計最專業,超強功能平台可客製化

趁疫情偷抓瀕危鱘魚 野生動物犯罪急升 WWF警告:中東歐保育類猛禽陷危機

環境資訊中心綜合外電;姜唯 編譯;林大利 審校

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※回頭車貨運收費標準

※產品缺大量曝光嗎?你需要的是一流包裝設計!

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

※推薦評價好的iphone維修中心

※教你寫出一流的銷售文案?

※台中搬家公司教你幾個打包小技巧,輕鬆整理裝箱!

※台中搬家遵守搬運三大原則,讓您的家具不再被破壞!

深入理解Java的動態編譯

前提

筆者很久之前就有個想法:參考現有的主流ORM框架的設計,造一個ORM輪子,在基本不改變使用體驗的前提下把框架依賴的大量的反射設計去掉,這些反射API構築的組件使用動態編譯加載的實例去替代,從而可以得到接近於直接使用原生JDBC的性能。於是帶着這樣的想法,深入學習Java的動態編譯。編寫本文的時候使用的是JDK11。

基本原理

下面這個很眼熟的圖來源於《深入理解Java虛擬機》前端編譯與優化的章節,主要描述編譯的過程:

上圖看起來只有三步,其實每一步都有大量的步驟,下圖嘗試相對詳細地描述具體的步驟(圖比較大難以分割,直接放原圖):

實際上,僅僅對於編譯這個過程來說,開發者或者使用者不必要完全掌握其中的細節,JDK提供了一個工具包javax.tools讓使用者可以用簡易的API進行編譯(其實在大多數請下,開發者是面向業務功能開發,像編譯和打包這些細節一般直接由開發工具、Maven、Gradle等工具完成):

具體的使用過程包括:

  • 獲取一個javax.tools.JavaCompiler實例。
  • 基於Java文件對象初始化一個編譯任務javax.tools.JavaCompiler$CompilationTask實例。
  • CompilationTask實例執行結果代表着編譯過程的成功與否。

我們熟知的javac編譯器其實就是JavaCompiler接口的實現,在JDK11中,對應的實現類為com.sun.tools.javac.api.JavacTool。在JDK8中不存在JavaCompiler接口,具體的編譯入口類為com.sun.tools.javac.main.JavaCompiler。

因為JVM裏面的Class是基於ClassLoader隔離的,所以編譯成功之後可以通過自定義的類加載器加載對應的類實例,然後就可以應用反射API進行實例化和後續的調用。

JDK動態編譯

JDK動態編譯的步驟在上一節已經清楚地說明,這裏造一個簡單的場景。假設存在一個接口如下:

package club.throwable.compile;

public interface HelloService {
    
    void sayHello(String name);
}

// 默認實現
package club.throwable.compile;

public class DefaultHelloService implements HelloService {

    @Override
    public void sayHello(String name) {
        System.out.println(String.format("%s say hello [by default]", name));
    }
}

我們可以通過字符串SOURCE_CODE定義一個類:

static String SOURCE_CODE = "package club.throwable.compile;\n" +
        "\n" +
        "public class JdkDynamicCompileHelloService implements HelloService{\n" +
        "\n" +
        "    @Override\n" +
        "    public void sayHello(String name) {\n" +
        "        System.out.println(String.format(\"%s say hello [by jdk dynamic compile]\", name));\n" +
        "    }\n" +
        "}";

// 這裏不需要定義類文件,還原類文件內容如下
package club.throwable.compile;

public class JdkDynamicCompileHelloService implements HelloService{

    @Override
    public void sayHello(String name) {
        System.out.println(String.format("%s say hello [by jdk dynamic compile]", name));
    }
}

在組裝編譯任務實例之前,還有幾項工作需要完成:

  • 內置的JavaFileObject標準實現SimpleJavaFileObject是面向類源碼文件,由於動態編譯時候輸入的是類源碼文件的內容字符串,需要自行實現JavaFileObject。
  • 內置的JavaFileManager是面向類路徑下的Java源碼文件進行加載,這裏也需要自行實現JavaFileManager。
  • 需要自定義一個ClassLoader實例去加載編譯出來的動態類。

實現JavaFileObject

自行實現一個JavaFileObject,其實可以簡單點直接繼承SimpleJavaFileObject,覆蓋需要用到的方法即可:

public class CharSequenceJavaFileObject extends SimpleJavaFileObject {

    public static final String CLASS_EXTENSION = ".class";

    public static final String JAVA_EXTENSION = ".java";

    private static URI fromClassName(String className) {
        try {
            return new URI(className);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(className, e);
        }
    }

    private ByteArrayOutputStream byteCode;
    private final CharSequence sourceCode;

    public CharSequenceJavaFileObject(String className, CharSequence sourceCode) {
        super(fromClassName(className + JAVA_EXTENSION), Kind.SOURCE);
        this.sourceCode = sourceCode;
    }

    public CharSequenceJavaFileObject(String fullClassName, Kind kind) {
        super(fromClassName(fullClassName), kind);
        this.sourceCode = null;
    }

    public CharSequenceJavaFileObject(URI uri, Kind kind) {
        super(uri, kind);
        this.sourceCode = null;
    }
    
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
        return sourceCode;
    }

    @Override
    public InputStream openInputStream() {
        return new ByteArrayInputStream(getByteCode());
    }
    
    // 注意這個方法是編譯結果回調的OutputStream,回調成功后就能通過下面的getByteCode()方法獲取目標類編譯后的字節碼字節數組
    @Override
    public OutputStream openOutputStream() {
        return byteCode = new ByteArrayOutputStream();
    }

    public byte[] getByteCode() {
        return byteCode.toByteArray();
    }
}

如果編譯成功之後,直接通過自行添加的CharSequenceJavaFileObject#getByteCode()方法即可獲取目標類編譯后的字節碼對應的字節數組(二進制內容)。這裏的CharSequenceJavaFileObject預留了多個構造函數用於兼容原有的編譯方式。

實現ClassLoader

只要簡單繼承ClassLoader即可,關鍵是要覆蓋原來的ClassLoader#findClass()方法,用於搜索自定義的JavaFileObject實例,從而提取對應的字節碼字節數組進行裝載,為了實現這一點可以添加一個哈希表作為緩存,鍵-值分別是全類名的別名(xx.yy.MyClass形式,而非URI模式)和目標類對應的JavaFileObject實例。

public class JdkDynamicCompileClassLoader extends ClassLoader {

    public static final String CLASS_EXTENSION = ".class";

    private final Map<String, JavaFileObject> javaFileObjectMap = Maps.newConcurrentMap();

    public JdkDynamicCompileClassLoader(ClassLoader parentClassLoader) {
        super(parentClassLoader);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        JavaFileObject javaFileObject = javaFileObjectMap.get(name);
        if (null != javaFileObject) {
            CharSequenceJavaFileObject charSequenceJavaFileObject = (CharSequenceJavaFileObject) javaFileObject;
            byte[] byteCode = charSequenceJavaFileObject.getByteCode();
            return defineClass(name, byteCode, 0, byteCode.length);
        }
        return super.findClass(name);
    }

    @Nullable
    @Override
    public InputStream getResourceAsStream(String name) {
        if (name.endsWith(CLASS_EXTENSION)) {
            String qualifiedClassName = name.substring(0, name.length() - CLASS_EXTENSION.length()).replace('/', '.');
            CharSequenceJavaFileObject javaFileObject = (CharSequenceJavaFileObject) javaFileObjectMap.get(qualifiedClassName);
            if (null != javaFileObject && null != javaFileObject.getByteCode()) {
                return new ByteArrayInputStream(javaFileObject.getByteCode());
            }
        }
        return super.getResourceAsStream(name);
    }

    /**
     * 暫時存放編譯的源文件對象,key為全類名的別名(非URI模式),如club.throwable.compile.HelloService
     */
    void addJavaFileObject(String qualifiedClassName, JavaFileObject javaFileObject) {
        javaFileObjectMap.put(qualifiedClassName, javaFileObject);
    }

    Collection<JavaFileObject> listJavaFileObject() {
        return Collections.unmodifiableCollection(javaFileObjectMap.values());
    }
}

實現JavaFileManager

JavaFileManager是Java文件的抽象管理器,它用於管理常規的Java文件,但是不局限於文件,也可以管理其他來源的Java類文件數據。下面就通過實現一個自定義的JavaFileManager用於管理字符串類型的源代碼。為了簡單起見,可以直接繼承已經存在的ForwardingJavaFileManager:

public class JdkDynamicCompileJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> {

    private final JdkDynamicCompileClassLoader classLoader;
    private final Map<URI, JavaFileObject> javaFileObjectMap = Maps.newConcurrentMap();

    public JdkDynamicCompileJavaFileManager(JavaFileManager fileManager, JdkDynamicCompileClassLoader classLoader) {
        super(fileManager);
        this.classLoader = classLoader;
    }

    private static URI fromLocation(Location location, String packageName, String relativeName) {
        try {
            return new URI(location.getName() + '/' + packageName + '/' + relativeName);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e);
        }
    }

    @Override
    public FileObject getFileForInput(Location location, String packageName, String relativeName) throws IOException {
        JavaFileObject javaFileObject = javaFileObjectMap.get(fromLocation(location, packageName, relativeName));
        if (null != javaFileObject) {
            return javaFileObject;
        }
        return super.getFileForInput(location, packageName, relativeName);
    }

    /**
     * 這裡是編譯器返回的同(源)Java文件對象,替換為CharSequenceJavaFileObject實現
     */
    @Override
    public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException {
        JavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, kind);
        classLoader.addJavaFileObject(className, javaFileObject);
        return javaFileObject;
    }

    /**
     * 這裏覆蓋原來的類加載器
     */
    @Override
    public ClassLoader getClassLoader(Location location) {
        return classLoader;
    }

    @Override
    public String inferBinaryName(Location location, JavaFileObject file) {
        if (file instanceof CharSequenceJavaFileObject) {
            return file.getName();
        }
        return super.inferBinaryName(location, file);
    }

    @Override
    public Iterable<JavaFileObject> list(Location location, String packageName, Set<JavaFileObject.Kind> kinds, boolean recurse) throws IOException {
        Iterable<JavaFileObject> superResult = super.list(location, packageName, kinds, recurse);
        List<JavaFileObject> result = Lists.newArrayList();
        // 這裏要區分編譯的Location以及編譯的Kind
        if (location == StandardLocation.CLASS_PATH && kinds.contains(JavaFileObject.Kind.CLASS)) {
            // .class文件以及classPath下
            for (JavaFileObject file : javaFileObjectMap.values()) {
                if (file.getKind() == JavaFileObject.Kind.CLASS && file.getName().startsWith(packageName)) {
                    result.add(file);
                }
            }
            // 這裏需要額外添加類加載器加載的所有Java文件對象
            result.addAll(classLoader.listJavaFileObject());
        } else if (location == StandardLocation.SOURCE_PATH && kinds.contains(JavaFileObject.Kind.SOURCE)) {
            // .java文件以及編譯路徑下
            for (JavaFileObject file : javaFileObjectMap.values()) {
                if (file.getKind() == JavaFileObject.Kind.SOURCE && file.getName().startsWith(packageName)) {
                    result.add(file);
                }
            }
        }
        for (JavaFileObject javaFileObject : superResult) {
            result.add(javaFileObject);
        }
        return result;
    }

    /**
     * 自定義方法,用於添加和緩存待編譯的源文件對象
     */
    public void addJavaFileObject(Location location, String packageName, String relativeName, JavaFileObject javaFileObject) {
        javaFileObjectMap.put(fromLocation(location, packageName, relativeName), javaFileObject);
    }
}

注意在這個類中引入了自定義類加載器JdkDynamicCompileClassLoader,目的是為了實現JavaFileObject實例的共享以及為文件管理器提供類加載器實例。

動態編譯和運行

前置準備工作完成,我們可以通過JavaCompiler去編譯這個前面提到的字符串,為了字節碼的兼容性更好,編譯的時候可以指定稍低的JDK版本例如1.6:

public class Client {

    static String SOURCE_CODE = "package club.throwable.compile;\n" +
            "\n" +
            "public class JdkDynamicCompileHelloService implements HelloService{\n" +
            "\n" +
            "    @Override\n" +
            "    public void sayHello(String name) {\n" +
            "        System.out.println(String.format(\"%s say hello [by jdk dynamic compile]\", name));\n" +
            "    }\n" +
            "}";

    /**
     * 編譯診斷收集器
     */
    static DiagnosticCollector<JavaFileObject> DIAGNOSTIC_COLLECTOR = new DiagnosticCollector<>();

    public static void main(String[] args) throws Exception {
        // 獲取系統編譯器實例
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        // 設置編譯參數 - 指定編譯版本為JDK1.6以提高兼容性
        List<String> options = new ArrayList<>();
        options.add("-source");
        options.add("1.6");
        options.add("-target");
        options.add("1.6");
        // 獲取標準的Java文件管理器實例
        StandardJavaFileManager manager = compiler.getStandardFileManager(DIAGNOSTIC_COLLECTOR, null, null);
        // 初始化自定義類加載器
        JdkDynamicCompileClassLoader classLoader = new JdkDynamicCompileClassLoader(Thread.currentThread().getContextClassLoader());
        // 初始化自定義Java文件管理器實例
        JdkDynamicCompileJavaFileManager fileManager = new JdkDynamicCompileJavaFileManager(manager, classLoader);
        String packageName = "club.throwable.compile";
        String className = "JdkDynamicCompileHelloService";
        String qualifiedName = packageName + "." + className;
        // 構建Java源文件實例
        CharSequenceJavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, SOURCE_CODE);
        // 添加Java源文件實例到自定義Java文件管理器實例中
        fileManager.addJavaFileObject(
                StandardLocation.SOURCE_PATH,
                packageName,
                className + CharSequenceJavaFileObject.JAVA_EXTENSION,
                javaFileObject
        );
        // 初始化一個編譯任務實例
        JavaCompiler.CompilationTask compilationTask = compiler.getTask(
                null,
                fileManager,
                DIAGNOSTIC_COLLECTOR,
                options,
                null,
                Lists.newArrayList(javaFileObject)
        );
        // 執行編譯任務
        Boolean result = compilationTask.call();
        System.out.println(String.format("編譯[%s]結果:%s", qualifiedName, result));
        Class<?> klass = classLoader.loadClass(qualifiedName);
        HelloService instance = (HelloService) klass.getDeclaredConstructor().newInstance();
        instance.sayHello("throwable");
    }
}

輸出結果如下:

編譯[club.throwable.compile.JdkDynamicCompileHelloService]結果:true
throwable say hello [by jdk dynamic compile]

可見通過了字符串的類源碼,實現了動態編譯、類加載、反射實例化以及最終的方法調用。另外,編譯過程的診斷信息可以通過DiagnosticCollector實例獲取。為了復用,這裏可以把JDK動態編譯的過程抽取到一個方法中:

public final class JdkCompiler {

    static DiagnosticCollector<JavaFileObject> DIAGNOSTIC_COLLECTOR = new DiagnosticCollector<>();

    @SuppressWarnings("unchecked")
    public static <T> T compile(String packageName,
                                String className,
                                String sourceCode,
                                Class<?>[] constructorParamTypes,
                                Object[] constructorParams) throws Exception {
        // 獲取系統編譯器實例
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        // 設置編譯參數
        List<String> options = new ArrayList<>();
        options.add("-source");
        options.add("1.6");
        options.add("-target");
        options.add("1.6");
        // 獲取標準的Java文件管理器實例
        StandardJavaFileManager manager = compiler.getStandardFileManager(DIAGNOSTIC_COLLECTOR, null, null);
        // 初始化自定義類加載器
        JdkDynamicCompileClassLoader classLoader = new JdkDynamicCompileClassLoader(Thread.currentThread().getContextClassLoader());
        // 初始化自定義Java文件管理器實例
        JdkDynamicCompileJavaFileManager fileManager = new JdkDynamicCompileJavaFileManager(manager, classLoader);
        String qualifiedName = packageName + "." + className;
        // 構建Java源文件實例
        CharSequenceJavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, sourceCode);
        // 添加Java源文件實例到自定義Java文件管理器實例中
        fileManager.addJavaFileObject(
                StandardLocation.SOURCE_PATH,
                packageName,
                className + CharSequenceJavaFileObject.JAVA_EXTENSION,
                javaFileObject
        );
        // 初始化一個編譯任務實例
        JavaCompiler.CompilationTask compilationTask = compiler.getTask(
                null,
                fileManager,
                DIAGNOSTIC_COLLECTOR,
                options,
                null,
                Lists.newArrayList(javaFileObject)
        );
        Boolean result = compilationTask.call();
        System.out.println(String.format("編譯[%s]結果:%s", qualifiedName, result));
        Class<?> klass = classLoader.loadClass(qualifiedName);
        return (T) klass.getDeclaredConstructor(constructorParamTypes).newInstance(constructorParams);
    }
}

Javassist動態編譯

既然有JDK的動態編譯,為什麼還存在Javassist這樣的字節碼增強工具?撇開性能或者效率層面,JDK動態編譯存在比較大的局限性,比較明顯的一點就是無法完成字節碼插樁,換言之就是無法基於原有的類和方法進行修飾或者增強,但是Javassist可以做到。再者,Javassist提供的API和JDK反射的API十分相近,如果反射平時用得比較熟練,Javassist的上手也就變得比較簡單。這裏僅僅列舉一個增強前面提到的DefaultHelloService的例子,先引入依賴:

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.27.0-GA</version>
</dependency>

編碼如下:

public class JavassistClient {

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass cc = pool.get("club.throwable.compile.DefaultHelloService");
        CtMethod ctMethod = cc.getDeclaredMethod("sayHello", new CtClass[]{pool.get("java.lang.String")});
        ctMethod.insertBefore("System.out.println(\"insert before by Javassist\");");
        ctMethod.insertAfter("System.out.println(\"insert after by Javassist\");");
        Class<?> klass = cc.toClass();
        System.out.println(klass.getName());
        HelloService helloService = (HelloService) klass.getDeclaredConstructor().newInstance();
        helloService.sayHello("throwable");
    }
}

輸出結果如下:

club.throwable.compile.DefaultHelloService
insert before by Javassist
throwable say hello [by default]
insert after by Javassist

Javaassist這個單詞其實是Java和Assist兩個單詞拼接在一起,意為Java助手,是一個Java字節碼增強類庫:

  • 可以基於已經存在的類進行字節碼增強,例如修改已經存在的方法、變量,甚至是直接在原有的類中添加新的方法等。
  • 可以完全像積木拼接一樣,動態拼出一個全新的類。

不像ASM(ASM的學習曲線比較陡峭,屬於相對底層的字節碼操作類庫,當然從性能上來看ASM對字節碼增強的效率遠高於其他高層次封裝的框架)那樣需要對字節碼編程十分了解,Javaassist降低了字節碼增強功能的入門難度。

進階例子

現在定義一個接口MysqlInfoMapper,用於動態執行一條已知的SQL,很簡單,就是查詢MySQL的系統表mysql裏面的用戶信息SELECT Host,User FROM mysql.user:

@Data
public class MysqlUser {

    private String host;
    private String user;
}

public interface MysqlInfoMapper {

    List<MysqlUser> selectAllMysqlUsers();
}

假設現在只提供一個MySQL的驅動包(mysql:mysql-connector-java:jar:8.0.20),暫時不能依賴任何高層次的框架,要動態實現MysqlInfoMapper接口,優先整理需要的組件:

  • 需要一個連接管理器去管理MySQL的連接。
  • 需要一個SQL執行器用於執行查詢SQL。
  • 需要一個結果處理器去提取和轉換查詢結果。

為了簡單起見,筆者在定義這三個組件接口的時候順便在接口中通過單例進行實現(部分配置完全寫死):

// 連接管理器
public interface ConnectionManager {

    String USER_NAME = "root";

    String PASS_WORD = "root";

    String URL = "jdbc:mysql://localhost:3306/mysql?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false";

    Connection newConnection() throws SQLException;

    void closeConnection(Connection connection);

    ConnectionManager X = new ConnectionManager() {

        @Override
        public Connection newConnection() throws SQLException {
            return DriverManager.getConnection(URL, USER_NAME, PASS_WORD);
        }

        @Override
        public void closeConnection(Connection connection) {
            try {
                connection.close();
            } catch (Exception ignore) {

            }
        }
    };
}

// 執行器
public interface SqlExecutor {

    ResultSet execute(Connection connection, String sql) throws SQLException;

    SqlExecutor X = new SqlExecutor() {

        @Override
        public ResultSet execute(Connection connection, String sql) throws SQLException {
            Statement statement = connection.createStatement();
            statement.execute(sql);
            return statement.getResultSet();
        }
    };
}

// 結果處理器
public interface ResultHandler<T> {

    T handleResultSet(ResultSet resultSet) throws SQLException;

    ResultHandler<List<MysqlUser>> X = new ResultHandler<List<MysqlUser>>() {
        @Override
        public List<MysqlUser> handleResultSet(ResultSet resultSet) throws SQLException {
            try {
                List<MysqlUser> result = Lists.newArrayList();
                while (resultSet.next()) {
                    MysqlUser item = new MysqlUser();
                    item.setHost(resultSet.getString("Host"));
                    item.setUser(resultSet.getString("User"));
                    result.add(item);
                }
                return result;
            } finally {
                resultSet.close();
            }
        }
    };
}

接着需要動態編譯MysqlInfoMapper的實現類,它的源文件的字符串內容如下(注意不要在類路徑下新建這個DefaultMysqlInfoMapper類):

package club.throwable.compile;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.List;

public class DefaultMysqlInfoMapper implements MysqlInfoMapper {

    private final ConnectionManager connectionManager;
    private final SqlExecutor sqlExecutor;
    private final ResultHandler resultHandler;
    private final String sql;

    public DefaultMysqlInfoMapper(ConnectionManager connectionManager,
                                  SqlExecutor sqlExecutor,
                                  ResultHandler resultHandler,
                                  String sql) {
        this.connectionManager = connectionManager;
        this.sqlExecutor = sqlExecutor;
        this.resultHandler = resultHandler;
        this.sql = sql;
    }

    @Override
    public List<MysqlUser> selectAllMysqlUsers() {
        try {
            Connection connection = connectionManager.newConnection();
            try {
                ResultSet resultSet = sqlExecutor.execute(connection, sql);
                return (List<MysqlUser>) resultHandler.handleResultSet(resultSet);
            } finally {
                connectionManager.closeConnection(connection);
            }
        } catch (Exception e) {
            // 暫時忽略異常處理,統一封裝為IllegalStateException
            throw new IllegalStateException(e);
        }
    }
}

然後編寫一個客戶端進行動態編譯和執行:

public class MysqlInfoClient {

    static String SOURCE_CODE = "package club.throwable.compile;\n" +
            "import java.sql.Connection;\n" +
            "import java.sql.ResultSet;\n" +
            "import java.util.List;\n" +
            "\n" +
            "public class DefaultMysqlInfoMapper implements MysqlInfoMapper {\n" +
            "\n" +
            "    private final ConnectionManager connectionManager;\n" +
            "    private final SqlExecutor sqlExecutor;\n" +
            "    private final ResultHandler resultHandler;\n" +
            "    private final String sql;\n" +
            "\n" +
            "    public DefaultMysqlInfoMapper(ConnectionManager connectionManager,\n" +
            "                                  SqlExecutor sqlExecutor,\n" +
            "                                  ResultHandler resultHandler,\n" +
            "                                  String sql) {\n" +
            "        this.connectionManager = connectionManager;\n" +
            "        this.sqlExecutor = sqlExecutor;\n" +
            "        this.resultHandler = resultHandler;\n" +
            "        this.sql = sql;\n" +
            "    }\n" +
            "\n" +
            "    @Override\n" +
            "    public List<MysqlUser> selectAllMysqlUsers() {\n" +
            "        try {\n" +
            "            Connection connection = connectionManager.newConnection();\n" +
            "            try {\n" +
            "                ResultSet resultSet = sqlExecutor.execute(connection, sql);\n" +
            "                return (List<MysqlUser>) resultHandler.handleResultSet(resultSet);\n" +
            "            } finally {\n" +
            "                connectionManager.closeConnection(connection);\n" +
            "            }\n" +
            "        } catch (Exception e) {\n" +
            "            // 暫時忽略異常處理,統一封裝為IllegalStateException\n" +
            "            throw new IllegalStateException(e);\n" +
            "        }\n" +
            "    }\n" +
            "}\n";

    static String SQL = "SELECT Host,User FROM mysql.user";

    public static void main(String[] args) throws Exception {
        MysqlInfoMapper mysqlInfoMapper = JdkCompiler.compile(
                "club.throwable.compile",
                "DefaultMysqlInfoMapper",
                SOURCE_CODE,
                new Class[]{ConnectionManager.class, SqlExecutor.class, ResultHandler.class, String.class},
                new Object[]{ConnectionManager.X, SqlExecutor.X, ResultHandler.X, SQL});
        System.out.println(JSON.toJSONString(mysqlInfoMapper.selectAllMysqlUsers()));
    }
}

最終的輸出結果是:

編譯[club.throwable.compile.DefaultMysqlInfoMapper]結果:true
[{"host":"%","user":"canal"},{"host":"%","user":"doge"},{"host":"localhost","user":"mysql.infoschema"},{"host":"localhost","user":"mysql.session"},{"host":"localhost","user":"mysql.sys"},{"host":"localhost","user":"root"}]

然後筆者查看本地安裝的MySQL中的結果,驗證該查詢結果是正確的。

這裏筆者為了簡化整個例子,沒有在MysqlInfoMapper#selectAllMysqlUsers()方法中添加查詢參數,可以嘗試一下查詢的SQL是SELECT Host,User FROM mysql.user WHERE User = 'xxx'場景下的編碼實現。

如果把動態實現的DefaultMysqlInfoMapper註冊到IOC容器中,就可以實現MysqlInfoMapper按照類型自動裝配。
如果把SQL和參數處理可以抽離到單獨的文件中,並且實現一個對應的文件解析器,那麼就可以把類文件和SQL隔離,Mybatis和Hibernate都是這樣做的。

小結

動態編譯或者更底層的面向字節碼層面的編程,其實是一個十分有挑戰性但是可以創造無限可能的領域,本文只是簡單分析了一下Java源碼編譯的過程,並且通過一些簡單的例子進行動態編譯的模擬,離使用於實際應用中還有不少距離,後面需要花更多的時間去分析一下相關領域的知識。

參考資料:

  • JDK11部分源碼
  • 《深入理解Java虛擬機 – 3rd》
  • Javassist

(本文完 c-4-d e-a-20200606 0:23)

技術公眾號(《Throwable文摘》),不定期推送筆者原創技術文章(絕不抄襲或者轉載):

娛樂公眾號(《天天沙雕》),甄選奇趣沙雕圖文和視頻不定期推送,緩解生活工作壓力:

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

【其他文章推薦】

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

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

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

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

※教你寫出一流的銷售文案?

※別再煩惱如何寫文案,掌握八大原則!

去擺攤吧,落魄的Java程序員

真的,我也打算去擺攤,宣傳語我都想好了。沉默王二,一枚有顏值卻靠才華苟且的程序員,《Web 全棧開發進階之路》作者,CSDN 明星博主,周排名第 4,總排名 40,這數據在眾多互聯網大咖面前不值一提,但在洛陽,我想還是有一席之地的。

況且我家裡有很多書,每天晚上帶上二三十本書,到河科大學校門口擺個攤,前十名免費送,後面的書,價格隨意,只要同學們能夠負擔得起,隨意,一塊,一毛都可以,能拉動點經濟是點,也算是做出貢獻了。

另外,我還附送上我的個人微信,這個價值比書還要值錢得多,對吧?加了我的微信,同學們可以隨時隨地找我提問,還可以第一時間從我的朋友圈收到各種有趣有益的消息,真的超值啊。

我這算是积極響應國家號召了,對吧?總理都點贊好幾次地攤經濟了,朋友圈和微信社群都刷爆了。有一段白岩松老師的話,我覺得挺經典的,分享出來,大家感受一下。

我不喜歡地攤這個詞,應該叫室外經營,或者有序佔道經營,只要地方政府能夠放得開,就一定能夠拉動商戶的經營狀況。

連我老婆都坐不住了,強烈建議我去擺地攤,並且願意下班后和我一起,不嫌丟人——真患難與共啊。宣傳文案她已經幫我打印好了,今天晚上我們一家三口(帶上女兒)就去大學門口體驗一下,之前從未有過這方面的經驗,一想到這,內心竟然有些小激動。

本來,擺攤在我心目中是一種挺 low 的行為,要拋頭露面,要使勁的吆喝,還要被城管追着屁股跑,實在是狼狽。但現在我改變看法了,覺得擺攤不僅接地氣,說不定真能體驗出不一樣的生活樂趣。掙不掙錢是小事,重在參与,重在振興城市經濟。

況且,生活實在是太難了,必須得做出點改變了。就拿我來說吧,公眾號的亂序讓文章的打開率下降到了 4% 左右,之前是 8% 左右,四月底那會真的是信心滿滿,現在基本上一半的打開率沒有了,搞得挺焦慮的。

讀者訂閱數在增加,但閱讀量在下降,微信這波操作挺讓我心碎的。雖然說,面對短視頻的衝擊,圖文的整體閱讀在下降,微信不得不做出改變。但這次改變,我顯然不是受益者。

想想也挺悲哀的。所有的作者都拼了命的,從外部引流到公眾號,結果公眾號學起了今日頭條,強行加了推薦算法。這意味着什麼?

作者不再是公號的主人,讀者不再是公號的客人,中間多了一層皮條客,他願意撮合你倆,你倆就能見面,不願意的話,哼,門都沒有。

這對於讀者訂閱數龐大的號來說,閱讀量根本就不會受到影響,對吧,反正這個讀者看不到,另外一個讀者能看得到。

有一小部分讀者應該知道,我還有一個小號,“沉默王三”,已經有一段時間沒有更新了,原因很簡單,讀者訂閱數出現了負增長,所以我就喪失了更新的動力。

你看,連我這種有一些讀者基數的作者都養不動一個小號,更何況那些真正零起步的作者——太難了,還不如想想辦法去擺攤吧,不,還是好好乾自己的本職工作吧。

幸好“沉默王二”這個號的讀者增長還算是不那麼令人失望,否則真的有點坐不住了。面對這種困難的局面,我所能做的就是堅持初心,擁抱變化。

我寫作的初心是什麼?就是為了分享自己的心聲,自己的故事,自己對技術的一些理解,對人生的一些思考,給需要的讀者一丟丟幫助。

也許之前一個讀者的留言是對的,我不應該過多的關注閱讀量,更要注重文章的質量。總之,先佛。

我應該做出哪些改變呢?擺攤算是一種吧。更概括性的說法,就是,把自己寫作的主題與社會的熱點貼近一些,同時,親身去體會一些從前從未嘗試過的事情。這不僅能夠讓我的臉皮更厚一些,也能讓我多接觸一些新鮮的事情,從生活中尋找寫作的靈感。

堅持和變化,兩者相輔相成,我想一定能夠幫助我渡過難關,我是有這種信心的。

下面這段話是我在網上看到的,我覺得挺符合我現在的心境的:

生活總要嘗試不同的風景,人生總要嘗試不同的體驗。就好比旅遊,不就是從一個自己待煩的地方去另一個別人待煩的地方嗎?

時隔多年後,究竟會怎樣,不重要,重要的是經歷。起起伏伏才是人生,平淡無奇才最無聊。我去擺攤,追求的不是利潤,而是生活的體驗。

如果這次賣書能夠大獲成功的話,我還有很多才藝可以就地販賣,比如說裝機、賣假髮、賣格子衫,對了,我精通 Java,沒有對象的同學,我可以幫你 new 一個。

如果覺得文章對你有點幫助,請微信搜索「 沉默王二 」第一時間閱讀。回復關鍵字「簡歷」更有一份技術大佬整理的優質簡歷模板,助你一臂之力。

本文已收錄 GitHub,傳送門~ ,裏面更有大廠面試完整考點,歡迎 Star。

我是沉默王二,一枚有顏值卻靠才華苟且的程序員。關注即可提升學習效率,別忘了三連啊,點贊、收藏、留言,我不挑,嘻嘻。

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

【其他文章推薦】

※網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司"嚨底家"!

※推薦評價好的iphone維修中心

※網頁設計最專業,超強功能平台可客製化

※別再煩惱如何寫文案,掌握八大原則!

Python 圖像處理 OpenCV (7):圖像平滑(濾波)處理

前文傳送門:

「Python 圖像處理 OpenCV (1):入門」

「Python 圖像處理 OpenCV (2):像素處理與 Numpy 操作以及 Matplotlib 显示圖像」

「Python 圖像處理 OpenCV (3):圖像屬性、圖像感興趣 ROI 區域及通道處理」

「Python 圖像處理 OpenCV (4):圖像算數運算以及修改顏色空間」

「Python 圖像處理 OpenCV (5):圖像的幾何變換」

「Python 圖像處理 OpenCV (6):圖像的閾值處理」

1. 引言

第一件事情還是先做名詞解釋,圖像平滑到底是個啥?

從字面意思理解貌似圖像平滑好像是在說圖像滑動。

emmmmmmmmmmmmmmm。。。。

其實半毛錢關係也沒有,圖像平滑技術通常也被成為圖像濾波技術(這個名字看到可能大家會有點感覺)。

每一幅圖像都包含某種程度的噪聲,噪聲可以理解為由一種或者多種原因造成的灰度值的隨機變化,如由光子通量的隨機性造成的噪聲等等。

而圖像平滑技術或者是圖像濾波技術就是用來處理圖像上的噪聲,其中,能夠具備邊緣保持作用的圖像平滑處理,成為了大家關注的重點。

這不廢話,處理個圖片降噪,結果把整個圖像搞的跟玻璃上糊上了一層水霧一樣,這種降噪有啥意義。

本文會介紹 OpenCV 中提供的圖像平滑的 4 個算法:

  • 均值濾波
  • 方框濾波
  • 高斯濾波
  • 中值濾波

下面開始一個一個看吧:)

先給出一個給馬里奧加噪聲的程序,程序來源於楊老師的博客:https://blog.csdn.net/Eastmount/article/details/82216380 ,完整代碼如下:

import cv2 as cv
import numpy as np

# 讀取圖片
img = cv.imread("maliao.jpg", cv.IMREAD_UNCHANGED)
rows, cols, chn = img.shape

# 加噪聲
for i in range(5000):
    x = np.random.randint(0, rows)
    y = np.random.randint(0, cols)
    img[x, y, :] = 255

cv.imshow("noise", img)

# 圖像保存
cv.imwrite("maliao_noise.jpg", img)

# 等待显示
cv.waitKey()
cv.destroyAllWindows()

上面這段程序實際上是在圖片上隨機加了 5000 個白點,這個噪聲真的是夠大的了。

2. 2D 圖像卷積

在介紹濾波之前先簡單介紹下 2D 圖像卷積,圖像卷積其實就是圖像過濾。

圖像過濾的時候可以使用各種低通濾波器( LPF ),高通濾波器( HPF )等對圖像進行過濾。

低通濾波器( LPF )有助於消除噪聲,但是會使圖像模糊。

高通濾波器( HPF )有助於在圖像中找到邊緣。

OpenCV 為我們提供了一個函數 filter2D() 來將內核與圖像進行卷積。

我們嘗試對圖像進行平均濾波, 5 x 5 平均濾波器內核如下:

\[ K = \frac{1}{25} \begin{bmatrix} 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \end{bmatrix} \]

具體操作如下:

我們保持這個內核在一個像素上,將所有低於這個內核的 25 個像素相加,取其平均值,然後用新的平均值替換中心像素。它將對圖像中的所有像素繼續此操作,完整的示例代碼如下:

import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt

# 讀取圖片
img = cv.imread("maliao_noise.jpg", cv.IMREAD_UNCHANGED)
rgb_img = cv.cvtColor(img, cv.COLOR_BGR2RGB)

kernel = np.ones((5,5),np.float32)/25

dst = cv.filter2D(rgb_img, -1, kernel)

titles = ['Source Image', 'filter2D Image']
images = [rgb_img, dst]

for i in range(2):
    plt.subplot(1, 2, i + 1), plt.imshow(images[i], 'gray')
    plt.title(titles[i])
    plt.xticks([]), plt.yticks([])

plt.show()

可以看到,噪點確實去除掉了,就是圖片變得模糊起來。

3. 均值濾波

均值濾波是指任意一點的像素值,都是周圍 N * M 個像素值的均值。

其實均值濾波和上面的那個圖像卷積的示例,做了同樣的事情,我只是用 filter2D() 這個方法手動完成了均值濾波,實際上 OpenCV 為我們提供了專門的均值濾波的方法,前面圖像卷積沒有看明白的同學,可以再一遍均值濾波,我盡量把這個事情整的明白的。

還是來畫個圖吧:

中間那個紅色的方框裏面的值,是周圍 25 個格子區域中的像素的和去除以 25 ,這個公式是下面這樣的:

\[ K = \frac{1}{25} \begin{bmatrix} 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \end{bmatrix} \]

我為了偷懶,所有的格子裏面的像素值都寫成 1 ,畢竟 n / n 永遠都等於 1 ,快誇我機智。

上面這個 5 * 5 的矩陣稱為核,針對原始圖像內的像素點,採用核進行處理,得到結果圖像。

這個核我們可以自定義大小,比如 5 * 5 ,3 * 3 , 10 * 10 等等,具體定義多大完全看療效。

OpenCV 為我提供了 blur() 方法用作實現均值濾波,原函數如下:

def blur(src, ksize, dst=None, anchor=None, borderType=None)
  • kSize: 內核參數,其實就是圖片進行卷積的時候相乘的那個矩陣,具體的卷積是如何算的,網上有很多,我這裏就不介紹了,所得到的圖像是模糊的,而且圖像其實是按照原來的比例缺少了(原圖像-內核參數+1)^2 個單元格。
  • anchor: Point 類型,即錨點,有默認值 Point(-1, -1) ,當坐標為負值,就表示取核的中心。
  • borderType: Int 類型,用於推斷圖像外部像素的某種邊界模式,有默認值 BORDER_DEFAULT 。

接下來是均值濾波的示例代碼:

import cv2 as cv
import matplotlib.pyplot as plt

# 讀取圖片
img = cv.imread("maliao_noise.jpg", cv.IMREAD_UNCHANGED)
rgb_img = cv.cvtColor(img, cv.COLOR_BGR2RGB)

# 均值濾波
blur_img = cv.blur(rgb_img, (3, 3))
# blur_img = cv.blur(img, (5, 5))
# blur_img = cv.blur(img, (10, 10))
# blur_img = cv.blur(img, (20, 20))

titles = ['Source Image', 'Blur Image']
images = [rgb_img, blur_img]

for i in range(2):
    plt.subplot(1, 2, i + 1), plt.imshow(images[i], 'gray')
    plt.title(titles[i])
    plt.xticks([]), plt.yticks([])

plt.show()

這個降噪的效果好像沒有前面 2D 卷積的那個降噪效果好,但是圖像更為清晰,因為我在這個示例中使用了更小的核 3 * 3 的核,順便我也試了下大核,比如代碼中註釋掉的 10 * 10 的核或者 20 * 20 的核,實時證明,核越大降噪效果越好,但是相反的是圖像會越模糊。

4. 方框濾波

方框濾波和均值濾波核基本一致,其中的區別是需不需要進行歸一化處理。

什麼是歸一化處理等下再說,我們先看方框濾波的原函數:

def boxFilter(src, ddepth, ksize, dst=None, anchor=None, normalize=None, borderType=None)
  • src: 原始圖像。
  • ddepth: Int 類型,目標圖像深度,通常用 -1 表示與原始圖像一致。
  • kSize: 內核參數。
  • dst: 輸出與 src 大小和類型相同的圖像。
  • anchor: Point 類型,即錨點,有默認值 Point(-1, -1) 。
  • normalize: Int 類型,表示是否對目標圖像進行歸一化處理。

當 normalize 為 true 時,需要執行均值化處理。

當 normalize 為 false 時,不進行均值化處理,實際上是求周圍各像素的和,很容易發生溢出,溢出時均為白色,對應像素值為 255 。

完整示例代碼如下:

import cv2 as cv
import matplotlib.pyplot as plt

# 讀取圖片
img = cv.imread('maliao_noise.jpg')
source = cv.cvtColor(img, cv.COLOR_BGR2RGB)

# 方框濾波
result = cv.boxFilter(source, -1, (5, 5), normalize = 1)

# 显示圖形
titles = ['Source Image', 'BoxFilter Image']
images = [source, result]

for i in range(2):
    plt.subplot(1, 2, i + 1), plt.imshow(images[i], 'gray')
    plt.title(titles[i])
    plt.xticks([]), plt.yticks([])

plt.show()

當我們把 normalize 的屬性設為 0 時,不進行歸一化處理,結果就變成了下面這個樣子:

5. 高斯濾波

為了克服簡單局部平均法的弊端(圖像模糊),目前已提出許多保持邊緣、細節的局部平滑算法。它們的出發點都集中在如何選擇鄰域的大小、形狀和方向、參數加平均及鄰域各店的權重係數等。

在高斯濾波的方法中,實際上是把卷積核換成了高斯核,那麼什麼是高斯核呢?

簡單來講就是方框還是那個方框,原來每個方框裏面的權是相等的,大家最後取平均,現在變成了高斯分佈的,方框中心的那個權值最大,其餘方框根據距離中心元素的距離遞減,構成一個高斯小山包,這樣取到的值就變成了加權平均。

下圖是所示的是 3 * 3 和 5 * 5 領域的高斯核。

高斯濾波是在 OpenCV 中是由 GaussianBlur() 方法進行實現的,它的原函數如下:

def GaussianBlur(src, ksize, sigmaX, dst=None, sigmaY=None, borderType=None)
  • sigmaX: 表示 X 方向方差。

這裏需要注意的是 ksize 核大小,在高斯核當中,核 (N, N) 必須是奇數, X 方向方差主要控制權重。

完整的示例代碼如下:

import cv2 as cv
import matplotlib.pyplot as plt

# 讀取圖片
img = cv.imread('maliao_noise.jpg')
source = cv.cvtColor(img, cv.COLOR_BGR2RGB)

# 方框濾波
result = cv.GaussianBlur(source, (3, 3), 0)

# 显示圖形
titles = ['Source Image', 'GaussianBlur Image']
images = [source, result]

for i in range(2):
    plt.subplot(1, 2, i + 1), plt.imshow(images[i], 'gray')
    plt.title(titles[i])
    plt.xticks([]), plt.yticks([])

plt.show()

6. 中值濾波

在使用鄰域平均法去噪的同時也使得邊界變得模糊。

而中值濾波是非線性的圖像處理方法,在去噪的同時可以兼顧到邊界信息的保留。

中值濾波具體的做法是選一個含有奇數點的窗口 W ,將這個窗口在圖像上掃描,把窗口中所含的像素點按灰度級的升或降序排列,取位於中間的灰度值來代替該點的灰度值。

下圖是一個一維的窗口的濾波過程:

在 OpenCV 中,主要是通過調用 medianBlur() 來實現中值濾波,它的原函數如下:

def medianBlur(src, ksize, dst=None)

中值濾波的核心數和高斯濾波的核心數一樣,必須要是大於 1 的奇數。

示例代碼如下:

import cv2 as cv
import matplotlib.pyplot as plt

# 讀取圖片
img = cv.imread('maliao_noise.jpg')
source = cv.cvtColor(img, cv.COLOR_BGR2RGB)

# 方框濾波
result = cv.medianBlur(source, 3)

# 显示圖形
titles = ['Source Image', 'medianBlur Image']
images = [source, result]

for i in range(2):
    plt.subplot(1, 2, i + 1), plt.imshow(images[i], 'gray')
    plt.title(titles[i])
    plt.xticks([]), plt.yticks([])

plt.show()

可以明顯看到,目前中值濾波是對原圖像降噪后還原度最高的,常用的中值濾波的圖形除了可以使用方框,還有十字形、圓形和環形,不同形狀的窗口產生不同的濾波效果。

方形和圓形窗口適合外輪廓線較長的物體圖像,而十字形窗口對有尖頂角狀的圖像效果好。

對於一些細節較多的複雜圖像,可以多次使用不同的中值濾波。

7. 示例代碼

如果有需要獲取源碼的同學可以在公眾號回復「OpenCV」進行獲取。

8. 參考

https://blog.csdn.net/Eastmount/article/details/82216380

http://www.woshicver.com/

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

【其他文章推薦】

※台北網頁設計公司這麼多該如何選擇?

※智慧手機時代的來臨,RWD網頁設計為架站首選

※評比南投搬家公司費用收費行情懶人包大公開

※回頭車貨運收費標準

※網頁設計最專業,超強功能平台可客製化

※別再煩惱如何寫文案,掌握八大原則!

軟件設計模式學習(二十四)狀態模式

狀態模式用於解決系統中複雜對象的狀態轉換以及不同狀態下行為的封裝問題

模式動機

很多情況下,一個對象的行為取決於一個或多個動態變化的屬性,這樣的屬性叫做狀態。一個對象可以擁有多個狀態,這些狀態可以相互轉換,當對象狀態不同時,其行為也有所差異。

假設一個人就是對象,人根據心情不同會有很多狀態,比如開心和傷心,這兩種狀態可以相互轉換。開心的人可能會突然接到女朋友的分手電話,然後哭得稀里嘩啦(醒醒!你哪來的女朋友?),過了一段時間后,又可能因為中了一百萬彩票而歡呼雀躍。而且不同狀態下人的行為也不同,有些人傷心時會通過運動、旅行、聽音樂來緩解心情,而開心時則可能會唱歌、跳舞、請客吃飯等等。

再來考慮軟件系統中的情況,如某酒店訂房系統,可以將房間設計為一個類,房間對象有已預訂、空閑、已入住等情況,這些狀態之間可以相互轉換,並且不同狀態的對象可能具有不同的行為,如已預訂或已入住的房間不能再接收其他顧客的預訂,而空閑的房間可以接受預訂。

在過去我們遇到這種情況,可以使用複雜的條件判斷來進行狀態判斷和轉換操作,這會導致代碼的可維護性和靈活性下降,當出現新的狀態時必須修改源代碼,違反了開閉原則。在狀態模式中,可以將對象狀態從包含該狀態的類中分離出來,做成一個個單獨的狀態類,如人的兩種情緒可以設計成兩個狀態類:

將開心與傷心兩種情緒從“人”中分離出來,從而避免在“人”中進行狀態轉換和判斷,將擁有狀態的對象和狀態對應的行為分離,這就是狀態模式的動機。

模式定義

允許一個對象在其內部狀態改變時改變它的行為,對象看起來似乎修改了它的類。其別名為狀態對象(Objects for States),狀態模式是一種對象行為型模式。

Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

模式結構與分析

我們把擁有狀態的對象稱為環境類,也叫上下文類。再引入一個抽象狀態類來專門表示對象的狀態,對象的每一種具體狀態類都繼承該抽象類,不同具體狀態類實現不同狀態的行為,包括各種狀態之間的轉換。在環境類中維護一個抽象狀態類 State 的實例,用來定義當前狀態。

得到狀態模式結構類圖如下:

環境類中的 request() 方法處理業務邏輯,根據狀態去調用對應的 handle() 方法,如果需要切換狀態,還提供了 setState() 用於設置當前房間狀態。如果我們希望執行操作后狀態自動發生改變,那麼我們還需要在 State 中定義一個 Context 對象,實現一個雙向依賴關係。

考慮前面提到的訂房系統,如果不使用狀態模式,可能就會存在如下代碼:

if (state == "空閑") {
	if (預訂房間) {
        預訂操作;
        state = "已預訂";
	} else if (住進房間) {
    	入住操作;
        state = "已入住";
    }
} else if(state == "已預訂") {
	if (住進房間) {
        入住操作;
        state = "已入住";
	} else if (取消預訂) {
    	取消操作;
        state = "空閑";
    }
}

上述代碼需要做頻繁且複雜的判斷操作,可維護性很差。因此考慮使用狀態模式將房間類的狀態分離出來,將與每種狀態有關的操作封裝在獨立的狀態類中。

我們來寫一個完整的示例

環境類(Room)

public class Room {
	
    // 維護一個狀態對象
    private State state;

    public Room() {
        // 默認為空閑狀態
        this.state = new IdleState(this);
    }

    public State getState() {
        return state;
    }

    public void setState(State state) {
        this.state = state;
    }

    public void reserve() {
        state.reserve();
    }

    public void checkIn() {
        state.checkIn();
    }

    public void cancelReserve() {
        state.cancelReserve();
    }

    public void checkOut() {
        state.checkOut();
    }
}

抽象狀態類(State)

public abstract class State {
	
    // 用於狀態轉換
    protected Room room;

    public State(Room room) {
        this.room = room;
    }

    public abstract void reserve();

    public abstract void checkIn();

    public abstract void cancelReserve();

    public abstract void checkOut();
}

具體狀態類(IdleState)

public class IdleState extends State {

    public IdleState(Room room) {
        super(room);
    }

    @Override
    public void reserve() {
        System.out.println("房間預訂成功");
        // 	切換狀態
        room.setState(new ReservedState(room));
    }

    @Override
    public void checkIn() {
        System.out.println("房間入住成功");
        room.setState(new InhabitedState(room));
    }

    @Override
    public void cancelReserve() {
        System.out.println("無法取消預訂,房間處於空閑狀態");
    }

    @Override
    public void checkOut() {
        System.out.println("無法退房,房間處於空閑狀態");
    }

}

具體狀態類(ReservedState)

public class ReservedState extends State {

    public ReservedState(Room room) {
        super(room);
    }

    @Override
    public void reserve() {
        System.out.println("無法預訂,房間處於已預訂狀態");
    }

    @Override
    public void checkIn() {
        System.out.println("房間入住成功");
        room.setState(new InhabitedState(room));
    }

    @Override
    public void cancelReserve() {
        System.out.println("取消預訂成功");
        room.setState(new IdleState(room));
    }

    @Override
    public void checkOut() {
        System.out.println("無法退房,房間處於已預訂狀態");
    }
}

具體狀態類(InhabitedState)

public class InhabitedState extends State {

    public InhabitedState(Room room) {
        super(room);
    }

    @Override
    public void reserve() {
        System.out.println("無法預訂,房間處於入住狀態");
    }

    @Override
    public void checkIn() {
        System.out.println("無法入住,房間處於入住狀態");
    }

    @Override
    public void cancelReserve() {
        System.out.println("無法取消預訂,房間處於入住狀態");
    }

    @Override
    public void checkOut() {
        System.out.println("退房成功");
        room.setState(new IdleState(room));
    }
}

客戶端測試類(Client)

public class Client {

    public static void main(String[] args) {

        Room room = new Room();
        room.cancelReserve();
        room.checkOut();
        room.reserve();
        System.out.println("--------------------------");
        room.reserve();
        room.checkOut();
        room.checkIn();
        System.out.println("--------------------------");
        room.reserve();
        room.checkIn();
        room.cancelReserve();
        room.checkOut();
    }
}

運行結果

模式優缺點

狀態模式的優點:

  • 封裝了轉換規則,將不同狀態之間的轉換狀態封裝在狀態類中,避免了冗長的條件判斷,提高了代碼的可維護性
  • 將所有與某個規則有關的行為放到一個類,可以很方便地增加新的狀態
  • 可以讓多個環境對象共享一個狀態對象,從而減少系統中對象的個數

狀態模式的缺點:

  • 增加了系統類和對象的個數
  • 結構較為複雜,使用不當將導致代碼混亂
  • 對於可以切換狀態的狀態模式,增加新的狀態類需要修改負責狀態轉換的代碼

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

【其他文章推薦】

※網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

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

※Google地圖已可更新顯示潭子電動車充電站設置地點!!

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

※別再煩惱如何寫文案,掌握八大原則!

※網頁設計最專業,超強功能平台可客製化

※回頭車貨運收費標準