多線程同步原理

前言

  • 今天主要學習Java多線程中線程安全的相關知識,主要包括簡單介紹線程的建立、詳細講解同步的原理以及讀寫鎖等其餘基礎知識。對於多年Java開發老司機,能夠跳過線程建立部分的知識。
  • 如今咱們發車了~

目錄

圖片

1、多線程基礎

1.1 進程與線程

nullhtml

面試題: 說一說你對線程和進程的理解

  • 進程是資源分配的最小單位,線程是程序執行的最小單位。
  • 進程有本身的獨立地址空間,每啓動一個進程,系統就會爲它分配地址空間,創建數據表來維護代碼段、堆棧段和數據段,這種操做很是昂貴。而線程是共享進程中的數據的,使用相同的地址空間,所以CPU切換一個線程的花費遠比進程要小不少,同時建立一個線程的開銷也比進程要小不少。
  • 線程之間的通訊更方便,同一進程下的線程共享全局變量、靜態變量等數據,而進程之間的通訊須要以通訊的方式(IPC)進行。不過如何處理好同步與互斥是編寫多線程程序的難點。
  • 可是多進程程序更健壯,多線程程序只要有一個線程死掉,整個進程雖然不會死掉,可是功能會受影響,而一個進程死掉並不會對另一個進程形成影響,由於進程有本身獨立的地址空間

用生活中的場景來比喻的話呢,就是假設你住在一個小區,這個小區就是一個操做系統,你家就是一個進程,你家的柴米油鹽是不跟其餘戶人家共享的,爲何?由於大家互相之間不要緊。這個柴米油鹽就是資源。 線程就是大家這個家的人,大家互相之間同時運行,能夠同時幹本身的事情。java

1.2 線程建立的方式

線程建立的方式主要包括:面試

圖片

  • 繼承Thread類建立線程
/**
     * 使用 Thread 類來定義工做
     */
    static void thread() {
        Thread thread = new Thread() {
            @Override
            public void run() {
                System.out.println("Thread started!");
            }
        };
        thread.start();
複製代碼
  • }

實現Runnable接口建立線程編程

/**
     * 使用 Runnable 類來定義工做
     */
    static void runnable() {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("Thread with Runnable started!");
            }
        };
        Thread thread = new Thread(runnable);
        thread.start();
    }
複製代碼
  • 使用Callable和Future建立線程

Callable是有返回值的Runnable。小程序

