解決線程安全問題


線程帶來的風險

  • 線程安全性問題
    • 出現安全性問題的需要滿足的條件:多線程環境、有共享資源、非原子性操作
  • 活躍性問題
    • 死鎖
    • 飢餓
    • 活鎖
  • 性能問題
    • cpu上下文切換會有性能問題(cpu分時間片執行)

自旋鎖

自旋其實就是當一個線程獲取到鎖之后,其他的線程會進行阻塞等待,一直到這個線程釋放鎖后才能進入

重入鎖 & 鎖重入

鎖重入即在一個對象中對兩個方法都加鎖了,那么在一個線程獲取到其中一個方法的鎖后,再執行另外一個方法時就不再需要獲取鎖了;同時如果一個線程獲取到了其中一個方法的鎖,那么其他的線程既不能執行這個方法,也不能執行另一個方法。

死鎖

當一個線程永遠的持有這把鎖而其他線程都嘗試獲取這把鎖的時候就形成了死鎖

死鎖問題最簡單的演示:

public class DeadLock {
    private Object obj1 = new Object();
    private Object obj2 = new Object();
    
    public void a() throws Exception {
        synchronized(obj1) {
            Thread.sleep(1000);
            synchronized(obj2) {
                System.out.println("a");
            }
        }
    }
    
    public void b() throws Exception {
        synchronized(obj2) {
            Thread.sleep(1000);
            synchronized(obj1) {
                System.out.println("b");
            }
        }
    }
}
/**
 * 上面a跟b方法形成了死鎖
 * 當a執行后會獲取obj1的鎖,b執行后會獲取obj2的鎖
 * 這時a想要再獲取obj2的鎖已經不可能了 而b想要獲取obj1的鎖也不可能了
 * 因此會一直阻塞,誰也不能執行
 */

synchronized原理與使用

synchronized力度是作用於對象上的,有三種用法

  • 同步實例方法,鎖是當前實例對象
    • 將synchronized關鍵字加載方法前面,鎖定的對象是當前類的this對象,跟括號里面寫this是一樣的。這樣加鎖必須保證單例
  • 同步類方法,鎖是當前類對象
    • 如果加了synchronized的方法是靜態方法,則鎖定的對象是當前實例的class對象
  • 同步代碼塊,鎖是括號里面的對象

java的synchronized關鍵字會被翻譯成字節碼指令monitorentermonitorexit,指令之間就是synchronized同步代碼塊之間執行的代碼。

既然任何對象都可以作為鎖,那么鎖信息又存在對象什么地方呢?答案是存在對象頭中。主要是存在於對象頭中的Mark Word中的,markword中存儲鎖的機制如下圖所示:

由上面的圖可知,jvm內置鎖又分為很多個,其實在不斷地優化中,jvm的內置鎖已經不再是當初那個笨重的鎖了,它會根據不同的情況來自動升級,大致的過程是:無鎖 ---> 偏向鎖 --> 輕量級鎖 ---> 重量級鎖

偏向鎖

在很多情況下,競爭鎖的不是由多個線程,而是由一個線程在使用,這時候如果還是像多線程那樣去獲取鎖再釋放鎖,會浪費很多資源。因此偏向鎖非常適用於同一個線程反復進入同一同步塊的場景

開啟偏向鎖:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

關閉偏向鎖:-XX:-UseBiasedLocking

無鎖狀態

對象一開始是沒有加鎖的,當一個線程訪問同步塊的時候會檢查標志位,如果是01表示要么是偏向鎖要么是無鎖,如果不是偏向鎖,那么會CAS修改對象頭MarkWord中的偏向位為1,變成偏向鎖,然后會將對象頭的Mark Word中前23位放入當前線程的ID,最后執行同步塊當中的代碼,等到達安全點后會暫停線程,這里的安全點是指cpu分配給當前線程的時間片用完,這個時候會進行判斷,看是否已經執行完了同步塊中的代碼,如果執行完了就沒有必要加鎖了,那么偏向鎖會被釋放,Mark Word中的線程ID會被清除,偏向會置為0;如果沒有執行完則會進一步將鎖升級為輕量級鎖。

有鎖狀態

當一個線程訪問同步塊,檢查偏向為1,表示是偏向鎖,這時候它會使用CAS修改對象頭前23位為自己的ID,但是由於線程ID已經有了,所以一定會修改失敗,這時候它會等待占用鎖的線程到達安全點后撤銷偏向鎖,將鎖升級為輕量級鎖。下圖紅色部分就是新的線程進入時的流程。

輕量級鎖

適用於線程交替執行的場景。

再輕量級鎖的狀態下,每個線程都會將markword的信息復制到自己線程棧的棧幀中,然后嘗試修改markword中的鎖標志位為輕量級鎖,並且對象的markword中會留下獲取到鎖的線程的信息,這個信息是一個指針,指向剛剛這個線程復制的markword信息。隨后的線程會嘗試修改markword中的內容,但是由於第一個線程正在占用,所以是修改不了的,只能不斷的等待,不斷的重新嘗試修改,最終等到第一個線程釋放鎖后才能修改成功。這個過程叫做自旋。在第二個線程獲取到鎖后就會將鎖升級到重量級鎖。

重量級鎖

重量級鎖的性能非常低,適合高並發場景。因為高並發的場景最終的結果一定是會升級到重量級鎖,所以不如一開始就使用重量級鎖,以免鎖升級的過程中造成過多的資源浪費。

Volatile

要理解volatile的作用,需要先了解java的內存模型jmm

在上面的圖中可以看到每個線程都會從主內存備份一份數據到自己的工作內存中,但是現在有一個問題,假設某個變量在線程B中被修改了,而由於線程B修改后的數據只會同步到主內存中,而不會影響到線程A中工作內存的數據,這就使得數據同步出現了問題,具體看下面代碼:

public class Volicity {
    private static boolean flag = false;

    public static void main(String[] args) {
        // 線程A等待線程B的數據
        new Thread(() -> {
            System.out.println("等待准備數據。。。");
            while(!flag) {
            }
            System.out.println("啟動系統。。。");
        }).start();

        new Thread(() -> {
            System.out.println("准備數據。。。");
            flag = true;
            System.out.println("數據准備完成。。。");
        }).start();
    }
}

在上面的程序中線程A會等待線程B修改數據,修改完成后會繼續往下執行,但是結果是即便線程B修改了數據,線程A仍然會停在循環處不會執行,這就是因為線程B修改后的數據不會影響到線程A的工作內存中的數據。

Volatile解決

上面的問題的解決辦法就是在變量前面添加volatile關鍵字

private static volatile boolean flag = false;

為什么加了volatile關鍵字后就能夠進行數據同步了呢

在說明這個問題之前需要先了解JMM將數據從主內存讀到工作內存以及再同步回來的原理

JMM主要是通過如下原子操作實現的:

  • read:從主內存中讀取數據
  • load:將主內存讀取到的數據寫入到工作內存中
  • use:從工作內存讀取數據來計算
  • assign:將計算好的值重新賦值到工作內存中
  • store:將工作內存數據寫入主內存
  • write:將store過去的變量值賦值給主內存中的變量
  • lock:將主內存變量加鎖,標識為線程獨占狀態
  • unlock:將主內存變量解鎖,解鎖后其他線程可以鎖定該變量

JMM就是通過如上的一些方法來實現主內存與各個線程之間的工作內存進行數據同步的

下面可以將上面的程序執行流程捋一遍:

  • 首先線程A啟動后通過read方法讀取到主內存中的flag變量,然后使用load方法將數據存到自己的工作內存中
  • 然后使用use方法從工作內存中讀取到數據取反進行判斷,判斷結果為true,那么會一直卡在這里
  • 這時線程B啟動,同樣通過read方法從主內存中讀取數據,然后通過load方法將數據存到自己的工作內存中
  • 然后程序繼續執行,使用use方法修改flag的值為true,然后使用assign方法將計算好的值重新賦值到工作內存中
  • 然后使用store方法將工作內存中修改了的數據寫入主內存
  • 最后使用write方法將store過去的變量賦值給主內存中的變量

具體流程圖如下:

現在問題出現了:當線程B將數據推送到主內存后,線程A並不知道,它仍舊使用的是自己工作內存中沒有更新的數據,所以會出問題

由此可知其實volatile關鍵字的作用就是當主內存中的數據改變后及時的將主內存的數據同步到線程A的工作內存中,那么它是如何做到的呢?

volatile解決JMM緩存不一致問題

為了解決上面的問題,在不同的時期使用了不同的方法,早期的時候使用的是總線加鎖,但是由於性能太低,后來使用了MESI緩存一致性協議

  • 總線加鎖(性能太低):cpu從主內存讀取數據到高速緩存,會在總線對這個數據加鎖,這樣其他cpu沒法去讀或寫這個數據,直到這個cpu使用完數據釋放鎖之后其它cpu才能讀取到該數據
  • MESI緩存一致性協議:多個cpu從主內存讀取同一個數據到各自的高速緩存,當其中某個cpu修改了緩存里的數據,該數據會馬上同步回主內存,其它cpu通過總線嗅探機制可以感知到總線上傳播的數據的變化從而將自己緩存里的數據失效

volatile緩存可見性實現原理

底層實現主要是通過匯編lock前綴指令,它會鎖定這塊內存區域的緩存(緩存行鎖定)並會寫到主內存

下面是對lock指令的解釋:

  • 會將當前處理器緩存行的數據立即寫回到系統內存
  • 這個寫回內存的操作會引起在其他cpu里緩存了該內存地址的數據無效(MESI協議)

synchronized解決

其實除了能夠使用volatile解決可見性問題外,還能夠使用synchronized解決可見性問題,只需要將程序修改為如下即可:

public class Demo01 {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("等待准備數據。。。");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (Demo01.class) {
                while (!flag) {
                }
                System.out.println("啟動系統。。。");
            }
        }).start();

        new Thread(() -> {
            System.out.println("准備數據。。。");
            synchronized (Demo01.class) {
                flag = true;
            }
            System.out.println("數據准備完成。。。");
        }).start();
    }
}

上面在對flag變量進行讀寫時都加了鎖,其實道理很簡單,這也是synchronized的特性導致的:

  • 線程解鎖前,必須把共享變量的最新值刷新到主內存中
  • 線程加鎖時,將清空工作內存中共享變量的值,從而使用共享變量是需要從主內存中重新讀取最新的值(加鎖與解鎖需要統一把鎖)

其實對比volatile的做法(利用cpu的嗅探機制嗅探主內存的值改變,進而使工作內存中的變量失效,從而重新去主內存中獲取值),這種方式只是人為的在讀取變量前強制程序去主內存中讀取變量。

有序性

上面說到了volatile的一個作用,保證可見性,其實除了可見性,volatile還能夠保證程序的有序性,當我們寫下的程序交給jvm去執行的時候,jvm並非是按照我們寫下的順序去執行的,而是會先進行一些指令重排,在保證程序正確執行的情況下做到盡可能的優化,例如下面這段例子:

public void test() {
    int a;
    int b;
    int c;
    a = 1;
}

上面的代碼原本的執行是這樣的:首先jvm會在當前線程棧中開辟一塊內存作為test方法的棧幀,然后將在棧幀中的局部變量表中為a變量開辟一塊內存,然后為b變量開辟一片內存,然后為c變量開辟一片內存;最后將常量1壓入棧幀中的操作數棧中,然后從操作數棧中將1彈出,並且存放到局部變量表中a變量所在的區域。

經過指令重排后:其實從上面的過程中我們就可以看出一個問題,在jvm為a變量開辟出內存后,為什么不直接執行a=1的操作呢?這樣就能避免后面再去尋找a變量的地址時形成的開銷,因此jvm會對指令重排,重排后的代碼如下

public void test() {
    int a;
    a = 1;
    int b;
    int c;
}

這樣做的本意是好的,但是有一個問題,在有些情況下做指令重排會導致一些問題,最著名的就是單例模式中利用雙重檢測鎖創建單例時出現的問題,如下:

public class Singleton {
	private Singleton singleton;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized(singleton.class) {
            	if (singleton == null) {
                	singleton = new Singleton();
                }
             }
        }
        return singleton;
    }
}

在上面的代碼中,看似很完美的實現了單例模式,但是由於jvm會進行指令重排,所以最終的結果或許並不如期待的那樣,但是如果稍作修改,在singleton變量前面加上volatile關鍵字,就可以很完美的解決這個問題。

