天天看點

Java 多線程之 synchronized 和 volatile 的比較概述版權說明記憶體語義分析執行個體論證Ref征集

概述

在做多線程并發處理時,經常需要對資源進行可見性通路和互斥同步操作。有時候,我們可能從前輩那裡得知我們需要對資源進行 volatile 或是 synchronized 關鍵字修飾處理。可是,我們卻不知道這兩者之間的差別,我們無法分辨在什麼時候應該使用哪一個關鍵字。本文就針對這個問題,展開讨論。

版權說明

著作權歸作者所有。

商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

本文作者:Coding-Naga

發表日期: 2016年4月5日

本文連結:http://blog.csdn.net/lemon_tree12138/article/details/51062421

來源:CSDN

更多内容:分類 >> 并發與多線程

記憶體語義分析

happens-before 模型簡介

如果你單從字面上的意思來了解 happens-before 模型,你可能會覺得這是在說某一個操作在另一個操作之前執行。不過,學習完 happens-before 之後,你就不會還這樣了解了。以下是《Java 并發程式設計的藝術》書上對 happens-before 的定義:

在 JMM(Java Memory Model) 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關系。這裡提到的兩個操作既可以在一個線程之内,也可以是在不同的線程之間。

volatile 的記憶體語義

對于多線程程式設計來說,每個線程是可以擁有共享記憶體中變量的一個拷貝,這一點在後面還是會講到,這裡就不作過多說明。如果一個變量被 volatile 關鍵字修飾時,那麼對這的變量的寫是将本地記憶體中的拷貝重新整理到共享記憶體中;對這個變量的讀會有一些不同,讀的時候是無視他的本地記憶體的拷貝的,隻是從共享變量中去讀取資料。

synchronized 的記憶體語義

我們說 synchronized 實際上是對變量進行加鎖處理。那麼不管是讀也好,寫也好都是基于對這個變量的加鎖操作。如果一個變量被 synchronized 關鍵字修飾,那麼對這的變量的寫是将本地記憶體中的拷貝重新整理到共享記憶體中;對這個變量的讀就是将共享記憶體中的值重新整理到本地記憶體,再從本地記憶體中讀取資料。因為全過程中變量是加鎖的,其他線程無法對這個變量進行讀寫操作。是以可以了解成對這個變量的任何操作具有原子性,即線程是安全的。

執行個體論證

上面的一些說明或是定義可能會有一些乏味枯燥,也不太好了解。這裡我們就列舉一些例子來說明,這樣比較具體和形象一些。

volatile 可見性測試

RunThread.java

public class RunThread extends Thread {
    private boolean isRunning = true;
    
    public boolean isRunning() {
        return isRunning;
    }
    
    public void setRunFlag(boolean flag) {
        isRunning = flag;
    }
    
    @Override
    public void run() {
        System.out.println("I'm come in...");
        boolean first = true;
        while(isRunning) {
            if (first) {
                System.out.println("I'm in while...");
                first = false;
            }
        }
        System.out.println("I'll go out.");
    }
}
           

MyRun.java

public class MyRun {
    public static void main(String[] args) throws InterruptedException {
        RunThread thread = new RunThread();
        thread.start();
        Thread.sleep(100);
        thread.setRunFlag(false);
        System.out.println("flag is reseted: " + thread.isRunning());
    }
}
           

對于上面的例子隻是一個很普通的多線程操作,這裡我們很容易就得到了 RunThread 線程在 while 中進入了死循環。

我們可以在 main() 方法裡看到一句 Thread.sleep(100) ,結合前面說到的 happens-before 記憶體模型,可知下面的 thread.setRunFlag(false) 并不會 happens-before 子線程中的 while 。這樣一來,雖然主線程中對 isRunning 進行了修改,然而對子線程中的 while 來說,并沒有改變,是以這就會引發在 while 中的死循環。

在這種情況下,線程工作時的記憶體模型像下面這樣

Java 多線程之 synchronized 和 volatile 的比較概述版權說明記憶體語義分析執行個體論證Ref征集

在這裡,可能你會奇怪,為什麼會有兩個“記憶體塊”?這是出于多線程的性能考慮的。雖然對象以及成員變量配置設定的記憶體是在共享記憶體中的,不過對于每個線程而言,還是可以擁有這個對象的拷貝,這樣做的目的是為了加快程式的執行,這也是現代多核處理器的一個顯著特征。從上面的記憶體模型可以看出,Java的線程是直接與它自身的工作記憶體(本地記憶體)互動,工作記憶體再與共享記憶體互動。這樣就形成了一個非原子的操作,在Java裡多線程的環境下非原子的操作是很危險的。這個我們都已經知道了,因為這可能會被異步的讀寫操作所破壞。

這裡工作記憶體被 while 占用,無法去更新主線程對共享記憶體 isRunning 變量的修改。是以,如果我們想要打破這種限制,可以通過 volatile 關鍵字來處理。通過 volatile 關鍵字修飾 while 的條件變量,即 isRunning。就像下面這樣修改 RunThread.java 代碼:

這樣一來, volatile 修改了 isRunning 的可見性,使得主線程的 thread.setRunFlag(false) 将會 happens-before 子線程中的 while 。最終,使得子線程從 while 的循環中跳出,問題解決。

