鎖優化的思路和方法

1 鎖優化的思路和方法

一旦用到鎖,就說明這是阻塞式的。這裏提到的鎖優化,是指在阻塞式的狀況下,如何讓性能不要變得太差。可是再怎麼優化,通常來講性能都會比無鎖的狀況差一些。java

1.1 減小鎖持有時間

public synchronized void syncMethod(){
    othercode1();
    mutextMethod();
    othercode2();
}

上述代碼,線程在進入方法前都要先獲取到鎖,同時其餘線程只能在外面等待。程序員

這裏優化的一點在於,要減小其餘線程等待的時間,因此,只在有線程安全要求的程序上加鎖。數組

public void syncMethod2(){
    othercode1();
    synchronized(this){
        mutextMethod();
    }
    othercode2();
}

1.2 減小鎖粒度

將大對象(這個對象可能會被不少線程訪問),折成小對象,大大增長並行度,下降鎖競爭、下降了鎖的競爭,偏向鎖,輕量級鎖成功率纔會提升。安全

最典型的減小鎖粒度的案例就是ConcurrentHashMap(ConcurrentHashMap內部使用Segment數組,每一個Segment相似於Hashtable。put操做時,先定位到Segment,鎖定一個Segment,執行put)。在減少鎖粒度後, ConcurrentHashMap容許若干個線程同時進入。多線程

1.3 鎖分離

最多見的鎖分離就是讀寫鎖ReadWriteLock,根據功能進行分離成讀鎖和寫鎖,這樣讀讀不互斥,讀寫互斥,寫寫互斥,即保證了線程的安全,又提升了性能。併發

讀寫分離思想能夠延伸,只要操做互不影響,鎖就能夠分離。app

好比:LinkedBlockingQueue(鏈表、隊列)高併發

從頭部取出,從尾部放數據。這有點相似ForkKoinPool中的工做竊取。源碼分析

1.4 鎖粗化

一般狀況下,爲了保證多線程間的有效併發,會要求每一個線程持有鎖的時間儘可能短,即在使用完公共資源後,應該當即釋放鎖。只有這樣,等待在這個鎖上的其餘線程才能儘早地獲取資源執行任務。可是凡事都有一個度,若是對同一個鎖不停地進行請求、同步和釋放,其自己也會消耗系統寶貴的資源,反而不利於性能的優化。性能

舉個例子:

public void demoMethod(){
    synchronized(lock){
        //do sth.
    }
    //作其餘不須要的同步的工做,但能很快執行完畢
    synchronized(lock){
        //do sth.
    }
}

這種狀況,根據鎖粗化的思想,應該合併:

public void demoMethod(){
    //整合成一次鎖請求
    synchronized(lock){
        //do sth.
        //作其餘不須要的同步的工做,但能很快執行完畢
    }
}

固然這是有前提的,前提就是中間那麼不須要同步的工做是很快執行完成的

再舉一個極端的例子:

for(int i=0;i<CIRCLE;i++){
    synchronized(lock){
        
    }
}

在循環內不停地獲取鎖。雖然JDK內部會對這個代碼作些優化,可是還不如直接寫成

synchronized(lock){
    for(int i=0;i<CIRCLE;i++){
    
    }
}

固然若是有需求說,循壞不能讓其餘線程等待過久,那隻能寫成第一種形式。若是沒有這樣相似的需求,仍是直接寫成第二種實現方式比較好。

1.5 鎖消除

在即時編譯時,若是發現不可能被共享的對象,則能夠消除這些對象的鎖操做。

也許你會以爲奇怪,既然有些對象不可能被多線程訪問,那爲何要加鎖呢?寫代碼時直接不加鎖不就行了。

可是有些鎖並非程序員所寫的,好比Vector和StringBuffer這樣的類,它們中的不少方法都是有鎖的。當咱們在一些不會有線程安全的狀況下使用這些類的方法時,達到某些條件時,編譯器會將鎖消除來提升性能。

例如:

public static void main(String args[]) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 2000000; i++) {
            createStringBuffer("JVM", "Diagnosis");
        }
        long bufferCost = System.currentTimeMillis() - start;
        System.out.println("craeteStringBuffer: " + bufferCost + " ms");
}

public static String createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
}

上述代碼中的StringBuffer.append是一個同步操做,可是StringBuffer倒是一個局部變量,而且方法也沒有把StringBuffer返回,因此不可能會有多線程去訪問它。

那麼此時StringBuffer中的同步操做就是沒有意義的。

開啓鎖消除是在JVM參數上設置的,固然須要在server模式下:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

