0、不同步的問題
並發的線程不安全問題:
多個線程同時操作同一個對象,如果控制不好,就會產生問題,叫做線程不安全。
我們來看三個比較經典的案例來說明線程不安全的問題。
0.1 訂票問題
例如前面說過的黃牛訂票問題,可能出現負數或相同。
0.2 銀行取錢
再來看一個取錢的例子:
/*
模擬一個賬戶
*/
class Account{
int money;
String name;
public Account(int money, String name) {
this.money = money;
this.name = name;
}
}
/*
模擬取款機,方便設置名字,繼承Thread而不是實現Runnable
*/
class Drawing extends Thread{
Account account;
int outMoney;//取出去了多少錢
int outTotal;//總共取到了多少錢
public Drawing(Account account, int outMoney,String name) {
super(name);
this.account = account;
this.outMoney = outMoney;
}
@Override
public void run() {
account.money -= outMoney;
outTotal += outMoney;
System.out.println(this.getName() + "---賬戶余額為:" + account.money);
System.out.println(this.getName() + "---總共取到了:" + outTotal);
}
}
然后我們寫個客戶端調用一下,假設兩個人同時取錢,操作同一個賬戶
public class Checkout {
public static void main(String[] args) {
Account account = new Account(200000,"禮金");
Drawing you = new Drawing(account,8000,"你");
Drawing wife = new Drawing(account,300000,"你老婆");
you.start();
wife.start();
}
}
運行起來,問題就會出現。
每次的結果都不一樣,而且,這樣肯定會把錢取成負數,顯然這是非法的(嘻嘻),首先邏輯上需要修改,當錢少於 0 了就應該退出,並且不能繼續取錢的動作了。按照這個思路,加上一個判斷呢?
if (account.money < outMoney){
System.out.println("余額不足");
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
可是即便是這樣,發現還是會出現結果為負的情況,無法保證線程安全。
0.3 數字遞增
還有一個經典的例子,那就是對於直接計算迭代過慢,而轉為多線程。
一個數字 num ,開辟一萬個線程對他做 ++ 操作,看結果會是多少。
public class AddSum {
private static int num = 0;
public static void main(String[] args) {
for (int i=0; i<=10000; i++){
new Thread(()->{
num++;
}).start();
}
System.out.println(num);
}
}
每次運算的結果都不一樣,一樣的是,結果永遠 < 10000 。
或者用給 list 里添加數字來測試:
List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
System.out.println(list.size());
一樣的結果。
線程不安全的問題如何解決呢?
一、同步(synchronized)
1.1 問題出現的原因
從前面的介紹里,我們總結出會出現同步問題的情況,也就是並發三要素:多個線程、同時操作、操作同一個對象。另外,操作的特點是:操作類型為修改。這個時候會產生並發的問題,線程安全問題。
1.2 解決方案
- 確保線程安全,第一就是排隊。只要排隊,那么不管多少線程,始終一個時間點只會有一個線程在執行,就保證了安全。
不過排隊會有一個問題:怎么直到輪到我了呢,也就是怎么知道排在前面的線程執行完了呢? - 現實生活中,可能會用類似房卡的形式,前一個人把卡交還了,才會有后面的人有機會入住。這就是鎖。
利用 隊列 + 鎖 的方式保證線程安全的方式叫線程同步,就是一種等待機制,多個同時訪問此對象的線程進入這個對象的等待池 形成隊列,前面的線程使用完畢后,下一個線程再使用。
鎖機制最開始在 java 里就是一個關鍵字 synchronized(同步),屬於排他鎖,當一個線程獲得對象的排他鎖,獨占資源,其他線程必須等待,使用后釋放鎖即可。
按照這種思路,可以想象到這種保證安全方式的弊端,也就是早期的 synchronized 存在的問題:
- 一個線程持有鎖會導致其他所有需要這個鎖的線程掛起;
- 多線程競爭下,加鎖、釋放鎖導致耗時嚴重,性能問題;
- 一個優先級高的線程等待一個優先級低的線程的鎖釋放,會使得本應該的優先級倒置,引起性能問題。
另外,Synchronized 是基於底層操作系統的 Mutex Lock 實現的,每次獲取和釋放鎖操作都會帶來用戶態和內核態的切換,從而增加系統性能開銷。因此,在鎖競爭激烈的情況下,Synchronized 同步鎖在性能上就表現得非常糟糕,它也常被大家稱為重量級鎖。
但是 jdk 6 之后有了很強的改進,這個內容待更新,留個坑。
二、同步關鍵字的用法
2.1 同步方法
synchronized 方法控制對 成員變量或者類變量 對象的訪問,每個對象對應一把鎖。寫法如下:
public synchronized void test(){
//。。。
}
- 如果修飾的是具體對象:鎖的是對象;
- 如果修飾的是成員方法:那鎖的就是 this ;
- 如果修飾的是靜態方法:鎖的就是這個對象.class。
每個 synchronized 方法都必須獲得調用該方法的對象的鎖才能執行,否則所屬的這個線程阻塞,方法一旦執行,就獨占該鎖,直到從該方法返回時,鎖釋放。
同步方法的寫法代碼,以上面的取錢案例歷的 取錢類為例,如果直接在提款機的操作,把 run 方法或者里面的內容提出來變成 test ,加上 synchronized 修飾:
@Override
public void run() {
test();
}
public synchronized void test(){
//內容都不變
}
會發現,仍然出現了負數。鎖定失敗。
分析:
我們認為在 test 方法里進行的對象修改,所以把他鎖上就好了,但是對於這個類,這個提款機類來說,test 方法是成員方法,因此鎖的對象實際上是 this ,也就是提款機。
但我們的初衷,要線程鎖的資源應該是 Account 對象,而不是提款機對象。
2.2 同步塊
除了方法,synchronized 還可以修飾塊,叫做同步塊。
synchronized 修飾同步塊的方式是:
synchronized (obj){
//...
}
其中的 obj 可以是任何對象,但是用到它,肯定是設置為那個共享資源,這個 obj 被稱為同步監視器。同步監視器的作用就是,判斷這個監視器是否被鎖定(是否能訪問),從而決定是否能執行其中的代碼。
java的花括號中內容有以下幾種:
- 方法里面的塊:局部塊。解決變量作用域的問題,快速釋放內存(比如方法里面再有個for循環,里面的變量);
- 類層的塊:構造塊。初始化信息,和構造方法是一樣的;
- 類層的靜態塊:靜態構造快。最早加載,不是對象的信息,而是類的信息;
- 方法里面的同步塊:監視對象。
第四種就是我們這里學習的同步塊。
注意,如果是同步方法里,沒必要指定同步監視器,因為同步方法的監視器已經是 this 或者 .class。
用同步塊的方式對提款機問題進行修改:
public void test(){
synchronized(account){
//內容不變
}
}
也就是加上對 account 的監視器,鎖住這個對象。這樣運行結果就正確了 。
這種做法效率不高,因為雖然對 account 上了鎖,但是每一次都要把整個流程走一遍,方法體的內容是很多的,另外,每次加鎖與否,都是性能的消耗,進入之后再出來,哪怕什么也不做,也是消耗。
其實,我們可以在加鎖的前面再加一重判斷,那么之后就沒必要再進行上鎖的過程了。
public void test(){
if (account.money ==0 ){
return;
}
synchronized(account){
}
}
就是這樣的一個代碼,在並發量很高的時候,往往可以大大提高效率。
對於上面的 10000 個線程的加法那個問題,我們也可以通過 synchronized 加鎖,來保證結果的正確性。
(但是 synchronized 修飾的要是引用類型,所以直接對 int num 加鎖不行,一般直接使用專門提供的原子類)
list 的里加數字的測試:
List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
synchronized (list){
list.add(Thread.currentThread().getName());
}
}).start();
}
Thread.sleep(2000);
System.out.println(list.size());
main方法,下面的print語句,這些都是線程,所以可能上面還沒有操作的時候,就已經輸出了,為了方便觀察,我們在最后輸出之前先讓main線程休眠一會,再看里面add的結果是否正確。

