【Java併發編程學習 2】多線程線程安全問題

1 什麼是線程安全

當多個線程同時共享,同一個全局變量或靜態變量,做寫的操作時,可能會發生數據衝突問題,也就是線程安全問題。但是做讀操作是不會發生數據衝突問題。

案例:需求現在有100張火車票,有三個窗口同時搶火車票,請使用多線程模擬搶票效果。

package com.lijie;

public class ThreadTrain implements Runnable {
	private int trainCount = 100;

	@Override
	public void run() {
		while (trainCount > 0) {
			try {
				Thread.sleep(50);
			} catch (Exception e) {

			}
			sale();
		}
	}

	public void sale() {
		if (trainCount > 0) {
			System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
			trainCount--;
		}
	}

	public static void main(String[] args) {
		ThreadTrain threadTrain = new ThreadTrain();
		Thread t1 = new Thread(threadTrain, "1號");
		Thread t2 = new Thread(threadTrain, "2號");
		Thread t3 = new Thread(threadTrain, "3號");
		t1.start();
		t2.start();
		t3.start();
	}
}

運行結果:
在這裏插入圖片描述
結論發現,多個線程共享同一個全局成員變量時,做寫的操作可能會發生數據衝突問題。

2 線程安全解決辦法

使用多線程之間同步synchronized或使用鎖(lock)。

爲什麼使用線程同步或使用鎖能解決線程安全問題呢
將可能會發生數據衝突問題,只能讓當前一個線程進行執行。代碼執行完成後釋放鎖,讓後才能讓其他線程進行執行。這樣的話就可以解決線程不安全問題。

3 synchronized內置的鎖

Java提供了一種內置的鎖機制來支持原子性:synchronized關鍵字

synchronized稱爲內置鎖,當線程進入同步代碼塊之前自動獲取到鎖,代碼塊執行完成正常退出或代碼塊中拋出異常退出時會釋放掉鎖

即:線程A獲取到鎖後,線程B阻塞直到線程A釋放鎖,線程B才能獲取到同一個鎖

3.1 synchronized的倆種使用方式

3.1.1 同步代碼塊方式
//就是將可能會發生線程安全問題的代碼,給包括起來。
synchronized(對象)//這個對象可以爲任意對象 
{ 
    需要被同步的代碼 
} 

對象如同鎖,持有鎖的線程可以在同步中執行 ,沒持有鎖的線程即使獲取CPU的執行權,也進不去

同步的前提:
1,必須要有兩個或者兩個以上的線程
2,必須是多個線程使用同一個鎖
3,必須保證同步中只能有一個線程在運行
好處:解決了多線程的安全問題
弊端:多個線程需要判斷鎖,較爲消耗資源、搶鎖的資源。

代碼演示:
修改上方搶票代碼的sale方法

	public void sale() {
		synchronized (this) {
			if (trainCount > 0) {
				System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
				trainCount--;
			}
		}
	}

此時不會出現數據衝突問題
在這裏插入圖片描述

3.1.2 同步方法方式

在方法上修飾synchronized 稱爲同步方法
代碼演示:

	//方法上添加synchronized 關鍵字即可
    public synchronized void sale() {
        if (trainCount > 0) {
            System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
            trainCount--;
        }
    }

4 多線程死鎖

4.1 什麼是多線程死鎖

例如:對象A持有一把鎖,他需要使用B中的代碼,結果B也持有一把鎖,他同時要使用A中的代碼。這是就會發生死鎖

在Java中使用多線程,就會有可能導致死鎖問題。死鎖會讓程序一直卡住,不再程序往下執行。我們只能通過中止並重啓的方式來讓程序重新執行。

互相加鎖引用 引起的死鎖代碼演示:

package com.lijie;

public class DeadLock {
    public static String obj1 = "obj1";
    public static String obj2 = "obj2";

    public static void main(String[] args) {
        Thread a = new Thread(new Lock1());
        Thread b = new Thread(new Lock2());
        a.start();
        b.start();
    }
}