而且要開啓逃逸分析。逃逸分析的做用呢,就是看看變量是否有可能逃出做用域的範圍。

好比上述的StringBuffer,上述代碼中createStringBuffer的返回是一個String,因此這個局部變量StringBuffer在其餘地方都不會被使用。若是將createStringBuffer改爲:

public static StringBuffer craeteStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
}

那麼這個StringBuffer被返回後,是有可能被任何其餘地方所使用的。那麼JVM的逃逸分析能夠分析出,這個局部變量StringBuffer逃出了它的做用域,鎖就不會被消除。

當JVM參數爲:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

輸出:

craeteStringBuffer: 302 ms

JVM參數爲:

-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks

輸出:

craeteStringBuffer: 660 ms

顯然,鎖消除的效果仍是很明顯的。

2. 虛擬機內的鎖優化

首先要介紹下對象頭,在JVM中,每一個對象都有一個對象頭。

  • Mark Word,對象頭的標記,32位
  • 描述對象的hash、鎖信息,垃圾回收標記,年齡

           – 指向鎖記錄的指針
           – 指向monitor的指針
           – GC標記
           – 偏向鎖線程ID

簡單來講,對象頭就是要保存一些系統性的信息。

2.1 偏向鎖

  • 大部分狀況是沒有競爭的,因此能夠經過偏向來提升性能
  • 所謂的偏向,就是偏愛,即鎖會偏向於當前已經佔有鎖的線程
  • 將對象頭Mark的標記設置爲偏向,並將線程ID寫入對象頭Mark
  • 只要沒有競爭,得到偏向鎖的線程,在未來進入同步塊,不須要作同步
  • 當其餘線程請求相同的鎖時,偏向模式結束
  • --XX:+UseBiasedLocking(默認開啓)
  • 在競爭激烈的場合,偏向鎖會增長系統負擔(每次都要加一次是否偏向的判斷)

偏向鎖的例子:

package test;

import java.util.List;
import java.util.Vector;

public class Test {
    public static List<Integer> numberList = new Vector<Integer>();

    public static void main(String[] args) throws InterruptedException {
        long begin = System.currentTimeMillis();
        int count = 0;
        int startnum = 0;
        while (count < 10000000) {
            numberList.add(startnum);
            startnum += 2;
            count++;
        }
        long end = System.currentTimeMillis();
        System.out.println(end - begin);
    }

}

Vector是一個線程安全的類,內部使用了鎖機制。每次add都會進行鎖請求。上述代碼只要main一個線程在反覆add請求鎖。使用以下的JVM參數來設置偏向鎖:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

BiasedLockingStartupDelay表示系統啓動幾秒鐘後啓用偏向鎖。默認爲4秒,緣由在於,系統剛啓動時,通常數據競爭是比較激烈的,此時啓用偏向鎖會下降性能。

因爲這裏爲了測試偏向鎖的性能,因此把延遲偏向鎖的時間設置爲0。

輸出:9209

下面關閉偏向鎖:

-XX:-UseBiasedLocking 

輸出:9627 

通常在無競爭時,啓用偏向鎖性能會提升5%左右

2.2 輕量級鎖

Java的多線程安全是基於Lock機制實現的,而Lock的性能每每不如人意。

緣由是,monitorenter與monitorexit這兩個控制多線程同步的bytecode原語,是JVM依賴操做系統互斥(mutex)來實現的。

互斥是一種會致使線程掛起,並在較短的時間內又須要從新調度回原線程的,叫我消耗資源的操做。

  • 普通的鎖處理性能不夠理想,輕量級鎖是一種快速的鎖定方法。
  • 若是對象沒有被鎖定
  •     – 將對象頭的Mark指針保存到鎖對象中
  •     – 將對象頭設置爲指向鎖的指針(在線程棧空間中)

輕量級鎖的總結

  • 若是輕量級鎖失敗,表示存在競爭,升級爲重量級鎖(常規鎖)
  • 在沒有鎖競爭的前提下,減小傳統鎖使用OS互斥量產生的性能損耗
  • 在競爭激烈時,輕量級鎖會多作不少額外操做,致使性能降低。

2.3 自旋鎖

當競爭存在時,由於輕量級鎖嘗試失敗,以後有可能會直接升級成重要級鎖動用操做系統層面的互斥,也有可能再嘗試一下自旋鎖。

  • 當競爭存在時,若是線程能夠很快得到鎖,那麼能夠不在OS層掛起線程,讓線程作幾個空操做(自旋),而且不停地嘗試拿到這個鎖(相似tryLock),固然循壞的次數是有限制的,當循壞次數達到之後,仍然會升級成重量級鎖。
  • JDK1.6中-XX:+UseSpinning開啓
  • JDK1.7中,去掉此參數,改成內置實現
  • 若是同步塊很長,自旋失敗,會下降系統性能
  • 若是同步塊很短,自旋成功,節省線程掛起切換時間,提高系統性能

