前言
前段時間在某個第三方平臺看到我寫作字?jǐn)?shù)居然突破了 10W 字,難以想象高中 800 字作文我都得巧妙的利用換行來完成(懂的人肯定也干過??)。
干了這行養(yǎng)成了一個習(xí)慣:能擼碼驗證的事情都自己驗證一遍辕狰。
于是在上周五通宵加班的空余時間寫了一個工具:
https://github.com/crossoverJie/NOWS
利用 SpringBoot
只需要一行命令即可統(tǒng)計自己寫了多少個字径簿。
java -jar nows-0.0.1-SNAPSHOT.jar /xx/Hexo/source/_posts
傳入需要掃描的文章目錄即可輸出結(jié)果(目前只支持 .md
結(jié)尾 Markdown
文件)
當(dāng)然結(jié)果看個樂就行(40 幾萬字)芬位,因為早期的博客我喜歡大篇的貼代碼凿渊,還有一些英文單詞也沒有過濾,所以導(dǎo)致結(jié)果相差較大挫鸽。
如果僅僅只是中文文字統(tǒng)計肯定是準(zhǔn)的说敏,并且該工具內(nèi)置靈活的擴展方式,使用者可以自定義統(tǒng)計策略丢郊,具體請看后文像云。
其實這個工具挺簡單的,代碼量也少蚂夕,沒有多少可以值得拿出來講的迅诬。但經(jīng)過我回憶不管是面試還是和網(wǎng)友們交流都發(fā)現(xiàn)一個普遍的現(xiàn)象:
大部分新手開發(fā)都會去看多線程、但幾乎都沒有相關(guān)的實踐婿牍。甚至有些都不知道多線程拿來在實際開發(fā)中有什么用侈贷。
為此我想基于這個簡單的工具為這類朋友帶來一個可實踐、易理解的多線程案例等脂。
至少可以讓你知道:
- 為什么需要多線程俏蛮?
- 怎么實現(xiàn)一個多線程程序?
- 多線程帶來的問題及解決方案上遥?
單線程統(tǒng)計
再談多線程之前先來聊聊單線程如何實現(xiàn)搏屑。
本次的需求也很簡單,只是需要掃描一個目錄讀取下面的所有文件即可粉楚。
所有我們的實現(xiàn)有以下幾步:
- 讀取某個目錄下的所有文件辣恋。
- 將所有文件的路徑保持到內(nèi)存。
- 遍歷所有的文件挨個讀取文本記錄字?jǐn)?shù)即可模软。
先來看前兩個如何實現(xiàn)伟骨,并且當(dāng)掃描到目錄時需要繼續(xù)讀取當(dāng)前目錄下的文件。
這樣的場景就非常適合遞歸:
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 ;
}
}
讀取之后將文件的路徑保持到一個集合中燃异。
需要注意的是這個遞歸次數(shù)需要控制下携狭,避免出現(xiàn)棧溢出(
StackOverflow
)。
最后讀取文件內(nèi)容則是使用 Java8
中的流來進行讀取回俐,這樣代碼可以更簡潔:
Stream<String> stringStream = Files.lines(Paths.get(path), StandardCharsets.UTF_8);
List<String> collect = stringStream.collect(Collectors.toList());
接下來便是讀取字?jǐn)?shù)逛腿,同時要過濾一些特殊文本(比如我想過濾掉所有的空格稀并、換行、超鏈接等)单默。
擴展能力
簡單處理可在上面的代碼中遍歷 collect
然后把其中需要過濾的內(nèi)容替換為空就行碘举。
但每個人的想法可能都不一樣。比如我只想過濾掉空格雕凹、換行殴俱、超鏈接
就行了政冻,但有些人需要去掉其中所有的英文單詞枚抵,甚至換行還得留著(就像寫作文一樣可以充字?jǐn)?shù))。
所有這就需要一個比較靈活的處理方式明场。
看過上文《利用責(zé)任鏈模式設(shè)計一個攔截器》應(yīng)該很容易想到這樣的場景責(zé)任鏈模式再合適不過了汽摹。
關(guān)于責(zé)任鏈模式
具體的內(nèi)容就不在詳述了,感興趣的可以查看上文苦锨。
這里直接看實現(xiàn)吧:
定義責(zé)任鏈的抽象接口及處理方法:
public interface FilterProcess {
/**
* 處理文本
* @param msg
* @return
*/
String process(String msg) ;
}
處理空格和換行的實現(xiàn):
public class WrapFilterProcess implements FilterProcess{
@Override
public String process(String msg) {
msg = msg.replaceAll("\\s*", "");
return msg ;
}
}
處理超鏈接的實現(xiàn):
public class HttpFilterProcess implements FilterProcess{
@Override
public String process(String msg) {
msg = msg.replaceAll("^((https|http|ftp|rtsp|mms)?:\\/\\/)[^\\s]+","");
return msg ;
}
}
這樣在初始化時需要將這些處理 handle
都加入責(zé)任鏈中逼泣,同時提供一個 API
供客戶端執(zhí)行即可。
這樣一個簡單的統(tǒng)計字?jǐn)?shù)的工具就完成了舟舒。
多線程模式
在我本地一共就幾十篇博客的條件下執(zhí)行一次還是很快的拉庶,但如果我們的文件是幾萬、幾十萬甚至上百萬呢秃励。
雖然功能可以實現(xiàn)氏仗,但可以想象這樣的耗時絕對是成倍的增加。
這時多線程就發(fā)揮優(yōu)勢了夺鲜,由多個線程分別去讀取文件最后匯總結(jié)果即可皆尔。
這樣實現(xiàn)的過程就變?yōu)椋?/p>
- 讀取某個目錄下的所有文件。
- 將文件路徑交由不同的線程自行處理币励。
- 最終匯總結(jié)果慷蠕。
多線程帶來的問題
也不是使用多線程就萬事大吉了,先來看看第一個問題:共享資源食呻。
簡單來說就是怎么保證多線程和單線程統(tǒng)計的總字?jǐn)?shù)是一致的流炕。
基于我本地的環(huán)境先看看單線程運行的結(jié)果:
總計為: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);
}
}
}
使用線程池管理線程仅胞,更多線程池相關(guān)的內(nèi)容請看這里:《如何優(yōu)雅的使用和理解線程池》
執(zhí)行結(jié)果:
我們會發(fā)現(xiàn)無論執(zhí)行多少次浪感,這個值都會小于我們的預(yù)期值。
來看看統(tǒng)計那里是怎么實現(xiàn)的饼问。
@Component
public class TotalWords {
private long sum = 0 ;
public void sum(int count){
sum += count;
}
public long total(){
return sum;
}
}
可以看到就是對一個基本類型進行累加而已影兽。那導(dǎo)致這個值比預(yù)期小的原因是什么呢?
我想大部分人都會說:多線程運行時會導(dǎo)致有些線程把其他線程運算的值覆蓋莱革。
但其實這只是導(dǎo)致這個問題的表象峻堰,根本原因還是沒有講清楚讹开。
內(nèi)存可見性
核心原因其實是由 Java 內(nèi)存模型(JMM
)的規(guī)定導(dǎo)致的。
這里引用一段之前寫的《你應(yīng)該知道的 volatile 關(guān)鍵字》一段解釋:
由于
Java
內(nèi)存模型(JMM
)規(guī)定捐名,所有的變量都存放在主內(nèi)存中旦万,而每個線程都有著自己的工作內(nèi)存(高速緩存)。
線程在工作時镶蹋,需要將主內(nèi)存中的數(shù)據(jù)拷貝到工作內(nèi)存中成艘。這樣對數(shù)據(jù)的任何操作都是基于工作內(nèi)存(效率提高),并且不能直接操作主內(nèi)存以及其他線程工作內(nèi)存中的數(shù)據(jù)贺归,之后再將更新之后的數(shù)據(jù)刷新到主內(nèi)存中淆两。
這里所提到的主內(nèi)存可以簡單認為是堆內(nèi)存,而工作內(nèi)存則可以認為是棧內(nèi)存拂酣。
如下圖所示:
所以在并發(fā)運行時可能會出現(xiàn)線程 B 所讀取到的數(shù)據(jù)是線程 A 更新之前的數(shù)據(jù)秋冰。
更多相關(guān)內(nèi)容就不再展開了,感興趣的朋友可以翻翻以前的博文婶熬。
直接來說如何解決這個問題吧剑勾,JDK 其實已經(jīng)幫我們想到了這些問題。
在 java.util.concurrent
并發(fā)包下有許多你可能會使用到的并發(fā)工具赵颅。
這里就非常適合 AtomicLong
虽另,它可以原子性的對數(shù)據(jù)進行修改。
來看看修改后的實現(xiàn):
@Component
public class TotalWords {
private AtomicLong sum = new AtomicLong() ;
public void sum(int count){
sum.addAndGet(count) ;
}
public long total(){
return sum.get() ;
}
}
只是使用了它的兩個 API
而已饺谬。再來運行下程序會發(fā)現(xiàn)結(jié)果居然還是不對捂刺。
甚至為 0 了。
線程間通信
這時又出現(xiàn)了一個新的問題商蕴,來看看獲取總計數(shù)據(jù)是怎么實現(xiàn)的叠萍。
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);
不知道大家看出問題沒有,其實是在最后打印總數(shù)時并不知道其他線程是否已經(jīng)執(zhí)行完畢了绪商。
因為 executorService.execute()
會直接返回苛谷,所以當(dāng)打印獲取數(shù)據(jù)時還沒有一個線程執(zhí)行完畢,也就導(dǎo)致了這樣的結(jié)果格郁。
關(guān)于線程間通信之前我也寫過相關(guān)的內(nèi)容:《深入理解線程通信》
大概的方式有以下幾種:
這里我們使用線程池的方式:
在停用線程池后加上一個判斷條件即可:
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);
這樣我們再次嘗試腹殿,發(fā)現(xiàn)無論多少次結(jié)果都是正確的了:
效率提升
可能還會有朋友問,這樣的方式也沒見提升多少效率啊例书。
這其實是由于我本地文件少锣尉,加上一個文件處理的耗時也比較短導(dǎo)致的。
甚至線程數(shù)開的夠多導(dǎo)致頻繁的上下文切換還是讓執(zhí)行效率降低决采。
為了模擬效率的提升自沧,每處理一個文件我都讓當(dāng)前線程休眠 100 毫秒來模擬執(zhí)行耗時。
先看單線程運行需要耗時多久。
總共耗時:[8404] ms
接著在線程池大小為 4 的情況下耗時:
總共耗時:[2350] ms
可見效率提升還是非常明顯的拇厢。
更多思考
這只是多線程其中的一個用法爱谁,相信看到這里的朋友應(yīng)該多它的理解更進一步了。
再給大家留個閱后練習(xí)孝偎,場景也是類似的:
在 Redis 或者其他存儲介質(zhì)中存放有上千萬的手機號碼數(shù)據(jù)访敌,每個號碼都是唯一的,需要在最快的時間內(nèi)把這些號碼全部都遍歷一遍衣盾。
有想法感興趣的朋友歡迎在文末留言參與討論????寺旺。
總結(jié)
希望看完的朋友心中能對文初的幾個問題能有自己的答案:
- 為什么需要多線程?
- 怎么實現(xiàn)一個多線程程序势决?
- 多線程帶來的問題及解決方案阻塑?
文中的代碼都在此處。
https://github.com/crossoverJie/NOWS
你的點贊與轉(zhuǎn)發(fā)是最大的支持徽龟。