歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux編程 >> Linux編程 >> Java多線程中的volatile和偽共享

Java多線程中的volatile和偽共享

日期:2017/3/1 9:12:25   编辑:Linux編程

偽共享 false sharing,顧名思義,“偽共享”就是“其實不是共享”。那什麼是“共享”?多CPU同時訪問同一塊內存區域就是“共享”,就會產生沖突,需要控制協議來協調訪問。會引起“共享”的最小內存區域大小就是一個cache line。因此,當兩個以上CPU都要訪問同一個cache line大小的內存區域時,就會引起沖突,這種情況就叫“共享”。但是,這種情況裡面又包含了“其實不是共享”的“偽共享”情況。比如,兩個處理器各要訪問一個word,這兩個word卻存在於同一個cache line大小的區域裡,這時,從應用邏輯層面說,這兩個處理器並沒有共享內存,因為他們訪問的是不同的內容(不同的word)。但是因為cache line的存在和限制,這兩個CPU要訪問這兩個不同的word時,卻一定要訪問同一個cache line塊,產生了事實上的“共享”。顯然,由於cache line大小限制帶來的這種“偽共享”是我們不想要的,會浪費系統資源。

  緩存系統中是以緩存行(cache line)為單位存儲的。緩存行是2的整數冪個連續字節,一般為32-256個字節。最常見的緩存行大小是64個字節。當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享。緩存行上的寫競爭是運行在SMP系統中並行線程實現可伸縮性最重要的限制因素。有人將偽共享描述成無聲的性能殺手,因為從代碼中很難看清楚是否會出現偽共享。

  為了讓可伸縮性與線程數呈線性關系,就必須確保不會有兩個線程往同一個變量或緩存行中寫。兩個線程寫同一個變量可以在代碼中發現。為了確定互相獨立的變量是否共享了同一個緩存行,就需要了解內存布局,或找個工具告訴我們。Intel VTune就是這樣一個分析工具。

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

  Java Memory Layout Java內存布局,在項目開發中,大多使用HotSpot的JVM,hotspot中對象都有兩個字(四字節)長的對象頭。第一個字是由24位哈希碼和8位標志位(如鎖的狀態或作為鎖對象)組成的Mark Word。第二個字是對象所屬類的引用。如果是數組對象還需要一個額外的字來存儲數組的長度。每個對象的起始地址都對齊於8字節以提高性能。因此當封裝對象的時候為了高效率,對象字段聲明的順序會被重排序成下列基於字節大小的順序:


•double (8字節) 和 long (8字節)
•int (4字節) 和 float (4字節)
•short (2字節) 和 char (2字節):char在java中是2個字節。java采用unicode,2個字節(16位)來表示一個字符。
•boolean (1字節) 和 byte (1字節)
•reference引用 (4/8 字節)
•<子類字段重復上述順序>

在了解這些之後,就可以在任意字段間用7個long來填充緩存行。偽共享在不同的JDK下提供了不同的解決方案。

  在JDK1.6環境下,解決偽共享的辦法是使用緩存行填充,使一個對象占用的內存大小剛好為64bytes或它的整數倍,這樣就保證了一個緩存行裡不會有多個對象。

package basic;

public class TestFlash implements Runnable {

public final static int NUM_THREADS = 4; // change
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;

/**
* 為了展示其性能影響,我們啟動幾個線程,每個都更新它自己獨立的計數器。計數器是volatile long類型的,所以其它線程能看到它們的進展。
*/
public final static class VolatileLong {

/* 用volatile[ˈvɑ:lətl]修飾的變量,線程在每次使用變量的時候,JVM虛擬機只保證從主內存加載到線程工作內存的值是最新的 */
public volatile long value = 0L;

/* 緩沖行填充 */
/* 37370571461 :不使用緩沖行執行納秒數 */
/* 16174480826 :使用緩沖行執行納秒數,性能提高一半 */
public long p1, p2, p3, p4, p5, p6, p7;
}

private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
static {
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
}

public TestFlash(final int arrayIndex){
this.arrayIndex = arrayIndex;
}

/**
* 我們不能確定這些VolatileLong會布局在內存的什麼位置。它們是獨立的對象。但是經驗告訴我們同一時間分配的對象趨向集中於一塊。
*/
public static void main(final String[] args) throws Exception {
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}

private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];

for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new TestFlash(i));
}

for (Thread t : threads) {
t.start();
}

for (Thread t : threads) {
t.join();
}
}

/*
* 為了展示其性能影響,我們啟動幾個線程,每個都更新它自己獨立的計數器。計數器是volatile long類型的,所以其它線程能看到它們的進展
*/
@Override
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
}

VolatileLong通過填充一些無用的字段p1,p2,p3,p4,p5,p6,再考慮到對象頭也占用8bit, 剛好把對象占用的內存擴展到剛好占64bytes(或者64bytes的整數倍)。這樣就避免了一個緩存行中加載多個對象。但這個方法現在只能適應JAVA6 及以前的版本了。

  在jdk1.7環境下,由於java 7會優化掉無用的字段。因此,JAVA 7下做緩存行填充更麻煩了,需要使用繼承的辦法來避免填充被優化掉。把填充放在基類裡面,可以避免優化(這好像沒有什麼道理好講的,JAVA7的內存優化算法問題,能繞則繞)。

package basic;

public class TestFlashONJDK7 implements Runnable {

public static int NUM_THREADS = 4;
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;

public TestFlashONJDK7(final int arrayIndex){
this.arrayIndex = arrayIndex;
}

public static void main(final String[] args) throws Exception {
Thread.sleep(10000);
System.out.println("starting....");
if (args.length == 1) {
NUM_THREADS = Integer.parseInt(args[0]);
}

longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}

private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new TestFlashONJDK7(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
}

@Override
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
}

class VolatileLong extends VolatileLongPadding {

public volatile long value = 0L;
}

class VolatileLongPadding {

public volatile long p1, p2, p3, p4, p5, p6, p7;
}

在jdk1.8環境下,緩存行填充終於被JAVA原生支持了。JAVA 8中添加了一個@Contended的注解,添加這個的注解,將會在自動進行緩存行填充。以上的例子可以改為:

package basic;

public class TestFlashONJDK8 implements Runnable {

public static int NUM_THREADS = 4;
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;

public TestFlashONJDK8(final int arrayIndex){
this.arrayIndex = arrayIndex;
}

public static void main(final String[] args) throws Exception {
Thread.sleep(10000);
System.out.println("starting....");
if (args.length == 1) {
NUM_THREADS = Integer.parseInt(args[0]);
}

longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}

private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new TestFlashONJDK8(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
}

@Override
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
}

@Contended
class VolatileLong {

  public volatile long value = 0L;
}

執行時,必須加上虛擬機參數-XX:-RestrictContended,@Contended注釋才會生效。很多文章把這個漏掉了,那樣的話實際上就沒有起作用。

補充:

byte字節 bit位 1byte=8bit

volatile說明

package basic;

public class TestVolatile {

public static int count = 0;

/* 即使使用volatile,依舊沒有達到我們期望的效果 */
// public volatile static int count = 0;

public static void increase() {
try {
// 延遲10毫秒,使得結果明顯
Thread.sleep(10);
count++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
new Thread(new Runnable() {

@Override
public void run() {
TestVolatile.increase();
}
}).start();
}
System.out.println("期望運行結果:10000");
System.out.println("實際運行結果:" + TestVolatile.count);
}
}

volatile關鍵字的使用:用volatile修飾的變量,線程在每次使用變量的時候,都會讀取變量修改後的最新值。但是由於操作不是原子性的,對於volatile修飾的變量,jvm虛擬機只是保證從主內存加載到線程工作內存的值是最新的。

在java 垃圾回收整理一文中,描述了jvm運行時刻內存的分配。其中有一個內存區域是jvm虛擬機棧,每一個線程運行時都有一個線程棧,線程棧保存了線程運行時候變量值信息。當線程訪問某一個對象時候值的時候,首先通過對象的引用找到對應在堆內存的變量的值,然後把堆內存變量的具體值load到線程本地內存中,建立一個變量副本,之後線程就不再和對象在堆內存變量值有任何關系,而是直接修改副本變量的值,在修改完之後的某一個時刻(線程退出之前),自動把線程變量副本的值回寫到對象在堆中變量。這樣在堆中的對象的值就產生變化了。上面一幅圖描述這些交互,過程如下:

•read and load 從主存復制變量到當前工作內存
•use and assign 執行代碼,改變共享變量值(其中use and assign 可以多次出現)
•store and write 用工作內存數據刷新主存相關內容

但是這些操作並不是原子性,也就是在read load之後,如果主內存count變量發生修改之後,線程工作內存中的值由於已經加載,不會產生對應的變化,所以計算出來的結果會和預期不一樣。對於volatile修飾的變量,JVM虛擬機只是保證從主內存加載到線程工作內存的值是最新的。例如假如線程1,線程2在進行read load操作中,發現主內存中count的值都是5,那麼都會加載這個最新的值。在線程1堆count進行修改之後,會write到主內存中,主內存中的count變量就會變為6。線程2由於已經進行read,load操作,在進行運算之後,也會更新主內存count的變量值為6。導致兩個線程即使使用volatile關鍵字修改之後,還是會存在並發的情況。

對於volatile修飾的變量,JVM虛擬機只能保證從主內存加載到線程工作內存的值是最新的。

Copyright © Linux教程網 All Rights Reserved