偏向鎖,輕量級鎖,自旋鎖總結:

  • 不是Java語言層面的鎖優化方法,是內置在JVM當中的
  • 首先偏向鎖是爲了不某個線程反覆獲取/釋放同一把鎖時的性能消耗,若是仍然是同個線程去得到這個鎖,嘗試偏向鎖時會直接進入同步塊。不須要再次得到鎖
  • 輕量級鎖自旋鎖都是爲了不直接調用操做系統層面的互斥操做由於掛起線程是一個很消耗資源的操做
  • 獲取鎖的優化方法和獲取鎖的步驟:
         – 偏向鎖可用會先嚐試偏向鎖
         – 輕量級鎖可用會先嚐試輕量級鎖
         – 以上都失敗(說明存在競爭),嘗試自旋鎖
         – 再失敗,嘗試普通鎖,使用OS互斥量在操做系統層掛起

3 一個錯誤使用鎖的案例

public class IntegerLock {
    static Integer i = 0;

    public static class AddThread extends Thread {
        public void run() {
            for (int k = 0; k < 100000; k++) {
                synchronized (i) {
                    i++;
                }
            }
        }
    }

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

一個很初級的錯誤在於,Integer是final不變的,每次++後,會產生一個新的Integer再賦值給i,因此兩個線程競爭的鎖是不一樣的。因此並非線程安全的。

4 ThreadLocal及其源碼分析

這裏來提ThreadLocal可能有點不合適,可是ThreadLocal是能夠把鎖代替的方式。因此仍是有必要提一下。

基本的思想就是,在一個多線程當中須要把有數據衝突的數據加鎖,使用ThreadLocal的話,爲每個線程都提供一個對象。不一樣的線程只訪問本身的對象,而不訪問其餘的對象。這樣鎖就不必存在了。

package test;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {
    private static final SimpleDateFormat sdf = new SimpleDateFormat(
            "yyyy-MM-dd HH:mm:ss");

    public static class ParseDate implements Runnable {
        int i = 0;

        public ParseDate(int i) {
            this.i = i;
        }

        public void run() {
            try {
                Date t = sdf.parse("2016-02-16 17:00:" + i % 60);
                System.out.println(i + ":" + t);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            es.execute(new ParseDate(i));
        }
    }

}

因爲SimpleDateFormat並不線程安全的,因此上述代碼是錯誤的使用。最簡單的方式就是,本身定義一個類去用synchronized包裝(相似於Collections.synchronizedMap)。這樣作在高併發時會有問題,對synchronized的爭用致使每一次只能進去一個線程,併發量很低。這裏使用ThreadLocal去封裝SimpleDateFormat就解決了這個問題。

package test;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {
    static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();

    public static class ParseDate implements Runnable {
        int i = 0;

        public ParseDate(int i) {
            this.i = i;
        }

        public void run() {
            try {
                if (tl.get() == null) {
                    tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
                }
                Date t = tl.get().parse("2016-02-16 17:00:" + i % 60);
                System.out.println(i + ":" + t);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            es.execute(new ParseDate(i));
        }
    }

}

每一個線程在運行時,會判斷當前線程是否有SimpleDateFormat對象:

if (tl.get() == null)

若是沒有的話,就new個SimpleDateFormat與當前線程綁定:

tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

而後用當前線程的SimpleDateFormat去解析:

tl.get().parse("2016-02-16 17:00:" + i % 60);

一開始的代碼中,只有一個SimpleDateFormat,使用了ThreadLocal,爲每個線程都new了一個SimpleDateFormat。須要注意的是,這裏不要把公共的一個SimpleDateFormat設置給每個ThreadLocal,這樣是沒用的。須要給每個都new一個SimpleDataFormar。

hibernate中,對ThreadLocal有典型的應用。

下面來看一下ThreadLocal的源碼實現

首先Thread類中有一個成員變量:

ThreadLocal.ThreadLocalMap threadLocals = null;

而這個Map就是ThreadLocal的實現關鍵:

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
}

根據ThreadLocal能夠set和get相對應的value。這裏的ThreadLocalMap實現和HashMap差很少,可是在hash衝突的處理上有區別。ThreadLocalMap中發生hash衝突時,不是像HashMap這樣用鏈表來解決衝突,而是將索引++,放到下一個索引來解決衝突。

相關文章
相關標籤/搜索