tips:對於容器的操作,Java的util.concurrent包里也直接提供了對應的安全容器CopyOnWriteArrayList。
CopyOnWriteArrayList<String > list1 = new CopyOnWriteArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
list1.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(2000);
System.out.println(list1.size());
2.3 問題
synchronized 塊太小,可能鎖不住,安全性又不行了,鎖的方法太大,又效率會降低,所以要很注意控制范圍。
而且,還有類似於 單例模式 里 Double-Check 寫法針對的問題,有時候一重鎖性質不夠,兩重鎖仍然不夠保證安全。
三、線程同步問題應用示例
3.1 快樂影院
電影院買票。
/**
* 快樂影院
*/
public class HappyCinema {
public static void main(String[] args) {
Cinema cinema = new Cinema(20, "萬達");
new Thread(new Customer(cinema,2)).start();
new Thread(new Customer(cinema,1)).start();
}
}
/**
* 電影院,提供訂票方法
*/
class Cinema{
int available;
String name;
public Cinema(int available, String name) {
this.available = available;
this.name = name;
}
//提供購票方法
public boolean bookTickets(int seats){
System.out.println("可用位置為:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
}
/**
* 顧客,有多個顧客,模仿多線程
*/
class Customer implements Runnable{
Cinema cinema;
int seats;
//顧客創建的時候帶上要預定的作為+訂哪個影院
public Customer(Cinema cinema, int seats) {
this.cinema = cinema;
this.seats = seats;
}
@Override
public void run() {
boolean flag = cinema.bookTickets(seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"買了 "+seats+" 張票");
}else{
System.out.println("出票失敗,"+Thread.currentThread().getName()+"買票,但位置不足 ");
}
}
}
對於一個電影院的票:available 資源來說,多個線程訪問,是需要同步的,否則就會出現不安全的問題。
解決:
@Override
public void run() {
synchronized (cinema){
//。。。
}
}
}
3.2 快樂影院進階
影院票的時候不是簡單計數,是可以選座位的,我們修改代碼,具體到某一個座位號的預定。
將 int 座位數目改成 List,那么購票方法改動如下:
public boolean bookTickets(List<Integer> seats){
System.out.println("可用位置為:" + available);
List<Integer> copy = new ArrayList<>(available);
//相減
copy.removeAll(seats);
//判斷改變后
if (available.size() != copy.size() + seats.size() ){
return false;
}
available = copy;
return true;
}
其他地方只需要做簡單的修改,在調用的時候傳入一個構造好的 list 即可,這個時候再來看:

