:告別for循環(huán),代碼量減半)
做Java開發(fā)快十年我越來越發(fā)現(xiàn)一個現(xiàn)象Java 8的Lambda和Stream是被浪費得最嚴重的一批特性。很多項目運行在JDK 8上代碼里卻還是十年前的for循環(huán)風格。更可惜的是不少人不是不想用是當初試過一次沒看懂就再也不碰了。我自己也經(jīng)歷過這個階段。最開始覺得Lambda只是省了幾行花括號Stream那套管道寫法繞來繞去不如for循環(huán)直觀。直到有一次重構(gòu)一個訂單統(tǒng)計模塊200多行全是for套if、再套Map累加我用一個下午改成40多行。同事看diff的第一反應是這誰寫的敢這么寫看完之后說確實比原版清楚多了。從那之后我對這兩個特性的態(tài)度就完全變了。這篇文章就是把那次重構(gòu)積累的經(jīng)驗完整講出來。不說教科書式的特性列表只講Lambda能做什么、Stream怎么用、哪些高頻場景能直接把代碼量砍半以及我踩過的幾個坑。適合所有正在用Java 8、但還沒真正把函數(shù)式寫法落到日常代碼里的開發(fā)者。1. Lambda的能與不能用匿名內(nèi)部類對比理解函數(shù)式接口1.1 一個監(jiān)聽器引發(fā)的重構(gòu)Lambda與匿名內(nèi)部類的真實差異要理解Lambda最直觀的入口是看它替換了什么。在Java 8之前給按鈕加點擊事件必須寫匿名內(nèi)部類button.addActionListener(new ActionListener() { Override public void actionPerformed(ActionEvent e) { System.out.println(按鈕被點擊了); } });換成Lambda之后button.addActionListener(e - System.out.println(按鈕被點擊了));6行變1行視覺沖擊力足夠強。但這里面有一個關(guān)鍵條件ActionListener接口里只有一個抽象方法actionPerformed。只有一個抽象方法的接口叫函數(shù)式接口官方還加了FunctionalInterface注解來標記比如Runnable、Comparator、Callable都是。Lambda之所以能替換匿名內(nèi)部類是因為編譯器只需要知道這個表達式對應的目標類型是什么就能把它變成那個接口的實現(xiàn)。同理new Thread(new Runnable(){...})這類代碼也能簡化成new Thread(() - System.out.println(線程執(zhí)行了))。很多搜索lambda調(diào)用內(nèi)部類示例的人其實找的就是這個套路把匿名內(nèi)部類換成箭頭表達式。但要提醒一點如果接口里有多個抽象方法Lambda是用不了的。有人給自定義接口加上FunctionalInterface注解編譯不過才反應過來原來接口里有兩個方法。所以設計接口時如果想支持Lambda就要保證只有一個抽象方法順便也可以看看JDK里有沒有現(xiàn)成的函數(shù)式接口能直接復用比如Function、Predicate、Consumer。1.2 變量捕獲的底線effectively final究竟在約束什么新手寫Lambda最容易踩的第一個坑是在Lambda里修改外部局部變量int count 0; ListString names Arrays.asList(a, b, c); names.forEach(name - count); // 編譯不通過報錯信息會提示local variables referenced from a lambda expression must be final or effectively final。原因不是Java故意為難你而是Lambda捕獲的變量必須是effectively final——也就是初始化之后不再被重新賦值。這個設計背后是并發(fā)安全的考慮Lambda可以被傳遞到任意線程執(zhí)行如果捕獲了一個可變變量多個線程同時修改就會產(chǎn)生競態(tài)條件。與其留下隱患編譯器直接禁止。這個約束其實和匿名內(nèi)部類時代一致只是Java 8稍微放寬了——只要代碼里沒重新賦值哪怕你沒寫final也默認視為final。想修改外部變量怎么辦實踐中通常有兩種做法。一是把可變狀態(tài)包裝成AtomicInteger或?qū)ο笞侄味怯肧tream的collect歸約讓結(jié)果由流自己返回。我個人更推薦后者把累加這件事交給Stream去處理自己也順便戒掉了在函數(shù)式代碼里弄可變狀態(tài)的壞習慣。這兩個選擇對應的是兩種編程思維的差異寫到后面Stream時會越來越明顯。1.3 方法引用代碼還能再短一點Lambda寫多了之后你會發(fā)現(xiàn)有一類Lambda體特別簡單只是把參數(shù)傳給某個已有方法。names.forEach(name - System.out.println(name)); // 可以寫成 names.forEach(System.out::println);這種寫法叫方法引用System.out::println中的::把方法歸屬和方法名連接起來。規(guī)則說起來有點繞當Lambda體正好是對某個方法的一次調(diào)用時就可以用類名::方法名或?qū)嵗?:方法名替換。比如list.stream().map(String::toUpperCase)等價于map(s - s.toUpperCase())products.stream().map(Product::getName)等價于map(p - p.getName())。方法引用的價值不只是讓代碼短幾個字符而是把調(diào)用哪個方法這個意圖表達得更明確。閱讀代碼時大腦可以直接跳轉(zhuǎn)到目標方法而不是先解析一層Lambda映射。這個好處在鏈式調(diào)用里體現(xiàn)得非常充分也是我后面寫Stream時默認優(yōu)先用方法引用的原因。2. Stream的思維遷移把循環(huán)過程換成業(yè)務意圖2.1 for循環(huán)的三宗罪樣板代碼、臨時變量、意圖被淹沒在Stream出現(xiàn)之前操作集合就是for循環(huán)加臨時變量。比如從一個訂單列表里找出金額大于100的訂單按下單時間排序取前5筆ListOrder bigOrders new ArrayList(); for (Order order : orders) { if (order.getAmount() 100) { bigOrders.add(order); } } Collections.sort(bigOrders, new ComparatorOrder() { Override public int compare(Order o1, Order o2) { return o1.getCreateTime().compareTo(o2.getCreateTime()); } }); ListOrder top5 bigOrders.subList(0, Math.min(5, bigOrders.size()));這段代碼本身不難但有三宗罪第一你寫了大量怎么遍歷的機械步驟真正的業(yè)務意圖過濾-排序-截斷被淹沒在循環(huán)結(jié)構(gòu)里第二為了收集中間結(jié)果不得不聲明一個又一個臨時List第三一旦需求變化比如改成統(tǒng)計金額總和整段邏輯就要重寫。Stream的寫法是這樣ListOrder top5 orders.stream() .filter(o - o.getAmount() 100) .sorted(Comparator.comparing(Order::getCreateTime)) .limit(5) .collect(Collectors.toList());業(yè)務意圖在函數(shù)名上直接排成一列過濾、排序、截斷、收集。閱讀者不需要模擬一遍循環(huán)過程只看每一步方法名就知道在做什么。這就是從外部迭代到內(nèi)部迭代的轉(zhuǎn)變——你告訴Stream要什么結(jié)果Stream自己去遍歷。我常說這個思維遷移的實質(zhì)是傳統(tǒng)for循環(huán)是在描述過程Stream是在描述意圖。2.2 中間操作與終端操作流水線為什么等到collect才開工很多人學Stream會卡在中間操作、終端操作、惰性求值這些術(shù)語上。用流水線類比特別容易理解。想象一條汽車組裝線filter是給車裝個過濾器sorted是調(diào)整順序limit是截取前幾輛——這些步驟只是布置好了工位汽車還沒開過去。直到collect()這個終端操作出現(xiàn)流水線才真正啟動。這種布置完才開工的機制就是惰性求值。這個機制帶來的性能優(yōu)勢很重要多個中間操作組合時Stream不是先完成一個再完成下一個而是在終端操作觸發(fā)后元素逐個流過整條流水線。比如filter、map、limit疊加時被filter篩掉的元素根本不會進入后面的map和limit整體迭代次數(shù)可能遠小于每個操作各自遍歷一遍集合。這也是鏈式調(diào)用比多次循環(huán)更快的底層原因。有一個細節(jié)容易被忽略Stream不能在中間操作和終端操作之間被重復消費。我第一次在實際項目里就踩了這個坑把一個stream用兩次第二次直接拋stream has already been operated upon or closed。原理很簡單流水線一旦啟動這條臨時管道就報廢了想再用必須重新從集合創(chuàng)建Stream。這也解釋了為什么Stream被設計成管道式而不是容器式——它不存儲數(shù)據(jù)更像一個遍歷過程的描述。2.3 parallelStream不是加速鍵并行流的三個前提Stream進階里最容易被誤用的就是parallelStream()??吹讲⑿袃蓚€字就下意識覺得快這是個誤解。并行流底層用的是公共的ForkJoinPool默認并行度是CPU核心數(shù)減一所有并行流共享這個池。如果你的任務本身很輕比如幾十個元素的集合做簡單過濾并行流的線程調(diào)度開銷反而超過串行遍歷實測經(jīng)常更慢。更隱蔽的問題是共享狀態(tài)。在并行流里對同一個外部List執(zhí)行add或者操作非線程安全的計數(shù)器結(jié)果就是不可預測的。我見過一個線上問題一個并行流往日志List里寫數(shù)據(jù)偶爾出現(xiàn)數(shù)據(jù)丟失排查了很久才意識到并發(fā)修改了共享集合。我的建議是三個前提數(shù)據(jù)量大、純CPU計算、不修改共享狀態(tài)。滿足這三個條件才考慮并行流。日常大多數(shù)集合操作耗時都在毫秒級完全沒必要賭這個性能。3. 五個高頻場景的前后對比代碼量是實打?qū)崪p了這一章是全文最核心的部分。我選了五個在真實項目中重構(gòu)過的場景每個都給出Java 7和Java 8的寫法對比。重構(gòu)前后的行數(shù)大致如下場景Java 7大約行數(shù)Java 8大約行數(shù)效果條件過濾集合4-61-2減少一半以上分組統(tǒng)計8-121-2減少約80%多字段排序6-101-3減少約70%嵌套集合處理4-61-2減少一半以上集合轉(zhuǎn)Map/提取字段4-61-2減少一半以上這些場景你在任何業(yè)務系統(tǒng)里幾乎每天都能遇到直接照著抄就行。3.1 過濾集合forif換成filtercollect最基礎的場景從客戶列表里篩出VIP且狀態(tài)正常的Java 7ListCustomer vipActive new ArrayList(); for (Customer c : customers) { if (VIP.equals(c.getLevel()) c.isActive()) { vipActive.add(c); } }Java 8ListCustomer vipActive customers.stream() .filter(c - VIP.equals(c.getLevel())) .filter(Customer::isActive) .collect(Collectors.toList());兩個filter合成一個也行取決于你想表達的語義。我的個人習慣是每個條件單獨一個filter因為將來加條件時改動更聚焦排查問題也更快——只要看一下是哪個filter把元素篩沒了就能定位是哪個條件出了問題。這種每個操作一步的風格正好也體現(xiàn)了Stream相比循環(huán)最大的優(yōu)勢操作步驟本身就是業(yè)務語義。3.2 分組統(tǒng)計從手動Map累加到一行g(shù)roupingBy這是我認為Stream最驚艷的場景沒有之一。需求按商品分類統(tǒng)計銷量。Java 7你得寫MapString, Integer countByCategory new HashMap(); for (Product p : products) { Integer count countByCategory.get(p.getCategory()); if (count null) { countByCategory.put(p.getCategory(), 1); } else { countByCategory.put(p.getCategory(), count 1); } }一個簡單的統(tǒng)計要處理空值判斷、裝箱、累加三步。Java 8只需要MapString, Long countByCategory products.stream() .collect(Collectors.groupingBy(Product::getCategory, Collectors.counting()));如果再進一步你還能拿到每個分類下銷量最高的商品MapString, OptionalProduct topByCategory products.stream() .collect(Collectors.groupingBy(Product::getCategory, Collectors.maxBy(Comparator.comparing(Product::getSales))));這種復雜度在Java 7時代至少要15行現(xiàn)在1到2行就完成。groupingBy接受一個分類函數(shù)和一個下游收集器天然支持嵌套分組、計數(shù)、求和、取最大最小等聚合操作是日常統(tǒng)計類需求的最強輔助。3.3 多字段排序Comparator鏈讓排序邏輯一目了然排序需求在現(xiàn)實中經(jīng)常是組合條件先按主字段、再按次字段。Java 7的寫法Collections.sort(orders, new ComparatorOrder() { Override public int compare(Order o1, Order o2) { int cmp Double.compare(o2.getAmount(), o1.getAmount()); if (cmp ! 0) return cmp; return o1.getCreateTime().compareTo(o2.getCreateTime()); } });Java 8配合Comparator鏈ListOrder sorted orders.stream() .sorted(Comparator.comparing(Order::getAmount).reversed() .thenComparing(Order::getCreateTime)) .collect(Collectors.toList());注意一個細節(jié)reversed()的作用范圍。上面這段是金額字段降序時間字段升序。如果寫成Comparator.comparing(Order::getCreateTime).reversed()那就只對時間字段取反。要整體反轉(zhuǎn)整個比較鏈得用Comparator.comparing(...).thenComparing(...).reversed()把整個比較器包起來。這個細節(jié)我在code review里見過不止一次被人寫錯測試數(shù)據(jù)不夠多樣時還發(fā)現(xiàn)不了。Comparator鏈最舒服的地方在于可以按需組合想改成先按狀態(tài)再按金額就調(diào)整鏈的順序不用重寫compare方法。這種組合性正是流式API的核心價值。3.4 扁平化處理flatMap處理嵌套集合不再雙重循環(huán)還有一個高頻場景是處理嵌套集合。比如一個作者列表每個作者有多篇文章想獲取所有文章標簽并去重。Java 7的寫法是雙重循環(huán)加SetSetString allTags new HashSet(); for (Author author : authors) { for (Article article : author.getArticles()) { allTags.addAll(article.getTags()); } }Java 8SetString allTags authors.stream() .flatMap(author - author.getArticles().stream()) .flatMap(article - article.getTags().stream()) .collect(Collectors.toSet());flatMap的作用是把每個元素展開成一個流再把所有流合并為一個流。我習慣把它理解成拍扁每個作者的articles是一個小列表flatMap把它們首尾相連形成一個包含所有文章的大流同樣道理每篇文章的tags再拍平一次就得到所有標簽。這個操作在寫代碼時是真正的減行數(shù)利器同時意味著你不再需要關(guān)心嵌套循環(huán)的細節(jié)每一層展開都變成獨立的聲明。3.5 集合轉(zhuǎn)Map與字段提取一行代碼替換循環(huán)拼裝最后這個場景在業(yè)務系統(tǒng)里幾乎每天出現(xiàn)把一個實體列表轉(zhuǎn)成Map方便后續(xù)O(1)查找。Java 7MapLong, User userMap new HashMap(); for (User u : users) { userMap.put(u.getId(), u); }Java 8MapLong, User userMap users.stream() .collect(Collectors.toMap(User::getId, Function.identity()));需要注意toMap在遇到重復key時會直接拋IllegalStateException而Java 7的循環(huán)寫法是后一個覆蓋前一個。如果你明確知道可能有重復且希望后者覆蓋記得加第三個參數(shù)Collectors.toMap(User::getId, Function.identity(), (oldValue, newValue) - newValue)字段提取就更簡單了users.stream().map(User::getName).collect(Collectors.toList());一行實現(xiàn)過去三五行的循環(huán)賦值。這個看起來不起眼但在批量導出、批量拼裝DTO、批量查詢回填的場景里累積節(jié)省的樣板代碼非??捎^。代碼簡潔從來不是靠某一個魔法而是這些每天都在寫的小片段慢慢攢出來的。4. 生產(chǎn)代碼里我踩過的Stream坑和一些真相4.1 一個Stream只能消費一次流水線啟動即報廢前面提過Stream一旦調(diào)用終端操作就不能再使用了。這個錯誤在實際項目里出現(xiàn)頻率極高報錯長這樣java.lang.IllegalStateException: stream has already been operated upon or closed我見過一種典型誤用寫了個方法返回Stream調(diào)用方第一段代碼用這個流過濾并collect第二段代碼又用同一個流做排序。第一次運行沒問題第二次就開始找bug了。解決方式很簡單每次使用前都從數(shù)據(jù)源重新創(chuàng)建流。如果不想重復創(chuàng)建可以先把中間結(jié)果存成List再對List調(diào)stream()。記住一個原則Stream是設計為單次使用的臨時管道不是可復用的集合對象。這個設計其實和2.2講的惰性求值一脈相承——它保存的是如何遍歷的描述而不是遍歷出來的數(shù)據(jù)。4.2 并行流背后的ForkJoinPool公共池被占用的坑并行流默認使用ForkJoinPool.commonPool()并行度是CPU核心數(shù)-1。如果你在Tomcat這類多線程環(huán)境里跑并行流某個請求的并行流卡住了比如里面調(diào)了遠程API或者做了長時間IO其他所有并行流任務都會在同一批線程上排隊。這個坑特別隱蔽表面看系統(tǒng)好像變慢了實際是公共線程池被占滿。我在項目里的要求很簡單不要在并行流里做IO操作尤其網(wǎng)絡請求和數(shù)據(jù)庫查詢。并行流適合的是純CPU計算、且數(shù)據(jù)量確實大到串行處理有明顯瓶頸的場景。如果你不確定先用基準測試工具測一下別靠直覺。順帶說一句在容器環(huán)境下availableProcessors()返回的是宿主機的CPU核數(shù)不是容器配額并行度往往會超出預期這又是一個容易被人忽略的細節(jié)。4.3 哪些場景我建議你繼續(xù)用for循環(huán)Stream雖好但不是所有循環(huán)都該換成它。我始終強調(diào)方案選型要看上下文有幾個場景用for反而更清晰。一是需要中途提前退出的循環(huán)。比如遍歷列表直到找到第一個滿足條件的元素雖然在Stream里可以用findFirst()但如果是遍歷到一半根據(jù)某個外部狀態(tài)決定要不要繼續(xù)for加break更直接。二是需要同時使用索引和相鄰元素的場景比如for (int i 0; i list.size() - 1; i)比較相鄰元素Stream寫起來要繞IntStream可讀性反而下降。三是兩個集合按下標一一對應操作的場景類似nameList和ageList按索引拼接Stream寫出來不直觀for循環(huán)一眼就懂。還有一個容易被忽略的點調(diào)試時for循環(huán)可以直接在循環(huán)體里打日志、加斷點、修改變量Stream鏈要調(diào)試就得拆鏈。不過IDEA的Trace Current Stream Chain功能已經(jīng)能直觀看到每個元素在流水線上的流轉(zhuǎn)這個功能用順手之后調(diào)試Stream的難度會低很多。4.4 別把stream名詞搞混網(wǎng)絡流、Stream Load與Java Stream無關(guān)寫這篇文章時我注意到很多搜索stream的人其實遇到了完全不相干的問題比如socket stream closed、stream disconnected before completion、StarRocks Stream Load還有人搜CentOS Stream。這里統(tǒng)一澄清一句這些都是英文單詞stream的其他領域用法和Java 8的Stream API沒有任何關(guān)系。Java Stream是集合流式處理的抽象用于對內(nèi)存中集合做聲明式操作。而網(wǎng)絡報錯里的stream是輸入輸出流概念出現(xiàn)stream disconnected before completion時應該往TCP連接、HTTP響應、超時方向排查別去翻Java Stream的代碼。StarRocks的Stream Load是數(shù)據(jù)庫基于HTTP的批量數(shù)據(jù)導入方式CentOS Stream是Linux發(fā)行版的一個版本系列Dart里的Stream則代表異步事件序列。這些同名術(shù)語的坑其實比很多框架的坑更容易讓人白忙半天。做一個簡單的區(qū)分表格術(shù)語領域含義 / 排查方向Java Stream APIJava開發(fā)集合的流式處理filter/map/collectSocket/網(wǎng)絡 Stream網(wǎng)絡編程輸入輸出字節(jié)流連接與超時問題Stream LoadStarRocks基于HTTP的批量數(shù)據(jù)導入方式CentOS StreamLinux發(fā)行版CentOS的滾動發(fā)布版本系列Dart StreamDart/Flutter異步事件序列下次看到帶stream的報錯先確定它出現(xiàn)在哪一層編譯期、運行時、網(wǎng)絡層還是數(shù)據(jù)庫工具層再去找對應的排查思路別條件反射對著Java代碼一頓查。最后說個實在話。Java 8發(fā)布這么多年Lambda和Stream不是拿來面試炫技的它們是幫你把注意力從怎么遍歷轉(zhuǎn)移到要什么結(jié)果的工具。如果你手頭正好有一個還沒重構(gòu)的舊循環(huán)今天可以挑一個最簡單的場景比如格式化輸出一份列表、給集合做一次分組統(tǒng)計試試用Stream寫一遍。從for循環(huán)到Stream的轉(zhuǎn)變不是語法層面的替換而是思考方式的切換。我自己的體會是一旦習慣了描述意圖而不是描述過程再看回那些手工循環(huán)代碼會明顯感覺信息密度太低。剩下的事就是在你手邊的項目里把它用起來了。