Java多執行緒程式設計核心技術(二)物件及變數的併發訪問
最近一直在忙比賽,四五個吧,時間有點緊張,部落格也沒時間更新~ 只能忙裡抽閒
本文屬於Java多執行緒程式設計系列的第二篇,旨在分享我對多執行緒程式設計技術的心得與感悟,順便做下筆記。
如果你閱讀完比較感興趣,歡迎關注我,等待更新後續篇章。
本文主要介紹Java多執行緒中的同步,也就是如何在Java語言中寫出執行緒安全的程式,如何在Java語言中解決非執行緒安全的相關問題。
1.synchronized同步方法
“非執行緒安全”其實會在多個執行緒對同一個物件中的例項變數進行併發訪問時發生,產生的後果就是”髒讀“,也就是讀取到的資料其實是被更改過的。而“執行緒安全”就是已獲得的例項變數的值是經過執行緒同步處理的,不會出現髒讀的現象。
1.1 方法內的變數為執行緒安全
“非執行緒安全”問題存在於“例項變數”中,如果是方法內部的私有變數,則不存在“非執行緒安全”問題,所得結果也就是“執行緒安全”的了。
1.2 例項變數的非執行緒安全
如果多個執行緒共同訪問1個物件中的例項變數,則有可能出現“非執行緒安全”問題。
用執行緒訪問的物件中如果有多個例項物件,則執行的結果有可能出現交叉的情況。
如果物件僅有一個例項變數,則有可能出現覆蓋的情況。
如果兩個執行緒同時訪問一個沒有同步的方法,如果兩個執行緒同時操作業務物件中的例項變數,則有可能出現“非執行緒安全”問題。解決這個問題的方法就是在方法前加關鍵字synchronized即可。
1.3 多個物件多個鎖
程式碼示例:
public class Run { public static void main(String[] args) { MyService service1 = new MyService(); Thread thread1 = new Thread(service1); thread1.start(); MyService service2 = new MyService(); Thread thread2 = new Thread(service2); thread2.start(); } } public class MyService implements Runnable { private int i = 0; @Override synchronized public void run() { System.out.println(++i); } }
上面示例是兩個執行緒分別訪問同一個類的兩個不同例項的相同的同步方法,效果卻是以非同步的方式執行的。本示例由於建立了2個業務物件,在系統中產生出2個鎖,所以執行結果是非同步的,列印的效果就是1 1。當我們把執行緒2的引數service2改成service1,列印結果變為1 2。為什麼是這樣的結果?
關鍵字 synchronized 取得的執行緒物件都是物件鎖,而不是把一段程式碼或方法(函式)當做鎖,所以在上面的示例中,哪個執行緒先執行帶 synchronized 關鍵字的方法,哪個執行緒就持有該方法所屬物件的鎖Lock,那麼其他執行緒只能呈等待狀態,前提是多個執行緒訪問的是同一個物件。
但如果多個執行緒訪問多個物件,則JVM會建立多個鎖。
1.4 synchronized方法與鎖物件
為了證明前面講的執行緒鎖是物件,示例程式碼如下:
public class MyService implements Runnable { @Override public void run() { System.out.println("begin: "+Thread.currentThread().getName()); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("end"); } } public class Run { public static void main(String[] args) { MyService service = new MyService(); Thread thread1 = new Thread(service,"A"); thread1.start(); Thread thread2 = new Thread(service,"B"); thread2.start(); } }
執行結果:
begin: B begin: A end end
在run方法前加入關鍵字synchronized進行同步處理。再次執行結果如下:
begin: A end begin: B end
通過上面的實驗得出結論,呼叫關鍵字synchronized宣告的方法一定是排隊執行的。另外需要牢牢記住“共享”這兩個字,只有共享資源讀寫訪問才需要同步化,如果不是共享資源,那麼基本就沒有同步的必要。
1.5 髒讀
public class MyService{ private String username = "AA"; private String password = "aa"; public void getValue() { System.out.println(Thread.currentThread().getName()+" : "+username+" "+password); } synchronized public void setValue(String username,String password){ this.username = username; try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } this.password = password; } public static void main(String[] args) throws InterruptedException { MyService service = new MyService(); Thread thread1 = new Thread(() -> service.setValue("BB","bb"),"Thread-A"); thread1.start(); Thread.sleep(200); Thread thread2 = new Thread(service::getValue,"Thread-B"); thread2.start(); } }
列印結果:
Thread-B : BB aa
出現髒讀是因為getValue方法不是同步的,所以可以在任意時候進行呼叫。解決方法就是加上同步synchronized關鍵字,程式碼如下:
synchronized public void getValue() { System.out.println(Thread.currentThread().getName()+" : "+username+" "+password); }
執行結果:
Thread-B : BB bb
通過上述示例不僅要知道髒讀是通過synchronized關鍵字解決的,還要知道如下內容:
當A執行緒呼叫例項物件的加入synchronized關鍵字的 X 方法時,A執行緒就獲得了 X 方法鎖,更準確地講,是獲得了物件的鎖,所以其他執行緒必須等A執行緒執行完畢了才可以呼叫 X 方法,但B執行緒可以隨意呼叫其他的非 synchronized 同步方法。
髒讀一定會出現操作例項變數的情況下,這就是不同執行緒“爭搶”例項變數的結果。
1.6 synchronized鎖重入
關鍵字synchronized擁有鎖重入的功能,也就是在使用synchronized時,當一個執行緒得到一個物件後,再次請求此物件鎖時是可以再次得到該物件的鎖的。這也證明了在一個synchronized方法/塊的內部呼叫本類的其他synchronized方法/塊,是永遠可以得到鎖的。
示例程式碼:
public class MyService{ synchronized public void service1(){ System.out.println("service1"); service2(); } synchronized public void service2(){ System.out.println("service2"); } }
“可重入鎖”的概念是:自己可以再次獲取自己的內部鎖。可重入鎖也支援在父子類繼承的環境中。
示例程式碼:
public class MyServiceChild extends MyService{ synchronized public void service(){ System.out.println("service1"); this.service2(); } }
說明子類是完全可以通過“可重入鎖”呼叫父類的同步方法的。
1.7 出現異常,鎖自動釋放
當一個執行緒執行的程式碼出現異常時,其所持有的鎖會自動釋放。
1.8 同步不具有繼承性
同步不可以繼承。子類繼承父類的同步方法時還需要新增synchronized關鍵字才能保持同步。
2.synchronized同步語句塊
用關鍵字synchronized宣告方法在某些情況下是有弊端的,比如A執行緒呼叫同步方法執行一個長時間的任務,那麼B執行緒則必須等待比較長的時間。在這樣的情況下可以使用synchronized同步語句塊來解決。synchronized 方法是對當前物件進行加鎖,而 synchronized程式碼塊是對某一個物件進行加鎖。
2.1 synchronized同步程式碼塊的使用
當兩個併發執行緒訪問同一個物件object中的synchronized(this)同步程式碼塊時,一段時間內只能有一個執行緒被執行,另一個執行緒必須等待當前執行緒執行完這個程式碼塊以後才能執行該程式碼塊。
示例程式碼:
public class Test { public void service(){ synchronized (this) { System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis()); } } public static void main(String[] args) { Test test = new Test(); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-A").start(); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-B").start(); } }
執行結果:
Thread-A begin: 1537000799741 Thread-A end: 1537000802742 Thread-B begin: 1537000802742 Thread-B end: 1537000805742
上述示例證明了同步synchronized程式碼塊真的是同步的。
2.2 一半同步,一半非同步
我們把前面的示例程式碼的service方法改造一下:
public void service(){ System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); synchronized (this) { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis()); } }
再次執行:
Thread-A begin: 1537001008952 Thread-B begin: 1537001008952 Thread-A end: 1537001011953 Thread-B end: 1537001014954
本實驗說明:不在synchronized程式碼塊中就是非同步執行,在synchronized塊中就是同步執行。
2.3 synchronized程式碼塊間的同步性
在使用synchronized(this)程式碼塊需要注意的是,當一個執行緒訪問object的一個synchronized(this)同步程式碼塊時,其它執行緒對同一個object中所有其他synchronized(this)同步訪問被阻塞,這說明synchronized使用的“物件監視器”是一個。
和synchronized關鍵字修飾的方法一樣,synchronize(this)程式碼塊也是鎖定的當前物件。
2.4 將任意物件作為物件監視器
多個執行緒呼叫同一個物件中得不同名稱的synchronized同步方法或synchronized(this)同步程式碼塊時,呼叫的效果就是按順序執行,也就是同步的,阻塞的。
這說明synchronized同步方法或synchronized同步程式碼塊分別有兩種作用。
(1)對其他synchronized同步方法或synchronized(this)同步程式碼塊呼叫呈阻塞狀態。
(2)同一時間只有一個執行緒可以執行synchronized同步方法或synchronized(this)同步程式碼塊中的程式碼。
在前面我們使用synchronized(this)格式來同步程式碼塊,其實Java還支援對“任意物件”作為“物件監視器”來實現同步的功能。這個”任意物件“大多數是例項變數及方法的引數,使用格式為synchronized(非this物件)。
根據前面對synchronized(this)同步程式碼塊的作用總結可知,synchronized(非this物件)格式的作用只有1種:synchronized(非this物件 X )同步程式碼塊。
(1)在多個執行緒持有”物件監視器“為同一個物件的前提下,同一時間只有一個執行緒可以執行synchronized(非this物件 X)同步程式碼塊。
(2)當持有”物件監視器“為同一個物件的前提下,同一時間只有一個執行緒可以執行synchronized(非this物件X)同步程式碼塊中的程式碼。
下面演示下任意物件作為物件監視器的示例:
public class Test { private String anyObject = new String(); public void service(){ synchronized (anyObject) { System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis()); } } public static void main(String[] args) { Test test = new Test(); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-A").start(); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-B").start(); } }
執行結果:
Thread-A begin: 1537008016172 Thread-A end: 1537008019173 Thread-B begin: 1537008019173 Thread-B end: 1537008022173
鎖非this物件具有一定的優點:如果在一個類中有很多個synchronized方法,這時雖然能實現同步,但會受到阻塞,所以影響執行效率;但如果使用同步程式碼塊鎖非this物件,則synchronized(非this)程式碼塊中的程式與同步方法是非同步的,不與其他鎖this同步方法爭搶this鎖,則可大大提高執行效率。
再來看下面的示例程式碼:
public class Test { private String anyObject = new String(); public void service(){ synchronized (anyObject) { System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis()); } } synchronized public void service2(){ System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); } public static void main(String[] args) { Test test = new Test(); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-A").start(); new Thread(new Runnable() { @Override public void run() { test.service2(); } },"Thread-B").start(); } }
執行結果:
Thread-A begin: 1537009027680 Thread-B begin: 1537009027681 Thread-A end: 1537009030680
可見,使用“synchronized(非this物件x)同步程式碼塊”格式進行同步操作時,物件監視器必須是同一個物件,如果不是同一個物件。如果不是同一個物件監視器,執行的結果就是非同步呼叫了,就會交叉執行。
2.5 細化三個結論
”synchronized(非this物件X)“格式的寫法是將x物件本身作為“物件監視器”,這樣就可以得出以下3個結論:
- 當多個執行緒同時執行synchronized(X){}同步程式碼塊時呈同步效果。
- 當其他執行緒執行X物件中synchronized同步方法時呈同步效果。
- 當其他執行緒執行X物件方法裡面的synchronized(this)程式碼塊時也呈現同步效果。
- 但需要注意的是,如果其他執行緒呼叫不加synchronized關鍵字的方法時,還是非同步呼叫。
2.6 靜態同步synchronized方法與synchronized(class)程式碼塊
關鍵字synchronized還可以在static靜態方法上,如果這樣寫,那是對當前的*.java檔案對應的Class類進行持鎖。
下面測試靜態同步方法:
public class Test2 { synchronized public static void service() { System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { Test2.service(); } }, "Thread-A").start(); new Thread(new Runnable() { @Override public void run() { Test2.service(); } }, "Thread-B").start(); } }
執行結果:
Thread-A begin: 1537011409603 Thread-A end: 1537011412608 Thread-B begin: 1537011412608 Thread-B end: 1537011415608
synchronized關鍵字加到static靜態方法上是給Class類上鎖,而synchronized關鍵字加到非static靜態方法上是給物件上鎖。
為了驗證物件鎖和Class鎖不是同一個鎖,來看下面的程式碼:
public class Test2 { synchronized public static void service() { System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } synchronized public void service2(){ System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { Test2.service(); } }, "Thread-A").start(); new Thread(new Runnable() { @Override public void run() { new Test2().service2(); } }, "Thread-B").start(); } }
執行結果:
Thread-A begin: 1537012019151 Thread-B begin: 1537012019152 Thread-A end: 1537012022152 Thread-B end: 1537012022152
非同步的原因是持有不同的鎖,一個是物件鎖,另外一個是Class鎖,Class鎖可以對所有類的例項物件起作用。
下面我們測試synchronized(class)程式碼塊,示例程式碼如下:
public class Test { public void service(){ synchronized (Test.class) { System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis()); } } public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { new Test().service(); } },"Thread-A").start(); new Thread(new Runnable() { @Override public void run() { new Test().service(); } },"Thread-B").start(); } }
執行結果:
Thread-A begin: 1537011197190 Thread-A end: 1537011200191 Thread-B begin: 1537011200191 Thread-B end: 1537011203191
同步synchronized(class)程式碼塊的作用其實和synchronized static方法的作用一樣。
2.7 資料型別String的常量池特性
在JVM中具有String常量池快取的功能,將synchronized(String)同步塊與String聯合使用時,要注意常量池以帶來的一些例外。
public class Test { public void service(String str){ synchronized (str) { while (true) { System.out.println(Thread.currentThread().getName() + " time: " + System.currentTimeMillis()); } } } public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { new Test().service("AA"); } },"Thread-A").start(); new Thread(new Runnable() { @Override public void run() { new Test().service("AA"); } },"Thread-B").start(); } }
執行結果:
Thread-A time: 1537013470535 Thread-A time: 1537013470535 Thread-A time: 1537013470535 ...
執行結果顯示A執行緒陷入了死迴圈,而B執行緒一直在等待未執行。出現這樣的結果就是兩個持有相同的鎖,所以造成B執行緒不能執行。這就是String常量池帶來的問題。因此在大多數情況下,同步synchronized程式碼塊都不使用String作為鎖物件,而改用其他,比如new Object()例項化一個Object物件,但它並不放入快取中。
改造後的程式碼:
public class Test { public void service(Object str){ synchronized (str) { while (true) { System.out.println(Thread.currentThread().getName() + " time: " + System.currentTimeMillis()); } } } public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { new Test().service(new Object()); } },"Thread-A").start(); new Thread(new Runnable() { @Override public void run() { new Test().service(new Object()); } },"Thread-B").start(); } }
執行結果:
Thread-A time: 1537015931981 Thread-A time: 1537015931982 Thread-B time: 1537015931982 Thread-B time: 1537015931982 ...
交替列印的原因是持有的鎖不是一個。
2.8 同步synchronized方法無限等待與解決
同步方法極易造成死迴圈。示例程式碼:
public class Test { synchronized public void serviceA() { System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); boolean is = true; while (is){ } System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } synchronized public void serviceB() { System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } public static void main(String[] args) { Test test = new Test(); new Thread(new Runnable() { @Override public void run() { test.serviceA(); } }, "Thread-A").start(); new Thread(new Runnable() { @Override public void run() { test.serviceB(); } }, "Thread-B").start(); } }
執行緒B永遠得不到執行的機會,鎖死了。
解決的方法就是使用同步塊。更改後的程式碼如下:
public class Test { private Object objectA = new Object(); public void serviceA() { synchronized (objectA) { System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); boolean is = true; while (is) { } System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } } private Object objectB = new Object(); synchronized public void serviceB() { synchronized (objectB) { System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis()); System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis()); } } .... }
2.9 多執行緒的死鎖
Java多執行緒死鎖是一個經典問題,因為不同的執行緒都在等待根本不可能被釋放的鎖,從而導致所有的任務都無法完成。在多執行緒技術中,“死鎖”是必須避免的,因為這會造成執行緒的“假死”。
public class DealThread implements Runnable { public String username; public Object locak1 = new Object(); public Object locak2 = new Object(); public void setFlag(String username){ this.username = username; } @Override public void run() { if (username.equals("a")){ synchronized (locak1){ System.out.println("username:"+username); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (locak2){ System.out.println("按lock1-》lock2執行"); } } } if (username.equals("b")){ synchronized (locak2){ System.out.println("username:"+username); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (locak1){ System.out.println("按lock2-》lock1執行"); } } } } public static void main(String[] args) throws InterruptedException { DealThread dealThread = new DealThread(); dealThread.setFlag("a"); Thread threadA = new Thread(dealThread); threadA.start(); Thread.sleep(100); dealThread.setFlag("b"); Thread threadB = new Thread(dealThread); threadB.start(); } }
執行結果,出現死鎖:
username:a username:b
死鎖是程式設計的Bug,在設計程式時就需要避免雙方互相持有對方的鎖的情況。需要說明的是,本實驗使用synchronized巢狀的程式碼結構來實現死鎖,其實不使用巢狀的程式碼結構也會出現死鎖,與巢狀不巢狀無任何關係,不要被程式碼結構所誤導。只要互相等待對方釋放鎖就有可能出現死鎖。
可以使用JDK自帶的工具來檢測是否有死鎖的現象。首先進入CMD命令列介面,再進入JDK的安裝資料夾中的
bin目錄,執行jps命令。得到執行的執行緒Run的id值。再執行jstack命令,檢視結果。
完整命令演示如下:
D:\Java\jdk1.8.0\bin>jps 8240 Launcher 13252 Jps 12312 7948 DealThread D:\Java\jdk1.8.0\bin>jstack -l 7948 .... Java stack information for the threads listed above: =================================================== "Thread-1": at cn.zyzpp.thread2_3.DealThread.run(DealThread.java:39) - waiting to lock <0x00000000d6089e80> (a java.lang.Object) - locked <0x00000000d6089e90> (a java.lang.Object) at java.lang.Thread.run(Thread.java:745) "Thread-0": at cn.zyzpp.thread2_3.DealThread.run(DealThread.java:25) - waiting to lock <0x00000000d6089e90> (a java.lang.Object) - locked <0x00000000d6089e80> (a java.lang.Object) at java.lang.Thread.run(Thread.java:745) Found 1 deadlock.
2.10 鎖物件的改變
在任何資料型別作為同步鎖時,需要注意的是,是否有多個執行緒同時持有鎖物件,如果同時持有鎖物件,則這些執行緒之間就是同步的;如果分別獲得鎖物件,這些執行緒之間就是非同步的。
public class Test { private String lock = "123"; public void service(){ synchronized (lock) { System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis()); lock = "456"; try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis()); } } public static void main(String[] args) throws InterruptedException { Test test = new Test(); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-A").start(); Thread.sleep(50); new Thread(new Runnable() { @Override public void run() { test.service(); } },"Thread-B").start(); } }
執行結果:
Thread-A begin: 1537019992452 Thread-B begin: 1537019992652 Thread-A end: 1537019994453 Thread-B end: 1537019994653
為什麼是亂序?因為50ms過後執行緒取得的鎖時“456”。
把lock = "456"放在Thread.sleep(2000)之後,再次執行。
Thread-A begin: 1537020101553 Thread-A end: 1537020103554 Thread-B begin: 1537020103554 Thread-B end: 1537020105558
執行緒A和執行緒B持有的鎖都是“123”,雖然將鎖改成了“456”,但結果還是同步的,因為A和B爭搶的鎖是“123”。
還需要提示一下,只要物件不變,即使物件的屬性被改變,執行的結果還是同步的。
3.volatile關鍵字
關鍵字volatile的主要作用是使變數在多個執行緒間可見。
3.1 關鍵字volatile與死迴圈
如果不是在多繼承的情況下,使用繼承Thread類和實現Runnable介面在取得程式執行的結果上並沒有多大的區別。如果一旦出現”多繼承“的情況,則用實現Runable介面的方式來處理多執行緒的問題就是很有必要的。
public class PrintString implements Runnable{ private boolean isContinuePrint = true; @Override public void run() { while (isContinuePrint){ System.out.println("Thread: "+Thread.currentThread().getName()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public boolean isContinuePrint() { return isContinuePrint; } public void setContinuePrint(boolean continuePrint) { isContinuePrint = continuePrint; } public static void main(String[] args) throws InterruptedException { PrintString printString = new PrintString(); Thread thread = new Thread(printString,"Thread-A"); thread.start(); Thread.sleep(100); System.out.println("我要停止它!" + Thread.currentThread().getName()); printString.setContinuePrint(false); } }
執行結果:
Thread: Thread-A 我要停止它!main
上面的程式碼執行起來沒毛病,但是一旦執行在 -server伺服器模式中64bit的JVM上時,會出現死迴圈。解決的辦法是使用volatile關鍵字。
關鍵字volatile的作用是強制從公共堆疊中取得變數的值,而不是從執行緒私有資料棧中取得變數的值。
3.2 解決非同步死迴圈
在研究volatile關鍵字之前先來做一個測試用例,程式碼如下:
public class PrintString implements Runnable{ private boolean isRunnning = true; @Override public void run() { System.out.println("Thread begin: "+Thread.currentThread().getName()); while (isRunnning == true){ } System.out.println("Thread end: "+Thread.currentThread().getName()); } public boolean isRunnning() { return isRunnning; } public void setRunnning(boolean runnning) { isRunnning = runnning; } public static void main(String[] args) throws InterruptedException { PrintString printString = new PrintString(); Thread thread = new Thread(printString,"Thread-A"); thread.start(); Thread.sleep(1000); printString.setRunnning(false); System.out.println("我要停止它!" + Thread.currentThread().getName()); } }
JVM有Client和Server兩種模式,我們可以通過執行:java -version來檢視jvm預設工作在什麼模式。我們在IDE中把JVM設定為在Server伺服器的環境中,具體操作只需配置執行引數為 -server
。然後啟動程式,列印結果:
Thread begin: Thread-A 我要停止它!main
程式碼 System.out.println("Thread end: "+Thread.currentThread().getName());
從未被執行。
是什麼樣的原因造成將JVM設定為-server就出現死迴圈呢?
在啟動thread執行緒時,變數 boolean isContinuePrint = true;
存在於公共堆疊及執行緒的私有堆疊中。在JVM設定為-server模式時為了執行緒執行的效率,執行緒一直在私有堆疊中取得isRunning的值是true。而程式碼thread.setRunning(false);雖然被執行,更新的卻是公共堆疊中的isRunning變數值false,所以一直就是死迴圈的狀態。記憶體結構圖:
這個問題其實就是私有堆疊中的值和公共堆疊中的值不同步造成的。解決這樣的問題就要使用volatile關鍵字了,它主要的作用就是當執行緒訪問isRunning這個變數時,強制性從公共堆疊中進行取值。
將程式碼更改如下:
volatile private boolean isRunnning = true;
再次執行:
Thread begin: Thread-A 我要停止它!main Thread end: Thread-A
通過使用volatile關鍵字,強制的從公共記憶體中讀取變數的值,記憶體結構如圖所示:
使用volatile關鍵字增加了例項變數在多個執行緒之間的可見性。但volatile關鍵字最致命的缺點是不支援原子性。
下面將關鍵字synchronized和volatile進行一下比較:
- 關鍵字volatile是執行緒同步的輕量級實現,所以volatile效能肯定比synchronized要好,並且volatile只能修飾於變數,而synchronized可以修飾方法,以及程式碼塊。隨著JDK新版本的釋出,synchronized關鍵字在執行效率上得到很大提升,在開發中使用synchronized關鍵字的比率還是比較大的。
- 多執行緒訪問volatile不會發生阻塞,而synchronized會出現阻塞。
- volatile能保證資料的可見性,但不能保證原子性;而synchronized可以保證原子性,也可以間接保證可見性,因為它會將私有記憶體和公共記憶體中的資料做同步。
- 再次重申一下,關鍵字volatile解決的是變數在多個執行緒之間的可見性;而synchronized關鍵字解決的是多個執行緒之間訪問資源的同步性。
執行緒安全包含原子性和可見性兩個方面,Java的同步機制都是圍繞這兩個方面來確保執行緒安全的。
3.3 volatile非原子性的特徵
關鍵字雖然增加了例項變數在多個執行緒之間的可見性,但它卻不具備同步性,那麼也就不具備原子性。
示例程式碼:
public class MyThread extends Thread { volatile private static int count; @Override public void run() { addCount(); } private void addCount() { for (int i = 0;i<100;i++){ count++; } System.out.println(count); } public static void main(String[] args) { MyThread[] myThreads = new MyThread[100]; for (int i=0;i<100;i++){ myThreads[i] = new MyThread(); } for (int i=0;i<100;i++){ myThreads[i].start(); } } }
執行結果:
... 8253 8353 8153 8053 7875 7675
在addCount方法上加入synchronized同步關鍵字與static關鍵字,達到同步的效果。
再次執行結果:
.... 9600 9700 9800 9900 10000
關鍵字volatile提示執行緒每次從共享記憶體中讀取變數,而不是從私有記憶體中讀取,這樣就保證了同步資料的可見性。但在這裡需要注意的是:如果修改例項變數中的資料,比如i++,也就是比
i=i+1,則這樣的操作其實並不是一個原子操作,也就是非執行緒安全。表示式i++的操作步驟分解為下面三步:
- 從記憶體中取i的值;
- 計算i的值;
- 將i值寫入到記憶體中。
假如在第二步計算i值的時候,另外一個執行緒也修改i的值,那麼這個時候就會髒資料。解決的方法其實就是使用synchronized關鍵字。所以說volatile關鍵字本身並不處理資料的原子性,而是強制對資料的讀寫及時影響到主記憶體中。
3.4 使用原子類進行i++操作
除了在i++操作時使用synchronized關鍵字實現同步外,還可以使用AtomicInteger原子類進行實現。
原子操作是不可分割的整體,沒有其他執行緒能夠中斷或檢查正在原子操作中的變數。它可以在沒有鎖的情況下做到執行緒安全。
示例程式碼:
public class MyThread extends Thread { private static AtomicInteger count = new AtomicInteger(0); @Override public void run() { addCount(); } private static void addCount() { for (int i = 0;i<100;i++){ System.out.println(count.incrementAndGet()); } } public static void main(String[] args) { MyThread[] myThreads = new MyThread[100]; for (int i=0;i<100;i++){ myThreads[i] = new MyThread(); } for (int i=0;i<100;i++){ myThreads[i].start(); } } }
列印結果:
.... 9996 9997 9998 9999 10000
成功達到累加的效果。
3.5 原子類也不安全
原子類在具有有邏輯性的情況下輸出結果也具有隨機性。
示例程式碼:
public class MyThread extends Thread { private static AtomicInteger count = new AtomicInteger(0); @Override public void run() { addCount(); } private static void addCount() { for (int i = 0;i<100;i++){ count.incrementAndGet(); } System.out.println(count); } public static void main(String[] args) { MyThread[] myThreads = new MyThread[100]; for (int i=0;i<100;i++){ myThreads[i] = new MyThread(); } for (int i=0;i<100;i++){ myThreads[i].start(); } } }
列印結果:
.... 7900 7200 7300 7100
可以看到,列印順序出錯了,出現這樣的原因是因為AtomicInteger的方法時原子的,但方法與方法之間的呼叫卻不是原子的。解決這樣的問題必須要用同步。
3.6 synchronized程式碼塊有volatile同步的功能
關鍵字synchronized可以使多個執行緒訪問同一個資源具有同步性,而且它還具有將執行緒工作記憶體中的私有變數與公共記憶體中的變數同步的功能。
我們把前面講到的非同步死迴圈程式碼改造一下:
public class PrintString implements Runnable{ private boolean isRunnning = true; @Override public void run() { String lock = new String(); System.out.println("Thread begin: "+Thread.currentThread().getName()); while (isRunnning == true){ synchronized (lock){ //加與不加的效果就是是否死迴圈 } } System.out.println("Thread end: "+Thread.currentThread().getName()); } public boolean isRunnning() { return isRunnning; } public void setRunnning(boolean runnning) { isRunnning = runnning; } public static void main(String[] args) throws InterruptedException { PrintString printString = new PrintString(); Thread thread = new Thread(printString,"Thread-A"); thread.start(); Thread.sleep(1000); printString.setRunnning(false); System.out.println("我要停止它!" + Thread.currentThread().getName()); } }
列印結果:
Thread begin: Thread-A 我要停止它!main Thread end: Thread-A
關鍵字synchronized可以保證在同一時刻,只有一個執行緒可以執行某一個方法或某一個程式碼塊。它包含兩個特徵:互斥相和可見性。同步synchronized不僅可以解決一個執行緒看到物件處於不一致的狀態,還可以保證進入同步方法或者同步程式碼塊的每個執行緒,都看到由同一個鎖保護之前所有的修改效果。
學習多執行緒併發。要著重“外修互斥,內修可見”,這是掌握多執行緒、學習多執行緒併發的重要技術點。
4.文末總結
本文應該著重掌握如下技術點:
- synchronized物件監視器為Object時的使用。
- synchronized物件監視器為Class時的使用。
- 非執行緒安全是如何出現的。
- 關鍵字volatile的主要作用。
- 關鍵字volatile與synchronized的區別及使用情況。
5.參考
《Java多執行緒程式設計核心技術》高洪巖著
文中若有筆誤歡迎評論區指正!