JAVA多線程線程安全性基礎

線程安全性

一個對象是否需要是線程安全的,取決於它是否被多個線程訪問,而不取決於對象要實現的功能

什麼是線程安全的代碼

核心:對 共享的 和 可變的 狀態的訪問進行管理。防止對數據發生不受控的並發訪問。

何為對象的狀態?

狀態是指存儲在對象的狀態變量(例如實例或靜態域)中的數據。還可能包括 其他依賴對象 的域。

eg:某個HashMap的狀態不僅存儲在HashMap對象本身,還存儲在許多Map.Entry對象中。

在這裡插入圖片描述

總而言之,在對象的狀態中包含瞭任何可能影響其外部可見行為的數據。

何為共享的?

共享的 是指變量可同時被多個線程訪問

何為可變的?

可變的 是指變量的值在其生命周期內可以發生變化。試想,如果一個共享變量的值在其生命周期內不會發生變化,那麼在多個

線程訪問它的時候,就不會出現數據不一致的現象,自然就不存在線程安全性問題瞭。

什麼是線程安全性

當多個線程訪問某個類時,不管運行時環境采用何種調度方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,達到預期的效果,那麼就稱這個類是線程安全的。

如下啟動10個線程,每個線程對inc執行1000次遞增,並添加一個計時線程,預期效果應為10000,而實際輸出值為6880,是一個小於10000的值,並未達到預期效果,因此INS類不是線程安全的,整個程序也不是線程安全的。原因是遞增操作不是原子操作,並且沒有適當的同步機制

package hgh0808;
public class Test {
    public static void main(String[] args){
        for(int i = 0;i < 10;i++){
            Thread th = new Thread(new CThread());
            th.start();
        }
        TimeThread tt = new TimeThread();
        tt.start();
        try{
            Thread.sleep(21000);
        }catch(Exception e){
            e.printStackTrace();
        }
        System.out.println(INS.inc);
    }
}
---------------------------------------------------------------------
package hgh0808;
import java.util.concurrent.atomic.*;
public class TimeThread extends Thread{
    @Override
    public void run(){
        int count = 1;
        for(int i = 0;i < 20;i++){
            try{
                Thread.sleep(1000);
            }catch(Exception e){
                e.printStackTrace();
            }
            System.out.println(count++);
        }
    }
}
---------------------------------------------------------------------
package hgh0808;
public class CThread implements Runnable{
    @Override
    public void run(){
        for(int j = 0;j < 1000;j++){
            INS.increase();
        }
    }
}
---------------------------------------------------------------------
package hgh0808;
public class INS{
    public static volatile int inc = 0;
    public static void increase(){
            inc++;
    }
}
=====================================================================

執行結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
6880

通過synchronized加鎖機制,對INS類實現同步,如下得到瞭正確的運行結果,很容易可以看出,主調代碼中並沒有任何額外的同步或協同,此時的INS類是線程安全的,整個程序也是線程安全的

package hgh0808;
public class INS{
    public static volatile int inc = 0;
    public static void increase(){
        synchronized (INS.class){
            inc++;
        }
    }
}

執行結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
10000

如何編寫線程安全的代碼
————————————————————————————————
如果當多個線程訪問同一個可變的狀態變量時沒有使用合適的同步,那麼程序就會出現錯誤,像上文中進行同步之前的代碼
有三種方式可以修復這個問題:
*不在線程之間共享該狀態變量
*將狀態變量修改為不可變的變量
*在訪問狀態變量時使用同步
前兩種方法是針對 共享 和 不變 這兩個屬性(見上文)解決問題,在有些情境下會違背程序設計的初衷(比如上文中INS類中的inc變量不可能不變,且在多核處理器的環境下為瞭提高程序性能,就需要多個線程同時處理,這樣變量就必然要被多個線程共享)。
基於此,我們針對第三種方式—— 在訪問狀態變量時使用同步 展開討論
在討論第三種方式之前,我們先介紹幾個簡單的概念
原子性 :一個操作序列的所有操作要麼不間斷地全部被執行,要麼一個也沒有執行
競態條件 :當某個計算的正確性取決於多個線程的的交替執行時序時,就會發生競態條件。通俗的說,就是某個程序結果的正確性取決於運氣時,就會發生競態條件。(競態條件並不總是會產生錯誤,還需要某種不恰當的執行時序)
常見的競態條件類型:
*檢查–執行(例如延遲初始化)
*讀取–修改–寫入(例如自增++操作)
針對以上兩種常見的競態條件類型,我們分別給出例子

延遲初始化(檢查--執行)
--------------------------------------------------------------------
package hgh0808;
import java.util.ArrayList;
public class Test1 {
    public ArrayList<Ball> list;
    public ArrayList<Ball> getInstance(){
        if(list == null){
            list = new ArrayList<Ball>();
        }
        return list;
    }
}
class Ball{
}

大概邏輯是先判斷list是否為空,若為空,創建一個新的ArrayList對象,若不為空,則直接使用已存在的ArrayList對象,這樣可以保證在整個項目中list始終指向同一個對象。這在單線程環境中是完全沒有問題的,但是如果在多線程環境中,list還未實例化時,A線程和B線程同時執行if語句,A和B線程都會認為list為null,A和B線程都會執行實例化語句,造成混亂。

