多線程編程很容易突然出現“錯誤情況”,這是由系統的線程調度具有一定隨機性造成的,不過即使程序偶然出現問題,那也是由於編程不當引起的。當使用多個線程 來訪問數據時,很容易“偶然”出現線程安全問題。
一個線程安全問題——銀行取錢問題
實現功能:
-系統判斷賬戶餘額是否大於取款金額,如果大於取款金額,則取款成功,否則取款失敗。
-模擬兩個人使用同一個賬戶併發取錢。
賬戶類代碼:
public class Account {
//封裝賬戶編號,賬戶餘額的兩個成員變量
private String accountNo;
private double balance;
//構造器
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//set,get方法
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
}
取錢的線程類:
public class DrawThread extends Thread{
//模擬賬戶用戶
private Account account;
//當前取現線程所希望取的錢數
private double drawAmout;
public DrawThread(String name, Account account, double drawAmout){
super(name);
this.account = account;
this.drawAmout = drawAmout;
}
//當多個線程修改同一個共享數據時,將涉及數據安全問題
public void run(){
//賬戶餘額大於取錢數目
if(account.getBalance() >= drawAmout){
//吐出鈔票
System.out.println(getName() + "取錢成功,取錢:" + drawAmout);
try{
Thread.sleep(1);
}catch (InterruptedException ex){
ex.printStackTrace();
}
//修改餘額
account.setBalance(account.getBalance() - drawAmout);
System.out.println("\t 餘額爲:" + account.getBalance());
} else {
System.out.println(getName() + "取錢失敗,餘額不足");
}
}
}
啓動:
public class DrawTest {
public static void main(String[] args) {
//創建一個賬戶
Account acct = new Account("123456", 1000);
//模擬兩個線程對同一個賬戶取錢
new DrawThread("甲", acct, 800).start();
new DrawThread("乙", acct, 800).start();
}
}
代碼結果:
甲取錢成功,取錢:800.0
乙取錢成功,取錢:800.0
餘額爲:200.0
餘額爲:-600.0
這裏可以看出線程出現了錯誤,餘額僅有1000卻能取出1600,並且餘額爲負值。出現這種錯誤是因爲run()方法的方法體不具有同步安全性——程序中有兩個併發線程在修改Account對象。
爲了解決這個問題,java的多線程支持引入了同步監視器來解決這個問題。
同步代碼塊
使用同步監視器的通用方法就是同步代碼塊。同步代碼塊的語法格式:
synchronized(obj){
...
//此處的代碼就是同步代碼塊
}
在上面語法中,括號內的obj就是同步監視器,線程開始執行同步代碼塊之前,必須先獲得對同步監視器的鎖定。
需要注意的一點是,任何時刻只能有一個線程可以獲得對同步監視器的鎖定,當同步代碼塊執行完成後,該線程會釋放對該同步監視器的鎖定。
同步監視器最大目的:阻止兩個線程對同一個共享資源進行併發訪問,因此通常把可能被併發訪問的共享資源充當同步監視器。在這裏我選擇account作爲同步監視器,修改DrawThread的代碼:
public class DrawThread extends Thread {
//模擬用戶賬戶
private Account account;
//模擬取錢線程所希望取錢數
private double drawAmout;
public NewDrawThread(String name, Account account, double drawAmout){
super(name);
this.account = account;
this.drawAmout = drawAmout;
}
//當多個線程修改同一個共享數據時,將涉及數據安全問題
public void run(){
//使用account作爲同步監視器,任何線程進入下面同步代碼之前,必須先獲得account賬戶的鎖定
//其他線程無法獲得所,也就無法修改它
//這種做法符合:加鎖->修改->釋放鎖 的邏輯
synchronized (account) {
//賬戶餘額大於取錢數目
if(account.getBalance() >= drawAmout){
//吐出鈔票
System.out.println(getName() + "取錢成功,取錢:" + drawAmout);
try{
Thread.sleep(1);
}catch (InterruptedException ex){
ex.printStackTrace();
}
//修改餘額
account.setBalance(account.getBalance() - drawAmout);
System.out.println("\t 餘額爲:" + account.getBalance());
} else {
System.out.println(getName() + "取錢失敗,餘額不足");
}
}
//同步代碼塊結束,該線程釋放同步鎖
}
}
運行結果:
甲取錢成功,取錢:800.0
餘額爲:200.0
乙取錢失敗,餘額不足
同步方法
與同步代碼塊對應,Java的多線程安全支持還提供了同步方法,同步方法就是使用synchronized關鍵字來修飾的方法。對於synchronized修飾的實例方法(非static方法)而言,無須顯式指定同步監視器,同步方法的監視器就是this,也就是調用該方法的對象。
對於同步方法,選擇修改Account類,使其成爲線程安全的類。
public class Account {
//封裝賬戶編號,賬戶餘額的兩個成員變量
private String accountNo;
private double balance;
//構造器
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//AccountNo set,get方法
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因爲賬戶餘額不允許隨便修改,所以只爲balance提供getter方法
public double getBalance() {
return balance;
}
//提供一個線程安全的draw()方法來完成取錢操作
public synchronized void draw(double drawAmount){
//賬戶餘額大於取錢數目
if(balance >= drawAmount){
//吐出鈔票
System.out.println(Thread.currentThread().getName() + "取錢成功,取錢:" + drawAmount);
try{
Thread.sleep(1);
}catch (InterruptedException ex){
ex.printStackTrace();
}
//修改餘額
balance -= drawAmount;
System.out.println("\t 餘額爲:" + balance);
} else {
System.out.println(Thread.currentThread().getName() + "取錢失敗,餘額不足");
}
}
}
上面程序中增加了一個代表取錢的draw()方法,並使用了synchronized關鍵字修飾該方法,把該方法變成同步方法,該同步方法的同步監視器是this,因此對於同一個Account賬戶而言,任意時刻只能有一個線程獲得對Account對象的鎖定,然後進入draw()方法執行取錢操作。
因爲Account類中提供了draw()方法,而且取消了setBalance()方法,所以需要改寫DrawThread線程類,該線程類的run()方法只要調用Account對象的draw()方法即可執行取錢操作。run()方法代碼片段如下。
public void run(){
//直接調用account對象的draw()方法來執行取錢操作
//同步方法的同步監視器是this,this代表調用draw方法對象
//線程進入draw()方法之前,必須先對account對象加鎖
account.draw(drawAmout);
}
此外synchronized關鍵字可以修飾方法,可以修飾代碼塊,但不能修飾構造器、成員變量。
既然獲得對同步監視器的鎖定,那麼什麼時候會釋放同步監視器的鎖定呢?
–當前線程的同步代碼塊、同步方法執行結束,當前線程即釋放同步監視器。
–當前線程在同步代碼塊、同步方法中遇到break、rerurn終止了該代碼塊、該方法的繼續執行,當前線程即釋放同步監視器。
–當前線程在同步代碼塊、同步方法中出現了未處理的Error或Exception,導致了該代碼塊、該方法異常結束時,當前線程即釋放同步監視器。
–當前線程執行同步代碼塊或同步方法時,程序執行了同步監視器對象的wait()方法,則當前線程暫停,並釋放同步監視器。
以下是線程不會釋放同步監視器的情況:
–線程執行同步代碼塊或同步方法時,程序調用了Thread.sleep()、Thread.yield()方法來暫停當前線程的執行,當前線程不會釋放同步監視器。
–線程執行同步代碼塊時,其他線程調用了該線程的suspend()方法將該線程掛起,該線程不會釋放同步監視器。
同步鎖(Lock)
從Java5開始,Java提供了一種功能更加強大的線程同步機制——通過顯示定義同步鎖對象來實現同步,在這種機制下,同步鎖由Lock對象充當。
Lock提供了比synchronized方法和synchronized代碼塊更加廣泛的鎖定操作,Lock允許實現更靈活的結構,可以具有差別很大的屬性沒並且支持多個相關的Condition對象。
Lock是控制多個線程對共享資源進行訪問的工具。通常鎖提供了對共享資源的獨佔訪問,每次只能有一個線程對Lock對象加鎖,線程開始訪問共享資源之前應先獲得Lock對象。
在實現線程安全的控制中比較常用的是ReentrantLock(可重入鎖)。使用該Lock對象可以顯式地加鎖、釋放鎖,通常使用ReentrantLock的代碼如下:
class X{
//定義鎖對象
private final ReentrantLock lock = new ReentrantLock();
...
//定義需要保證線程安全的方法
public void m(){
//加鎖
lock.lock();
try{
//需要保證線程安全的代碼
} finally{
lock.unlock();
}
}
}
對Account類使用同步鎖:
public class Account {
//定義鎖對象
private final ReentrantLock lock = new ReentrantLock();
//封裝賬戶編號,賬戶餘額的兩個成員變量
private String accountNo;
private double balance;
//構造器
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//AccountNo set,get方法
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因爲賬戶餘額不允許隨便修改,所以只爲balance提供getter方法
public double getBalance() {
return balance;
}
//提供一個線程安全的draw()方法來完成取錢操作
public void draw(double drawAmount){
//加鎖
lock.lock();
try{
//賬戶餘額大於取錢數目
if(balance >= drawAmount){
//吐出鈔票
System.out.println(Thread.currentThread().getName() + "取錢成功,取錢:" + drawAmount);
try{
Thread.sleep(1);
}catch (InterruptedException ex){
ex.printStackTrace();
}
//修改餘額
balance -= drawAmount;
System.out.println("\t 餘額爲:" + balance);
} else {
System.out.println(Thread.currentThread().getName() + "取錢失敗,餘額不足");
}
}finally{
//修改完成,釋放鎖
lock.unlock();
}
}
}
ReentrantLock鎖具有可重入性,也就是說,一個線程可以對已被加鎖的ReentrantLock鎖再次加鎖,ReentrantLock對象會維持一個計數器來追蹤lock()方法的嵌套調用,線程在每次調用lock()加鎖後,必須顯示調用unlock()來釋放鎖,所以一段被鎖保護的代碼可以調用另一個被相同鎖保護的方法。
死鎖
當兩個線程相互等待對方釋放同步監視器時就會發生死鎖,Java虛擬機沒有監測,也沒有採取措施來處理死鎖情況,所以多線程編程時應該採取措施避免死鎖出現。一旦出現死鎖,整個程序既不會發生任何異常,也不會給出任何提示,只是所有線程處於阻塞狀態,無法繼續。
死鎖是很容易發生的,尤其在系統中出現多個同步監視器的情況下。
出現死鎖的代碼:
class A{
public synchronized void foo( B b ){
System.out.println("當前線程名:" + Thread.currentThread().getName() +
"進入了A實例的foo()方法");
try{
Thread.sleep(200);
}catch (InterruptedException ex){
ex.printStackTrace();
}
System.out.println("當前線程名:" + Thread.currentThread().getName() +
"企圖調用B實例的last()方法");
b.last();
}
public synchronized void last(){
System.out.println("進入了A類的last()方法內部");
}
}
class B{
public synchronized void bar( A a ){
System.out.println("當前線程名:" + Thread.currentThread().getName() +
"進入了B實例的bar()方法");
try{
Thread.sleep(200);
}catch (InterruptedException ex){
ex.printStackTrace();
}
System.out.println("當前線程名:" + Thread.currentThread().getName() +
"企圖調用A實例的last()方法");
a.last();
}
public synchronized void last(){
System.out.println("進入了B類的last()方法內部");
}
}
public class DeadLock implements Runnable{
A a = new A();
B b = new B();
public void init(){
Thread.currentThread().setName("主線程");
//調用a對象的foo()方法
a.foo(b);
System.out.println("進入了主線程之後");
}
public void run(){
Thread.currentThread().setName("副線程");
//調用b對象的bar()方法
b.bar(a);
System.out.println("進入了副線程之後");
}
public static void main(String[] args){
DeadLock dl = new DeadLock();
//以dl爲target啓動新線程
new Thread(dl).start();
//調用init()方法
dl.init();
}
}
代碼結果:
當前線程名:副線程進入了B實例的bar()方法
當前線程名:主線程進入了A實例的foo()方法
當前線程名:主線程企圖調用B實例的last()方法
當前線程名:副線程企圖調用A實例的last()方法
上面代碼出現了主線程保持着A對象德爾鎖,等待對B對象加鎖,而副線程保持着B對象的鎖,等待對A對象加鎖,兩個線程互相等待對方先釋放,所以出現了死鎖。