class Lock1 implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("Lock1 running");
            while (true) {
                synchronized (DeadLock.obj1) {//加鎖
                    System.out.println("Lock1 lock obj1");
                    Thread.sleep(3000);//獲取obj1後先等一會兒,讓Lock2有足夠的時間鎖住obj2
                    synchronized (DeadLock.obj2) {//應用第二個加鎖
                        System.out.println("Lock1 lock obj2");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class Lock2 implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("Lock2 running");
            while (true) {
                synchronized (DeadLock.obj2) {//加鎖
                    System.out.println("Lock2 lock obj2");
                    Thread.sleep(3000);//獲取obj2後先等一會兒,讓Lock1有足夠的時間鎖住obj1
                    synchronized (DeadLock.obj1) {//應用第一個加鎖
                        System.out.println("Lock2 lock obj1");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

4.2 產生死鎖的四個必要條件

  1. 互斥條件:在一段時間內某資源只由一個進程佔用。如果此時還有其它進程請求資源,就只能等待,直至佔有資源的進程用畢釋放。

  2. 佔有且等待條件:指進程已經保持至少一個資源,但又提出了新的資源請求,而該資源已被其它進程佔有,此時請求進程阻塞,但又對自己已獲得的其它資源保持不放。

  3. 不可搶佔條件:別人已經佔有了某項資源,你不能因爲自己也需要該資源,就去把別人的資源搶過來。

  4. 循環等待條件:若干進程之間形成一種頭尾相接的循環等待資源關係。(比如一個進程集合,A在等B,B在等C,C在等A)

4.3 避免死鎖的方式

  1. 避免一個線程同時獲得多個鎖
  2. 避免一個線程在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源
  3. 嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制

5 Threadlocal

5.1 什麼是Threadlocal

ThreadLocal提高一個線程的局部變量,訪問某個線程擁有自己局部變量。

當使用ThreadLocal維護變量時,ThreadLocal爲每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立地改變自己的副本,而不會影響其它線程所對應的副本。

5.2 ThreadLocal的接口方法

ThreadLocal類接口很簡單,只有4個方法:

  1. void set(Object value)設置當前線程的線程局部變量的值。
  2. public Object get()該方法返回當前線程所對應的線程局部變量。
  3. public void remove()將當前線程局部變量的值刪除,目的是爲了減少內存的佔用,該方法是JDK 5.0新增的方法。需要指出的是,當線程結束後,對應該線程的局部變量將自動被垃圾回收,所以顯式調用該方法清除線程的局部變量並不是必須的操作,但它可以加快內存回收的速度。
  4. protected Object initialValue()返回該線程局部變量的初始值,該方法是一個protected的方法,顯然是爲了讓子類覆蓋而設計的。這個方法是一個延遲調用方法,在線程第1次調用get()或set(Object)時才執行,並且僅執行1次。ThreadLocal中的缺省實現直接返回一個null。

5.3 ThreadLocal代碼演示

案例:創建三個線程,每個線程生成自己獨立序列號。

package com.lijie;

class Res {
    // 生成序列號共享變量
    public static Integer count = 0;
    //創建ThreadLocal變量
    public static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {
        protected Integer initialValue() {
            return 0;
        }
        ;
    };
    //每次獲取序列號+1
    public Integer getNum() {
        int count = threadLocal.get() + 1;
        threadLocal.set(count);
        return count;
    }
}

public class ThreadLocaDemo extends Thread {
    private Res res;

    public ThreadLocaDemo(Res res) {
        this.res = res;
    }

    public void run() {
        for (int i = 0; i < 3; i++) {
            System.out.println(Thread.currentThread().getName() + "---" + "i---" + i + "--num:" + res.getNum());
        }

    }

    public static void main(String[] args) {
        Res res = new Res();
        ThreadLocaDemo threadLocaDemo1 = new ThreadLocaDemo(res);
        ThreadLocaDemo threadLocaDemo2 = new ThreadLocaDemo(res);
        ThreadLocaDemo threadLocaDemo3 = new ThreadLocaDemo(res);
        threadLocaDemo1.start();
        threadLocaDemo2.start();
        threadLocaDemo3.start();
    }
}

5.4 ThreadLocal實現原理

ThreadLocal底層通過map集合,Map.put(“當前線程”,值);

6多線程有三大特性

原子性、可見性、有序性
話說多線程編程必須要保證原子性、可見性以及有序性,缺一不可,不然就可能導致結果執行不正確。(我個人決定其實很多情況並不一定是需要保證三大特性的)

6.1 原子性

即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
一個很經典的例子就是銀行賬戶轉賬問題:
比如:從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。這2個操作必須要具備原子性才能保證中間不出現一些意外的問題。

6.2 可見性

當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

若兩個線程在不同的cpu,那麼線程1改變了i的值還沒刷新到主存,線程2又使用了i,那麼這個i值肯定還是之前的,線程1對變量的修改線程沒看到這就是可見性問題。

6.3 有序性

程序執行的順序按照代碼的先後順序執行。
一般來說處理器爲了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

int a = 5;    //語句1
int r = 3;    //語句2
a = a + 2;    //語句3
r = a*a;      //語句4

則因爲重排序,他還可能執行順序爲(這裏標註的是語句的執行順序) 2-1-3-4,1-3-2-4
但絕不可能 2-1-4-3,因爲這打破了依賴關係。
顯然重排序對單線程運行是不會有任何問題,但是多線程就不一定了,所以我們在多線程編程時就得考慮這個問題了。

7.多線程如何保證三大特性呢

話說多線程編程必須要保證原子性、可見性以及有序性,缺一不可,不然就可能導致結果執行不正確。(我個人決定其實很多情況並不一定是需要保證三大特性的)

7.1 保證原子性

保證多線程原子性很簡單,使用synchronized或者lock鎖來保證原子性

7.2 保證可見性

用好volatile關鍵字即可保證可見性(下一章會講到)

7.3 保證有序性

線程一旦多起來保證有序性就很麻煩了。Java中可通過volatile在一定程序上保證順序性,另外還可以通過synchronized和lock鎖和保證原子性一樣的來保證順序性。 還可以利用ThreadLocal,讓訪問某個線程擁有自己局部變量巧妙的來保證。

還有除了從應用層面保證目標代碼段執行的順序性外,JVM還通過被稱爲happens-before原則隱式地保證順序性。

7.4 什麼是happens-before原則

簡單來說就是:前一個操作的結果可以被後續的操作獲取。講白點就是前面一個操作把變量A賦值爲1,那後面一個操作肯定能知道A已經變成了1。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章