Java多線程——線程之間的同步
摘要:本文主要學習多線程之間是如何同步的,如何使用volatile關鍵字,如何使用synchronized修飾的同步代碼塊和同步方法解決線程安全問題。
部分內容來自以下博客:
https://www.cnblogs.com/hapjin/p/5492880.html
https://www.cnblogs.com/paddix/p/5367116.html
https://www.cnblogs.com/paddix/p/5428507.html
https://www.cnblogs.com/liuzunli/p/10181869.html
https://www.cnblogs.com/zhaoyan001/p/6365064.html
多線程之間的並發問題
在使用多線程的時候,如果多個線程之間有共享的數據,並且其中一個線程在操作共享數據的時候,其他線程也能操作共享數據,那么就有可能引發線程的並發問題。
多售票窗口同時售票引發的並發問題
情景說明:
有2個售票窗口同時售賣3張車票,在這個情境中,用2個線程模擬2個售票窗口,3張車票是共享資源,可售賣的編號是1到3,從3號車票開始售賣。
如果在售票時沒有考慮線程的並發問題,2個窗口都能同時修改車票資源,則很容易引發多線程的安全問題。
代碼如下:
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread dt = new DemoThread(); 4 Thread t1 = new Thread(dt, "窗口1"); 5 Thread t2 = new Thread(dt, "窗口2"); 6 t1.start(); 7 t2.start(); 8 } 9 } 10 11 class DemoThread implements Runnable { 12 private int ticket = 3; 13 14 @Override 15 public void run() { 16 while (ticket > 0) { 17 System.out.println(Thread.currentThread().getName() + " 進入賣票環節 "); 18 try { 19 Thread.sleep(1); 20 } catch (InterruptedException e) { 21 e.printStackTrace(); 22 } 23 System.out.println(Thread.currentThread().getName() + " 售賣的車票編號為: " + ticket--); 24 } 25 } 26 }
運行結果如下:
1 窗口1 進入賣票環節 2 窗口2 進入賣票環節 3 窗口1 售賣的車票編號為: 3 4 窗口2 售賣的車票編號為: 2 5 窗口1 進入賣票環節 6 窗口2 進入賣票環節 7 窗口1 售賣的車票編號為: 1 8 窗口2 售賣的車票編號為: 0
結果說明:
從結果中我們看到窗口1在最后一次售賣中,賣出了編號為0的車票,實際上是不存在的。
出現這種問題的原因是當車票還剩1張的時候,2個窗口同時判斷車票數量是否大於1,這時2個窗口就同時進入了售票扣減的代碼,導致本來只能賣出1張的車票被2個窗口各自賣出了1張,從而產生了不存在的車票。
在程序里產生這種問題一般都是因為時間片的切換導致的,當一個線程進入操作共享資源的代碼塊時,時間片用完,另一個線程也通過判斷進入了同一個代碼塊,導致第二個線程在操作共享資源時,沒有重新進行判斷。也就是說線程對共享資源的操作時不完整的,中間有可能被其他線程對資源進行修改。
單例模式的線程安全問題
◆ 懶漢式存在線程安全問題
這種寫法起到了延遲加載的效果,但是只能在單線程下使用。如果在多線程下,一個線程進入了判斷語句塊,還沒來得及往下執行,另一個線程也通過了這個判斷語句,這時便會產生多個實例,所以在多線程環境下不可使用這種方式。
1 public class Singleton { 2 private static Singleton singleton; 3 4 private Singleton() {} 5 6 public static Singleton getInstance() { 7 if (singleton == null) { 8 singleton = new Singleton(); 9 } 10 return singleton; 11 } 12 }
為了解決線程安全問題,我們可以使用synchronized關鍵字來修飾獲取線程的公有方法,但是這么做會導致每次都要進入到同步方法里判斷一下,方法進行同步效率太低。
1 public class Singleton { 2 private static Singleton singleton; 3 4 private Singleton() {} 5 6 public static synchronized Singleton getInstance() { 7 if (singleton == null) { 8 singleton = new Singleton(); 9 } 10 return singleton; 11 } 12 }
為了不需要每次都進行同步,可以使用雙重檢查,只需要在創建的時候進入同步方法,以后只要判斷已經存在實例就直接返回實例,不需要再次進入同步方法。
1 public class Singleton { 2 private static volatile Singleton singleton; 3 4 private Singleton() {} 5 6 public static Singleton getInstance() { 7 if (singleton == null) { 8 synchronized (Singleton.class) { 9 if (singleton == null) { 10 singleton = new Singleton(); 11 } 12 } 13 } 14 return singleton; 15 } 16 }
除了使用同步機制保證線程安全之外,還可以使用靜態內部類來保證線程安全。
這種方式跟餓漢式方式采用的機制類似,但又有不同。兩者都是采用了類裝載的機制來保證初始化實例時只有一個線程。不同的地方在餓漢式方式是只要Singleton類被裝載就會實例化,沒有延遲加載的作用,而靜態內部類方式在Singleton類被裝載時並不會立即實例化,而是在需要實例化時,調用getInstance方法,才會裝載SingletonInstance類,從而完成Singleton的實例化。
類的靜態屬性只會在第一次加載類的時候初始化,所以在這里,JVM幫助我們保證了線程的安全性,在類進行初始化時,別的線程是無法進入的。
1 public class Singleton { 2 private Singleton() {} 3 4 private static class SingletonInstance { 5 private static final Singleton INSTANCE = new Singleton(); 6 } 7 8 public static Singleton getInstance() { 9 return SingletonInstance.INSTANCE; 10 } 11 }
◆ 餓漢式不存在線程安全問題
餓漢式的寫法比較簡單,就是在類裝載的時候就完成實例化,避免了線程同步問題。
但這樣會導致在類加載時就進行了實例化,沒有做到延遲加載,如果這個實例沒有被用到,會造成內存浪費。
1 public class Singleton { 2 private final static Singleton INSTANCE = new Singleton(); 3 4 private Singleton() {} 5 6 public static Singleton getInstance() { 7 return INSTANCE; 8 } 9 }
產生並發問題的原因
多個線程操作共享的數據。
一個線程在操作共享數據時,其他線程也操作了共享數據。
使用volatile關鍵字
可見性
要想理解volatile關鍵字,得先了解下JAVA的內存模型:
每個線程都有一個自己的本地內存空間,線程執行時,先把變量從主內存讀取到線程自己的本地內存空間,然后再對該變量進行操作。
對該變量操作完后,在某個時間再把變量刷新回主內存。
代碼如下:
1 public class Demo { 2 public static void main(String[] args) { 3 try { 4 DemoThread thread = new DemoThread(); 5 thread.start(); 6 Thread.sleep(100); 7 thread.setRunning(false); 8 } catch (InterruptedException e) { 9 e.printStackTrace(); 10 } 11 } 12 } 13 14 class DemoThread extends Thread { 15 private boolean isRunning = true; 16 17 public void setRunning(boolean isRunning) { 18 this.isRunning = isRunning; 19 } 20 21 @Override 22 public void run() { 23 System.out.println("進入方法"); 24 while (isRunning) { 25 } 26 System.out.println("執行完畢"); 27 } 28 }
運行結果如下:
1 進入方法
結果說明:
線程一直在運行,並沒有因為調用了setRunning()方法就停止了運行。
現在有兩個線程,一個是main線程,另一個是RunThread。它們都試圖修改isRunning變量。按照JVM內存模型,main線程將isRunning讀取到本地線程內存空間,修改后,再刷新回主內存。
而在JVM設置成-server模式運行程序時,線程會一直在私有堆棧中讀取isRunning變量。因此,RunThread線程無法讀到main線程改變的isRunning變量,從而出現了死循環,導致RunThread無法終止。
解決辦法就是在isRunning變量上加上volatile關鍵字修飾,它強制線程從主內存中取volatile修飾的變量。
代碼如下:
1 private volatile boolean isRunning = true;
運行結果如下:
1 進入方法
2 執行完畢
有序性
重排序是指編譯器和處理器為了優化程序性能而對指令序列進行排序的一種手段。但是重排序也需要遵守一定規則:
1)重排序操作不會對存在數據依賴關系的操作進行重排序。
比如: a=1;b=a; 這個指令序列,由於第二個操作依賴於第一個操作,所以在編譯時和處理器運行時這兩個操作不會被重排序。
2)重排序是為了優化性能,但是不管怎么重排序,單線程下程序的執行結果不能被改變。
比如: a=1;b=2;c=a+b; 這三個操作,第一步 a=1; 和第二步 b=2; 由於不存在數據依賴關系,所以可能會發生重排序,但是 c=a+b; 這個操作是不會被重排序的,因為需要保證最終的結果一定是c=a+b=3。
重排序在單線程模式下是一定會保證最終結果的正確性,但是在多線程環境下,問題就出來了。
但是運行代碼並不能找到支持指令重排序的結果,所以這個地方以后還需要補充。
代碼如下:
1 public class Demo { 2 private int count = 1; 3 private boolean flag = false; 4 5 public void write() { 6 count = 2; 7 flag = true; 8 } 9 10 public void read() { 11 if (flag) { 12 System.out.print(count); 13 } 14 } 15 16 public static void main(String[] args) { 17 for (int i = 0; i < 100; i++) { 18 Demo demo = new Demo(); 19 Thread write = new Thread(() -> { 20 demo.write(); 21 }); 22 Thread read = new Thread(() -> { 23 demo.read(); 24 }); 25 write.start(); 26 read.start(); 27 } 28 } 29 }
預測結果說明:
控制台打印的數據中應該有1出現,但實際情況卻只以后2,這個並不能看出程序作了重排序。
預測有1出現的原因是,為了提供程序並行度,編譯器和處理器可能會對指令進行重排序,而在write()方法中由於第一步 count = 2; 和第二步 flag = true; 不存在數據依賴關系,有可能會被重排序。。
使用volatile關鍵字修飾共享變量便可以禁止這種重排序。若用volatile修飾共享變量,在編譯時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。
原子性
所謂原子性,就是某系列的操作步驟要么全部執行,要么都不執行。
volatile只能保證對單次讀/寫的原子性,不能保證復合類操作的原子性。
代碼如下:
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread demoThread = new DemoThread(); 4 Thread[] threads = new Thread[10]; 5 for (int i = 0; i < 10; i++) { 6 threads[i] = new Thread(demoThread); 7 threads[i].start(); 8 } 9 try { 10 Thread.sleep(1000); 11 System.out.println(demoThread.count); 12 } catch (InterruptedException e) { 13 e.printStackTrace(); 14 } 15 } 16 } 17 18 class DemoThread extends Thread { 19 public volatile int count = 0; 20 21 @Override 22 public void run() { 23 try { 24 Thread.sleep(1); 25 } catch (InterruptedException e) { 26 e.printStackTrace(); 27 } 28 add(); 29 } 30 31 private void add() { 32 for (int i = 0; i < 100; i++) { 33 count++; 34 } 35 } 36 }
運行結果如下:
1 986
結果說明:
在多線程環境下,有可能一個線程將count讀取到本地內存中,此時其他線程可能已經將count增大了很多,線程依然對過期的count進行自加,重新寫到主存中,最終導致了count的結果不合預期,而是小於1000。
如果想要在復合類的操作中保證原子性,可用使用synchronized關鍵字來實現,還可以通過Java並發包中的循環CAS的方式來保證。
使用synchronized關鍵字
synchronized是Java中解決並發問題的一種最常用的方法,也是最簡單的一種方法。
synchronized的作用有三個:
◆ 確保線程互斥的訪問同步代碼。
◆ 保證共享變量的修改能夠及時可見。
◆ 有效解決重排序問題。
從語法上講,synchronized總共有三種用法:
◆ 修飾普通方法。
◆ 修飾靜態方法。
◆ 修飾代碼塊。
接下來我就通過幾個例子程序來說明一下這三種使用方式。
使用synchronized的同步代碼塊
使用synchronized關鍵字修飾的代碼塊將對共享資源的操作封裝起來,當有一個線程運行代碼塊時,其他線程只能等待,從而避免共享資源被其他線程修改。
要求多個線程同步使用的鎖都必須是同一個才能保證同步,常用的是使用一個Object對象,或者使用this,或者使用類的class對象。
代碼如下:
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread dt = new DemoThread(); 4 Thread t1 = new Thread(dt, "窗口1"); 5 Thread t2 = new Thread(dt, "窗口2"); 6 t1.start(); 7 t2.start(); 8 } 9 } 10 11 class DemoThread implements Runnable { 12 private int ticket = 3; 13 14 @Override 15 public void run() { 16 while (ticket > 0) { 17 try { 18 Thread.sleep(1); 19 } catch (InterruptedException e) { 20 e.printStackTrace(); 21 } 22 synchronized (DemoThread.class) { 23 if (ticket > 0) { 24 System.out.println(Thread.currentThread().getName() + " 進入賣票環節 "); 25 System.out.println(Thread.currentThread().getName() + " 售賣的車票編號為: " + ticket--); 26 } 27 } 28 } 29 } 30 }
運行結果如下:
1 窗口1 進入賣票環節 2 窗口1 售賣的車票編號為: 3 3 窗口2 進入賣票環節 4 窗口2 售賣的車票編號為: 2 5 窗口1 進入賣票環節 6 窗口1 售賣的車票編號為: 1
結果說明:
線程在進入賣票的代碼塊之前,先看一下當前是否由其他線程在執行代碼塊,如果有其他線程在執行代碼塊則會等待,直到其他線程執行完之后才能進入代碼塊,從而保證了線程並發的安全問題。
使用synchronized的普通同步方法
將操作共享資源的代碼封裝為方法,添加synchronized關鍵字修飾,這個方法就是同步方法,使用的鎖是this對象。
代碼如下:
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread dt = new DemoThread(); 4 Thread t1 = new Thread(dt, "窗口1"); 5 Thread t2 = new Thread(dt, "窗口2"); 6 t1.start(); 7 t2.start(); 8 } 9 } 10 11 class DemoThread implements Runnable { 12 private int ticket = 3; 13 14 @Override 15 public void run() { 16 while (ticket > 0) { 17 try { 18 Thread.sleep(1); 19 } catch (InterruptedException e) { 20 e.printStackTrace(); 21 } 22 sale(); 23 } 24 } 25 26 public synchronized void sale() { 27 if (ticket > 0) { 28 System.out.println(Thread.currentThread().getName() + " 進入賣票環節 "); 29 System.out.println(Thread.currentThread().getName() + " 售賣的車票編號為: " + ticket--); 30 } 31 } 32 }
運行結果如下:
1 窗口1 進入賣票環節 2 窗口1 售賣的車票編號為: 3 3 窗口2 進入賣票環節 4 窗口2 售賣的車票編號為: 2 5 窗口2 進入賣票環節 6 窗口2 售賣的車票編號為: 1
結果說明:
在每次調用sale()方法售票的時候,程序會將實例對象this作為鎖,保證一個時間只能有一個線程在操作共享資源。
使用synchronized的靜態同步方法
如果該方法是靜態方法,因為靜態方法優先於類的實例化,所以靜態方法是不能持有this的,靜態同步方法的瑣是類的class對象。
代碼如下:
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread dt = new DemoThread(); 4 Thread t1 = new Thread(dt, "窗口1"); 5 Thread t2 = new Thread(dt, "窗口2"); 6 t1.start(); 7 t2.start(); 8 } 9 } 10 11 class DemoThread implements Runnable { 12 private static int ticket = 3; 13 14 @Override 15 public void run() { 16 while (ticket > 0) { 17 try { 18 Thread.sleep(1); 19 } catch (InterruptedException e) { 20 e.printStackTrace(); 21 } 22 sale(); 23 } 24 } 25 26 public static synchronized void sale() { 27 if (ticket > 0) { 28 System.out.println(Thread.currentThread().getName() + " 進入賣票環節 "); 29 System.out.println(Thread.currentThread().getName() + " 售賣的車票編號為: " + ticket--); 30 } 31 } 32 }
運行結果如下:
1 窗口2 進入賣票環節 2 窗口2 售賣的車票編號為: 3 3 窗口1 進入賣票環節 4 窗口1 售賣的車票編號為: 2 5 窗口2 進入賣票環節 6 窗口2 售賣的車票編號為: 1
結果說明:
使用靜態同步方法除了需要注意共享資源也要用static修飾外,其他的和普通同步方法是一樣的。
synchronized關鍵字和volatile關鍵字的區別
含義
volatile主要用在多個線程感知實例變量被更改了場合,從而使得各個線程獲得最新的值。它強制線程每次從主內存中講到變量,而不是從線程的私有內存中讀取變量,從而保證了數據的可見性。
synchronized主要通過對象鎖控制線程對共享數據的訪問,持有相同對象鎖的線程只能等其他持有同一個對象鎖的線程執行完畢之后,才能持有這個對象鎖訪問和處理共享數據。
比較
◆ 量級比較
volatile輕量級,只能修飾變量。
synchronized重量級,還可修飾方法。
◆ 可見性和原子性
volatile只能保證數據的可見性,不能用來同步,因為多個線程並發訪問volatile修飾的變量不會阻塞。
synchronized不僅保證可見性,而且還保證原子性,因為,只有獲得了鎖的線程才能進入臨界區,從而保證臨界區中的所有語句都全部執行。多個線程爭搶synchronized鎖對象時,會出現阻塞。
同步使用總結
要使用synchronized,必須要有兩個以上的線程。單線程使用沒有意義,還會使效率降低。
要使用synchronized,線程之間需要發生同步,不需要同步的沒必要使用synchronized,例如只讀數據。
使用synchronized的缺點是效率非常低,因為加鎖、釋放鎖和釋放鎖后爭搶CPU執行權的操作都很耗費資源。