自增++操作(讀取--修改--寫入)
------------------------------------------------------------------------
參考上文中為改進之前的代碼(對INS類中inc的自增)

以上兩個例子告訴我們,必須添加適當的同步策略,保證復合操作的原子性,防止競態條件的出現

策略一:使用原子變量類,在java.util.concurrent.atomic包中包含瞭一些原子變量類

在這裡插入圖片描述

package hgh0808;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

public class INS{
    public static AtomicInteger inc = new AtomicInteger(0);
    public static void increase(){
        inc.incrementAndGet();
    }
}

執行結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
10000

值得註意的是,隻有一個狀態變量時,可以通過原子變量類實現線程安全。但是如果有多個狀態變量呢?

設想一個情景

多個線程不斷產生1到10000的隨機數並且發送到一個計算線程,計算線程每獲取一個數字n,就計算sinx在[0,n]上的積分並打印到控制臺上,為瞭提高程序性能,設計一個緩存機制,保存上次的數字n和積分結果(兩個狀態變量)。如果本次的數字和上次的數字相等,直接打印積分結果,避免重復計算。

看代碼:

package hgh0808;
import java.util.concurrent.atomic.AtomicReference;

public class Calculate extends Thread{
    private final AtomicReference<Double> lastNumber  = new AtomicReference<Double>();  //緩存機制,原子變量類
    private final AtomicReference<Double> lastRes = new AtomicReference<Double>();      //緩存機制,原子變量類
    private static final double N = 100000;    //將區間[0,e]分成100000份,方便定積分運算
    public void service() throws Exception{
        getData();
        Thread.sleep(10000);   //等待MyQueue隊列中有一定數量的元素後,再開始從其中取元素
        while(true){
            Double e;
                if(!MyQueue.myIsEmpty()){
                     e = MyQueue.myRemove();
                }else{
                    return;
                }
            if(e.equals(lastNumber.get())){
                System.out.println(lastNumber.get()+" "+lastRes.get());
            }else{
                Double temp = integral(e);
                lastNumber.set(e);
                lastRes.set(temp);
                System.out.println(e+" "+temp);
            }
            Thread.sleep(2000);
        }
    }
    public void getData(){   //創建並啟動四個獲取隨機數的線程,這四個線程交替向MyQueue隊列中添加元素
        Thread1 th1 = new Thread1();
        Thread2 th2 = new Thread2();
        Thread3 th3 = new Thread3();
        Thread4 th4 = new Thread4();
        th1.start();
        th2.start();
        th3.start();
        th4.start();
    }
    public Double integral(double e){    //計算定積分
        double step = (e-0)/N;
        double left = 0,right = step;
        double sum = 0;
        while(right <= e){
            double mid = left+(right-left)/2;
            sum+=Math.sin(mid);
            left+=step;
            right+=step;
        }
        sum*=step;
        return sum;
    }
}
---------------------------------------------------------------------
package hgh0808;
import java.util.LinkedList;
public class MyQueue {      //由於LinkedList是線程不安全的,因此需要將其改寫為線程安全類
    private static LinkedList<Double> queue = new LinkedList<>();
    synchronized public static void myAdd(Double e){
        queue.addLast(e);
    }
    synchronized public static void myClear(){
        queue.clear();
    }
    synchronized public static int mySize(){
        return queue.size();
    }
    synchronized public static boolean myIsEmpty(){
        return queue.isEmpty();
    }
    synchronized public static double myRemove(){
        return queue.removeFirst();
    }
}
-----------------------------------------------------------------------
package hgh0808;
import java.util.Random;
public class Thread1 extends Thread{
    private double data;
    @Override
    public void run(){
        while(true){
            Random random = new Random();
            data = (double) (random.nextInt(10000)+1);
            if(MyQueue.mySize() > 10000){     //由於從隊列中取元素的速度低於四個線程向隊列中加元素的速度,因此隊列的長度是趨於擴張的,當達到一定程度時,清空隊列
                MyQueue.myClear();
            }
            MyQueue.myAdd(data);
            try {
                Thread.sleep(1000);
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }
}
------------------------------------------------------------------------
package hgh0808;
import java.util.Random;
public class Thread2 extends Thread{
    private double data;
    @Override
    public void run(){
        while(true){
            Random random = new Random();
            data = (double) (random.nextInt(10000)+1);
            if(MyQueue.mySize() > 10000){
                MyQueue.myClear();
            }
            MyQueue.myAdd(data);
            try {
                Thread.sleep(1000);
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }
}
-----------------------------------------------------------------------
package hgh0808;
import java.util.Random;
public class Thread3 extends Thread{
    private double data;
    @Override
    public void run(){
        while(true){
            Random random = new Random();
            data = (double) (random.nextInt(10000)+1);
            if(MyQueue.mySize() > 10000){
                MyQueue.myClear();
            }
            MyQueue.myAdd(data);
            try {
                Thread.sleep(1000);
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }
}
------------------------------------------------------------------------
package hgh0808;
import java.util.Random;
public class Thread4 extends Thread{
    private double data;
    @Override
    public void run(){
        while(true){
            Random random = new Random();
            data = (double) (random.nextInt(10000)+1);
            if(MyQueue.mySize() > 10000){
                MyQueue.myClear();
            }
            MyQueue.myAdd(data);
            try {
                Thread.sleep(1000);
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }
}

隻看Calculate線程,不看其他線程和MyQueue中的鎖機制,本問題的焦點在於Calculate線程中對多個狀態變量的同步策略

存在問題:

盡管對lastNumber和lastRes的set方法的每次調用都是原子的,但仍然無法同時更新lastNumber和lastRes;如果隻修改瞭其中一個變量,那麼在這兩次修改操作之間,其它線程將發現不變性條件被破壞瞭。換句話說,就是沒有足夠的原子性

**當在不變性條件中涉及多個變量時,各個變量間並不是彼此獨立的,而是某個變量的值會對其它變量的值產生約束。因此當更新某一個變量時,需要在同一個原子操作中對其他變量同時進行更新。

改進 ================>加鎖機制 內置鎖 synchronized

之所以每個對象都有一個內置鎖,隻是為瞭免去顯式地創建鎖對象

synchronized修飾方法就是橫跨整個方法體的同步代碼塊

非靜態方法的鎖—–方法調用所在的對象

靜態方法的鎖—–方法所在類的class對象

public class Calculate extends Thread{
    private final AtomicReference<Double> lastNumber  = new AtomicReference<Double>();  //緩存機制,原子變量類
    private final AtomicReference<Double> lastRes = new AtomicReference<Double>();      //緩存機制,原子變量類
    private static final double N = 100000;    //將區間[0,e]分成100000份,方便定積分運算
    public void service() throws Exception{
        getData();
        Thread.sleep(10000);   //等待MyQueue隊列中有一定數量的元素後,再開始從其中取元素
        while(true){
            Double e;
            synchronized (this){    //檢查--執行 使用synchronized同步,防止出現競態條件
                if(!MyQueue.myIsEmpty()){
                     e = MyQueue.myRemove();
                }else{
                    return;
                }
            }
            if(e.equals(lastNumber.get())){
                System.out.println(lastNumber.get()+" "+lastRes.get());
            }else{
                Double temp = integral(e);
                synchronized (this) {     //兩個狀態變量在同一個原子操作中更新
                    lastNumber.set(e);
                    lastRes.set(temp);
                }
                System.out.println(e+" "+temp);
            }
            Thread.sleep(2000);
        }
    }
    public void getData(){   //創建並啟動四個獲取隨機數的線程,這四個線程交替向MyQueue隊列中添加元素
        Thread1 th1 = new Thread1();
        Thread2 th2 = new Thread2();
        Thread3 th3 = new Thread3();
        Thread4 th4 = new Thread4();
        th1.start();
        th2.start();
        th3.start();
        th4.start();
    }
    public Double integral(double e){    //計算定積分
        double step = (e-0)/N;
        double left = 0,right = step;
        double sum = 0;
        while(right <= e){
            double mid = left+(right-left)/2;
            sum+=Math.sin(mid);
            left+=step;
            right+=step;
        }
        sum*=step;
        return sum;
    }
}

對於包含多個變量的不變性條件中,其中涉及的所有變量都需要由同一個鎖來保護

synchronized (this) {     //兩個狀態變量在同一個原子操作中更新
                    lastNumber.set(e);
                    lastRes.set(temp);
                }

鎖的重入

如果某個線程試圖獲得一個已經由它自己持有的鎖,那麼這個請求就會成功,“重入”意味著獲取鎖的操作的粒度是‘線程’,而不是‘調用’。

重入的一種實現方式 :

為每個鎖關聯一個獲取計數值和一個所有者線程。當計數值為0時,這個鎖就被認為是沒有被任何線程持有。當線程請求一個未被持有的鎖時,JVM將記下鎖的持有者,並且將獲取計數值置為1。如果同一個線程再次獲取這個鎖,計數值將遞增,當線程退出同步代碼塊時,計數器會相應地遞減。當計數值為0時,這個鎖將被釋放。

如果內置鎖不可重入,那麼以下這段代碼將發生死鎖(每個doSomething方法在執行前都會獲取Father上的內置鎖)
----------------------------------------------------------------------
public class Father{
  public synchronized void doSomething(){
  }
}

public class Son extends Father{
   @Override
   public synchronized void doSomething(){
       System.out.println("重寫");
       super.doSomething();
   }
}

線程安全性與性能和活躍性之間的平衡

活躍性:是否會發生死鎖饑餓等現象
性能:線程的並發度
不良並發的應用程序:可同時調用的線程數量,不僅受到可用處理資源的限制,還受到應用程序本身結構的限制。幸運的是,通過縮小同步代碼塊的作用范圍,可以平衡這個問題。
縮小作用范圍的原則====>當執行時間較長的計算或者可能無法快速完成的操作時,一定不能持有鎖!!!

總結

本篇文章就到這裡瞭,希望能給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!

推薦閱讀: