Java變量共享引發的慘案,不得已走進的悲觀鎖

Java變量共享引發的慘案,不得已走進的悲觀鎖

相關:

精湛細膩版-Java多線程與併發編程
硬核學習Synchronized原理(底層結構、鎖優化過程)

不加鎖帶來的問題

主要是共享變量帶來的問題:

兩個線程對初始值爲 0 的靜態變量一個做自增,一個做自減,各做 5000 次,結果是 0 嗎?

package c2;

public class TestJoin {

    static int count = 0 ; //共享變量
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count ++ ;
            }
        },"t1") ;

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count -- ;
            }
        },"t2") ;

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }
}

問題分析

以上的結果可能是正數、負數、零。爲什麼呢?

因爲 Java 中對靜態變量的自增,自減並不是原子操作

要徹底理解,必須從字節碼來進行分析

例如對於 i++ 而言(i 爲靜態變量),實際會產生如下的 JVM 字節碼指令:

getstatic i // 獲取靜態變量i的值
iconst_1 // 準備常量1
iadd // 自增
putstatic i // 將修改後的值存入靜態變量i

而 Java 的內存模型如下,完成靜態變量的自增,自減需要在主存和工作內存中進行數據交換:

如果是單線程以上 8 行(算上i–)代碼是順序執行(不會交錯)沒有問題

但多線程下這 8 行代碼可能交錯運行,如下

本質上,這種問題是指令交錯運行導致的(假設單核多線程)

在之後,我會講之抽象到從JMM內存模型來看待這個問題

那麼,多線程如何解決共享變量的正確性的呢?

臨界區

一個程序運行多個線程本身是沒有問題的

問題出在多個線程訪問共享資源

  • 多個線程讀共享資源其實也沒有問題
  • 在多個線程對共享資源讀寫操作時發生指令交錯,就會出現問題

一段代碼塊內如果存在對共享資源的多線程讀寫操作,稱這段代碼塊爲臨界區

static int counter = 0;
static void increment() 
// 臨界區
{ 
 counter++; }
static void decrement() 
// 臨界區
{ 
 counter--; }

多個線程在臨界區內執行,由於代碼的執行序列不同而導致結果無法預測,稱之爲發生了競態條件

解決方案:爲了避免臨界區的競態條件發生,有多種手段可以達到目的

  • 基於樂觀鎖思想的阻塞式的解決方案:synchronized,Lock

  • 基於悲觀鎖思想的非阻塞式的解決方案:原子變量

Java - synchronized 解決方案

硬核學習Synchronized原理(底層結構、鎖優化過程)

變量的線程安全分析

類成員變量

  • 如果沒有被共享,則線程安全
  • 如果被共享了
    • 如果只有讀操作,則線程安全
    • 如果有讀寫操作,則這段代碼是臨界區,需要考慮線程安全

局部變量

  • 局部變量是線程安全的,因爲JVM會爲每個線程建立棧幀,局部變量存在棧幀中,是線程獨享的,不會被共享

  • 但局部變量引用的對象則未必

    • 如果該對象沒有逃離方法的作用範圍,它是線程安全的(逃逸分析),或引用的對象在方法內,爲局部變量

    • 如果該對象逃離方法的作用範圍,或引用的對象爲類成員變量,需要考慮線程安全

重點說下局部變量的引用問題

觀察下面代碼,分析

package c2;

import java.util.ArrayList;

class ThreadUnsafe {

    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;  //循環次數

    public static void main(String[] args) {
        ThreadUnsafe test = new ThreadUnsafe();
        for (int i = 0; i < THREAD_NUMBER; i++) { //創建兩個線程
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }

    /*類成員變量*/
    ArrayList<String> list = new ArrayList<>();

    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 臨界區, 會產生競態條件
            method2();
            method3();
            // }
        }
    }
    private void method2() {
        list.add("1");
    }
    private void method3() {
        list.remove(0);
    }

}

分析:

無論哪個線程中的 method2、method3 引用的都是同一個對象, list 成員變量

將list改爲局部變量,則線程安全

   public void method1(int loopNumber) {
        /*類成員變量*/
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            // { 臨界區, 會產生競態條件
            method2(list);
            method3(list);
            // }
        }
    }
    private void method2(List list) {
        list.add("1");
    }
    private void method3(List list) {
        list.remove(0);
    }

  • list 是局部變量,每個線程調用時會創建其不同實例,沒有共享

  • 而 method2 的參數是從 method1 中傳遞過來的,與 method1 中引用同一個對象,在同一個線程內的方法的同步調用的

  • method3 的參數分析與 method2 相同

這樣就安全了嗎?未必,如果method2或method3的方法修飾符是public,同時又有一個子類繼承了這兩個方法的一個,並且在子類的重寫方法中又創建了一個線程,那麼又造成了多個線程訪問一個變量的情況,即使是局部變量,它也變成了共享的了,這是逃逸分析中的對象參數逃逸,簡單理解爲list逃逸了原來方法的作用範圍,跑到了子類中的方法

可以看出 private 或 fifinal 提供【安全】的意義所在,請體會設計模式中開閉原則中的【閉】

常見的線程安全類

  • String
  • Integer
  • StringBuffffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的類

這裏說它們是線程安全的是指,多個線程調用它們同一個實例的某個方法時,是線程安全的。如

Hashtable table = new Hashtable();
new Thread(()->{
 table.put("key", "value1");
}).start();
new Thread(()->{
 table.put("key", "value2");
}).start();
  • 線程安全類的的每個方法是原子的

  • 注意它們多個方法的組合不是原子的

    Hashtable table = new Hashtable();
    // 線程1,線程2
    //   --- 整段代碼非線程安全
    if( table.get("key") == null) {
     table.put("key", value);
    }  //
    

String、Integer 等都是不可變類,因爲其內部的狀態不可以改變,因此它們的方法都是線程安全的

你或許有疑問,String 有 replace,substring 等方法可以改變值啊,那麼這些方法又是如何保證線程安全的呢?事實上,這些方法並不是真的改變值,而是通過建立新的串或值來達到改變的效果

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