在開發多線程程序時,如果每個多線程處理的事情都不一樣,每個線程都互不相關,這樣開發的過程就非常輕松。但是很多時候,多線程程序是需要同時訪問同一個對象,或者變量的。這樣,一個對象同時被多個線程訪問,會出現處理的結果和預期不一致的可能。因此,需要了解如何對對象及變量並發訪問,寫出線程安全的程序,所謂線程安全就是處理的對象及變量的時候是同步處理的,在處理的時候其他線程是不會干擾。本文將從以下幾個角度闡述這個問題。所有的代碼都在char02
- 對於方法的同步處理
- 對於語句塊的同步處理
- 對類加鎖的同步處理
- 保證可見性的關鍵字——volatile
對於方法的同步處理
對於一個對象的方法,如果有兩個線程同時訪問,如果不加控制,訪問的結果會出乎意料。所以我們需要對方法進行同步處理,讓一個線程先訪問,等訪問結束,在讓另一個線程去訪問。對於要處理的方法,用synchronized
修飾該方法。我們下面看一下對比的例子。
首先是沒有同步修飾的方法,看看會有什么意料之外的事情
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);
}
}
在這個對象中,有一個成員變量num, 如果username是a,則num應該等於100,如果是b,則num應該等於200,threadA與threadB同時去訪問addI方法,預期的結果應該是a num=100 b num=200。但是實際的結果如下:
a set over!
b set over!
b num=200
a num=200
這是為什么呢?因為threadA先調用addI方法,但是因為傳入的參數的是a,所示ThreadA線程休眠2s,這是B線程也已經調用了addI方法,然后將num的值改為了200,這是輸出語句輸出的是b改之后的num的值也就是200,a的值被b再次修改覆蓋了。
這個方法是線程不安全的,我們給這個方法添加synchronized
,修改如下:
synchronized 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();
}
}
其他地方保持不變,現在我們在看一下,結果:
a set over!
a num=100
b set over!
b num=200
這個結果是不是就符合預期的結果,調用的順序也是一致的。
用synchronized
可以保證多線程調用同一個對象的方法的時候,是同步進行的,注意是同一個對象,也就是說synchronized
的方法是對象鎖,鎖住的是對象,如果是不同的對象,就沒有這個線程不安全的問題。我們在上面的修改的基礎上,去掉
synchronized
,然后修改測試方法,讓兩個線程調用不同對象的方法,修改如下:
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);
}
}
結果如下:
b set over!
b num=200
a set over!
a num=100
因為threadB是不需要休眠的,所以兩個線程同時調用的時候,一定是B線程先出結果,這個結果是符合預期的。但是這樣是無法證明synchronized
是對象鎖的,只能說明不同線程訪問不同對象是不會出現線程不安全的情況的。在補充一個例子來證明:同一個對象,有兩個同步方法,但是兩個線程分別調用其中一個同步方法,如果返回的結果不是同時出現的,則說明是對象鎖,即鎖住了一個對象,該對象的其他方法也要等該對象鎖釋放,才能調用。
public class MyObject {
synchronized public void methodA(){
try{
System.out.println("begin methodA threadName=" + Thread.currentThread().getName()+
" begin time =" + System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch (InterruptedException e){
e.printStackTrace();
}
}
synchronized public void methodB(){
try{
System.out.println("begin methodB threadName=" + Thread.currentThread().getName() +
" begin time =" + System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class SynchronizedMethodThread extends Thread{
private MyObject object;
public SynchronizedMethodThread(MyObject object){
this.object = object;
}
@Override
public void run() {
super.run();
if(Thread.currentThread().getName().equals("A")){
object.methodA();
}else{
object.methodB();
}
}
}
測試方法如下:
public class SynchronizedMethodThreadTest extends TestCase {
public void testRun() throws Exception {
MyObject object = new MyObject();
SynchronizedMethodThread a = new SynchronizedMethodThread(object);
a.setName("A");
SynchronizedMethodThread b = new SynchronizedMethodThread(object);
b.setName("B");
a.start();
b.start();
Thread.sleep(1000 * 15);
}
}
A,B兩個線程分別調用methodA與methodB, 兩個方法也打印出了他們的開始和結束時間。
結果如下:
begin methodA threadName=A begin time =1483603953885
end
begin methodB threadName=B begin time =1483603958886
end
可以看出兩個方法是同步調用,一前一后,結果無交叉。說明synchronized
修飾方法添加的確實是對象鎖。
這樣,用synchronized
修飾的方法,都需要多線程同步調用,但是沒用他修飾的方法,多線程還是直接去調用的。也就是說,雖然多線程會同步調用synchronized
修飾的方法,但是在一個線程同步調用方法的時候,其他線程可能先調用了非同步方法,這個在某些時候會有問題。比如出現臟讀。
A線程先同步調用了set方法,但是可能在set的過程中出現了等待,然后其他線程在get的時候,數據是set還沒有執行完的數據。看如下代碼:
public class PublicVar {
public String username = "A";
public String password = "AA";
synchronized public void setValue(String username,String password){
try{
this.username = username;
Thread.sleep(3000);
this.password = password;
System.out.println("setValue method thread name=" + Thread.currentThread().getName() + " username="
+ username + " password=" + password);
}catch (InterruptedException e){
e.printStackTrace();
}
}
public void getValue(){
System.out.println("getValue method thread name=" + Thread.currentThread().getName() + " username=" + username
+ " password=" + password);
}
}
public class PublicVarThreadA extends Thread {
private PublicVar publicVar;
public PublicVarThreadA(PublicVar publicVar){
this.publicVar = publicVar;
}
@Override
public void run() {
super.run();
publicVar.setValue("B","BB");
}
}
看測試的例子:
public class PublicVarThreadATest extends TestCase {
public void testRun() throws Exception {
PublicVar publicVarRef = new PublicVar();
PublicVarThreadA threadA = new PublicVarThreadA(publicVarRef);
threadA.start();
Thread.sleep(40);
publicVarRef.getValue();
Thread.sleep(1000 * 5);
}
}
期待的結果應該是"A","AA",或者是"B","BB",然而結果是:
getValue method thread name=main username=B password=AA
setValue method thread name=Thread-0 username=B password=BB
所以,對於同一個對象中的數據讀與取,都需要用synchronized
修飾才能同步。臟讀一定會出現在操作對象情況下,多線程"爭搶"對象的結果。
下面,說一些同步方法其他特性,當一個線程得到一個對象鎖的時候,他再次請求對象鎖,一定會再次得到該對象的鎖。這往往出現在一個對象方法里調用這個對象的另一個方法,而這兩個方法都是同步的。這樣設計是有原因,因為如果不能再次獲得這個對象鎖的話,很容易造成死鎖。這種直接獲取鎖的方式稱之為可重入鎖。
Java中的可重入鎖支持在繼承中使用,也就是說可以在子類的同步方法中調用父類的同步方法。
下面,看個例子:
public class FatherSynService {
public int i = 10;
synchronized public void operateIMainMethod(){
try{
i--;
System.out.println("main print i=" +i);
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class SonSynService extends FatherSynService{
synchronized public void operateISubMethod(){
try{
while (i > 0){
i--;
System.out.println("sub print i=" + i);
Thread.sleep(1000);
this.operateIMainMethod();
}
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class SonSynTread extends Thread{
@Override
public void run() {
super.run();
SonSynService son = new SonSynService();
son.operateISubMethod();
}
}
測試的例子如下:
public class SonSynTreadTest extends TestCase {
public void testRun() throws Exception {
SonSynTread thread = new SonSynTread();
thread.start();
Thread.sleep(1000 * 10);
}
}
結果就是i是連續輸出的。這說明,當存在父子類繼承關系時,子類是完全可以通過"可重入鎖"調用父類的同步方法的。但是在繼承關系中,同步是不會被繼承的,也就是說如果父類的方法是同步的方法,然而子類在覆寫該方法的時候,沒有加同步的修飾,則子類的方法不算是同步方法。
關於同步方法還有一點,就是同步方法出現未捕獲的異常,則自動釋放鎖。
對於語句塊的同步處理
對於上面的同步方法而言,其實是有些弊端的,如果同步方法是需要執行一個很長時間的任務,那么多線程在排隊處理同步方法時就會等待很久,但是一個方法中,其實並不是所有的代碼都需要同步處理的,只有可能會發生線程不安全的代碼才需要同步。這時,可以采用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 在 1483630533783 進入printA
線程名稱為:Thread-2 在 1483630533783 進入printC
線程名稱為:Thread-2 在 1483630533783 離開printC
線程名稱為:Thread-0 在 1483630536786 離開printA
線程名稱為:Thread-1 在 1483630536787 進入printB
線程名稱為:Thread-1 在 1483630536787 離開printB
很明顯的看出來,對類加鎖和對對象加鎖兩者方法是異步執行的,而對類加鎖的兩個方法是呈現同步執行。
其特性也和同步對象鎖一樣。
關於同步加鎖的簡單使用的介紹就到這里了。最后還有注意一點,鎖對象鎖的是該對象的內存地址,其存儲的內容改變,並不會讓多線程並發的時候認為這是不同的鎖。所以改變鎖對象的內容,並不會同步失效。
保證可見性的關鍵字——volatile
在多線程爭搶對象的時候,處理該對象的變量的方式是在主內存中讀取該變量的值到線程私有的內存中,然后對該變量做處理,處理后將值在寫入到主內存中。上面舉的例子,之所以出現結果與預期不一致都是因為線程自己將值復制到自己的私有棧后修改結果而不知道其他線程的修改結果。如果我們不用同步的話,我們就需要一個能保持可見的,知道其他線程修改結果的方法。JDK提供了volatile
關鍵字,來保持可見性,關鍵字volatile的作用是強制從公共堆棧中取得變量的值,而不是從線程私有數據棧中取得變量值。但是該關鍵字並不能保證原子性,以爭搶一個對象中的count變量來看下圖的具體說明:
java 垃圾回收整理一文中,描述了jvm運行時刻內存的分配。其中有一個內存區域是jvm虛擬機棧,每一個線程運行時都有一個線程棧,線程棧保存了線程運行時候變量值信息。當線程訪問某一個對象時候值的時候,首先通過對象的引用找到對應在堆內存的變量的值,然后把堆內存變量的具體值load到線程本地內存中,建立一個變量副本,之后線程就不再和對象在堆內存變量值有任何關系,而是直接修改副本變量的值,在修改完之后的某一個時刻(線程退出之前),自動把線程變量副本的值回寫到對象在堆中變量。這樣在堆中的對象的值就產生變化了。
volatile在此過程中的具體說明如下:
read and load 從主存復制變量到當前工作內存
use and assign 執行代碼,改變共享變量值
store and write 用工作內存數據刷新主存相關內容
其中use and assign 可以多次出現
但是這一些操作並不是原子性,也就是 在read load之后,如果主內存count變量發生修改之后,線程工作內存中的值由於已經加載,不會產生對應的變化,所以計算出來的結果會和預期不一樣對於volatile修飾的變量,jvm虛擬機只是保證從主內存加載到線程工作內存的值是最新的例如假如線程1,線程2 在進行read,load 操作中,發現主內存中count的值都是5,那么都會加載這個最新的值在線程1堆count進行修改之后,會write到主內存中,主內存中的count變量就會變為6線程2由於已經進行read,load操作,在進行運算之后,也會更新主內存count的變量值為6導致兩個線程及時用volatile關鍵字修改之后,還是會存在並發的情況。
上述對於volatile的解析均摘自java中volatile關鍵字的含義
總結
至此,關於Java同步的知識就告一段落了,上文講的都是比較粗淺的用法,我放在github的代碼中有更多的例子,地址是:char02
關於多線程通信的知識就放在了char03的代碼中。