由此可見,volatile保證程序有序性的原因就是能夠阻止jvm對指令的重排序

總結

  • synchronized能夠保證原子性跟可見性
  • volatile能夠保證可見性跟有序性
  • 由於volatile會阻止cpu優化(阻止指令重排),因此會造成性能問題,也要合理使用
  • synchronized能夠完全替代volatile,但是有些非原子性操作或者不需要保證原子性的時候,使用volatile更加輕量
  • 使用這兩個關鍵字就能解決並發編程的三大特點:可見性、原子性、有序性

原子類保證原子性問題

在對數據使用volatile后,雖然能夠保證數據在各個線程之間的可見性,但是並不能保證原子性,想要保證數據的原子性,需要使用juc包下面的原子類

原子類大致分為四類:

  • 原子更新基本類型
  • 原子更新數組
  • 原子更新引用類型
  • 原子更新引用類型字段

原子更新基本類型

基本類型有AtomicInteger、AtomicBoolean、AtomicLong這幾個,基本的使用方法如下:

private AtomicInteger value = new AtomicInteger(0);

value.getAndIncrement();   // 獲取值再自加
value.incrementAndGet();   // 自加再獲取值
value.getAndAdd(10);   // 獲取值再加10

LongAddr

jdk1.8之后又推出了一個LongAdder,首先,這個類實現的功能其實是跟AtomicLong一樣的,那么為什么有了AtomicLong了還要有LongAddr呢?原因就是AtomicLong性能不是特別好,同一時間只能允許一個線程修改。

那么LongAddr是怎樣提升效率的呢?我們可以看到原先的AtomicLong是所有線程去修改一個數,這樣自然同一時間只能允許一個線程修改,但是LongAddr是將這個數拆分為了幾個數,單個的數還是只能同時允許一個線程修改。譬如6拆分成1 2 3,那么現在有三個線程,它們就可以同時去修改,線程1修改數字1,線程2修改數字2,線程3修改數字3,最后改變的結果是2 3 4,如果用戶去獲取結果就把這幾個部分的數字加起來,也就是9。但是如果再來一個線程,就繼續拆分,因此不會存在自旋現象。

DoubleAddr

DoubleAddr跟LongAddr解決的問題是相同的

原子更新數組

有例如AtomicIntegerArray等類,基本操作如下:

private AtomicIntegerArray value = new AtomicIntegerArray(new int[] {1, 2, 5});

value.getAndIncrement(2);   // 獲取數組第三個元素再加一
value.getAndAdd(2, 10);   // 獲取數組第三個元素再加10

原子更新引用類型

使用AtomicReference類更新引用類型

private AtomicReference<User> user = new AtomicReference<>();
// 這時更新的時整個user對象

原子更新引用類型字段

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新長整型字段的更新器。
  • AtomicStampedReference:原子更新帶有版本號的引用類型。該類將整數值與引用關聯起來,可用於原子的更數據和數據的版本號,可以解決使用CAS進行原子更新時,可能出現的ABA問題。

對於字段的要求有如下三點:

  • 字段必須加上volatile關鍵字
  • 不能是類變量,即字段前面不能加static關鍵字
  • 只能是可修改變量,即字段前面不能加final
// 后面兩個參數是要進行原子操作的類以及要修改類中的哪一個字段
AtomicIntegerFieldUpdater<User> old = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");

User user = new User("nick", 12);
old.getAndIncrement(user);
System.out.println(user.getAge());  // 此時age字段變為13

Lock接口

前面已經了解了解決線程安全問題的三個方式,分別是使用synchronized、Volatile以及使用原子類,使用synchronized是可以解決所有線程安全性問題的,但是由於比較笨重,使用了volatile替代,但是volatile只能解決可見性跟有序性問題,不能解決原子性問題,於是出現了原子類,但是原子類只能保證單個數據修改的原子性,當要進行一系列的操作的時候仍舊不能夠保證原子性,於是就出現了Lock接口。

基本使用

首先注意下面代碼的問題:

public class Sequence {
    private int value;

    public int getNext() {
        return value++;    // 線程不安全的
    }

    public static void main(String[] args) {
        Sequence s = new Sequence();
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                while (true) {
                    System.out.println(Thread.currentThread().getName() + "==" + s.getNext());
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

上面的代碼執行value++的操作是線程不安全的,想要解決只需要再value++前后上鎖即可

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Sequence {
    private int value;
    private Lock lock = new ReentrantLock();

    public int getNext() {
        lock.lock();  // 上鎖
        value++;
        lock.unlock();  // 釋放鎖
        return value;
    }

    public static void main(String[] args) {
        Sequence s = new Sequence();
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                while (true) {
                    System.out.println(Thread.currentThread().getName() + "==" + s.getNext());
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

使用lock.lock()以及lock.unlock()方法即可對操作進行上鎖,需要注意的是這時的鎖對象lock必須是同一個,也就是多個線程使用同一把鎖,否則是沒有用的

Lock的好處

  • Lock相比於synchronized需要顯示的獲取和釋放鎖,但是換來了更靈活的操作,例如可以在任意地方釋放鎖
  • Lock可以方便的實現線程執行的公平性
  • 能夠非阻塞的獲取鎖,能被中斷的獲取鎖,能超時獲取鎖

AQS

AQS即AbstractQueuedSynchronizer,是實現各種阻塞鎖以及各種同步容器的基礎。

使用AQS實現自己的鎖

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedLongSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

public class MyLock2 implements Lock {
    private Helper helper = new Helper();

    private class Helper extends AbstractQueuedLongSynchronizer {
        @Override
        protected boolean tryAcquire(long arg) {
            // 如果是第一個線程進來 可以拿到鎖 返回true
            // 第二個線程進來拿不到鎖 返回false
            int state = (int) getState();
            if (state == 0) {
                if (compareAndSetState(0, arg)) {
                    setExclusiveOwnerThread(Thread.currentThread());
                    return true;
                }
            } else if (getExclusiveOwnerThread() == Thread.currentThread()) {
                setState(state + arg);
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(long arg) {
            if (Thread.currentThread() != getExclusiveOwnerThread()) {
                throw new RuntimeException("所被其他線程占用");
            }
            int state = (int) (getState() - arg);
            setState(state);
            if (state == 0) {
                setExclusiveOwnerThread(null);
                return true;
            }
            return true;
        }

        public Condition newCondition() {
            return new ConditionObject();
        }
    }

    @Override
    public void lock() {
        helper.acquire(1);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        helper.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return helper.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return helper.tryAcquireNanos(1, unit.toNanos(time));
    }

    @Override
    public void unlock() {
        helper.release(1);
    }

    @Override
    public Condition newCondition() {
        return helper.newCondition();
    }
}

讀寫鎖

前面所了解的鎖都是排他鎖,也就是同一個時間里面只能允許一個線程進行訪問,但是在有些時候並不需要如此,例如在讀操作的時候可以同時多個線程訪問,這時候的鎖可以設置為共享鎖。

對於讀寫鎖有:讀跟讀是互斥的、讀跟寫是互斥的、讀跟讀是不互斥的

下面簡單實現讀寫鎖的用法:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDemo {
    private Map<String, Object> map = new HashMap<>();
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();

    public Object get(String key) {
        readLock.lock();
        System.out.println(Thread.currentThread() + "讀操作開始..");
        Object o = map.get(key);
        readLock.unlock();
        System.out.println(Thread.currentThread() + "讀操作結束..");
        return o;
    }

    public void put(String key, Object value) {
        writeLock.lock();
        System.out.println(Thread.currentThread() + "寫操作開始..");
        map.put(key, value);
        writeLock.unlock();
        System.out.println(Thread.currentThread() + "寫操作結束..");
    }
}

鎖降級

鎖降級是指寫鎖降級為讀鎖,原理就是在寫鎖還沒釋放的時候將鎖設置為讀鎖,以致讓別的寫線程沒辦法競爭到寫鎖

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDemo1 {
    private Map<String, Object> map = new HashMap<>();
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();
    private volatile boolean isUpdate;

    public void readWrite() {
        if (isUpdate) {
            writeLock.lock();  // 在此處添加寫鎖
            map.put("xxx", "xxx");
            readLock.lock();  // 在這里進行鎖降級
            /*
             * 在這里釋放寫鎖后其他的寫線程會來競爭寫鎖繼續寫數據,為了不讓其他的線程來寫數據
             * 應該在寫鎖釋放前將鎖降級為讀鎖 這樣其他的寫線程就沒辦法競爭到寫鎖了
             * 因為讀鎖與寫鎖互斥
             */
            writeLock.unlock();
        }
        System.out.println(map.get("xxx"));
        readLock.unlock();
    }
}

StampedLock

這是jdk1.8出現的一個鎖,是對ReentrantReadWriteLock進行的一個增強,之所以出現這個類是因為讀寫鎖經常會遇到一個問題,再高並發的環境下,讀的線程遠遠大於寫的線程,由於讀寫互斥,可能導致寫線程飢餓問題,如果使用讀寫鎖的公平模式又會導致性能問題,因此急需要一個類對讀寫鎖進行增強。

在StampedLock中讀鎖是不會阻塞寫鎖的,那么如何保證讀寫一致性呢?解決的方法很簡單,就是在讀的過程中如果發現了寫操作就重新讀。

在StamptedLock中分樂觀鎖跟悲觀鎖,悲觀鎖跟讀寫鎖沒什么區別,都是讀寫互斥。只有樂觀鎖是讀寫不互斥的。

悲觀鎖演示:

import java.util.concurrent.locks.StampedLock;

public class StamptedLockDemo {
    private StampedLock stampedLock = new StampedLock();
    private int balance;

    public void read() {
        long stampted = stampedLock.readLock();
        int c = balance;
        System.out.println(c);
        stampedLock.unlockRead(stampted);
    }

    public void write(int value) {
        long stampted = stampedLock.writeLock();
        balance += value;
        stampedLock.unlockWrite(stampted);
    }
}

樂觀鎖演示:

樂觀鎖主要是在讀鎖上進行更改,只需要在讀取后進行一次判斷,如果判斷結果是寫鎖修改了數據,就重新讀一次

import java.util.concurrent.locks.StampedLock;

public class StamptedLockDemo {
    private StampedLock stampedLock = new StampedLock();
    private int balance;

    // 讀鎖示例
    public void read() {
        long stampted = stampedLock.tryOptimisticRead();
        int c = balance;
        // 這里可能會出現寫操作,因此要進行判斷
        if (!stampedLock.validate(stampted)) {
            try {
                // 發生了寫操作 重新讀取
                stampted = stampedLock.readLock();
                c = balance;
            } finally {
                // 釋放鎖
                stampedLock.unlockRead(stampted);
            }
        }
        System.out.println(c);
    }
    
    
    
    /**
     * 讀寫鎖轉換
     * @param value
     */
    public void conditionReadWrite(int value) {
        long stampted = stampedLock.readLock();   // 拿到悲觀的讀鎖,方便下面判斷數據
        while (balance > 0) {
            // 將讀鎖轉換為寫鎖修改數據
            stampted = stampedLock.tryConvertToWriteLock(stampted);
            if (stampted != 0) {   // 成功轉換為寫鎖
                // 進行修改操作
                balance += value;
                break;
            } else {  // 沒有轉換成功
                // 需要先釋放讀鎖,然后再拿到寫鎖
                stampedLock.unlockRead(stampted);
                // 獲取寫鎖
                stampted = stampedLock.writeLock();
            }
        }
        stampedLock.unlock(stampted);   // 釋放任何的鎖
    }
}

偽共享問題

在聊偽共享問題之前,需要先了解cpu的緩存,隨着cpu速度的不斷提升,而內存的速度卻始終沒有質的突破,因此cpu與內存之間同步交換數據成為了一個尷尬的難題,因此為了充分發揮cpu的性能,在cpu於內存之間加入了緩存,緩存存在於cpu中,緩存有三個級別:

  • L1(一級緩存)是最接近cpu的,它容量非常小,一般只有32K,但是速度最快,每個核上都有一個L1 Cache(准確地說每個核上有兩個L1 Cache,一個存數據 L1d Cache,一個存指令 L1i Cache)。
  • L2(二級緩存)更大一些,一般256K,速度要慢一些,一般情況下每個核上都有一個獨立的L2 Cache;
  • L3(三級緩存)是三級緩存中最大的一級,例如12MB,同時也是最慢的一級,在同一個CPU插槽之間的核共享一個L3 Cache。

緩存行

聊完了緩存,接下來就是聊往緩存里存取數據的問題,在使用緩存的時候並不是一個字節一個字節的讀取的,而是一行一行的讀取的,這一行就稱為一個緩存行,一般一行緩存有64個字節

偽共享

了解緩存行是為了解決java中偽共享做准備的,什么是偽共享呢?首先偽共享是發生在緩存中的,通過先前的三個級別的緩存的介紹,我們可以了解到一二級緩存存在於每個核中,每個核都有自己獨有的一二級緩存,但是三級緩存卻是共享的,那么問題就來了,如果現在cpu的兩個核分別執行兩個線程,在這兩個線程中有自己獨立的變量。
那么偽共享的定義就是,當這兩個線程分別修改自己獨立的變量時,假如這兩個線程中的變量存在於同一個緩存行中,那么修改就會影響彼此的性能,這就是偽共享。

偽共享帶來的性能問題

在核心1上運行的線程想更新變量X,同時核心2上的線程想要更新變量Y,不幸的是,這兩個變量在同一個緩存行中(三級緩存中)。
每個線程都要去競爭緩存行的所有權來更新變量。
如果核心1獲得了所有權,緩存子系統將會使核心2中對應的緩存行失效(核心2一二級緩存中對應的緩存行失效)。
當核心2獲得了所有權然后執行更新操作,核心1就要使自己對應的緩存行失效(核心1一二級緩存中對應的緩存行失效)。
這會來來回回的經過L3緩存,大大影響了性能。如果互相競爭的核心位於不同的插槽,就要額外橫跨插槽連接,問題可能更加嚴重。

偽共享解決辦法

JDK1.8之前解決方法 ———— padding方式

在1.8之前,通常是使用8個長整型變量將需要分開的存儲在不同緩存行中的數據隔開的,因為一個長整型變量是8個字節,也就是說8個變量一共是 8 * 8 = 64字節,剛好是一個緩存行的長度,那么如果在兩個變量之間插入這8個變量,就一定可以將兩個變量分別存儲在不同的緩存行中

public class DataPadding{
    long a1,a2,a3,a4,a5,a6,a7,a8;//防止與前一個對象產生偽共享
    int value;
    long modifyTime;
    long b1,b2,b3,b4,b5,b6,b7,b8;//防止不相關變量偽共享;
    boolean flag;
    long c1,c2,c3,c4,c5,c6,c7,c8;//
    long createTime;
    char key;
    long d1,d2,d3,d4,d5,d6,d7,d8;//防止與下一個對象產生偽共享
}

JDK1.8之后————Contended注解方式

在JDK1.8中,新增了一種注解@sun.misc.Contended,來使各個變量在Cache line中分隔開。

注意,jvm需要添加參數-XX:-RestrictContended才能開啟此功能
用時,可以在類前或屬性前加上此注釋。

// 類前加上代表整個類的每個變量都會在單獨的cache line中
@sun.misc.Contended
@SuppressWarnings("restriction")
public class ContendedData {
    int value;
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
}
    
或者這種:

// 屬性前加上時需要加上組標簽
@SuppressWarnings("restriction")
public class ContendedGroupData {
        @sun.misc.Contended("group1")
        int value;
        @sun.misc.Contended("group1")
        long modifyTime;
        @sun.misc.Contended("group2")
        boolean flag;
        @sun.misc.Contended("group3")
        long createTime;
        @sun.misc.Contended("group3")
        char key;
}

這部分偽共享相關的知識是我借鑒的下面這篇博客的,感謝他!
原文鏈接:https://www.cnblogs.com/niutao/p/10567822.html

線程安全性問題總結

出現線程安全性問題的條件

  • 在多線程條件下
  • 必須有共享資源
  • 對共享資源進行原子性操作

解決線程安全性問題的途徑

  • synchronized(通吃 但是效率低)
  • volatile保證線程可見
  • 原子類
  • 使用Lock

認識的鎖

  • 偏向鎖
  • 輕量級鎖
  • 重量級鎖
  • 重入鎖
  • 自旋鎖
  • 共享鎖
  • 獨占鎖
  • 排他鎖
  • 讀寫鎖
  • 公平鎖
  • 非公平鎖
  • 死鎖
  • 活鎖


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM