Detaillierte Erklärung des flüchtigen Prinzips

Volatile ist ein einfacher Synchronisationsmechanismus, der von der Java Virtual Machine bereitgestellt wird

Das flüchtige Schlüsselwort hat die folgenden zwei Funktionen

  • Stellen Sie sicher, dass die von volatile modifizierte gemeinsam genutzte Variable für die Gesamtzahl aller Threads sichtbar ist. Wenn ein Thread den Wert einer von volatile modifizierten gemeinsam genutzten Variablen ändert, kann der neue Wert anderen Threads immer sofort bekannt sein.

  • Verbieten Sie die Optimierung der Neuordnung von Anweisungen.

Sichtbarkeit von flüchtigen

public class VolatileVisibilitySample {
    
    
    private boolean initFlag = false;
    static Object object = new Object();

    public void refresh(){
    
    
        this.initFlag = true; //普通写操作,(volatile写)
        String threadname = Thread.currentThread().getName();
        System.out.println("线程:"+threadname+":修改共享变量initFlag");
    }

    public void load(){
    
    
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){
    
    
            synchronized (object){
    
    
                i++;
            }
            //i++;
        }
        System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
    }

    public static void main(String[] args){
    
    
        VolatileVisibilitySample sample = new VolatileVisibilitySample();
        Thread threadA = new Thread(()->{
    
    
            sample.refresh();
        },"threadA");

        Thread threadB = new Thread(()->{
    
    
            sample.load();
        },"threadB");

        threadB.start();
        try {
    
    
             Thread.sleep(2000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        threadA.start();
    }

}

Nachdem Thread A das initFlag-Attribut geändert hat, wird Thread B sofort erkannt

Flüchtig kann keine Atomizität garantieren

public class VolatileAtomicSample {
    
    

    private static volatile int counter = 0;

    public static void main(String[] args) {
    
    
        for (int i = 0; i < 10; i++) {
    
    
            Thread thread = new Thread(()->{
    
    
                for (int j = 0; j < 1000; j++) {
    
    
                    counter++; //不是一个原子操作,第一轮循环结果是没有刷入主存,这一轮循环已经无效
                    //1 load counter 到工作内存
                    //2 add counter 执行自加
                    //其他的代码段?
                }
            });
            thread.start();
        }

        try {
    
    
            Thread.sleep(1000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }

        System.out.println(counter);
    }
}

Sie können es ausführen, das Endergebnis wird nicht 10000 sein

Flüchtig verhindert die Optimierung der Umlagerung

Umlagerung bestellen

Neuordnung bezieht sich auf ein Mittel, mit dem der Compiler und der Prozessor die Befehlssequenz neu anordnen, um die Leistung des Programms zu optimieren. Die Java-Sprachspezifikation legt fest, dass die sequentielle Semantik im JVM-Thread beibehalten wird. Das heißt, solange das Endergebnis des Programms dem Ergebnis seiner Sequenzierung entspricht, kann die Ausführungsreihenfolge der Anweisungen mit der Codereihenfolge inkonsistent sein. Dieser Prozess wird als Befehlsumordnung bezeichnet. Was bedeutet die Neuordnung von Anweisungen? JVM kann Maschinenanweisungen entsprechend den Prozessoreigenschaften (mehrstufiges CPU-Cache-System, Multi-Core-Prozessor usw.) entsprechend neu anordnen, so dass die Maschinenanweisungen besser mit den Ausführungseigenschaften der CPU und der Maschinenleistung übereinstimmen können kann maximiert werden.

Fügen Sie hier eine Bildbeschreibung ein
Es gibt zwei Hauptphasen der Neuanordnung von Anweisungen:

1. Kompilierungsphase des Compilers: Der Compiler ordnet die Anweisungen neu an, wenn die Klassendatei geladen und in Maschinencode kompiliert wird

2. CPU-Ausführungsphase: Wenn die CPU Montageanweisungen ausführt, können die Anweisungen neu angeordnet werden

public class VolatileReOrderSample {
    
    
    private static int x = 0, y = 0;
    private static int a = 0, b =0;
    static Object object = new Object();

    public static void main(String[] args) throws InterruptedException {
    
    
        int i = 0;

        for (;;){
    
    
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable() {
    
    
                public void run() {
    
    
                    //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                    shortWait(10000);
                    a = 1; //是读还是写?store,volatile写
                    //storeload ,读写屏障,不允许volatile写与第二部volatile读发生重排
                    //手动加内存屏障
                    //UnsafeInstance.reflectGetUnsafe().storeFence();
                    x = b; // 读还是写?读写都有,先读volatile,写普通变量
                    //分两步进行,第一步先volatile读,第二步再普通写
                }
            });
            Thread t2 = new Thread(new Runnable() {
    
    
                public void run() {
    
    
                    b = 1;
                    //手动增加内存屏障
                    //UnsafeInstance.reflectGetUnsafe().storeFence();
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
    
    
                System.err.println(result);
                break;
            } else {
    
    
                System.out.println(result);
            }
        }
    }
    public static void shortWait(long interval){
    
    
        long start = System.nanoTime();
        long end;
        do{
    
    
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

Das endgültige Ausführungsergebnis kann 0 0 sein, was durch die Ausführung der Neuordnung verursacht wird, da die Neuordnung in einem einzelnen Thread das Ausführungsergebnis nicht als seriell beeinflusst, jedoch nicht unbedingt in einem Multithread.

als ob seriell

    public static void main(String[] args) {
    
    
        /**
         * as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)
         * 程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
         *
         * 以下例子当中1、2步存在指令重排行为,但是1、2不能与第三步指令重排
         * 也就是第3步不可能先于1、2步执行,否则将改变程序的执行结果
         */
        double p = 3.14; //1
        double r = 1.0; //2
        double area = p * r * r; //3计算面积
    }

public class DoubleCheckLock {
    
    

	private static DoubleCheckLock instance;
	
	private DoubleCheckLock(){
    
    }
	
	public static DoubleCheckLock getInstance(){
    
    
		//第一次检测
		if (instance==null){
    
    
			//同步
			synchronized (DoubleCheckLock.class){
    
    
				if (instance == null){
    
    
					//多线程环境下可能会出现问题的地方
					instance = new DoubleCheckLock();
				}
			}
		}
		return instance;
	}
}

Der obige Code ist ein klassischer Singleton-Doppelerkennungscode. Dieser Code hat in einer Umgebung mit einem Thread kein Problem, in einer Umgebung mit mehreren Threads können jedoch Probleme mit der Thread-Sicherheit auftreten. Der Grund dafür ist, dass das Instanzreferenzobjekt möglicherweise nicht initialisiert wird, wenn ein Thread bis zur ersten Erkennung ausgeführt wird und der Instanzlesevorgang nicht null ist. Da instance = new DoubleCheckLock (); in die folgenden 3 Schritte unterteilt werden kann (Pseudocode)

memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时
instance!=null

Da es zwischen Schritt 1 und Schritt 2 zu einer Neuordnung kommen kann, gehen Sie wie folgt vor:

memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance指向刚分配的内存地址,此时instance!
=null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象

Da zwischen den Schritten 2 und 3 keine Datenabhängigkeit besteht und sich das Ausführungsergebnis des Programms in einem einzelnen Thread vor oder nach der Neuanordnung nicht ändert, ist diese Neuanordnungsoptimierung zulässig. Die Neuanordnung von Befehlen stellt jedoch nur die Konsistenz der Ausführung der seriellen Semantik (einzelner Thread) sicher, berücksichtigt jedoch nicht die semantische Konsistenz zwischen mehreren Threads. Wenn ein Thread, der auf eine Instanz zugreift, nicht null ist, weil die Instanzinstanz möglicherweise nicht initialisiert wurde, werden daher Thread-Sicherheitsprobleme verursacht. Wie man es löst, ist sehr einfach. Wir können volatile verwenden, um zu verhindern, dass die Instanzvariable ausgeführt wird, und die Anweisung neu anordnen und optimieren.

//禁止指令重排优化
private volatile static DoubleCheckLock instance;

Speicherbarriere

Die Speicherbarriere (Memory Barrier), auch als Speicherbarriere bezeichnet, ist ein CPU-Befehl. Sie hat zwei Funktionen: Eine dient dazu, die Ausführungsreihenfolge bestimmter Operationen sicherzustellen, und die andere dient dazu, die Speichersichtbarkeit bestimmter Variablen sicherzustellen (unter Verwendung dieser Funktion flüchtige Speicher Sichtbarkeit zu erreichen). Weil sowohl der Compiler als auch der Prozessor eine Optimierung der Befehlsumlagerung durchführen können. Wenn eine Speicherbarriere zwischen Anweisungen eingefügt wird, teilt dies dem Compiler und der CPU mit, dass mit dieser Speicherbarriere-Anweisung keine Anweisungen neu angeordnet werden können, dh durch Einfügen einer Speicherbarriere sind die Anweisungen vor und nach der Speicherbarriere verboten Durchführen einer Neuordnungsoptimierung. Eine weitere Funktion von Memory Barrier besteht darin, das Löschen der Cache-Daten verschiedener CPUs zu erzwingen, damit jeder Thread auf der CPU die neueste Version dieser Daten lesen kann. Kurz gesagt, flüchtige Variablen erreichen ihre Semantik im Speicher durch die Speicherbarriere (Sperranweisung), Sichtbarkeit und Optimierung des Verbots der Umlagerung.

Die folgende Abbildung zeigt eine Tabelle mit Regeln für die flüchtige Neuordnung, die von JMM für den Compiler formuliert wurden.

Fügen Sie hier eine Bildbeschreibung ein
Zum Beispiel bedeutet die letzte Zelle in der dritten Zeile: Wenn die erste Operation im Programm das Lesen oder Schreiben gewöhnlicher Variablen ist und die zweite Operation flüchtiges Schreiben ist, kann der Compiler die beiden Operationen nicht neu anordnen.
Wie aus der obigen Abbildung ersichtlich ist:

  • Wenn die zweite Operation ein flüchtiger Schreibvorgang ist, kann sie unabhängig von der ersten Operation nicht neu angeordnet werden. Diese Regel stellt sicher, dass Operationen vor flüchtigen Schreibvorgängen vom Compiler nicht nach flüchtigen Schreibvorgängen neu angeordnet werden.
  • Wenn die erste Operation ein flüchtiger Lesevorgang ist, kann sie unabhängig von der zweiten Operation nicht neu angeordnet werden. Diese Regel stellt sicher, dass Operationen nach dem flüchtigen Lesen vom Compiler nicht vor dem flüchtigen Lesen neu angeordnet werden.
  • Wenn die erste Operation ein flüchtiger Schreibvorgang und die zweite Operation ein flüchtiger Lesevorgang ist, kann sie nicht neu angeordnet werden.

Um die Speichersemantik von flüchtig zu realisieren, fügt der Compiler eine Speicherbarriere in die Befehlssequenz ein, um bestimmte Arten der Prozessorumordnung beim Erzeugen von Bytecode zu verhindern. Für den Compiler ist es fast unmöglich, eine optimale Anordnung zu finden, um die Gesamtzahl der Einfügungsbarrieren zu minimieren. Aus diesem Grund verfolgt JMM eine konservative Strategie. Das Folgende ist eine JMM-Strategie zum Einfügen von Speicherbarrieren, die auf einer konservativen Strategie basiert.

  • Fügen Sie vor jedem flüchtigen Schreibvorgang eine StoreStore-Barriere ein.
  • Fügen Sie nach jedem flüchtigen Schreibvorgang eine StoreLoad-Barriere ein.
  • Fügen Sie nach jedem flüchtigen Lesevorgang eine LoadLoad-Barriere ein.
  • Fügen Sie nach jedem flüchtigen Lesevorgang eine LoadStore-Barriere ein.

Das Folgende ist ein schematisches Diagramm der Befehlssequenz, die erzeugt wird, nachdem ein flüchtiger Schreibvorgang
Fügen Sie hier eine Bildbeschreibung ein
unter einer konservativen Strategie in die Speicherbarriere eingefügt wurde . Die folgende Abbildung ist ein schematisches Diagramm der Befehlssequenz, die erzeugt wurde, nachdem ein flüchtiger Lesevorgang in die Speicherbarriere unter a eingefügt wurde konservative Strategie.
Fügen Sie hier eine Bildbeschreibung ein
Codebeispiel

public class VolatileBarrierExample {
    
    
    int a;
    volatile int m1 = 1;
    volatile int m2 = 2;

    void readAndWrite() {
    
    
        int i = m1;   // 第一个volatile读
        int j = m2;   // 第二个volatile读

        a = i + j;    // 普通写

        m1 = i + 1;   // 第一个volatile写
        m2 = j * 2;   // 第二个 volatile写
    }

}

Fügen Sie hier eine Bildbeschreibung ein
Beachten Sie, dass die letzte StoreLoad-Barriere nicht weggelassen werden kann. Denn nachdem das zweite flüchtige Element geschrieben wurde, kehrt die Methode sofort zurück. Zu diesem Zeitpunkt kann der Compiler möglicherweise nicht genau bestimmen, ob später ein flüchtiges Lesen oder Schreiben erfolgt. Aus Sicherheitsgründen fügt der Compiler hier normalerweise eine StoreLoad-Barriere ein.

Die obige Optimierung gilt für jede Prozessorplattform. Da verschiedene Prozessoren unterschiedliche "Dichtheits" -Prozessorspeichermodelle haben, kann das Einfügen von Speicherbarrieren auch gemäß dem spezifischen Prozessorspeichermodell optimiert werden. Nehmen Sie als Beispiel den X86-Prozessor. Mit Ausnahme der letzten StoreLoad-Barriere in Abbildung 3-21 werden andere Barrieren weggelassen. Das flüchtige Lesen und Schreiben unter der vorherigen konservativen Strategie kann wie in der folgenden Abbildung auf der X86-Prozessorplattform gezeigt optimiert werden. Wie bereits erwähnt, ordnet der X86-Prozessor nur Schreib- und Lesevorgänge neu an. X86 ordnet Lese-, Lese-, Lese- und Schreib- / Schreibvorgänge nicht neu an, sodass die Speicherbarrieren, die diesen drei Arten von Vorgängen entsprechen, im X86-Prozessor weggelassen werden. In X86 muss JMM nach dem flüchtigen Schreiben nur eine StoreLoad-Barriere einfügen, um die Semantik des flüchtigen Schreib- / Lesespeichers korrekt zu implementieren. Dies bedeutet, dass in X86-Prozessoren die Kosten für flüchtige Schreibvorgänge viel höher sind als die für flüchtige Lesevorgänge (da die Ausführung der StoreLoad-Barriere teurer ist).
Fügen Sie hier eine Bildbeschreibung ein

Das zugrunde liegende Prinzip der Volatilität

Durch das Schlüsselwort volatile modifizierte Variablen können Sichtbarkeit und Reihenfolge garantieren, jedoch keine Atomizität. Schauen wir uns den Singleton-Modus für Double Check und Lock an. Diese globale Variable muss flüchtig sein. Drucken wir die Assembly-Anweisungen aus und sehen, was das flüchtige Schlüsselwort bewirkt.

So drucken Sie die Montageanleitung aus

  • -XX: + UnlockDiagnosticVMOptions -XX: + PrintAssembly -Xcomp
  • Hsdis Plugin
public class Singleton {
    
    
    private volatile static Singleton myinstance;
 
    public static Singleton getInstance() {
    
    
        if (myinstance == null) {
    
    
            synchronized (Singleton.class) {
    
    
                if (myinstance == null) {
    
    
                    myinstance = new Singleton();//对象创建过程,本质可以分文三步
                }
            }
        }
        return myinstance;
    }
 
    public static void main(String[] args) {
    
    
        Singleton.getInstance();
    }
}
0x00000000038064dd: mov    %r10d,0x68(%rsi)
0x00000000038064e1: shr    $0x9,%rsi
0x00000000038064e5: movabs $0xf1d8000,%rax
0x00000000038064ef: movb   $0x0,(%rsi,%rax,1)  ;*putstatic myinstance
                                                ; - com.it.edu.jmm.Singleton::getInstance@24 (line 22)

0x0000000003cd6edd: mov    %r10d,0x68(%rsi)
0x0000000003cd6ee1: shr    $0x9,%rsi
0x0000000003cd6ee5: movabs $0xf698000,%rax
0x0000000003cd6eef: movb   $0x0,(%rsi,%rax,1)
0x0000000003cd6ef3: lock addl $0x0,(%rsp)     ;*putstatic myinstance
                                                ; - com.it.edu.jmm.Singleton::getInstance@24 (line 22)

Durch Vergleich wird festgestellt, dass die Schlüsseländerung die Variable mit flüchtiger Modifikation ist. Nach der Zuweisung (movb $ 0x0, (% rsi,% rax, 1) oben ist die Zuweisungsoperation) wird eine weitere "Sperre addl $ 0x0, ( % rsp ")" -Operation, diese Operation entspricht einer Speicherbarriere.

Der Schlüssel hier ist das Sperrpräfix. Seine Funktion besteht darin, den Cache dieses Prozessors in den Speicher zu schreiben. Diese Schreibaktion führt auch dazu, dass andere Prozessoren oder andere Kerne seinen Cache ungültig machen (Invalidate, der I-Status des MESI-Protokolls). Diese Operation entspricht der Ausführung der in der vorherigen Einführung zum Java-Speichermodell erwähnten "Speichern und Schreiben" -Operationen für die Variablen im Cache. Daher kann durch eine solche Operation die Änderung der vorherigen flüchtigen Variablen für andere Prozessoren sofort sichtbar sein. Die untergeordnete Implementierung der Sperranweisung: Wenn die Cache-Zeile unterstützt wird, wird eine Cache-Sperre (MESI) hinzugefügt. Wenn die Cache-Sperre nicht unterstützt wird, wird eine Bussperre hinzugefügt.

Fügen Sie manuell eine Speicherbarriere hinzu

public class UnsafeInstance {
    
    

    public static Unsafe reflectGetUnsafe() {
    
    
        try {
    
    
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            return (Unsafe) field.get(null);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        return null;
    }
}
UnsafeInstance.reflectGetUnsafe().loadFence();//读屏障

UnsafeInstance.reflectGetUnsafe().storeFence();//写屏障

UnsafeInstance.reflectGetUnsafe().fullFence();//读写屏障

Ich denke du magst

Origin blog.csdn.net/qq_37904966/article/details/112548739
Empfohlen
Rangfolge