下面我們來看看 volatile 是如何修改了 isRunning 的可見性的吧。

Java 多線程之 synchronized 和 volatile 的比較概述版權說明記憶體語義分析執行個體論證Ref征集

這裡,因為 isRunning 被 volatile 修飾,那麼當子線程想要通路工作記憶體中的 inRunning 時,被強制地直接從共享記憶體中擷取。而共享記憶體中的 isRunning 被主線程修改過了,已經被修改成了 false ,while 被打破,這樣子線程就從 while 的循環中跳出來了。

volatile 原子性測試

volatile 确實有很多優點,可是它卻有一個緻命的缺點,那就是 volatile 并不是原子操作。也就是在多線程的情況,仍然是不安全的。

可能,這個時候你會發問說,既然 volatile 保證了它線上程間的可見性,那麼在什麼時候修改它,怎麼修改它,對于其他線程是可見的,某一個線程讀到的都會是修改過的值,為什麼還要說它還是不安全的呢?

我們通過一個例子來說明吧,這樣更形象一些。大家看下面這樣一段代碼:

public class DemoNoProtected {

    static class MyThread extends Thread {
        static int count = 0;
        
        private static void addCount() {
            for (int i = 0; i < 100; i++) {
                count++;
            }
            System.out.println("count = " + count);
        }
        
        @Override
        public void run() {
            addCount();
        }
    }
    
    public static void main(String[] args) {
        MyThread[] threads = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            threads[i] = new MyThread();
        }
        
        for (int i = 0; i < 100; i++) {
            threads[i].start();
        }
    }
}
           
count = 300
count = 300
count = 300
count = 400
... ...
count = 7618
count = 7518
count = 9918
           

這是一個未經任何處理的,很直白的過程。可是它的結果,也很直白。其實這個結果并不讓人意外,從我們學習Java的時候,就知道Java的多線程并不安全。是不是從上面的學習中,你感覺這個可以通過 volatile 關鍵字解決?既然你這麼說,那麼我們就來試一試,給 count 變量添加 volatile 關鍵字,如下:

public class DemoVolatile {
    static class MyThread extends Thread {
        static volatile int count = 0;
        ... ...
    }
    
    public static void main(String[] args) {
        ... ...
    }
}
           
count = 100
count = 300
count = 400
count = 200
... ...
count = 9852
count = 9752
count = 9652
... ...
count = 8154
count = 8054
           

不知道這個結果是不是會讓你感覺到意外。對于 count 的混亂的數字倒是好了解一些,應該多個線程同時修改時就發生這樣的事情。可是我們在結果為根本找不到邏輯上的最大值“10000”,這就有一些奇怪了。因為從邏輯上來說, volatile修改了 count 的可見性,對于線程 A 來說,它是可見線程 B 對 count 的修改的。隻是從結果中并沒有展現這一點。

我們說,volatile并沒有保證線程安全。在上面子線程中的 addCount() 方法裡,執行的是 count++ 這樣一句代碼。而像 count++ 這樣一句代碼從學習Java變量自增的第一堂課上,老師就應該強調過它的執行過程。count++ 可以類比成以下的過程:

int tmp = count;
tmp = tmp + 1;
count = tmp;
           

可見,count++ 并非原子操作。任何兩個線程都有可能将上面的代碼分離進行,安全性便無從談起了。

是以,到這裡我們知道了 volatile 可以改變變量線上程之間的可見性,卻不能改變線程之間的同步。而同步操作則需要其他的操作來保證。

synchronized 同步測試

上面說到 volatile 不能解決線程的安全性問題,這是因為 volatile 不能建構原子操作。而在多線程程式設計中有一個很友善的同步處理,就是 synchronized 關鍵字。下面來看看 synchronized 是如何處理多線程同步的吧,代碼如下:

public class DemoSynchronized {

    static class MyThread extends Thread {
        static int count = 0;
        
        private synchronized static void addCount() {
            for (int i = 0; i < 100; i++) {
                count++;
            }
            System.out.println("count = " + count);
        }
        
        @Override
        public void run() {
            addCount();
        }
    }
    
    public static void main(String[] args) {
        MyThread[] threads = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            threads[i] = new MyThread();
        }
        
        for (int i = 0; i < 100; i++) {
            threads[i].start();
        }
    }
}
           
count = 100
count = 200
count = 300
... ...
count = 9800
count = 9900
count = 10000
           

通過 synchronized 我們可以很容易就獲得了理想的結果。而關于 synchronized 關鍵字的記憶體模型可以這樣來表示:

Java 多線程之 synchronized 和 volatile 的比較概述版權說明記憶體語義分析執行個體論證Ref征集

某一個線程在通路一個被 synchronized 修飾的變量時,會對此變量的共享記憶體進行加鎖,那麼這個時候其他線程對其的通路就會被互斥。 synchronized 的内部實作其實也是鎖的概念。

Ref

  • 《Java多線程程式設計核心技術》
  • 《Java并發程式設計的藝術》

征集

如果你也需要使用ProcessOn這款線上繪圖工具,可以使用如下邀請連結進行注冊:

https://www.processon.com/i/56205c2ee4b0f6ed10838a6d