為什么會有多線程?什么是線程安全?如何保證線程安全?

本文將會回答這幾個問題:

  1. 為什么會有多線程
  2. 什么是線程安全?
  3. 怎么樣保證線程安全?

為什么會有多線程

顯然,線程安全的問題只會出現在多線程環境中,那么為什么會有多線程呢?
最早期的計算機十分原始,還沒有操作系統。想要使用計算機時,人們先把計算機可以執行的指令刻在紙帶上,然后讓計算機從紙帶上讀取每一條指令,依次執行。這時候的計算機每次只能執行一個任務,是地地道道的單線程。
這種情況下就產生了三個問題:
1. 計算資源的嚴重浪費
計算機在執行任務時,總少不了一些輸入輸出操作,比如計算結果的打印等。這時候CPU只能等待輸入輸出的完成。所以往往一個任務執行下來,可能CPU大部分人時間都是空閑的。而在當時CPU可是一種非常昂貴的資源,于是人們就想怎么能夠提高CPU的利用率呢?
2. 任務分配的不公平
現在假如我們有十個任務需要執行,這可是很常見的。而計算機每次只能執行一個任務,直到執行結束,中間不能中斷。那么問題來了,是先執行張三給的任務呢?還是先干李四的活呢?張三和李四可能擁有同樣的優先級,因此無論怎么分配任務總會有人不滿意,覺得不公平。
3. 程序編寫十分困難
計算機一次只能執行一個任務,所以編寫程序的時候往往要把很多工作集成到一個程序中,這給程序的編寫人員帶來了極大的挑戰。能不能把程序分模塊編寫,然后讓模塊之間只進行必要的通信呢?
為了解決這些問題,計算機操作系統應運而生。操作系統就是管理計算機硬件與軟件資源的計算機程序。那么操作系統如何同時執行多個任務呢?操作系統給每個任務分配一個進程,然后給進程分配相應的計算資源、IO資源等,這樣進程就能執行起來了。操作系統會控制多個進程之間的切換,給每個進程分配一定的執行時間,然后再切換另一個進程,這樣多個進程便可以輪流著交替執行。因為輪流的時間很短,用戶會覺得仿佛在獨占計算機資源來執行自己的任務。
進程雖然一定程度上緩解了我們提到的那三個問題,但是還是會存在問題。給大家舉兩個例子。一個例子是進程只能干一件事,或者說進程中的代碼是串行執行的。這有什么問題嗎?當然有。比如我們用軟件安裝包安裝一個程序,安裝過程中突然不想安裝了,然后點擊了取消按鈕,結果你發現程序并沒有取消安裝。為什么呢?因為進程正在執行安裝程序的代碼,用戶的輸入只有等待安裝程序的代碼完成之后才能執行。所以你發現等進程響應了你取消安裝的輸入時,其實安裝程序早已執行完成。用專業術語來說,就是用戶接口的響應性太差了,用戶的輸入不能第一時間響應,甚至出現界面假死現象。另一個例子是現在大部分的處理器是多處理器,比如現在有一個雙處理器,而只有一個任務。那么這個任務只能由一個進程來執行,而一個進程只能由一個處理器來執行,那么就有50%的計算資源被浪費了。
這時候,就要說到線程了。線程是進程中實施調度和分派的基本單位。一個進程可以有多個線程,但至少有一個線程;而一個線程只能在一個進程的地址空間內活動。內存資源分配給進程,同一個進程的所有線程共享該進程所有資源。而CPU分配給線程,即真正在處理器運行的是線程。多線程的出現便解決了我們之前提到的三個問題,但是多線程往往會帶來許多意想不到的問題,這就是接下來我們要說的線程安全了。

什么是線程安全

在談什么是線程安全的問題之前,先給大家舉一個線程不安全的例子,直接上代碼

