给我十分钟带你过完java多线程所有基础知识
目錄:
1.并發并行與線程進程
2.認識CPU和主線程
3.多線程的三種創建方式
4.三種創建多線程方式的進一步探究和對比
5.匿名內部類的多線程創建
6.多線程內存的分析
7.深度了解線程run()和start()方法的作用
8.獲取和設置線程的名字
9.多線程多個窗口賣票的安全問題及三種解決方法
10.線程的五種狀態
11.消費者生產者問題
12.線程常用方法總結
1.并發并行與線程進程
(1) 并發并行
并發:指兩個或者多個事件在同一個時間段發生(交替執行)
并行:指兩個或者多個在同一時刻發生(同時執行)
并發就是你有兩袋辣條,你想知道兩袋辣條哪一個袋好吃,所以你就先打開第一袋吃一口,然后又打開第二袋吃一口,你發現都好吃,所以你就換這吃,吃一口第一袋,再吃一口第二袋,再吃一口第一袋,再第二袋再…,直到把兩袋辣條吃完
并行就是你有兩袋辣條你分給你女朋友(醒醒你不可能有女朋友)一袋吃,讓后你倆同時吃,直到把辣條吃完
并發就是CPU在執任務的時候同時在多個任務之間切換,并行就是同時擁有多個CPU同時執行任務
(2)線程與進程
進程:指一個內存中運行的應用程序,每個進程都有一個獨立的內存空間,一個應用程序可以同時運行多個進程,進程是程序的一次執行過程。
線程:線程是進程的一個執行單位,一個進程可以有多個線程。一個程序運行后至少有一個進程,一個進程可以有多個線程
2.認識CPU和主線程
(1)CPU:中央處理器,對數據進行計算指揮電腦中軟件和硬件干活
CPU分為AMD(單核心單線程CPU)和Inter(多核心多線程CPU)具體下圖有
對于上圖補充解釋:
應用程序保存在硬盤中,打開應用程序,在內存中會打開一塊空間用于執行應用程序,進入內存的程序叫進程
點擊應用程序其中的功能(功能由代碼實現),然后就會開啟一條應用程序到CPU的執行路徑,CPU可以通過路徑執行功能,這個路徑有個名字叫做線程
(2)執行Main的方法的進程叫做主線程
jvm在執行main方法時,main方法進入棧內存, jvm會讓操作系統開辟一條cpu到main方法的路徑叫主線程
3.創建多線程的三種方式
(1)繼承Thread類創建多線程
分為四步:
- 1.定義繼承Thread的子類
- 2.重寫該類的run()方法,將線程執行的操作聲明在run()的方法里
- 3.創建Thread類子類的對象
- 調用start()方法啟動線程
(2)實現Runable接口
分為五步:
- 1.定義一個實現Runable接口的類
- 2.重寫run()方法設置線程任務
- 3.創建實現類對象
- 4.將創建好的實現類對象傳到Thread類的構造器中創建Thread對象
- 5.調用start()啟動多線程
例子;
package untl1; public class MyThread implements Runnable {public void run(){for (int i = 0; i <100 ; i++) {System.out.println("我已經數到"+i+"了");}}public static void main(String[] args) {MyThread p1=new MyThread();Thread p2=new Thread(p1);p2.start();for (int i = 0; i <100 ; i++) {System.out.println("hello world!");}} }(3)使用Callable和Future創建線程
分為五步:
- 1.創建一個Callable的實現類,實現call方法,并將此線程的任務放在call方法里
- 2.創建Callable的實現類對象
- 3.將此對象傳到FuterTask(是Futer的實現類,下一塊會講)構造器里,創建FuterTask對象
- 4.將創建的FuterTask對象傳入Thread的構造器中,創建Thread對象
- 5.調用FuterTask對象的get()方法獲得結束后的返回值,需要我們手動拋出或者捕獲異常
4.三種創建多線程方式的進一步探究和對比
(1)第一種創建多線程的方式,為啥要繼承Thread呢?是抽象類?
答案當然是NO,你可以直接創建Thread的對象但是run方法里沒有線程任務,所以我們需要繼承Thread和重寫run方法
(2)關于Future和FutureTask的繼承關系
(3) Callable和Future
Callable 接口類似于Runnable,從名字就可以看出來了,但是 Runnable 不會返回結果,并且無法拋出返回結果的異常,而 Callable 功能更強大一些,被線程執行后,可以返回值,這個返回值可以被 Future 拿到,也就是說,Future 可以拿到異步執行任務的返回值。
Future 接口表示異步任務,是一個可能還沒有完成的異步任務的結果。所以說 Callable用于產生結果,Future 用于獲取結果。
(4)FutureTask
表示一個異步運算的任務。FutureTask 里面可以傳入一個 Callable 的具體實現類,可以對這個異步運算的任務的結果進行等待獲取、判斷是否已經完成、取消任務等操作。只有當運算完成的時候結果才能取回,如果運算尚未完成 get 方法將會阻塞。一個 FutureTask 對象可以對調用了 Callable 和 Runnable 的對象進行包裝,由于FutureTask 也是Runnable 接口的實現類,所以 FutureTask 也可以放入線程池中。
(5)關于繼承Thread和實現Runable的區別:
- 1.第一種方法直接繼承了Thread,因為java是單繼承所以,繼承了Thread就不能繼承其他的類了,第二種沒有類的單繼承局限性,只是實現了接口而且java支持實現多個接口
- 2.Runable可以發生多態,即傳入Thread構造器中不同類型的對象就會有不同的線程任務
- 3.在run()方法內獲取當前線程,繼承Thread方法下可以使用this不用使用Thread.currentThread(),但是在Runable的實現類不能使用this必須使用Thread.currentThread()
(6)實現Runnable和實現Callable的區別
- 1.Runnable 接口 run()方法無返回值
- 2.Callable 接口 call()方法有返回值,是個泛型,和Future、FutureTask配合可以用來獲取異步執行的結果
- 3.Runnable 接口 run()方法只能拋出運行時異常,且無法捕獲處理;Callable 接口 call() 方法允許拋出和捕獲異常,可以獲取異常信息
5.匿名內部類的多線程創建
第一種直接用Thread:
package acm; public class acm {public static void main(String[] args) {new Thread() {public void run(){for (int i = 0; i <10 ; i++) {System.out.println(i);}}unable}.start();}第二種new一個Runable接口放在Thread的構造器里邊:
package acm; public class acm {public static void main(String[] args) {new Thread(new Runnable() {public void run() {for (int i = 0; i < 10; i++) {System.out.println(i);}}}).start();} }6.多線程內存的分析
package untl1; public class MyThread {public static void main(String[] args) {for(int i=11;i<20;i++){System.out.println(i);}Thread p=new person();p.run();p.start();} } class person extends Thread{public void run() {for (int i = 0; i <10 ; i++) {System.out.println(i);}} }我們隨便拿上圖多線程的例子來說:
JVM在執行main方法時找操作系統開辟一條CPU通往main方法的路徑,這條路徑叫做main線程,同理還會創建另一條路徑通往CPU,這條路徑就是我們創建的新的線程,兩個線程搶奪CPU的使用權,誰搶到執行誰的代碼,多個線程之間互不影響,因為所屬棧空間不同
兩個線程并發進行,搶奪cpu使用權(使用時間,使用時間結束后,又開始新一輪的搶奪),java屬于搶占式調度,那個線程的優先級高那個就先執行,如果是同一優先級那么隨機選擇一個執行。
7.線程run()和start()方法
| run() | 每個線程都是通過某個特定Thread對象所對應的方法run()來完成其操作的,run()方法稱為線程體。run() 方法用于執行線程的運行時代碼。可以重復調用 |
| start() | 通過調用Thread類的start()方法來啟動一個線程 ,而且 start() 只能調用一次。 |
那么還有一個問題啟動多線程為啥不直接調用run方法而是調用start再間接調用run方法?
首先我們要了解兩點:
1.start()方法來啟動一個線程,真正實現了多線程運行。調用start()方法無需等待run方法體代碼執行完畢,可以直接繼續執行其他的代碼; 此時線程是處于就緒狀態,并沒有運行。 然后通過此Thread類調用方法run()來完成其運行狀態, run()方法運行結束, 此線程終止。然后CPU再調度其它線程。
2.run()方法是在本線程里的,只是線程里的一個函數,而不是多線程的。 如果直接調用run(),其實就相當于是調用了一個普通函數而已,直接待用run()方法必須等待run()方法執行完畢才能執行下面的代碼,所以執行路徑還是只有一條,根本就沒有線程的特征,所以在多線程執行時要使用start()方法而不是run()方法。
了解之后就可以總結為:
new 一個 Thread,線程進入了新建狀態。調用 start() 方法,會啟動一個線程并使線程進入了就緒狀態,當分配到時間片后就可以開始運行了。 start() 會執行線程的相應準備工作,然后自動執行 run() 方法的內容,這是真正的多線程工作。而直接執行 run() 方法,會把 run 方法當成一個 main 線程下的普通方法去執行,并不會在某個線程中執行它,所以這并不是多線程工作。
8.獲取和設置線程的名字
(1)獲取線程的名字:
第一種Thread類中有getname()方法獲得線程名字:
第二種可以先獲得當前所在線程,再使用getname()方法:
package untl1; public class MyThread extends Thread {public void run(){String name=getName();System.out.println(name);}public static void main(String[] args) {MyThread p=new MyThread();p.start();Thread a=Thread.currentThread();System.out.println(a.getName());} }Thread.currentThread()是一個靜態方法可以獲得當前線程
(2)設置線程的名字
第一種方式:
用對象名.setname("名字”)直接修改
第二種方式:
調用父親的構造方法傳參幫子類線程取名字
9.多線程多個窗口賣票的安全問題
首先我們有三個窗口賣100張票,分別是窗口1窗口2和窗口3
我們使用多線程的對象來模擬三個窗口來賣票
我們為了使三個窗口的票是共享的所以我們把票聲明成static
然后進行賣票
如下:
package untl1; public class MyThread extends Thread {private static int ticket=100;public void run() {while(true){if(ticket>0){System.out.println("第"+Thread.currentThread().getName()+"在賣第"+ticket+"票");ticket--;}else{System.out.println("第"+Thread.currentThread().getName()+"已經無票可賣");break;}}}public static void main(String[] args) {MyThread oneThread=new MyThread();MyThread twoThread=new MyThread();MyThread threeThread=new MyThread();oneThread.setName("一號窗口");twoThread.setName("二號窗口");threeThread.setName("三號窗口");oneThread.start();twoThread.start();threeThread.start();} }sleep靜態方法是Thread里邊的靜態方法,以毫秒為單位使程序進入睡眠狀態,在run方法中使用的時候要進行捕獲異常的操作
由于運行結果太長就不展示了,我總結一下以上代碼會出現的問題
1.會出現賣重復的票,賣不存在的票,以及有的票號沒有賣出。
賣重復的票是因為當cpu在執行到語句后喪失了cpu的使用權(此時還沒執行if里邊的內容),被另一個線程搶到了cpu的使用權,同樣的情況,在沒執行if里邊的內容就被別的線程搶走使用權。然后第一次的哪個線程搶到使用權后,還沒來得及自減就被別的線程搶走了cpu使用權,然后就出現重復。
不存在的票是當三個都進入了if循環,但是此時票只剩下1張,打印結束后還沒來得及自減,就被別的線程搶走了cpu的使用權,別的線程又打印0張票,最后一個打印-1張票。
有的票沒賣出,就比如99號票,當重復賣出100張票的時候幾個,如果兩個線程都執行到ticket–,而且在執行這兩個tacket–的時候沒有賣出過票,那么就會有票漏賣
2.必須使用static才能實現數據的共享
那么如何解決呢
第一個問題,我們稱為線程安全問題,我們可以通過java同步機制解決
第二個問題我們可以使用一個實現了Runable接口的類所實例化的對象傳給Thread的不同引用,那么數據一樣可以共享
我們先用代碼解決第二個問題:
package untl1; public class MyThread implements Runnable{private int ticket=100;public void run() {while(true){if(ticket>0){try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("第"+Thread.currentThread().getName()+"在賣第"+ticket+"張票");ticket--;}else{System.out.println("第"+Thread.currentThread().getName()+"已經無票可賣");break;}}}public static void main(String[] args) {MyThread myThread=new MyThread();Thread oneThread=new Thread(myThread);Thread twoThread=new Thread(myThread);Thread threeThread=new Thread(myThread);oneThread.setName("一號窗口");twoThread.setName("二號窗口");threeThread.setName("三號窗口");oneThread.start();twoThread.start();threeThread.start();} }那么解決第一個問題有三種方式
1.同步代碼塊
2.同步方法
3.鎖機制
其實我們要解決的根本問題是當一個線程在執行共享數據的時候,另外的所有線程要等到它執行完成后再執行,那么就不會發生線程安全問題了,所有解決方法都是以此為原理展開的
1.同步代碼塊
synchronized(鎖對象){ 這里邊存放可能出現線程安全的代碼塊(訪問了共享數據的代碼) }代碼詳解:
package untl1; public class MyThread implements Runnable{private int ticket=100;Object o=new Object();public void run() {while (true){synchronized (o){if(ticket>0){try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("第"+Thread.currentThread().getName()+"在賣第"+ticket+"張票");ticket--;}else{System.out.println("第"+Thread.currentThread().getName()+"已經無票可賣");break;}}}}public static void main(String[] args) {MyThread myThread=new MyThread();Thread oneThread=new Thread(myThread);Thread twoThread=new Thread(myThread);Thread threeThread=new Thread(myThread);twoThread.setName("二號窗口");oneThread.setName("一號窗口");threeThread.setName("三號窗口");oneThread.start();twoThread.start();threeThread.start();} }首先鎖對象可以是任意的對象,但是必須保證多線程使用一個鎖對象,這就是我把鎖對象定義為類元素成員的原因
鎖對象的作用:
把同步代碼鎖住,只讓一個線程在同步代碼塊中進行。
同步技術原理:
使用了一個鎖對象,這個鎖叫做同步鎖,也叫對象鎖,也叫對象監視器,三個線程一起搶奪cpu使用權,誰搶到誰執行run賣票,當第一個線程遇到synchronized代碼塊會檢查是否有鎖對象,有的話獲取鎖對象進入同步中執行,(只有拿到鎖對象才能進入同步代碼塊中執行,否則,不能執行)。當第一個線程的代碼塊沒執行完cpu就被第二個線程搶走,那么檢查鎖對象,結果發現鎖對象被第一個線程拿著,所以第二個線程處于阻塞狀態,只有第一個線程執行完代碼塊中的內容才會歸還(沒執行完不會釋放鎖),然后執行第二個線程,這樣就會避免多線程的安全問題。
同步代碼塊的優缺點:
優點:保證了一個線程執行共享數據里邊的內容,保證了線程安全。
缺點:由于程序頻繁的判斷鎖獲取鎖,釋放鎖程序效率比較低。
2.同步方法(分為靜態方法和非靜態方法)
訪問修飾符 (static) synchronized 返回值類型 方法名(){ 這里邊存放可能出現線程安全的代碼塊(訪問了共享數據的代碼) }代碼詳解:
package untl1; public class MyThread implements Runnable{private int ticket=100;Object o=new Object();public synchronized void func(){if(ticket>0){try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("第"+Thread.currentThread().getName()+"在賣第"+ticket+"張票");ticket--;}}public void run(){while (true){if(ticket>0){func();}else{System.out.println("第"+Thread.currentThread().getName()+"已經無票可賣");break;}}}public static void main(String[] args) {MyThread myThread=new MyThread();Thread oneThread=new Thread(myThread);Thread twoThread=new Thread(myThread);Thread threeThread=new Thread(myThread);twoThread.setName("二號窗口");oneThread.setName("一號窗口");threeThread.setName("三號窗口");oneThread.start();twoThread.start();threeThread.start();} }其實同步方法也是利用的鎖對象鎖住,鎖對象就是this,對于上述例子來說就是myThread這個對象
以上是非靜態方法,那么靜態方法就是方法帶上static,并且ticket也要帶上static,我們都知道靜態方法內沒有this對象,所以靜態同步方法的鎖對象變成本類生成的class文件對象(在反射里講在這里不必深究了解即可)
3.鎖機制
實際上就是利用java中的Lock接口,里邊有兩個方法:
void lock();獲取鎖
void unlock();釋放鎖
在可能出現線程安全的代碼塊前獲取鎖,在可能出現線程安全地代碼塊后釋放鎖,但是前提也是同一個Lock接口實現類對象
在java中有一個Lock接口實現類ReentrantLock
代碼實現:
package untl1; import java.util.concurrent.locks.ReentrantLock; public class MyThread implements Runnable{private int ticket=100;ReentrantLock lock=new ReentrantLock();public void run() {while (true){lock.lock();if(ticket>0){try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("第"+Thread.currentThread().getName()+"在賣第"+ticket+"張票");ticket--;}else{System.out.println("第"+Thread.currentThread().getName()+"已經無票可賣");break;}lock.unlock();}}public static void main(String[] args) {MyThread myThread=new MyThread();Thread oneThread=new Thread(myThread);Thread twoThread=new Thread(myThread);Thread threeThread=new Thread(myThread);twoThread.setName("二號窗口");oneThread.setName("一號窗口");threeThread.setName("三號窗口");oneThread.start();twoThread.start();threeThread.start();} }10.線程的五種狀態
| New(新建) | 新創建了一個線程對象 |
| Runable(可運行狀態也稱就緒狀態) | 線程對象創建后,當調用線程對象的start()方法,該線程處于就緒狀態,等待被線程調度選中,獲取cpu的使用權。 |
| Runing(運行狀態) | 可運行狀態(runnable)的線程獲得了cpu時間片(timeslice),執行程序代碼。注:就緒狀態是進入到運行狀態的唯一入口,也就是說,線程要想進入運行狀態執行,首先必須處于就緒狀態中; |
| Blocked(阻塞狀態) | 處于運行狀態中的線程由于某種原因,暫時放棄對 CPU的使用權,停止執行,此時進入阻塞狀態,直到其進入到就緒狀態,才 有機會再次被 CPU 調用以進入到運行狀態。阻塞的情況分三種:(一). 等待阻塞:運行狀態中的線程執行 wait()方法,JVM會把該線程放入等待隊列(waitting queue)中,使本線程進入到等待阻塞狀態;(二). 同步阻塞:線程在獲取 synchronized 同步鎖失敗(因為鎖被其它線程所占用),,則JVM會把該線程放入鎖池(lock pool)中,線程會進入同步阻塞狀態;(三). 其他阻塞: 通過調用線程的 sleep()或 join()或發出了 I/O 請求時,線程會進入到阻塞狀態。當 sleep()狀態超時、join()等待線程終止或者超時、或者 I/O 處理完畢時,線程重新轉入就緒狀態。 |
| Dead(死亡狀態) | 線程run()、main()方法執行結束,或者因異常退出了run()方法,則該線程結束生命周期。死亡的線程不可再次復生。 |
11.消費者生產者問題
生產者與消費者問題其實就是等待喚醒機制,等待喚醒機制是一種協作機制,平常線程之間都是競爭關系,比如競爭鎖,但是并不是所有線程都是競爭關系,就像我們現在講的協作關系。等待喚醒的一種簡單實現是利用以下兩個方法
1.wait():生產者或消費者線程停止自己的執行,釋放鎖,使自己處于等待狀態,讓其它線程執行。
2.notify():向其他等待的線程發出通知,同時釋放鎖,使自己處于等待狀態,讓其它線程執行。
場景:顧客來買包子,賣包子的人沒包子了,又由于顧客一年沒吃飯了,所以賣包子的人做好一個提醒顧客吃一個(別問為啥賣包子的一次只能做一個)
整體思路:
1.創建一個顧客線程:告知老板要的包子的數量和種類,調用wait()方法,放棄cpu的執行,進入等待狀態
2.創建一個老板線程,包子做好后調用notify()方法,喚醒顧客吃包子
3.顧客和老板必須用同步代碼塊包裹起來,保證等待和喚醒只有一個在執行
代碼實現:
package untl1; public class acm{public static void main(String[] args) {Object p=new Object();person p1=new person(p);Thread p2=new Thread(p1);shopower p3=new shopower(p);Thread p4=new Thread(p3);p2.start();p4.start();} } class person implements Runnable{Object p;person(Object p){this.p=p;}public void run(){while(true){synchronized (p){System.out.println("我要買包子老板");try {p.wait();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("吃光了");}}} } class shopower implements Runnable{Object p;shopower(Object p){this.p=p;}public void run(){while(true){try {Thread.sleep(500);//在父類的run未拋異常子類也不能拋} catch (InterruptedException e) {e.printStackTrace();}synchronized (p){System.out.println("包子花五秒做好了,請吃把");p.notify();}}} }12.線程常用方法總結
1.如圖:
| setName (String) ; | 設置線程的名稱 |
| String getName () | 返回線程的名稱 |
| start() | 啟動線程,并執行對象的 run() 方法 |
| run() | 線程在被調度時執行的 操作,子線程要執行的代碼放入run()方法 |
| interrupt() | 中斷線程,由運行狀態到死亡狀態 |
| join(long millis) | 等待該線程終止的時間最長為 millis 毫秒。join()方法會使當前線程等待調用 join() 方法的線程結束后才能繼續執行。注意該方法也需要捕捉異常。 |
| sleep(long millis) | 靜態方法,睡眠指定時間,程序暫停運行,睡眠期間會讓出CPU的執行權,去執行其它線程,同時CPU也會監視睡眠的時間,一旦睡眠時間到就會立刻執行(因為睡眠過程中仍然保留著鎖,有鎖只要睡眠時間到就能立刻執行)。 |
| yield() | 是一種靜態的方法,暫停當前正在執行的線程對象,并執行其他線程。 |
| stop() | 強制線程生命周期結束,已經廢止 |
| wait() | 一旦執行此方法,當前線程就進入阻塞狀態,并釋放鎖。而當前線程 排隊 等候其他線程調用notify() 或 notifyAll() 方法喚醒,喚醒后等待重新獲得對監視器的所有 權后才能繼續執行. |
| notify() | 一旦執行此方法,就會喚醒被wait的一個線程。如果有多個線程被wait,就喚醒優先級高的那個,如果優先級相同就喚醒等待時間最長的哪一個。 |
| notifyAll() | 一旦執行此方法,就會喚醒所有被wait的線程。 |
以上方法除了wait()、notify()和notifyAll()定義在Object這個類里邊其他都定義在Thread里
2.關于notify()和notifyAll()的區別:
這里是引用如果線程調用了對象的 wait()方法,那么線程便會處于該對象的等待池中,等待池中的線程不會去競爭該對象的鎖。notifyAll() 會喚醒所有的線程,notify() 只會喚醒一個線程。notifyAll() 調用后,會將全部線程由等待池移到鎖池,然后參與鎖的競爭,競爭成功則繼續執行,如果不成功則留在鎖池等待鎖被釋放后再次參與競爭。而 notify()只會喚醒一個線程,具體喚醒哪一個線程由虛擬機控制。
3.sleep()方法和 yield()方法有什么區別?
(1) sleep()方法給其他線程運行機會不考慮線程的優先級,因此會給低優先級的線程以運行的機會;yield()方法只會給相同優先級或更高優先級的線程以運行的機會;
(2) 線程執行 sleep()方法后轉入阻塞(blocked)狀態,而執行 yield()方法后轉入就緒(ready)狀態;
(3)sleep()方法聲明拋出 InterruptedException,而 yield()方法沒有聲明任何異常;
(4)sleep()方法比 yield()方法(跟操作系統 CPU 調度相關)具有更好的可移植性,通常不建議使用yield()方法來控制并發線程的執行。
4.sleep() 和 wait() 兩者都可以暫停線程那有什么區別?
1.類的不同:sleep() 是 Thread線程類的靜態方法,wait() 是 Object類的方法
2.是否釋放鎖:sleep() 不釋放鎖;wait() 釋放鎖。
3.用途不同:Wait 通常被用于線程間交互/通信,sleep 通常被用于暫停執行。
4.用法不同:wait() 方法被調用后,線程不會自動蘇醒,需要別的線程調用同一個對象上的 notify() 或者 notifyAll() 方法。sleep() 方法執行完成后,線程會自動蘇醒。或者可以使用wait(long timeout)超時后線程會自動蘇醒。
總結
以上是生活随笔為你收集整理的给我十分钟带你过完java多线程所有基础知识的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java网络编程从0——》入门
- 下一篇: What?Java这么神奇的lambda