前段時間在某個第三方平臺看到我寫做字數竟然突破了 10W 字,不可思議高中 800 字做文我都得巧妙的利用換行來完成(懂的人確定也幹過😏)。java
幹了這行養成了一個習慣:能擼碼驗證的事情都本身驗證一遍。git
因而在上週五通宵加班的空餘時間寫了一個工具:github
https://github.com/crossoverJie/NOWS面試
利用 SpringBoot
只須要一行命令便可統計本身寫了多少個字。shell
java -jar nows-0.0.1-SNAPSHOT.jar /xx/Hexo/source/_posts
傳入須要掃描的文章目錄便可輸出結果(目前只支持 .md
結尾 Markdown
文件)緩存
固然結果看個樂就行(40 幾萬字),由於早期的博客我喜歡大篇的貼代碼,還有一些英文單詞也沒有過濾,因此致使結果相差較大。多線程
若是僅僅只是中文文字統計確定是準的,而且該工具內置靈活的擴展方式,使用者能夠自定義統計策略,具體請看後文。
其實這個工具挺簡單的,代碼量也少,沒有多少能夠值得拿出來說的。但通過我回憶不論是面試仍是和網友們交流都發現一個廣泛的現象:併發
大部分 新手開發都會去看多線程、但幾乎都沒有相關的實踐。甚至有些都不知道多線程拿來在實際開發中有什麼用。
爲此我想基於這個簡單的工具爲這類朋友帶來一個可實踐、易理解的多線程案例。ide
至少可讓你知道:工具
再談多線程以前先來聊聊單線程如何實現。
本次的需求也很簡單,只是須要掃描一個目錄讀取下面的全部文件便可。
全部咱們的實現有如下幾步:
先來看前兩個如何實現,而且當掃描到目錄時須要繼續讀取當前目錄下的文件。
這樣的場景就很是適合遞歸:
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 或者其餘存儲介質中存放有上千萬的手機號碼數據,每一個號碼都是惟一的,須要在最快的時間內把這些號碼所有都遍歷一遍。
有想法感興趣的朋友歡迎在文末留言參與討論🤔🤨。
但願看完的朋友心中能對文初的幾個問題能有本身的答案:
文中的代碼都在此處。
https://github.com/crossoverJie/NOWS
你的點贊與轉發是最大的支持。