一份針對於新手的多執行緒實踐
前言
前段時間在某個第三方平臺看到我寫作字數居然突破了 10W 字,難以想象高中 800 字作文我都得巧妙的利用換行來完成(懂的人肯定也幹過)。
幹了這行養成了一個習慣:能擼碼驗證的事情都自己驗證一遍。
於是在上週五通宵加班的空餘時間寫了一個工具:
https://github.com/crossoverJie/NOWS
利用 SpringBoot 只需要一行命令即可統計自己寫了多少個字。
java -jar nows-0.0.1-SNAPSHOT.jar /xx/Hexo/source/_posts
傳入需要掃描的文章目錄即可輸出結果(目前只支援 .md 結尾 Markdown 檔案)
當然結果看個樂就行(40 幾萬字),因為早期的部落格我喜歡大篇的貼程式碼,還有一些英文單詞也沒有過濾,所以導致結果相差較大。
如果僅僅只是中文文字統計肯定是準的,並且該工具內建靈活的擴充套件方式,使用者可以自定義統計策略,具體請看後文。
其實這個工具挺簡單的,程式碼量也少,沒有多少可以值得拿出來講的。但經過我回憶不管是面試還是和網友們交流都發現一個普遍的現象:
大部分新手開發都會去看多執行緒、但幾乎都沒有相關的實踐。甚至有些都不知道多執行緒拿來在實際開發中有什麼用。
為此我想基於這個簡單的工具為這類朋友帶來一個可實踐、易理解的多執行緒案例。
至少可以讓你知道:
- 為什麼需要多執行緒?
- 怎麼實現一個多執行緒程式?
- 多執行緒帶來的問題及解決方案?
單執行緒統計
再談多執行緒之前先來聊聊單執行緒如何實現。
本次的需求也很簡單,只是需要掃描一個目錄讀取下面的所有檔案即可。
所有我們的實現有以下幾步:
- 讀取某個目錄下的所有檔案。
- 將所有檔案的路徑保持到記憶體。
- 遍歷所有的檔案挨個讀取文字記錄字數即可。
先來看前兩個如何實現,並且當掃描到目錄時需要繼續讀取當前目錄下的檔案。
這樣的場景就非常適合遞迴:
public List<String> getAllFile(String path){
File f = new File(path) ;
File[] files = f.listFiles();
for (File file : files) {
if (file.isDirectory()){
String directoryPath = file.getPath();
getAllFile(directoryPath);
}else {
String filePath = file.getPath();
if (!filePath.endsWith(".md")){
continue;
}
allFile.add(filePath) ;
}
}
return allFile ;
}
}
讀取之後將檔案的路徑保持到一個集合中。
需要注意的是這個遞迴次數需要控制下,避免出現棧溢位(StackOverflow)。
最後讀取檔案內容則是使用 Java8 中的流來進行讀取,這樣程式碼可以更簡潔:
Stream<String> stringStream = Files.lines(Paths.get(path), StandardCharsets.UTF_8);
List<String> collect = stringStream.collect(Collectors.toList());
接下來便是讀取字數,同時要過濾一些特殊文字(比如我想過濾掉所有的空格、換行、超連結等)。
擴充套件能力
簡單處理可在上面的程式碼中遍歷 collect 然後把其中需要過濾的內容替換為空就行。
但每個人的想法可能都不一樣。比如我只想過濾掉空格、換行、超連結就行了,但有些人需要去掉其中所有的英文單詞,甚至換行還得留著(就像寫作文一樣可以充字數)。
所有這就需要一個比較靈活的處理方式。
看過上文《利用責任鏈模式設計一個攔截器》應該很容易想到這樣的場景責任鏈模式再合適不過了。
關於責任鏈模式具體的內容就不在詳述了,感興趣的可以檢視上文。
這裡直接看實現吧:
定義責任鏈的抽象介面及處理方法:
public interface FilterProcess {
/**
* 處理文字
* @param msg
* @return
*/
String process(String msg) ;
}
處理空格和換行的實現:
public class WrapFilterProcess implements FilterProcess{
@Override
public String process(String msg) {
msg = msg.replaceAll("\s*", "");
return msg ;
}
}
處理超連結的實現:
public class HttpFilterProcess implements FilterProcess{
@Override
public String process(String msg) {
msg = msg.replaceAll("^((https|http|ftp|rtsp|mms)?:\/\/)[^\s]+","");
return msg ;
}
}
這樣在初始化時需要將這些處理 handle 都加入責任鏈中,同時提供一個 API 供客戶端執行即可。
這樣一個簡單的統計字數的工具就完成了。
多執行緒模式
在我本地一共就幾十篇部落格的條件下執行一次還是很快的,但如果我們的檔案是幾萬、幾十萬甚至上百萬呢。
雖然功能可以實現,但可以想象這樣的耗時絕對是成倍的增加。
這時多執行緒就發揮優勢了,由多個執行緒分別去讀取檔案最後彙總結果即可。
這樣實現的過程就變為:
- 讀取某個目錄下的所有檔案。
- 將檔案路徑交由不同的執行緒自行處理。
- 最終彙總結果。
多執行緒帶來的問題
也不是使用多執行緒就萬事大吉了,先來看看第一個問題:共享資源。
簡單來說就是怎麼保證多執行緒和單執行緒統計的總字數是一致的。
基於我本地的環境先看看單執行緒執行的結果:
總計為:414142 字。
接下來換為多執行緒的方式:
List<String> allFile = scannerFile.getAllFile(strings[0]);
logger.info("allFile size=[{}]",allFile.size());
for (String msg : allFile) {
executorService.execute(new ScanNumTask(msg,filterProcessManager));
}
public class ScanNumTask implements Runnable {
private static Logger logger = LoggerFactory.getLogger(ScanNumTask.class);
private String path;
private FilterProcessManager filterProcessManager;
public ScanNumTask(String path, FilterProcessManager filterProcessManager) {
this.path = path;
this.filterProcessManager = filterProcessManager;
}
@Override
public void run() {
Stream<String> stringStream = null;
try {
stringStream = Files.lines(Paths.get(path), StandardCharsets.UTF_8);
} catch (Exception e) {
logger.error("IOException", e);
}
List<String> collect = stringStream.collect(Collectors.toList());
for (String msg : collect) {
filterProcessManager.process(msg);
}
}
}
使用執行緒池管理執行緒,更多執行緒池相關的內容請看這裡:《如何優雅的使用和理解執行緒池》
執行結果:
我們會發現無論執行多少次,這個值都會小於我們的預期值。
來看看統計那裡是怎麼實現的。
@Component
public class TotalWords {
private long sum = 0 ;
public void sum(int count){
sum += count;
}
public long total(){
return sum;
}
}
可以看到就是對一個基本型別進行累加而已。那導致這個值比預期小的原因是什麼呢?
我想大部分人都會說:多執行緒執行時會導致有些執行緒把其他執行緒運算的值覆蓋。
但其實這只是導致這個問題的表象,根本原因還是沒有講清楚。
記憶體可見性
核心原因其實是由 Java 記憶體模型(JMM)的規定導致的。
這裡引用一段之前寫的《你應該知道的 volatile 關鍵字》一段解釋:
由於 Java 記憶體模型(JMM)規定,所有的變數都存放在主記憶體中,而每個執行緒都有著自己的工作記憶體(快取記憶體)。
執行緒在工作時,需要將主記憶體中的資料拷貝到工作記憶體中。這樣對資料的任何操作都是基於工作記憶體(效率提高),並且不能直接操作主記憶體以及其他執行緒工作記憶體中的資料,之後再將更新之後的資料重新整理到主記憶體中。
這裡所提到的主記憶體可以簡單認為是堆記憶體,而工作記憶體則可以認為是棧記憶體。
如下圖所示:
所以在併發執行時可能會出現執行緒 B 所讀取到的資料是執行緒 A 更新之前的資料。
更多相關內容就不再展開了,感興趣的朋友可以翻翻以前的博文。
直接來說如何解決這個問題吧,JDK 其實已經幫我們想到了這些問題。
在 java.util.concurrent 併發包下有許多你可能會使用到的併發工具。
這裡就非常適合 AtomicLong,它可以原子性的對資料進行修改。
來看看修改後的實現:
@Component
public class TotalWords {
private AtomicLong sum = new AtomicLong() ;
public void sum(int count){
sum.addAndGet(count) ;
}
public long total(){
return sum.get() ;
}
}
只是使用了它的兩個 API 而已。再來執行下程式會發現結果居然還是不對。
甚至為 0 了。
執行緒間通訊
這時又出現了一個新的問題,來看看獲取總計資料是怎麼實現的。
List<String> allFile = scannerFile.getAllFile(strings[0]);
logger.info("allFile size=[{}]",allFile.size());
for (String msg : allFile) {
executorService.execute(new ScanNumTask(msg,filterProcessManager));
}
executorService.shutdown();
long total = totalWords.total();
long end = System.currentTimeMillis();
logger.info("total sum=[{}],[{}] ms",total,end-start);
知道大家看出問題沒有,其實是在最後列印總數時並不知道其他執行緒是否已經執行完畢了。
因為 executorService.execute() 會直接返回,所以當列印獲取資料時還沒有一個執行緒執行完畢,也就導致了這樣的結果。
關於執行緒間通訊之前我也寫過相關的內容:《深入理解執行緒通訊》
大概的方式有以下幾種:
這裡我們使用執行緒池的方式:
在停用執行緒池後加上一個判斷條件即可:
executorService.shutdown();
while (!executorService.awaitTermination(100, TimeUnit.MILLISECONDS)) {
logger.info("worker running");
}
long total = totalWords.total();
long end = System.currentTimeMillis();
logger.info("total sum=[{}],[{}] ms",total,end-start);
這樣我們再次嘗試,發現無論多少次結果都是正確的了:
效率提升
可能還會有朋友問,這樣的方式也沒見提升多少效率啊。
這其實是由於我本地檔案少,加上一個檔案處理的耗時也比較短導致的。
甚至執行緒數開的夠多導致頻繁的上下文切換還是讓執行效率降低。
為了模擬效率的提升,每處理一個檔案我都讓當前執行緒休眠 100 毫秒來模擬執行耗時。
先看單執行緒執行需要耗時多久。
總共耗時:[8404] ms
接著線上程池大小為 4 的情況下耗時:
總共耗時:[2350] ms
可見效率提升還是非常明顯的。
更多思考
這只是多執行緒其中的一個用法,相信看到這裡的朋友應該多它的理解更進一步了。
再給大家留個閱後練習,場景也是類似的:
在 Redis 或者其他儲存介質中存放有上千萬的手機號碼資料,每個號碼都是唯一的,需要在最快的時間內把這些號碼全部都遍歷一遍。
有想法感興趣的朋友歡迎在文末留言參與討論。
總結
希望看完的朋友心中能對文初的幾個問題能有自己的答案:
- 為什麼需要多執行緒?
- 怎麼實現一個多執行緒程式?
- 多執行緒帶來的問題及解決方案?