如果兩個顧客同時訂票的位置沖突

可以看到完成了同步。
3.3 火車票
還是類似於訂票,因為上面電影院的部分我們都使用 同步塊 的方式鎖定某個對象,這里使用同步方法來加深上鎖的理解。
模仿第一種電影院訂票的初始不加鎖寫法。
public class Happy12306 {
public static void main(String[] args) {
Railway railway = new Railway(20, "京西G12138");
new Thread(new Passenger(railway,2)).start();
new Thread(new Passenger(railway,1)).start();
}
}
/**
* 鐵路系統,提供訂票方法
*/
class Railway{
int available;
String name;
public Railway(int available, String name) {
this.available = available;
this.name = name;
}
//提供購票方法
public boolean bookTickets(int seats){
System.out.println("可用位置為:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
}
/**
* 顧客,有多個顧客,模仿多線程
*/
class Passenger implements Runnable{
Railway railway;
int seats;
public Passenger(Railway railway, int seats) {
this.railway = railway;
this.seats = seats;
}
@Override
public void run() {
boolean flag = railway.bookTickets(seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"買了 "+seats+" 張票");
}else{
System.out.println("出票失敗,"+Thread.currentThread().getName()+"買票,但位置不足 ");
}
}
}
現在開始給方法加鎖,考慮這個問題:
- 本來的 run 方法寫了 同步塊 對一個資源加鎖,這個資源是 票所在的 鐵路系統(上一個例子的電影院);
- 所以如果鎖 run 方法,我們前面說過的,鎖成員方法相當於鎖的 this,也就是鎖了 乘客 類,是沒有用的,因為被修改的資源不在這里;
- 應該將這個方法放到 鐵路系統 類里,然后對這個方法上鎖。
這樣會帶來新的問題,模擬多個線程的線程體應該來源於 乘客 ,不能是鐵路系統,所以乘客類也要繼續修改,繼承 Thread 類,本身作為一個代理,去找到目標接口的實現類:鐵路系統 ,然后start。
public class Happy12306 {
public static void main(String[] args) {
Railway railway = new Railway(5, "京西G12138");
new Passenger(5,railway,"乘客B").start();
new Passenger(2,railway,"乘客A").start();
}
}
/**
* 鐵路系統,提供訂票方法,本身就是一個線程,
*/
class Railway implements Runnable{
int available;
String name;
public Railway(int available, String name) {
this.available = available;
this.name = name;
}
//提供購票方法,加入同步
public synchronized boolean bookTickets(int seats){
System.out.println("可用位置為:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
//run方法從 顧客類里 挪過來,
@Override
public void run() {
//運行時需要知道哪個線程在操作自己,也就是seats的來源
Passenger p = (Passenger) Thread.currentThread();
boolean flag = this.bookTickets(p.seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"買了 "+p.seats+" 張票");
}else{
System.out.println("出票失敗,"+Thread.currentThread().getName()+"買票,但位置不足 ");
}
}
}
/**
* 顧客,作為代理,是 Thread 的子代理
*/
class Passenger extends Thread{
int seats;
public Passenger(int seats, Runnable target, String name) {
super(target,name);//用父類方法找到目標,也就是鐵路系統
this.seats = seats;
}
}
總結:
- synchronized 修飾成員方法鎖定的是 this,所以要加入鐵路系統類,
- 鐵路系統通過 Thread.currentThread() 方法 確定當前的線程,同時獲取到訂票信息;
- 乘客變成了 代理,是 Thread 的子類,在這個基礎上加入訂票信息;
- 最后調用的時候,本應該使用 Thread 作為代理去執行,改為用乘客類,起到了一個系統用多個不同線程的作用。
乘客本身作為代理子類可能比較難理解。
但是我們回頭看看,對於上一種方式:
new Thread(new Passenger(railway,2)).start();
new Thread(new Passenger(railway,1)).start();
雖然這么寫的,但是其實傳入的一個 Runnable的實現類,在 Thread 源碼里面調用了構造方法:

可以看到,傳入一個 Runnable ,這個構造器加上了額外的信息,所以其實我們這種做法:
public Passenger(int seats, Runnable target, String name) {
super(target,name);//用父類方法找到目標,也就是鐵路系統
this.seats = seats;
}
是模擬了源碼的寫法而已。
四、多線程死鎖的產生與解決
4.1 問題
死鎖:當多個線程各自占有一些共享資源,並且互相等待其他線程占有的資源才能進行,從而導致兩個或者多個線程都在等待對方釋放資源,都停止執行的情況。
最簡單的,某一個同步塊同時擁有“兩個以上的對象的鎖”的時候,就可能會發生死鎖問題。
- 如果兩個線程,那就是塗口紅、照鏡子的問題,每個人都想先拿了一個再拿另一個;
- 如果是多個線程,對應哲學家就餐問題,每個人都想左手拿刀、右手拿叉。
口紅鏡子問題示例:
/**
* 死鎖的產生
*/
public class DeadLock {
public static void main(String[] args) {
Makup makup = new Makup(1,"女孩1");
Makup makup1 = new Makup(0,"女孩2");
makup.start();
makup1.start();
}
}
/**
* 口紅
*/
class Lipstick{ }
/**
* 鏡子
*/
class Mirror{ }
/**
* 化妝
*/
class Makup extends Thread{
static Lipstick lipstick = new Lipstick();
static Mirror mirror = new Mirror();
int choice;//選擇
String girl;
public Makup(int choice, String girl){
this.choice = choice;
this.girl = girl;
}
@Override
public void run() {
makeup();
}
//相互持有對方的對象鎖
private void makeup(){
if (choice == 0){
synchronized (lipstick){
System.out.println(this.girl + "獲得口紅");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (mirror){
System.out.println(this.girl + "然后獲得鏡子");
}
}
}else{
synchronized (mirror){
System.out.println(this.girl + "獲得鏡子");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lipstick){
System.out.println(this.girl + "然后獲得口紅");
}
}
}
}
}

可以發現程序停不下來了,死鎖已經產生。
其中的過程就是:
- 女孩 1 先拿到了鏡子,對其上鎖;
- 女孩 1 休息的時候,女孩 2 先拿到了口紅,對其上鎖;
- 女孩 2 休息的時候,女孩 1 休息結束,想要獲取口紅,但此時口紅上鎖,因此等待;
- 女孩 2 休息結束,想要獲取鏡子,但此時鏡子上鎖,因此等待。
4.2 解決
解決這個問題的方法:
不要出現 鎖的 嵌套 ,將等待后獲取另一個鎖的代碼放到第一個加鎖的后面就可以解決這個問題了:
private void makeup(){
if (choice == 0){
synchronized (lipstick){
System.out.println(this.girl + "獲得口紅");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (mirror){
System.out.println(this.girl + "然后獲得鏡子");
}
}else{
synchronized (mirror){
System.out.println(this.girl + "獲得鏡子");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (lipstick){
System.out.println(this.girl + "然后獲得口紅");
}
}
}
總結:盡量不要讓 一個同步代碼塊 同時擁有“兩個以上的對象的鎖”。