static void callable() {
        Callable<String> callable = new Callable<String>() {
            @Override
            public String call() {
                try {
                    Thread.sleep(1500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                return "Done!";
            }
        };


        ExecutorService executor = Executors.newCachedThreadPool();
        Future<String> future = executor.submit(callable);
        try {
            String result = future.get(); //get是一個阻塞方法,雖然你換了個線程,可是你取數據的時候仍是會卡住
            System.out.println("result: " + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
複製代碼

feature.get()是一個阻塞方法,那麼有沒有辦法不卡住線程呢? 答案是有的,那就是循環去查:緩存

 Future<String> future = executor.submit(callable);
        try {
          while(!future.isDone){
        //檢查是否已經完成,若是否,那麼可讓主線程去作其餘操做,不會被阻塞
       
          }
            String result = future.get(); //get是一個阻塞方法,雖然你換了個線程,可是你取數據的時候仍是會卡住
            System.out.println("result: " + result);
            
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
複製代碼
  • Executors

JDK 1.5後引入的Executor框架的最大優勢是把任務的提交和執行解耦。經過Executors 的工具類能夠建立如下類型的線程池: 安全

圖片
下面介紹經常使用的兩種線程池: (1)FixThreadPool : 建立固定大小的線程池。線程池的大小一旦達到最大值就會保持不變,若是某個線程由於執行異常而結束,那麼線程池會補充一個新線程。 適用於集中處理多個任務。 舉個例子:若是如今我須要優先處理一下圖片,可是處理完就釋放掉這些線程,那麼代碼能夠這麼寫:

ExecutorService imageProcessor = Executor.newFixedThreadPool(); //我須要你立刻給我不少個線程,而後一旦用完我就不要了

List<Image>  images; //圖片集合
 for(Image image : images){
  //處理圖片
  improcessor.excutor(iamgeRunnable,image);
 }
  //等圖片處理完成後終止線程
 imageProcessor.shutdown();
複製代碼

(2)cacheThreadPool 緩存線程池 當提交任務速度高於線程池中任務處理速度時,緩存線程池會不斷的建立線程 適用於提交短時間的異步小程序,以及負載較輕的服務器bash

static void executor() {
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("Thread with Runnable started!");
        }
    };


    Executor executor = Executors.newCachedThreadPool();
    executor.execute(runnable);
    executor.execute(runnable);
    executor.execute(runnable);
}
複製代碼
  • shutdown和shutdownNow方法的使用

Executor接口裏面有兩個重要的方法,一個是shutdown,一個是shutdownNow。 他們兩個的區別是:shutdown再也不容許扔新的runnable進來。shutdownNow不僅是新的不容許,就算是正在執行的任務也不容許再繼續執行。服務器

🙋‍♀️闢謠: 網上有一個說法是:建立的線程池大小,取決於CPU的核數。 好比,你CPU有8個核,就建立8個線程,每一個線程分配給你一個核,這樣想一想頗有道理。可是實際上是沒道理的!你有8個核,你就佔了全部的核了嗎?不是這樣的。不過,你的線程數跟你的CPU掛鉤是有道理,它可讓你的軟件在不一樣機器上表現相對一致。 因此,你的線程數跟你的CPU掛鉤有道理,可是線程數=CPU核數就沒道理了。你們記住了吧。多線程

2、線程同步

線程同步主要包括如下內容:

圖片

2.1 JVM內存模型

在說明synchronized爲何能保證線程安全以前,咱們先簡單過一下JVM內存模型。

圖片
Java的內存模型有如下特色:

  • Java全部變量都存儲在主內存中
  • 每一個線程都有本身獨立的工做內存,裏面保存該線程使用到的變量副本
  • 線程對共享變量的全部操做都必須在本身的工做內存中進行,不能直接在主內存讀寫
  • 不一樣線程之間沒法訪問其餘線程內存中的變量,線程間變量值的傳遞須要經過主內存來完成。線程1對共享變量的修改,要想被線程2及時看到,必須通過以下2個過程:

(1)把工做內存1中更新過的共享變量刷新到主內存中 (2)將主內存中最新的共享變量的值更新到工做內存2中

2.2 可見性

若是一個線程對共享變量的修改,可以被其餘線程看到,那麼就說此時是可見的。

2.3 原子性

原子性也就是不可再分,不能再分爲分步操做。 好比:

int a =1 ;//是原子操做
a+= 1;//不是原子操做
 
a+=1 實際分爲三步:
1. 取出a = 1
2. 計算a + 1
3. 將計算結果寫入內存
複製代碼

2.4 重排序

在Java中,代碼書寫的順序並不等於代碼執行的順序。有時候,編譯器或者處理器爲了能提升程序性能,會對代碼指令進行重排序。

重排序不會給單線程帶來內存可見性問題,可是在進行多線程編程時,重排序可能會形成內存可見性問題。

舉個例子:

int num1= 1; //第一行代碼
int num2 = 2; //第二行代碼
int sum = num1 + num2; //第三行代碼

//在進行重排序的時候,若是將sum = num1 + num2 先於前兩行代碼執行,此時計算結果就會出錯;
複製代碼

固然,重排序的內容不是本文重點,有興趣的讀者自行百度。

3、synchronized

3.1 做用

synchronized能夠保證在同一時刻只有一個線程執行被synchronized修飾的方法/代碼,即保證操做的原子性和可見性。

3.2 基本使用

synchronized能夠被用在三個地方:

  • 實例方法
  • 靜態方法
  • 代碼塊

下面咱們經過代碼來進行實踐一下。

3.2.1 synchronized做用於實例方法

當沒有明確給synchronized指明鎖時,默認獲取到的是對象鎖。

public synchronized void Method1(){ 
        System.out.println("我是對象鎖也是方法鎖"); 
        try{ 
            Thread.sleep(500); 
        } catch (InterruptedException e){ 
            e.printStackTrace(); 
        } 
 
    } 
複製代碼

3.2.2 synchroinzed做用於靜態方法

// 類鎖:鎖靜態方法
  public static synchronized void Method1(){ 
        System.out.println("我是類鎖"); 
        try{ 
            Thread.sleep(500); 
        } catch (InterruptedException e){ 
            e.printStackTrace(); 
        }
  }
複製代碼

3.2.3 synchronized做用於代碼塊

// 類鎖:鎖靜態代碼塊
    public void Method2(){ 
        synchronized (Test.class){ 
            System.out.println("我是類鎖"); 
            try{ 
                Thread.sleep(500); 
            } catch (InterruptedException e){ 
                e.printStackTrace(); 
            } 
        }  
    } 
    

  // 對象鎖
    public void Method(){ 
        synchronized (this){ 
            System.out.println("我是對象鎖"); 
            try{ 
                Thread.sleep(500); 
            } catch (InterruptedException e){ 
                e.printStackTrace(); 
            } 
        } 
    } 
 }
複製代碼

3.3 工做原理

synchronized的工做流程是:

  • 獲取互斥鎖
  • 清空工做內存中的共享變量的值
  • 在主內存中拷貝最新變量的副本到工做內存
  • 執行代碼
  • 將更改後的共享變量的值刷新到主內存中
  • 釋放互斥鎖

synchronized可以實現原子性和可見性,本質上依賴的是 底層操做系統的 互斥鎖機制。

3.4 單例寫法討論

你們平時在寫單例模式的時候,確定知道用雙重鎖的方式,那麼,爲何不用下面這種方式,這種方式存在什麼缺點?

static synchroinzed SingleMan newInstance(){
   if(sInstance = null){
     sInstance= new SingleMan();
   }
}
複製代碼

這個寫法有什麼壞處呢? 壞處是,把synchronized加上方法上時,做用的是整個對象的資源,當其餘訪問這個對象中的其餘資源時,也須要等待。代價很是大。 舉個例子:

public synchronized void setX(int x){
   this.x = x; 
}

public synchronized int getY(){
  return this.y;
}

//當調用setX方法時,若是此時有其餘線程想要調用getY方法,那麼須要進行等待,由於此時鎖已經被當前線程拿了。因此若是把synchroinzed加在方法上時,就算操做的不是相同的資源,也須要等待。代價比較大。
複製代碼

那麼,好的單例模式的寫法是什麼呢?答案是 不要使用對象鎖,使用局部鎖:

private static volatile SingleMan sInstance; //這裏爲何要用volatile呢?由於有些對象在還沒初始化完成的時候,對外就已經暴露不爲空,可是此時還不能用,若是此時有線程使用了這個對象,就會有問題。加入volatile就能夠同步狀態

static  SingleMan newInstance(){
  if(sInstance = null){ //可能有兩個線程同時到了這個地方,都以爲是空,而後可能會同時去嘗試拿monitor,而後另一個進入等待,當對象初始化後,等待的線程往下走,此時就已經不爲空。因此,須要雙重檢查
    synchroinzed(SingleMan.class){
      if(sInstance = null){
        sInstance= new SingleMan();
  	 }
    }
  } 
}
複製代碼

4、volatile

4.1 基本使用

volatile關鍵字只能用於修飾變量,沒法用於修飾方法。而且volatile只能保證可見性,但不能保證操做的原子性。在具體編程中體現爲:volatile只能保證基本類型以及通常對象的引用賦值是線程安全的。舉個例子:

volatile User user;
private void setUserName(String userName){
   user.name = userName;//不安全的
}
private void setUser(User user){
   this.user = user;//安全的,只能保證引用
}
複製代碼

4.2 工做原理

爲何volatile只能保證可見性,不能保證原子性呢? 這跟它的工做原理有關。

線程寫volaitle變量的步驟爲:

  1. 改變線程工做內存中volatile變量副本的值
  2. 將改變後的副本的值從工做內存刷新到主內存

線程讀volatile變量的步驟爲:

  1. 從主內存讀取volatile變量的最新值到線程的工做內存中
  2. 從工做內存中讀取volatile變量的副本

因爲在整個過程沒有涉及到鎖相關的操做,因此沒法保證原子性,可是因爲實時刷新了主內存中的變量值,所以任什麼時候刻,不一樣線程總能看到該變量的最新值,保證了可見性。

下面出個練習來練練手: 有下面👇這麼一句代碼:

private volatile int number =0;
複製代碼

問:當建立500個線程同時操做number ++ 時,是否能保證最終打印的值是500?

答案:不能;由於number++不是原子操做,而volatile沒法保證原子性。

那要如何改呢?

解法1:synchronized關鍵字
synchronized(this){
number++;
}
解法2:使用ReentrankLock
private ReentrankLock lock = new ReentrankLock();
lock.lock();
try{
number++;
}finally{
lock.unlock();
}
解法3: 將int改爲AtomicIntege
複製代碼

4.3 適用場合

要在多線程中安全的使用volatile變量,必須同時知足:

  • 對變量的吸入操做不依賴其當前值
    • 不知足舉例:number++、count = count + 5
    • 知足舉例: boolean 變量等
  • 該變量沒有包含在具備其餘變量的不等式中
    • 不知足舉例:不變時low < up

在實際項目中,因爲不少狀況下都不滿意volatile的使用條件,因此volatile使用的場景並無synchronized廣。

4.4 synchronized和volatile比較

圖片

4.5 注意事項

在Java中,對64位(long、double)變量的讀寫可能不是原子操做,由於Java內存模型容許JVM將沒有被volatile修飾的64位數據類型的讀寫操做劃分爲兩次32位的讀寫操做來進行。 所以致使:有可能會出現讀取到」半個變量「的狀況; 解決方案是:加volatile關鍵字。

這裏有同窗可能會問啦,不是說volatile不保證原子性嗎?爲何對於64位類型的變量用volatile修飾? 緣由是:volatile自己不保證獲取和設置操做的原子性,僅僅保持修改的可見性。可是java的內存模型保證聲明爲volatile的long和double變量的get和set操做是原子的。

5、讀寫鎖

請你們先思考如下問題: 對於一個公共變量,若是:

  1. 同時兩個線程都在寫,會出問題嗎?
  2. 當一個線程在對變量進行寫的時候,有另一個線程想讀呢?會出問題嗎 ?
  3. 當一個線程在對變量進行讀的時候,有另一個線程寫呢?會出問題嗎?
  4. 當一個線程在對變量進行讀的時候,有另一個線程也準備讀呢?會出問題嗎?

答案是:一、二、3會出問題,4不會出問題。一、二、3出問題的緣由在於有一個線程對變量進行了修改,此時會致使數據發生改變,若是有另一個線程要進行讀取,會出現讀取的數據可能出錯。可是,當兩個線程同時進行讀操做的時候,是OK的,不會出現你讀出來是個1,我讀出來是個2的問題。

由於,當多個線程同時進行讀操做的時候,咱們就沒有必要進行同步,浪費資源。爲了減小這種資源浪費,讀寫鎖就出現了~

5.1 讀寫鎖的定義

讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,同一時刻,能夠有多個線程拿到讀鎖,可是隻有一個線程拿到寫鎖。 總結起來爲:讀讀不互斥,讀寫互斥,寫寫互斥。

5.2 讀寫鎖的使用

讀寫鎖在Java中是ReentrantReadWriteLock,使用方式是:

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDemo implements TestDemo {
    ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();


    private int x = 0;


    private void count() {
        writeLock.lock();
        try {
            x++;
        } finally {
            writeLock.unlock();// 保證當讀的時候若是出現異常,會釋放鎖,synchronized爲何不用呢?由於synchronized內部已經幫咱們作了~
        }
    }


    private void print(int time) {
        readLock.lock();
        try {
            for (int i = 0; i < time; i++) {
                System.out.print(x + " ");
            }
            System.out.println();
        } finally {
            readLock.unlock();// 保證當讀的時候若是出現異常,會釋放鎖,synchronized爲何不用呢?由於synchronized內部已經幫咱們作了~
        }
    }


    @Override
    public void runTest() {
    }
}
複製代碼

6、Atomic包

這個包裏的類自己就被設計成原子的,能夠方便咱們實現線程安全。 好比:

int count ;
//若是你想保證count++是安全的,可是不想用synchronized,那麼使用AtomicInteger;
複製代碼

7、面試題

好了,到這裏本篇文章就已經結束了。在此次的文章中,咱們主要簡單介紹了線程和進程,詳細瞭解了synchronized和volatile的工做原理,並對他們二者的使用場景進行了比較。相信你對多線程應該已經稍微熟悉一點了,如今來幾道面試練練手,加深印象吧~ Java線程面試題Top50

對於面試題裏面的notify,wait,sleep等線程通訊知識不瞭解的童鞋,歡迎期待下一場chat~

參考文章

相關文章
相關標籤/搜索