public class Test {
     private  static  int count;
    private static class Thread1 extends Thread {
        public void run() {
            for (int i = 0; i < 1000; i++) {
                count ++;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread1  t1 = new Thread1();
        Thread1  t2 = new Thread1();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

這段代碼實現的邏輯很簡單,首先定義了一個int型的count變量,然后開啟了兩個線程,每個線程執行1000次循環,循環中對count進行加1操作。等待兩個線程都執行完成后,打印count的值。那么這段代碼的輸出結果是多少呢?可能很多人會說是2000。但是程序運行后卻發現結果大概率不是2000,而是一個比2000略小的數,比如1998這樣,而且每次運行的結果可能都不相同。
那么這是為什么呢?這就是線程不安全。線程安全是指在多線程環境下,程序可以始終執行正確的行為,符合預期的邏輯。比如我們剛剛的程序,共兩個線程,每個線程對count變量累加1000次,預期的邏輯是count被累加了2000次,而代碼執行的結果卻不是2000,所以它是線程不安全的。
為什么是不安全的呢?因為count++的指令在實際執行的過程中不是原子性的,而是要分為讀、改、寫三步來進行;即先從內存中讀出count的值,然后執行+1操作,再將結果寫回內存中,如下圖所示。

image

這就是線程在計算機中真實的執行過程,看起來好像沒問題啊,別急,再看一張圖

image

看出來問題了么?上圖中線程1執行了兩次自加操作,而線程2執行了一次自加操作,但是count卻從6變成了8,只加了2.我們看一下為什么會出現這種情況。當線程1讀取count的值為6完成后,此時切換到了線程2執行,線程2同樣讀取到了count的值為6,而后進行改和寫操作,count的值變為了7;此時線程又切回了線程1,但是線程1中count的值依然是線程2修改前的6,這就是問題所在!!!即線程2修改了count的值,但是這種修改對線程1不可見,導致了程序出現了線程不安全的問題,沒有符合我們預期的邏輯。

相信大家現在已經對線程不安全已經有了一定的認識了。現在我們總結一下導致線程不安全的原因,主要有三點:

  • 原子性:一個或者多個操作在 CPU 執行的過程中被中斷
  • 可見性:一個線程對共享變量的修改,另外一個線程不能立刻看到
  • 有序性:程序執行的順序沒有按照代碼的先后順序執行
    前兩點前面已經舉例了,現在在解釋一下第三點。為什么程序執行的順序會和代碼的執行順序不一致呢?java平臺包括兩種編譯器:靜態編譯器(javac)和動態編譯器(jit:just in time)。靜態編譯器是將.java文件編譯成.class文件(二進制文件),之后便可以解釋執行。動態編譯器是將.class文件編譯成機器碼,之后再由jvm運行。問題一般會出現在動態編譯器上,因為動態編譯器為了程序的整體性能會對指令進行重排序,雖然重排序可以提升程序的性能,但是重排序之后會導致源代碼中指定的內存訪問順序與實際的執行順序不一樣,就會出現線程不安全的問題。

如何保證線程安全

下面簡單談談針對以上的三個問題,java程序如何保證線程安全呢?
針對問題1:JDK里面提供了很多atomic類,比如AtomicInteger, AtomicLong, AtomicBoolean等等,這些類本身可以通過CAS來保證操作的原子性;另外Java也提供了各種鎖機制,來保證鎖內的代碼塊在同一時刻只能有一個線程執行,比如剛剛的例子我們就可以加鎖,如下:
java synchronized (Test.class){ count ++; }
這樣,就能夠保證一個線程在多count值進行讀、改、寫操作時,其他線程不可對count進行操作,從而保證了線程的安全性。
針對問題2:同樣可以通過synchronized關鍵字加鎖來解決。與此同時,java還提供了一種輕量級的鎖,即volatile關鍵字,要優于synchronized的性能,同樣可以保證修改對其他線程的可見性。volatile一般用于對變量的寫操作不依賴于當前值的場景中,比如狀態標記量等。
針對問題3:可以通過synchronized關鍵字定義同步代碼塊或者同步方法保障有序性,另外也可以通過Lock接口保障有序性。
怎么樣?現在是不是對線程安全有了更加深入的理解了呢?

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容