上篇文章記錄到volatile在硬件層面怎么保證線程間可見性的,是通過lock鎖緩存行緩存一致性協議來實現的。但是這樣會有一個偽共享的問題。
首先緩存行在64bit機中一般為64字節,具體緩存行大小可以通過下面的命令查看:
cat /proc/cpuinfo
假設有一個對象有兩個long類型的數據x,y,在java中long類型數據長度為8個字節,所以讀取一個對象數據后x,y會在一個緩存行中。有兩個CPU讀取這個對象如下圖的Core1,Core2,其中Core1只對x進行修改,Core2只對y進行修改。
根據緩存一致性協議知道,當Core1中數據修改后會導致Core2中的緩存行失效,Core2中的數據就要從主存中重新獲取,同理:當Core2中數據修改后會導致Core1中的緩存行失效,Core1中的數據就要從主存中重新獲取。兩核之間存在緩存行的競爭關系。
這會導致性能損耗。下面舉個例子看下這種情況。

public class HuanCun { public static void main(String[] args) throws InterruptedException { testPointer(new Pointer()); } private static void testPointer(Pointer pointer) throws InterruptedException { long start = System.currentTimeMillis(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100000000; i++) { pointer.x++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100000000; i++) { pointer.y++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(System.currentTimeMillis() - start); } } class Pointer { // 避免偽共享: @Contended + jvm參數:-XX:-RestrictContended //@Contended volatile long x; //避免偽共享: 緩存行填充 //long p1, p2, p3, p4, p5, p6, p7; volatile long y; }
上面的代碼執行結果:
將近三秒執行。
為了避免讓x,y在同一個緩存行,我們可以把x,y分開在不同緩存行,可以通過在x變量后面添加七個long類型變量,這樣x,y就位於不同的緩存行上了。
如下:
這樣運行結果看下:
速度提升還是很明顯的。
但是上面消除偽共享的方法不太合適,還要再單獨定義 其它變量。在jdk8中有新的方式可以消除偽共享的情況。
就是:
避免偽共享: @Contended + jvm參數:-XX:-RestrictContended
這種方式的執行時間也提升了很多。
volatile的有序性
我上面以及上一篇都是在講volatile的可見性是如何保證的,volatile還有一種作用就是有序性。
我們可以從double check中看到場景。
JMM中關於synchronized有如下規定,線程加鎖時,必須清空工作內存中共享變量的值,從而使用共享變量時需要從主內存重新讀取;線程在解鎖時,需要把工作內存中最新的共享變量的值寫入到主存,以此來保證共享變量的可見性。(ps這里是個泛指,不是說只有在退出synchronized時才同步變量到主存)。
那么加了 synchronized, myInstance的可見性已經保證了,為什么還要加上volatile呢?
private volatile static SingletonFactory myInstance; public static SingletonFactory getMyInstance() { if (myInstance == null) {
synchronized (SingletonFactory.class) { if (myInstance == null) { myInstance = new SingletonFactory(); } } } return myInstance; }
這個要說下對象的創建過程說起了,對象創建也就是說new這個操作JVM到底做了什么事,這個問題之前在CSDN上記錄過可以去看下:https://blog.csdn.net/A7_A8_A9/article/details/105730007
我把new操作的匯編貼一下:
JIT可能會對指令重排序,處理器也有可能是亂序執行,因為這樣可以提升性能。對於如何提升性能的我們可以從下面的偽代碼中窺探一二:
右邊調整了順序之后可以減少i的加載和store,雖然例子只是一個int變量,如果是一個很大的對象/數據,這個節約的時間就是值得的。這種指令重排是JIT編譯之后的結果。處理器的亂序執行是當前指令在Load數據等待中,cpu可能先執行下面的指令了。
為什么JIT會進行重排序呢?我們知道我們java文件編譯成class文件,然后JVM編譯成指令序列再到匯編指令,JVM有兩個編譯方式,一種是讀取一行解釋一行,這種效率自然是很慢的,還有一種是JIT即時編譯它會檢測一些頻繁使用的代碼編譯成熱點代碼,這樣不用每次使用都要編譯了。
指令重排的重現例子:
private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException{ int i=0; while (true) { i++; x = 0; y = 0; a = 0; b = 0; /** * x,y: * 每次循環,x,y,a,b都會賦初值0,兩個線程各執行一次,執行完,x,y可能的值有: * 0,1 1,0 1,1 這是我們按正常邏輯思考可能出現的結果,但是有一種情況是只會在重排序的情況出現 * 就是 0,0 也就是這種情況下兩個線程變成了這樣的邏輯。 * thread1: * x=b; * a=1; * thread2: * y=a; * b=1; * 我們執行看下是否會出現這樣的情況呢? */ Thread thread1 = new Thread(new Runnable() { @Override public void run() { shortWait(20000); a = 1; x = b; } }); Thread thread2 = new Thread(new Runnable() { @Override public void run() { b = 1; y = a; } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("第" + i + "次(" + x + "," + y + ")"); if (x==0&&y==0){ break; } } } public static void shortWait(long interval){ long start = System.nanoTime(); long end; do{ end = System.nanoTime(); }while(start + interval >= end); }
結果:
指令重排出現了。
為了避免指令重排,可以給變量加上volatile關鍵字,禁止重排序,保證有序性也保證了可見性。我們從上篇知道,可見性它是靠Lock指令,觸發緩存一致性協議(MESI)保證的,通過總線嗅探機制把緩存數據的狀態變化廣播到所有的核。禁止指令重排也是靠這個Lock保證的,
它起到了內存屏障的作用,它雖然不是內存屏障指令。注意在linux_x86的系統中內存屏障mfence用lock來替代了。
我們也可以手動添加內存屏障來禁止重排序,使用Unsafe類。
public class UnsafeUtil { public static Unsafe getUnsafe(){ try { Field field= Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe)field.get(null); } catch (Exception e) { e.printStackTrace(); } return null; } }
使用的地方。
storefence是本地方法,我們可以去看下jdk的源碼。
可以看到 jvm屏蔽掉了不同的cpu。
我以linux_x86為例來看。
查看orderAccess
我們看到定義了四種"內存屏障操作"但是對於x86處理器來說,它只關心sotreload這一種內存屏障,其它的它不管。這里我們只要看fence就可以了,
if里判斷是否是多核處理架構,根據上面的注釋也可以知道使用 lock; addl來代替mfence。這是x86里面的內存屏障實現方式。
Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以理解為CPU指令級的一種鎖。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令
為什么只關注StoreLoad指令呢,因為在JSR-133規范中定義了x86內核是如何對內存屏障和原子性進行支持的。
x86就是靠lock指令實現的。具體這個的解釋如下 ,參考了:https://www.zhihu.com/question/65372648
先行發生模型--happens-before
JMM模型就是在先行發生模型上面的優化。線程的可見性,有序性,原子性都可以從先行發生模型來考慮。
我們從一個例子來看下:
我們約定線程A執行write(),線程B執行read(),且線程A優先於線程B執行(時間先后順序),那么線程B獲得結果是什么?這段代碼是線程安全的嗎
private int i = 0; public void write(int j ){ i = j; } public int read(){ return i; }
按上面的執行是線程不安全的,雖然線程A先於線程B執行,但是線程B對於線程A的執行結果確實不可見的,這也是先行發生模型強調的點,這個先行不是代碼執行時間上的先后順序而是執行結果是順序的。
線程內存模型有: