Java 8 中的流操作-基本使用性能测试
??點擊上方?好好學java?,選擇?星標?公眾號
重磅資訊、干貨,第一時間送達 今日推薦:一個線程池 bug 引發的 GC 思考!一、流(Stream)簡介
流是 Java8 中 API 的新成員,它允許你以聲明式的方式處理數據集合(通過查詢語句來表達,而不是臨時編寫一個實現)。這有點兒像是我們操作數據庫一樣,例如我想要查詢出熱量較低的菜品名字我就可以像下面這樣:
SELECT?name?FROM?dishes?WHERE?calorie?<?400;您看,我們并沒有對菜品的什么屬性進行篩選(比如像之前使用迭代器一樣每個做判斷),我們只是表達了我們想要什么。那么為什么到了 Java 的集合中,這樣做就不行了呢?
另外一點,如果我們想要處理大量的數據又該怎么辦?是否是考慮使用多線程進行并發處理呢?如果是,那么可能編寫的關于并發的代碼比使用迭代器本身更加的復雜,而且調試起來也會變得麻煩。
基于以上的幾點考慮,Java 設計者在 Java 8 版本中,引入了流的概念,來幫助您節約時間!并且有了 lambda 的參與,流操作的使用將更加順暢!
特點一:內部迭代
就現在來說,您可以把它簡單的當成一種高級的迭代器(Iterator),或者是高級的 for 循環,區別在于,前面兩者都是屬于外部迭代,而流采用內部迭代。
上圖簡要說明了內部迭代與外部迭代的差異,我們再舉一個生活中實際的例子(引自《Java 8 實戰》),比如您想讓您兩歲的孩子索菲亞把她的玩具都收到盒子里面去,你們之間可能會產生如下的對話:
你:“索菲亞,我們把玩具收起來吧,地上還有玩具嗎?”
索菲亞:“有,球。”
你:“好,把球放進盒子里面吧,還有嗎?”
索菲亞:“有,那是我的娃娃?!?/p>
你:“好,把娃娃也放進去吧,還有嗎?”
索菲亞:“有,有我的書。”
你:“好,把書也放進去,還有嗎?”
索菲亞:“沒有了。”
你:“好,我們收好啦?!?/p>
這正是你每天都要對 Java 集合做的事情。你外部迭代了一個集合,顯式地取出每個項目再加以處理,但是如果你只是跟索菲亞說:“把地上所有玩具都放進盒子里”,那么索菲亞就可以選擇一手拿娃娃一手拿球,或是選擇先拿離盒子最近的那個東西,再拿其他的東西。
采用內部迭代,項目可以透明地并行處理,或者用優化的順序進行處理,要是使用 Java 過去的外部迭代方法,這些優化都是很困難的。
這或許有點雞蛋里挑骨頭,但這差不多就是 Java 8 引入流的原因了——Streams 庫的內部迭代可以自動選擇一種是和你硬件的數據表示和并行實現。
特點二:只能遍歷一次
請注意,和迭代器一樣,流只能遍歷一次。當流遍歷完之后,我們就說這個流已經被消費掉了,你可以從原始數據那里重新獲得一條新的流,但是卻不允許消費已消費掉的流。例如下面代碼就會拋出一個異常,說流已被消費掉了:
List<String>?title?=?Arrays.asList("Wmyskxz",?"Is",?"Learning",?"Java8",?"In",?"Action"); Stream<String>?s?=?title.stream(); s.forEach(System.out::println); s.forEach(System.out::println); //?運行上面程序會報以下錯誤 /* Exception?in?thread?"main"?java.lang.IllegalStateException:?stream?has?already?been?operated?upon?or?closedat?java.util.stream.AbstractPipeline.sourceStageSpliterator(AbstractPipeline.java:279)at?java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:580)at?Test1.main(Tester.java:17) */特點三:方便的并行處理
Java 8 中不僅提供了方便的一些流操作(比如過濾、排序之類的),更重要的是對于并行處理有很好的支持,只需要加上 .parallel() 就行了!例如我們使用下面程序來說明一下多線程流操作的方便和快捷,并且與單線程做了一下對比:
public?class?StreamParallelDemo?{/**?總數?*/private?static?int?total?=?100_000_000;public?static?void?main(String[]?args)?{System.out.println(String.format("本計算機的核數:%d",?Runtime.getRuntime().availableProcessors()));//?產生1000w個隨機數(1?~?100),組成列表Random?random?=?new?Random();List<Integer>?list?=?new?ArrayList<>(total);for?(int?i?=?0;?i?<?total;?i++)?{list.add(random.nextInt(100));}long?prevTime?=?getCurrentTime();list.stream().reduce((a,?b)?->?a?+?b).ifPresent(System.out::println);System.out.println(String.format("單線程計算耗時:%d",?getCurrentTime()?-?prevTime));prevTime?=?getCurrentTime();//?只需要加上?.parallel()?就行了list.stream().parallel().reduce((a,?b)?->?a?+?b).ifPresent(System.out::println);System.out.println(String.format("多線程計算耗時:%d",?getCurrentTime()?-?prevTime));}private?static?long?getCurrentTime()?{return?System.currentTimeMillis();} }以上程序分別使用了單線程流和多線程流計算了一千萬個隨機數的和,輸出如下:
本計算機的核數:8
655028378
單線程計算耗時:4159
655028378
多線程計算耗時:540
并行流的內部使用了默認的 ForkJoinPool 分支/合并框架,它的默認線程數量就是你的處理器數量,這個值是由 Runtime.getRuntime().availableProcessors() 得到的(當然我們也可以全局設置這個值)。我們也不再去過度的操心加鎖線程安全等一系列問題。
二、流基本操作
至少我們從上面了解到了,流操作似乎是一種很強大的工具,能夠幫助我們節約我們時間的同時讓我們程序可讀性更高,下面我們就具體的來了解一下 Java 8 帶來的新 API Stream,能給我們帶來哪些操作。
1、篩選和切片
filter
Stream 接口支持 filter 方法,該操作會接受一個返回 boolean 的函數作為參數,并返回一個包含所有符合該條件的流。例如,你可以這樣選出所有以字母 w 開頭的單詞并打印:
List<String>?words?=?Arrays.asList("wmyskxz",?"say",?"wow",?"to",?"everybody"); words.stream().filter(word?->?word.startsWith("w")).forEach(System.out::println); //?==============輸出:=============== //?wmyskxz //?wow這個過程類似下圖:
當然如果您不是想要輸出而是想要返回一個集合,那么可以使用 .collect(toList()),就像下面這樣:
List<String>?words?=?Arrays.asList("wmyskxz",?"say",?"wow",?"to",?"everybody"); List<String>?filteredWords?=?words.stream().filter(word?->?word.startsWith("w")).collect(Collectors.toList());distinct
流還支持一個叫做 distinct 的方法,它會返回一個元素各異(根據流所生成的元素的 hashCode 和 equals 方法實現)的流。例如,以下代碼會篩選出列表中所有的偶數,并確保沒有重復:
List<Integer>?numbers?=?Arrays.asList(1,?2,?1,?3,?2,?1,?3,?4); numbers.stream().filter(integer?->?integer?%?2?==?0).distinct().forEach(System.out::println); //?==============輸出:=============== //?2 //?4limit
流支持 limit(n) 方法,該方法會返回一個不超過給定長度的流,所需長度需要作為參數傳遞給 limit。如果流是有序的,則最多會返回前 n 個元素。比如,你可以建立一個 List,選出前 3 個元素:
List<Integer>?numbers?=?Arrays.asList(1,?2,?3,?4,?5,?6,?7,?8); numbers.stream().filter(integer?->?integer?%?2?==?0).limit(3).forEach(System.out::println); //?==============輸出:=============== //?2 //?4 //?6請注意雖然上述的集合是有序的,但 limit 本身并不會做任何排序的操作。
skip
流還支持 skip(n) 方法,返回一個扔掉了前 n 個元素的流。如果流中元素不足 n 個,則返回一個空流。請注意 litmit 和 skip 是互補的!例如,下面這段程序,選出了所有的偶數并跳過了前兩個輸出:
List<Integer>?numbers?=?Arrays.asList(1,?2,?3,?4,?5,?6,?7,?8); numbers.stream().filter(integer?->?integer?%?2?==?0).skip(2).forEach(System.out::println); //?==============輸出:=============== //?6 //?82、映射
一個非常常見的數據處理套路就是從某些對象中選擇信息。比如在 SQL 里,你可以從表中選擇一列,Stream API 也通過 map 和 flatMap 方法提供了類似的工具。
map
流支持 map 方法,他會接受一個函數作為參數。這個函數會被應用到每個元素身上嗎,并將其映射成一個新的函數。例如,下面的代碼把方法引用 Words::getContent 傳給了 map 方法,來提取流中 Words 的具體內容:
public?static?void?main(String[]?args)?{List<Words>?numbers?=?Arrays.asList(new?Words("我沒有三顆心臟"),new?Words("公眾號"),?new?Words("wmyskxz"));numbers.stream().map(Words::getContent).forEach(System.out::println); }@Data @AllArgsConstructor private?static?class?Words?{private?String?content; } //?==============輸出:=============== //?我沒有三顆心臟 //?公眾號 //?wmyskxz但是如果你現在只想要找出每個 Words 具體內容的長度又該怎么辦呢?我們可以再進行一次映射:
public?static?void?main(String[]?args)?{List<Words>?numbers?=?Arrays.asList(new?Words("我沒有三顆心臟"),new?Words("公眾號"),?new?Words("wmyskxz"));numbers.stream().map(Words::getWords).map(String::length).forEach(System.out::println); }@Data @AllArgsConstructor private?static?class?Words?{private?String?words; } //?==============輸出:=============== //?7 //?3 //?7flatMap:流的扁平化
你已經看到我們是如何使用 map 方法來返回每個 Words 的具體長度了,現在讓我們來擴展一下:對于一個 Words 集合,我需要知道這個集合里一共有多少個不相同的字符呢?例如,給定單詞列表為:["Hello", "World"],則需要返回的列表是:["H", "e", "l", "o", "W", "r", "d"]。
您可能會覺得簡單,而后寫下下列錯誤的第一版本:
List<String>?words?=?Arrays.asList("Hello",?"World"); words.stream().map(s?->?s.split("")).distinct().collect(Collectors.toList()).forEach(System.out::println); //?==============輸出:=============== //?[Ljava.lang.String;@238e0d81 //?[Ljava.lang.String;@31221be2為什么會這樣呢?這個方法的問題在于,傳遞給 map 方法的 lambda 表達式為每個單詞返回了一個 String[],所以經過 map 方法之后返回的流就不是我們預想的 Stream<String>,而是 Stream<String[]>,下圖就說明了這個問題:
幸好我們可以使用 flatMap 來解決這個問題:
List<String>?words?=?Arrays.asList("Hello",?"World"); words.stream().map(s?->?s.split("")).flatMap(Arrays::stream).distinct().collect(Collectors.toList()).forEach(System.out::println); //?==============輸出:=============== //?H //?e //?l //?o //?W //?r //?d使用 flatMap 方法的效果是,各個數組并不是分別映射成一個流,而是映射成流的內容。一言蔽之就是 flatMap 讓你一個流中的每個值都轉換成另一個六,然后把所有的流連接起來成為一個流,具體過程如下圖:
3、查找和匹配
另一個常見的數據處理套路是看看數據集中的某些元素是否匹配一個給定的屬性,Stream API 通過 allMatch、anyMatch、noneMatch、findFirst 和 findAny 方法提供了這樣的工具(其實到這里看名字就會大概能夠知道怎么使用了)。
我們簡單的舉兩個例子就好。
比如,你可以用它來看看集合里面是否有偶數:
List<Integer>?numbers?=?Arrays.asList(1,?2,?3); if?(numbers.stream().anyMatch(i?->?i?%?2?==?0))?{System.out.println("集合里有偶數!"); }再比如,你可以用來它來檢驗是否集合里都為偶數:
List<Integer>?numbers?=?Arrays.asList(2,?2,?4); if?(numbers.stream().allMatch(i?->?i?%?2?==?0))?{System.out.println("集合里全是偶數!"); }再或者,給定一個數字列表,找出第一個平方能被 3 整除的數:
List<Integer>?numbers?=?Arrays.asList(1,?2,?3,?4,?5,?6,?7); Optional<Integer>?firstSquareDivisibledByThree?=numbers.stream().map(x?->?x?*?x).filter(x?->?x?%?3?==?0).findFirst(); System.out.println(firstSquareDivisibledByThree.get()); //?==============輸出:=============== //?9Optional 簡介:
Optional<T> 類是 java.util.Optional 包里的一個容器類,代表一個值存在或者不存在。在上面的代碼中,findFirst() 可能什么元素都找不到,Java 8 的設計人員引入了 Optional<T>,這樣就不用返回眾所周知容易出問題的 null 了。我們在這里不對 Optional 做細致的討論。
4、歸約:reduce
到目前為止,你見到過的終端操作(下面我們會說到這些操作其實分為中間操作和終端操作)都是返回一個 boolean(allMatch 之類的)、void(forEach)或 Optional 對象(findFirst 等)。你也見到過了使用 collect 來將流中的所有元素合并成一個 List。
接下來我們來接觸更加復雜的一些操作,比如 “挑出單詞中長度最長的的單詞” 或是 “計算所有單詞的總長度”。此類查詢需要將流中的元素反復結合起來,得到一個值。這樣的查詢可以被歸類為歸約操作(將流歸約成一個值)。
數組求和
在研究 reduce 之前,我們先來回顧一下我們在之前是如何對一個數字數組進行求和的:
List<Integer>?numbers?=?Arrays.asList(1,?2,?3,?4,?5); int?sum?=?0; for?(int?x?:?numbers)?{sum?+=?x; } System.out.println(sum); //?==============輸出:=============== //?15numbers 中的每個元素都用加法運算符反復迭代來得到結果。通過反復使用加法,我們最終把一個數字列表歸約成了一個數字。在這段代碼中,我們一共使用了兩個參數:
sum:總和變量的初始值,在這里是 0;
x:用于接受 numbers 中的每一個元素,并與 sum 做加法操作不斷迭代;
要是還能把所有的數字相乘,而不用復制粘貼這段代碼,豈不是很好?這正是 reduce 操作的用武之地,它對這種重復應用的模式做了抽象。你可以像下面這樣對流中所有的元素求和:
List<Integer>?numbers?=?Arrays.asList(1,?2,?3,?4,?5); int?sum?=?numbers.stream().reduce(0,?(a,?b)?->?a?+?b); System.out.println(sum); //?==============輸出:=============== //?15其中 reduce 接受了兩個參數:
一個初始值,這里是 0;
一個是 BinaryOperator 來將兩個元素結合起來產生一個新值,這里我們用的是 lambda (a, b) -> a + b;
你也可以很容易改造成所有元素相乘的形式,只需要將另一個 Lambda:(a, b) -> a * b 傳遞給 reduce 就可以了:
int?product?=?numbers.stream().reduce(0,?(a,?b)?->?a?*?b);我們先來深入研究一下 reduce 是如何對一個數字流進行求和的:
如上圖所示一樣的,reduce 每一次都把結果返回并與下一次的元素進行操作,比如第一次當遍歷到元素 1 時,此時返回初始值 0 + 1 = 1,然后再用此時的返回值 1 與第二個元素進行疊加操作,如此往復,便完成了對數字列表的求和運算。
當然你也可以使用方法引用讓這段代碼更加簡潔:
int?sum?=?numbers.stream().reduce(0,?Integer::sum);無初始值
reduce 還有一個重載的變體,它不接受初始值,但是會返回一個 Optional 對象(考慮到流中沒有任何元素的情況):
Optional<Integer>?sum?=?numbers.stream().reduce(Integer::sum);最大值和最小值
有點類似于上面的操作,我們可以使用下面這樣的 reduce 來計算流中的最大值or最小值:
//?最大值 Optional<Integer>?max?=?numbers.stream().reduce(Integer::max); //?最小值 Optional<Integer>?max?=?numbers.stream().reduce(Integer::min);5、中間操作和結束操作(終端操作)
Stream API 上的所有操作分為兩類:中間操作和結束操作。中間操作只是一種標記,只有結束操作才會觸發實際計算。
中間操作又可以分為無狀態的(Stateless)和有狀態的(Stateful),無狀態中間操作是指元素的處理不受前面元素的影響,而有狀態的中間操作必須等到所有元素處理之后才知道最終結果,比如排序是有狀態操作,在讀取所有元素之前并不能確定排序結果;
結束操作又可以分為短路操作和非短路操作,短路操作是指不用處理全部元素就可以返回結果,比如找到第一個滿足條件的元素。之所以要進行如此精細的劃分,是因為底層對每一種情況的處理方式不同。為了更好的理解流的中間操作和終端操作,可以通過下面的兩段代碼來看他們的執行過程:
IntStream.range(1,?10).peek(x?->?System.out.print("\nA"?+?x)).limit(3).peek(x?->?System.out.print("B"?+?x)).forEach(x?->?System.out.print("C"?+?x)); //?==============輸出:=============== //?A1B1C1 //?A2B2C2 //?A3B3C3中間操作是懶惰的,也就是不會對數據做任何操作,直到遇到了結束操作。而結束操作都是比較熱情的,他們會回溯之前所有的中間操作。
拿上面的例子來說,當執行到 forEach() 的時候,它會回溯到上一步中間操作,再到上一步中間操作,再上一步..直到第一步,也就是這里的 .peek(x -> System.out.println("\nA" + x),然后開始自上而下的依次執行,輸出第一行的 A1B1C1,然而第二次執行 forEach() 操作的時候等同,以此類推..
我們再來看第二段代碼:
IntStream.range(1,?10).peek(x?->?System.out.print("\nA"?+?x)).skip(6).peek(x?->?System.out.print("B"?+?x)).forEach(x?->?System.out.print("C"?+?x)); //?==============輸出:=============== //?A1 //?A2 //?A3 //?A4 //?A5 //?A6 //?A7B7C7 //?A8B8C8 //?A9B9C9根據上面介紹的規則,同樣的當第一次執行 .forEach() 的時候,會回溯到第一個 peek 操作,打印出 A1,然后執行 skip,這個操作的意思就是跳過,也就是相當于 for 循環里面的 continue,所以前六次的 forEach() 操作都只會打印 A。
而第七次開始,skip 失效之后,就會開始分別執行 .peek() 和 forEach() 里面的打印語句了,就會看到輸出的是:A7B7C7。
OK,到這里也算是對 Stream API 有了一定的認識,下面我們對中間操作和結束操作做一個總結:
圖片截自:https://github.com/CarpenterLee/JavaLambdaInternals/blob/master/6-Stream%20Pipelines.md
三、Stream 性能測試
引用自:下方參考文檔第 4 條。
已經對 Stream API 的用法鼓吹夠多了,用起簡潔直觀,但性能到底怎么樣呢?會不會有很高的性能損失?本節我們對 Stream API 的性能一探究竟。
為保證測試結果真實可信,我們將 JVM 運行在 -server 模式下,測試數據在 GB 量級,測試機器采用常見的商用服務器,配置如下:
| OS | CentOS 6.7 x86_64 |
| CPU | Intel Xeon X5675, 12M Cache 3.06 GHz, 6 Cores 12 Threads |
| 內存 | 96GB |
| JDK | java version 1.8.0_91, Java HotSpot(TM) 64-Bit Server VM |
測試所用代碼在這里,測試結果匯總.
測試方法和測試數據
性能測試并不是容易的事,Java性能測試更費勁,因為虛擬機對性能的影響很大,JVM對性能的影響有兩方面:
GC的影響。GC的行為是Java中很不好控制的一塊,為增加確定性,我們手動指定使用CMS收集器,并使用10GB固定大小的堆內存。具體到JVM參數就是-XX:+UseConcMarkSweepGC -Xms10G -Xmx10G
JIT(Just-In-Time)即時編譯技術。即時編譯技術會將熱點代碼在JVM運行的過程中編譯成本地代碼,測試時我們會先對程序預熱,觸發對測試函數的即時編譯。相關的JVM參數是-XX:CompileThreshold=10000。
Stream并行執行時用到ForkJoinPool.commonPool()得到的線程池,為控制并行度我們使用Linux的taskset命令指定JVM可用的核數。
測試數據由程序隨機生成。為防止一次測試帶來的抖動,測試4次求出平均時間作為運行時間。
實驗一 基本類型迭代
測試內容:找出整型數組中的最小值。對比for循環外部迭代和Stream API內部迭代性能。
測試程序IntTest,測試結果如下圖:
圖中展示的是for循環外部迭代耗時為基準的時間比值。分析如下:
對于基本類型Stream串行迭代的性能開銷明顯高于外部迭代開銷(兩倍);
Stream并行迭代的性能比串行迭代和外部迭代都好。
并行迭代性能跟可利用的核數有關,上圖中的并行迭代使用了全部 12 個核,為考察使用核數對性能的影響,我們專門測試了不同核數下的Stream并行迭代效果:
分析,對于基本類型:
使用Stream并行API在單核情況下性能很差,比Stream串行API的性能還差;
隨著使用核數的增加,Stream并行效果逐漸變好,比使用for循環外部迭代的性能還好。
以上兩個測試說明,對于基本類型的簡單迭代,Stream串行迭代性能更差,但多核情況下Stream迭代時性能較好。
實驗二 對象迭代
再來看對象的迭代效果。
測試內容:找出字符串列表中最小的元素(自然順序),對比for循環外部迭代和Stream API內部迭代性能。
測試程序StringTest,測試結果如下圖:
結果分析如下:
對于對象類型Stream串行迭代的性能開銷仍然高于外部迭代開銷(1.5倍),但差距沒有基本類型那么大。
Stream并行迭代的性能比串行迭代和外部迭代都好。
再來單獨考察Stream并行迭代效果:
分析,對于對象類型:
使用Stream并行API在單核情況下性能比for循環外部迭代差;
隨著使用核數的增加,Stream并行效果逐漸變好,多核帶來的效果明顯。
以上兩個測試說明,對于對象類型的簡單迭代,Stream串行迭代性能更差,但多核情況下Stream迭代時性能較好。
實驗三 復雜對象歸約
從實驗一、二的結果來看,Stream串行執行的效果都比外部迭代差(很多),是不是說明Stream真的不行了?先別下結論,我們再來考察一下更復雜的操作。
測試內容:給定訂單列表,統計每個用戶的總交易額。對比使用外部迭代手動實現和Stream API之間的性能。
我們將訂單簡化為<userName, price, timeStamp>構成的元組,并用Order對象來表示。測試程序ReductionTest,測試結果如下圖:
分析,對于復雜的歸約操作:
Stream API的性能普遍好于外部手動迭代,并行Stream效果更佳;
再來考察并行度對并行效果的影響,測試結果如下:
分析,對于復雜的歸約操作:
使用Stream并行歸約在單核情況下性能比串行歸約以及手動歸約都要差,簡單說就是最差的;
隨著使用核數的增加,Stream并行效果逐漸變好,多核帶來的效果明顯。
以上兩個實驗說明,對于復雜的歸約操作,Stream串行歸約效果好于手動歸約,在多核情況下,并行歸約效果更佳。我們有理由相信,對于其他復雜的操作,Stream API也能表現出相似的性能表現。
結論
上述三個實驗的結果可以總結如下:
對于簡單操作,比如最簡單的遍歷,Stream串行API性能明顯差于顯示迭代,但并行的Stream API能夠發揮多核特性。
對于復雜操作,Stream串行API性能可以和手動實現的效果匹敵,在并行執行時Stream API效果遠超手動實現。
所以,如果出于性能考慮,1. 對于簡單操作推薦使用外部迭代手動實現,2. 對于復雜操作,推薦使用Stream API, 3. 在多核情況下,推薦使用并行Stream API來發揮多核優勢,4.單核情況下不建議使用并行Stream API。
如果出于代碼簡潔性考慮,使用Stream API能夠寫出更短的代碼。即使是從性能方面說,盡可能的使用Stream API也另外一個優勢,那就是只要Java Stream類庫做了升級優化,代碼不用做任何修改就能享受到升級帶來的好處。
參考文檔
https://redspider.gitbook.io/concurrent/di-san-pian-jdk-gong-ju-pian/19 - Java 8 Stream并行計算原理
http://hack.xingren.com/index.php/2018/10/17/java-stream/ - 原來你是這樣的 Stream —— 淺析 Java Stream 實現原理
https://github.com/CarpenterLee/JavaLambdaInternals/blob/master/6-Stream%20Pipelines.md - Stream Pipelines
https://github.com/CarpenterLee/JavaLambdaInternals/blob/master/8-Stream%20Performance.md - Stream Performance
《Java 8 實戰》
總結
以上是生活随笔為你收集整理的Java 8 中的流操作-基本使用性能测试的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 有关于Java Map,应该掌握的8个问
- 下一篇: 3000 字说说跨域!面试官听完之后露出