1. 程式人生 > >一份針對於新手的多線程

一份針對於新手的多線程

rect work sha 初始 follow 行修改 如何實現 運行時 字數

前言
前段時間在某個第三方平臺看到我寫作字數居然突破了 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 或者其他存儲介質中存放有上千萬的手機號碼數據,每個號碼都是唯一的,需要在最快的時間內把這些號碼全部都遍歷一遍。

有想法感興趣的朋友歡迎在文末留言參與討論。

總結
希望看完的朋友心中能對文初的幾個問題能有自己的答案:

為什麽需要多線程?
怎麽實現一個多線程程序?
多線程帶來的問題及解決方案?

一份針對於新手的多線程