Java多執行緒之物件及變數的併發訪問
Java物件及變數的併發訪問
當多個執行緒同時對同一個物件中的例項變數進行併發訪問時可能會產生執行緒安全問題。產生的後果就是”髒讀”,即收到的資料其實是被更改過的。
如果訪問的是方法中的變數,則不存在”非執行緒安全”問題
可以通過以下幾種方式來解決,在對物件及變數併發訪問過程中的安全問題
1. synchronize同步方法
2. 同步語句塊
3. volatile關鍵字
synchronize同步方法
如果兩個執行緒同時訪問同一個物件的方法,不加控制,會出現意外的結果。通過用synchronize修飾方法,可以取得物件鎖,那個執行緒先訪問就先持有物件鎖,其餘的執行緒只能等待。
首先是沒有用synchronize修飾的情況
public class HasSelfPrivateNum { private int num = 0; public void addI(String username){ try{ if (username.equals("a")){ num = 100; System.out.println("a set over!"); Thread.sleep(2000); }else { num = 200; System.out.println("b set over!"); } System.out.println(username + " num=" + num); }catch (Exception e){ e.printStackTrace(); } } } public class SelfPrivateThreadA extends Thread{ private HasSelfPrivateNum num; public SelfPrivateThreadA(HasSelfPrivateNum num){ this.num = num; } @Override public void run() { super.run(); num.addI("a"); } } public class SelfPrivateThreadB extends Thread{ private HasSelfPrivateNum num; public SelfPrivateThreadB(HasSelfPrivateNum num){ this.num = num; } @Override public void run() { super.run(); num.addI("b"); } }
測試類
public class HasSelfPrivateNumTest extends TestCase { public void testAddI() throws Exception { HasSelfPrivateNum numA = new HasSelfPrivateNum(); //HasSelfPrivateNum numB = new HasSelfPrivateNum(); SelfPrivateThreadA threadA = new SelfPrivateThreadA(numA); threadA.start(); SelfPrivateThreadB threadB = new SelfPrivateThreadB(numA); threadB.start(); Thread.sleep(1000 * 3); } }
預期結果應該是a num=100 b num=200
但是實際結果如下:
a set over!
b set over!
b num=200
a num=200
用synchronize修飾方法addI()方法之後結果如下:
a set over!
a num=100
b set over!
b num=200
多個物件多個鎖
取消測試類中註釋的程式碼,因為2個執行緒訪問的是2個不同的物件,2個執行緒仍然是非同步執行。
synchronize修飾方法新增的是物件鎖
當2個執行緒同時訪問一個類中,2個不同的用synchronize修飾的方法時,有一個方法被訪問,另一個仍舊不能訪問。因為synchronize修飾方法新增的是物件鎖
如果資料的設定和獲取方法不是同步的,可以在任意時刻進行呼叫,可能會出現”髒讀”情況,可以通過在設定和獲取方法之前用synchronize修飾解決
synchronize擁有鎖重入的功能
鎖重入:即當一個執行緒獲得一個物件鎖之後,再次請求該物件可以再次得到該物件的鎖。即synchronize方法/塊的內部呼叫本類的其他synchronize方法/塊時,可以永遠得到鎖的。
子類繼承父類的時候,子類可以通過”可重入鎖”呼叫父類的同步方法
出現異常,鎖會自用釋放
同步不具有繼承性
同步語句塊
對於上面的同步方法而言,其實是有些弊端的,如果同步方法是需要執行一個很長時間的任務,那麼多執行緒在排隊處理同步方法時就會等待很久,但是一個方法中,其實並不是所有的程式碼都需要同步處理的,只有可能會發生執行緒不安全的程式碼才需要同步。這時,可以採用synchronized來修飾語句塊讓關鍵的程式碼進行同步。用synchronized修飾同步塊,其格式如下:
synchronized(物件){
//語句塊
}
這裡的物件,可以是當前類的物件this,也可以是任意的一個Object物件,或者間接繼承自Object的物件,只要保證synchronized修飾的物件被多執行緒訪問的是同一個,而不是每次呼叫方法的時候都是新生成就就可以。但是特別注意String物件,因為JVM有String常量池的原因,所以相同內容的字串實際上就是同一個物件,在用同步語句塊的時候儘可能不用String。
下面,看一個例子來說明同步語句塊的用法和與同步方法的區別:
public class LongTimeTask {
private String getData1;
private String getData2;
public void doLongTimeTask(){
try{
System.out.println("begin task");
Thread.sleep(3000);
String privateGetData1 = "長時間處理任務後從遠端返回的值 1 threadName=" + Thread.currentThread().getName();
String privateGetData2 = "長時間處理任務後從遠端返回的值 2 threadName=" + Thread.currentThread().getName();
synchronized (this){
getData1 = privateGetData1;
getData2 = privateGetData2;
}
System.out.println(getData1);
System.out.println(getData2);
System.out.println("end task");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class LongTimeServiceThreadA extends Thread{
private LongTimeTask task;
public LongTimeServiceThreadA(LongTimeTask task){
super();
this.task = task;
}
@Override
public void run() {
super.run();
CommonUtils.beginTime1 = System.currentTimeMillis();
task.doLongTimeTask();
CommonUtils.endTime1 = System.currentTimeMillis();
}
}
public class LongTimeServiceThreadB extends Thread{
private LongTimeTask task;
public LongTimeServiceThreadB(LongTimeTask task){
super();
this.task = task;
}
@Override
public void run() {
super.run();
CommonUtils.beginTime2 = System.currentTimeMillis();
task.doLongTimeTask();
CommonUtils.endTime2 = System.currentTimeMillis();
}
}
測試類:
public class LongTimeServiceThreadATest extends TestCase {
public void testRun() throws Exception {
LongTimeTask task = new LongTimeTask();
LongTimeServiceThreadA threadA = new LongTimeServiceThreadA(task);
threadA.start();
LongTimeServiceThreadB threadB = new LongTimeServiceThreadB(task);
threadB.start();
try{
Thread.sleep(1000 * 10);
}catch (InterruptedException e){
e.printStackTrace();
}
long beginTime = CommonUtils.beginTime1;
if (CommonUtils.beginTime2 < CommonUtils.beginTime1){
beginTime = CommonUtils.beginTime2;
}
long endTime = CommonUtils.endTime1;
if (CommonUtils.endTime2 < CommonUtils.endTime1){
endTime = CommonUtils.endTime2;
}
System.out.println("耗時:" + ((endTime - beginTime) / 1000));
Thread.sleep(1000 * 20);
}
}
結果如下:
begin task
begin task
長時間處理任務後從遠端返回的值 1 threadName=Thread-1
長時間處理任務後從遠端返回的值 2 threadName=Thread-1
end task
長時間處理任務後從遠端返回的值 1 threadName=Thread-1
長時間處理任務後從遠端返回的值 2 threadName=Thread-1
end task
耗時:3
兩個執行緒併發處理耗時任務只用了3s, 因為只在賦值的時候進行同步處理,同步語句塊以外的部分都是多個執行緒非同步處理的。
下面,說一下同步語句塊的一些特性:
- 當多個執行緒同時執行synchronized(x){}同步程式碼塊時呈同步效果。
- 當其他執行緒執行x物件中的synchronized同步方法時呈同步效果。
- 當其他執行緒執行x物件中的synchronized(this)程式碼塊時也呈現同步效果。
細說一下每個特性,第一個特性上面的例子已經闡述了,就不多說了。第二個特性,因為同步語句塊也是物件鎖,所有當對x加鎖的時候,x物件內的同步方法也呈現同步效果,當x為this的時候,該物件內的其他同步方法也要等待同步語句塊執行完,才能執行。第三個特性和上面x為this是不一樣的,第三個特性說的是,x物件中有一個方法,該方法中有一個synchronized(this)的語句塊的時候,也呈現同步效果。即A執行緒呼叫了對x加鎖的同步語句塊的方法,B執行緒在呼叫該x物件的synchronized(this)程式碼塊是有先後的同步關係。
上面說同步語句塊比同步方法在某些方法中執行更有效率,同步語句塊還有一個優點,就是如果兩個方法都是同步方法,第一個方法無限在執行的時候,第二個方法就永遠不會被執行。這時可以對兩個方法做同步語句塊的處理,設定不同的鎖物件,則可以實現兩個方法非同步執行。
對類加鎖的同步處理
和物件加鎖的同步處理一致,對類加鎖的方式也有兩種,一種是synchronized修飾靜態方法,另一種是使用synchronized(X.class)同步語句塊。在執行上看,和物件鎖一致都是同步執行的效果,但是和物件鎖卻有本質的不同,對物件加鎖是訪問同一個物件的時候成同步的狀態,不同的物件就不會。但是對類加鎖是用這個類的靜態方法都是呈現同步狀態。
下面,看這個例子:
public class StaticService {
synchronized public static void printA(){
try{
System.out.println(" 執行緒名稱為:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 進入printA");
Thread.sleep(1000 * 3);
System.out.println(" 執行緒名稱為:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 離開printA");
}catch (InterruptedException e){
e.printStackTrace();
}
}
synchronized public static void printB(){
System.out.println(" 執行緒名稱為:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 進入printB");
System.out.println(" 執行緒名稱為:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 離開printB");
}
synchronized public void printC(){
System.out.println(" 執行緒名稱為:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 進入printC");
System.out.println(" 執行緒名稱為:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 離開printC");
}
}
測試類:
public class StaticServiceTest extends TestCase {
public void testPrint() throws Exception{
new Thread(new Runnable() {
public void run() {
StaticService.printA();
}
}).start();
new Thread(new Runnable() {
public void run() {
StaticService.printB();
}
}).start();
new Thread(new Runnable() {
public void run() {
new StaticService().printC();
}
}).start();
Thread.sleep(1000 * 3);
}
}
結果如下:
執行緒名稱為:Thread-0 在 1487684345462 進入printA
執行緒名稱為:Thread-2 在 1487684345462 進入printC
執行緒名稱為:Thread-2 在 1487684345462 離開printC
執行緒名稱為:Thread-0 在 1487684348465 離開printA
執行緒名稱為:Thread-1 在 1487684348466 進入printB
執行緒名稱為:Thread-1 在 1487684348466 離開printB
很明顯的看出來,對類加鎖和對物件加鎖兩者方法是非同步執行的,而對類加鎖的兩個方法是呈現同步執行。
其特性也和同步物件鎖一樣。
鎖物件鎖的是該物件的記憶體地址,其儲存的內容改變,並不會讓多執行緒併發的時候認為這是不同的鎖。所以改變鎖物件的內容,並不會同步失效。
volatile關鍵字
主要作用是使變數在多個執行緒間可見
在多執行緒爭搶物件的時候,處理該物件的變數的方式是在主記憶體中讀取該變數的值到執行緒私有的記憶體中,然後對該變數做處理,處理後將值在寫入到主記憶體中。上面舉的例子,之所以出現結果與預期不一致都是因為執行緒自己將值複製到自己的私有棧後修改結果而不知道其他執行緒的修改結果。如果我們不用同步的話,我們就需要一個能保持可見的,知道其他執行緒修改結果的方法。JDK提供了volatile關鍵字,來保持可見性,關鍵字volatile的作用是強制從公共堆疊中取得變數的值,而不是從執行緒私有資料棧中取得變數值。但是該關鍵字並不能保證原子性。
volatile不支援原子性。
synchronize與volatile的比較:
1. volatile是執行緒同步的輕量級實現,所以效能比synchronize好。但是volatile只能修飾變數,synchronize可以修飾方法。
2. volatile不會發生阻塞,synchronize會出現阻塞
3. volatile保證資料可見性,不能保證原子性;synchronize可以保證原子性,也可以間接保證可見性,因為他會將私有記憶體和公共記憶體中的資料做同步。
4. volatile解決變數在多個執行緒之間的可見性,synchronize解決多個執行緒之間訪問資源的同步性。
原子操作:一個完整的操作,操作一旦開始就一直執行到結束
原子操作也不一定完全安全
因為有的情況下雖然方法雖然是原子的,但是方法和方法之間的呼叫卻不是原子的。仍然需要同步去解決問題。