かとじゅんの技術日誌

技術の話をするところ

マルチコア時代に備えて本気でメモリモデルを理解しておこう - メモリバリア編 -

このエントリを読む前提条件として、マルチコア時代に備えて本気でメモリモデルを理解しておこう - リオーダー & finalフィールド 編 - - じゅんいち☆かとうの技術日誌を読んで、リオーダーとは何かを理解していることとします。
前回のおさらいをすると、

  • プログラムの実行順序は、リオーダーが許可される場合と禁止される場合がある。並行処理ではリオーダーを想定しなければ、処理結果の整合性が確保できない。(特にマルチプロセッサ環境)
  • リオーダーを禁止して、可視性を保証する。(finalフィールドはコンストラクト時に完全に初期化され、コンストラクト後はスレッドから見えるようになる)

でした。

リオーダーについて理解できたら、今度はメモリバリア命令でスレッド毎に扱うメモリと、大域のメインメモリとのメモリI/Oについて見ていきたいと思います。メモリバリアが理解できれば、以下のソース*1のスレッドがなぜ停止しないかが理解できるようになります。

public class StopThreadTest {
    private static boolean stopRequested;
    
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }

}

前置きが長くなりましたが、いよいよ、本編のメモリバリア編です。

メモリバリア命令とその種類

The JSR-133 Cookbok - Memory Barriers*2で、メモリバリアについて以下のように記述されています。

Compilers and processors must both obey reordering rules. No particular effort is required to ensure that uniprocessors maintain proper ordering, since they all guarantee "as-if-sequential" consistency. But on multiprocessors, guaranteeing conformance often requires emitting barrier instructions. Even if a compiler optimizes away a field access (for example because a loaded value is not used), barriers must still be generated as if the access were still present. (Although see below about independently optimizing away barriers.)
コンパイラとプロセッサは共に順序変更のルールに従わなければならない.単一プロセッサでは,「まるで逐次であるかのような (as-if sequential)」一貫性がいつも保証されるので,命令の特定の順序を維持することを保証するのに,特殊な努力は必要ない.しかしマルチプロセッサ上で準拠していることを保証するには,多くはバリア命令(*)の発行が必要になる.たとえコンパイラが最適化の結果,フィールドアクセス自体を無くしてしまったとしても,(たとえば,ロードされた値が使用されない場合,)それでもアクセスがある時と同じ様にバリアを生成しなければならない.(しかし,独立した最適化によるバリアの除去については,以下を参照すること.)

単一のプロセッサ上で動作するプログラムでは、「まるで逐次であるかのような(as-if-sequential)」一貫性があるとしています。単一のプロセッサではマルチスレッドといっても、コンテキストスイッチをが伴う擬似的な並行処理(コンカレント=concurrent)を行うため、この説明は理解できます。しかし、複数のプロセッサ上で動作するプログラムでは、コンテキストスイッチが伴うコンカレントではなく、実際に同時に複数の処理が行われる並行処理(パラレル=parallel)です。そのパラレルに対応するには多くのバリア命令の発行が必要になると言っています。マルチコアはプロセッサが複数存在するので、広義の意味ではマルチプロセッサです。そのマルチプロセッサでは、命令の実行を並列に行うアウトオブオーダー実行が可能です。そういう意味ではスレッド間でメモリI/Oの整合性を取るのにメモリバリアが必要というのも理解できます。

そして、以下のようにそのメモリバリアの特徴を述べています。

Memory barriers are only indirectly related to higher-level notions described in memory models such as "acquire" and "release". And memory barriers are not themselves "synchronization barriers". And memory barriers are unrelated to the kinds of "write barriers" used in some garbage collectors. Memory barrier instructions directly control only the interaction of a CPU with its cache, with its write-buffer that holds stores waiting to be flushed to memory, and/or its buffer of waiting loads or speculatively executed instructions. These effects may lead to further interaction among caches, main memory and other processors. But there is nothing in the JMM that mandates any particular form of communication across processors so long as stores eventually become globally performed; i.e., visible across all processors, and that loads retrieve them when they are visible.
メモリ=バリアは,「獲得」や「解放」のようなメモリモデルで描写される,上位の概念とは間接的にしか関連していない.メモリ=バリアはそれ自体は「同期バリア」ではない.そして,メモリ=バリアはある種のGCで使われる「ライト= バリア」とも関係がない.
メモリ=バリア命令はCPUとそのキャッシュ−もう少し具体的言えば,メモリへフラッシュされるストアデータを保持している書き込みバッファと,ロードデータと投機的実行される命令の(読み込み)バッファ− 間の相互作用を直接的に制御する.これらの効果は,キャッシュ,メインメモリ,他のプロセッサとの間に,さらなる相互作用をもたらすかも知れない.
しかし最終的にストアが大域的に実行される−即ち,全てのプロセッサに見えるようになり,そしてそれが見えるようになった時に,そのロードはそれらを取得する.(?)−限り,JMMはプロセッサ間通信の形式についてはなんら規定していない.

ちょっと英語も日本語の訳も理解が難しいのですが、、
"上位概念"というのが何を指すかわかりませんが、"「獲得」と「解放」"から考えるとロックのようなもの?とは間接的にしか関連がないということでしょうか。そして同期バリアではない。GCで利用される書込みバリアとも関連がないと言っています。
そして、メモリバリア命令は、CPUとキャッシュ間で相互作用を直接的に制御する。キャッシュとはスレッド単位でできるローカルキャッシュのことではないかと思います。ここでは、"メモリにフラッシュされる"保存待ち"を保持する書込みバッファ、読み込み待ちバッファ、投機的に実行される命令"と表現されているように思います。投機的に実行される命令とは、リオーダーされたバッファにI/Oする命令のことでしょうか。これらの相互作用は、キャッシュ、メインメモリ、他のプロセッサにも影響するとも書かれています。そして、ローカルなキャッシュからグローバルなメモリに保存されれば、すべてのプロセッサで可視となり、読込プロセスではそれを参照する。その際のプロセッサ間の通信の形式は規定していないというような表現です。
簡単に言ってしまえば、バリア命令が伴わない変数への読み書きは、スレッドごとのローカルキャッシュなど*3への読み書きとなり、他のスレッドにはその内容が伝わらないと考えたほういいということだと思います。

メモリバリア命令の詳解

ここではバリアがメモリアクセスに対してどのようなことを保証するのかを説明します。
基本的なバリア命令は以下です。

  • LoadLoadバリア
  • LoadStoreバリア
  • StoreStoreバリア
  • StoreLoadバリア

詳しい意味は以下です。まずは、基本的に太字のところが理解できれば問題ないと思います。

  • LoadLoadバリア

The sequence: Load1; LoadLoad; Load2
ensures that Load1's data are loaded before data accessed by Load2 and all subsequent load instructions are loaded. In general, explicit LoadLoad barriers are needed on processors that perform speculative loads and/or out-of-order processing in which waiting load instructions can bypass waiting stores. On processors that guarantee to always preserve load ordering, the barriers amount to no-ops.
Load1; LoadLoad; Load2において
Load2がデータにアクセスしたり,その後の全てのロード命令がロードするより先に,Load1のデータがロードされることが保証される.一般論として,投機的なロード,及び待機中のロード命令が待機中のストア命令をバイパスできる out-of-order 実行機能を有するプロセッサには,明示的なLoadLoad バリアが必要である.ロードの順序が保存されるプロセッサ上では,このバリアはno-opsに等しい.

  • StoreStoreバリア

The sequence: Store1; StoreStore; Store2
ensures that Store1's data are visible to other processors (i.e., flushed to memory) before the data associated with Store2 and all subsequent store instructions. In general, StoreStore barriers are needed on processors that do not otherwise guarantee strict ordering of flushes from write buffers and/or caches to other processors or main memory.
Store1; StoreStore; Store2という命令列において
Store2とその後の全てのストア命令に関連したデータが他のプロセッサに見えるようになる前に,Store1 データが見えるようになることを保証する(即ち,メモリへフラッシュする).一般論として,他のプロセッサや主メモリに対する,ライトバッファやキャッシュからのフラッシュの強い順序付けが,それなしには保証できないプロセッサに,StoreStore バリアは必要である.

  • LoadStoreバリア

The sequence: Load1; LoadStore; Store2
ensures that Load1's data are loaded before all data associated with Store2 and subsequent store instructions are flushed. LoadStore barriers are needed only on those out-of-order procesors in which waiting store instructions can bypass loads.
Load1; LoadStore; Store2という命令列において
Store2とその後のストア命令に関連した全てのデータがフラッシュされるより先に,Load1のデータが先にロードされることが保証される.LoadStore バリアは待機しているストア命令がロード命令をバイパスできる out-of-order 有りのプロセッサにのみ必要である.

  • StoreLoadバリア

The sequence: Store1; StoreLoad; Load2
ensures that Store1's data are made visible to other processors (i.e., flushed to main memory) before data accessed by Load2 and all subsequent load instructions are loaded. StoreLoad barriers protect against a subsequent load incorrectly using Store1's data value rather than that from a more recent store to the same location performed by a different processor. Because of this, on the processors discussed below, a StoreLoad is strictly necessary only for separating stores from subsequent loads of the same location(s) as were stored before the barrier. StoreLoad barriers are needed on nearly all recent multiprocessors, and are usually the most expensive kind. Part of the reason they are expensive is that they must disable mechanisms that ordinarily bypass cache to satisfy loads from write-buffers. This might be implemented by letting the buffer fully flush, among other possible stalls.
Store1; StoreLoad; Load2という命令列において
Load2とその後の全てのロード命令のデータがロードされる前に,Store1 のデータは他のプロセッサより見えることが保証される.(即ちメインメモリにフラッシュされる.) StoreLoad 命令は,異なるプロセッサが実行した同じ場所に対するストア命令に対する保護というよりは,その後のロード命令がStore1 のデータの値を不正に使用することを防ぐためのものである.このため,以下で議論されているプロセッサにおいて,StoreLoad 命令は,バリア以前にストアされた場所と同じ場所に対する,後続のロード命令から独立したストア命令においてのみ厳密に必要である(?).StoreLoad バリアは最新のマルチプロセッサのほとんど全てに必要で,そして,それは通常,最も高価な類の命令である.それらが高価になる理由の一部は,それらがライトバッファからのロードを満足させるために,キャッシュをバイパスする通常のメカニズムを無効にしなければならないためである(*).これは他の可能なストール(失速)の中でも,バッファ全体を完全にフラッシュすることで実装されるかもしれない,

より具体的に理解しやすいように単純なコード例で説明します。
ここでまず注目しなければならないのは、インタースレッド動作です。メソッド内のローカル変数への操作はインタースレッド動作ではないので注意してください。その上で、The JSR-133 Cookbook - Categories(区分)にある表でバリア命令がどれになるか調べる必要があります。ちなみに、以下のコード例のfunc?メソッドは、複数のスレッドで実行している前提で読んでください。

public class Fuga {
     private int a;
     private volatile int v1;
     private volatile int v2;
     
     // 以下のメソッドを複数のスレッドで実行していると想定
     // 順序変更の最適化が許される
     public void func1() {
          a = 1; // Normal Store
          int b = a; // Normal Load
     }

Cookbookの表を見ればわかるように、func1メソッド内の[Normal Store, Normal Load]に対応するメモリバリア命令は存在しません。リオーダーも禁止されません。メモリバリアがないということは、スレッド毎のローカルキャッシュで読み書きされることを意味します。つまり、ここでの変更はすぐには伝わらないということになります
また、func1メソッドを複数のスレッドで実行した場合は、命令の順序はスレッド毎のメモリモデルで意味が解釈されるため、リオーダーが発生する可能性があります。スレッド間の動作をリオーダーされない想定で設計すると思わぬ不具合が発生する可能性があります。

     // 以下のメソッドを複数のスレッドで実行していると想定
     // 順序変更の最適化が許されない hb(1, 2)
     public void func2() {
          int b = v1; // Volatile Load (1)
          // LoadStoreバリア : [Volatile Load (1), Normal Store (2)]
          a = b; // Normal Store (2)
     }

func2メソッドでは、(1)と(2)の間にLoadStoreバリア命令が挿入され、(2)のロードより前の(1)のロードが保証されます。つまり、スレッド毎のローカルキャッシュではなく、メインメモリから値をロードすることが保証されます。
また、リオーダーも禁止されるため、(2)では(1)でロードされた値が可視です。このことは複数のスレッドで実行してもこのセマンティクスは変わりません。*4

     // 以下のメソッドを複数のスレッドで実行していると想定
     // 順序変更の最適化が許されない hb(1, 2),hb(2, 3)
     public void func3() {
          v1 = 1; // Volatile Store (1)
          // StoreLoadバリア : [Volatile Store (1), Volatile Load (2)]
          // StoreStoreバリア : [Volatile Store (1), Volatile Store (3)]
          v2 = v1; // Volatile Load (2) 
          // LoadStoreバリア : [Volatile Load (2), Volatile Store (3)]
          // Volatile Store (3)
     }
}

func3メソッドでは、[Volatile Store (1), Volatile Load (2)],[Volatile Store (1), Volatile Store (3)],[Volatile Load (2), Volatile Store (3)]の組み合わせの命令があります。それらに対するメモリバリア命令はStoreLoad, StoreStore, LoadStoreの3つです。[Volatile Store (1), Volatile Store (3)]は見落としがちなので注意が必要です*5
(1)でv1 = 1; が実行され、v2 = v1; もその次に実行されます。さらに、リオーダーも禁止されますので、(2)から、(1)のv1は可視であり、値が1という事を観測できるはずです。StoreLoadバリアでは、ロードの前にメインメモリにストアすることが保証され、StoreStoreバリアでは、ストアする前にメインメモリにストアすることが保証されるわけです。複数のスレッドで実行してもこのセマンティクスは変わりません。
そして、finalフィールドの初期化安全性についてはすでに前回のエントリで述べましたが、finalフィールドでは以下の条件でStoreStoreのバリアが挿入され、finalフィールドを保持するオブジェクトへの参照の初期化ロードの前に、finalフィールドへの初期化ロードが保証されます。これがfinalフィールドの初期化安全性を保証する具体的な根拠となります。

// 以下のメソッドを複数のスレッドで実行していると想定
public void funcFinal(){
    Employee employee = new Employee("Junichi Kato"); // employee.name = "Junichi Kato"; Normal Store (1)
    // StoreStoreバリア --- (2)のStoreよりも前に(1)がStoreされることが保証される
    EmployeeHolder.employee = employee; // Normal Store (2)
}

次にThe JSR-133 Cookbookにあるソースコードは若干分かりにくいので同じようなものを作ってみました。

public class Sample {
    private int a;
    private int b;
    private volatile int v;
    private volatile int u;

    // 以下のメソッドを複数のスレッドで実行していると想定
    public void func() {
        int i, j;
        i = a; // Normal Load (1)
        j = b; // Normal Load (2)
        // LoadStoreバリア : [Normal Load (1)(2), Volatile Store (7)] -- (7)のStoreより前に(1)(2)がLoadされることが保証される。(*)
        i = v; // Volatile Load (3)
        // LoadLoadバリア : [Volatile Load (3), Volatile Load (4)] -- (4)のLoadより前に(3)がLoadされることが保証される。
        j = u; // Volatile Load (4)
        // LoadStoreバリア : [Volatile Load (4), Normal Store (5)(6)] -- (5)(6)のStoreより前に(4)がLoadされることが保証される。
        a = i; // Normal Store (5)
        b = j; // Normal Store (6)
        // StoreStoreバリア : [Normal Store (5)(6), Volatile Store (7)] -- (7)のStoreより前に(5)(6)がStoreされることが保証される。
        v = i; // Volatile Store (7)
        // StoreStoreバリア : [Volatile Store (7), Volatile Store (8)] -- (8)のStoreより前に(7)がStoreされることが保証される。
        u = j; // Volatile Store (8)
        // (StoreLoadバリア : [Volatile Store (8), Volatile Load (9)] -- (9)のLoadより前に(8)がStoreすることが保証される。
        i = u; // Volatile Load (9)
        // LoadLoadバリア : [Volatile Load (9), Normal Load (10)] -- (10)のLoadより前に(9)がLoadされることが保証される。(*)
        // LoadStoreバリア : [Volatile Load (9), Normal Load (11)] -- (11)のStoreより前に(9)がLoadされることが保証される。(*)
        j = b; // Normal Load (10)
        a = i; // Normal Store (11)
    }
}

長いので非常に分かりにくいですが、じっくり読めば見えてくるはずです。(*)はThe JSR-133 Cookbookにあるソースコードに説明が抜けているバリアではないかと思っています。

モニターとの相互作用

The JSR-133 Cookbook - Interactions with Atomic Instructions(アトミック命令間の相互作用)に、メモリバリアがモニター(synchronized)との相互作用の仕様が記述されています。

異なるプロセッサ上で必要とされるその種のバリアは, MonitorEnterと MonitorExitの実装ににさらなる相互作用を引き起こす.ロックとアンロックはアトミックな条件付き更新操作である CompareAndSwap(CAS)や LoadLinked/StoreConditional (LL/SC)を含んでいる.それらはセマンティクス的には volatileロードに続く volatileストアになる.CASや LL/SCがあれば最低限の機能は満たすけれど,幾つかのプロセッサでは他のアトミックな命令(例えば "unconditional exchange",無条件変換)もサポートしている.それはアトミックな条件付き更新( "conditional update")の代わりに,あるいはそれと一緒に使われる.

The JSR-133 Cookbook - Interactions with Atomic Instructions(アトミック命令間の相互作用)の表をみてもらえればわかるのですが、バリア名はEnterはLoad、ExitはStoreに置き換えて読むことができるようです。また、その表の下にfinalフィールドのStoreStoreバリアについても解説があります。

モニターに関連するメモリバリア命令の詳解

さらに詳しく説明されているバリア命令が以下です。

  • EnterLoad
  • StoreExit
  • ExitEnter
  • EnterEnter

詳しい意味は以下です。まずは、基本的に太字のところが理解できれば問題ないと思います。

  • EnterLoadについて

EnterLoad is needed on entry to any synchronized block/method that performs a load.
It is the same as LoadLoad unless an atomic instruction is used in MonitorEnter and itself provides a barrier with at least the properties of LoadLoad, in which case it is a no-op.
EnterLoadは,内部でロードが実行される全てのsynchronizedブロック/メソッドに入る時に必要とされる. MonitorEnter内でアトミック命令が使われ,それ自体が最低でも LoadLoadの特性のバリアを提供しない限り,それ(EnterLoad)は LoadLoad と同じである.それ(LoadLoad級のバリア)が提供された場合はno-opになる.

以下のようなことを意味していると思います。

// 以下の処理を複数のスレッドで実行していると想定
synchronized(this){ // MonitorEnter
    // EnterLoadバリア (LoadLoadバリア相当) -- 次のLoadよりも前にモニターの開始が保証される
    // EnterStoreバリア (LoadStoreバリア相当)
    int x = v; // Volatile Load
    // LoadStoreバリア
    a = 1; // Normal Store
    // LoadExitバリア (LoadStoreバリア相当)
    // StoreExitバリア (次のStoreExitを参照)
} // MonitorExit

EnterLoadはLoadLoad相当なので、次のLoadよりも前にモニターの開始が保証されるのではないかと思います。

  • StoreExitについて

StoreExit is needed on exit of any synchronized block/method that performs a store. It is the same as StoreStore unless an atomic instruction is used in MonitorExit and itself provides a barrier with at least the properties of StoreStore, in which case it is a no-op.
StoreExitは,その内部でストアを行う全ての synchronizedブロックやメソッドの出口で必要になる. MonitorExitでアトミック命令が使われ,それ自身が最低でも StoreStoreと同じ性質のバリアを提供しない限り,それ(StoreExit)は StoreStoreと同じである.それ(StoreStore級のバリア)が提供される場合はno-opとなる.

// 以下の処理を複数のスレッドで実行していると想定
synchronized(this){ // MonitorEnter
    // EnterLoadバリア (前のEnterLoadを参照)
    // EnterStoreバリア (LoadStoreバリア相当)
    int x = v; // Volatile Load
    // LoadStoreバリア
    a = 1; // Normal Store
    // LoadExitバリア (LoadStoreバリア相当)
    // StoreExitバリア (StoreStore相当) -- モニターが終了するよりも前にStoreが保証される
} // MonitorExit

StoreExitはStoreStore相当なので、モニターが終了するよりも前にStoreが保証されるのではないかと思います。

  • ExitEnterについて

ExitEnter is the same as StoreLoad unless atomic instructions are used in MonitorExit and/or MonitorEnter and at least one of these provide a barrier with at least the properties of StoreLoad, in which case it is a no-op.
MonitorExitや MonitorEnter内でアトミック命令が使われており,そして少なくともその中の一つが,最低でも StoreLoadと同じ性質のバリアを提供しない限り, ExitEnterは StoreLoadと同じである.それ(StoreLoad級のバリア)が提供されない場合はno-opとなる.

// 以下の処理を複数のスレッドで実行していると想定
synchronized(this){
    // EnterLoadバリア
    v = 1; // Volatile Store
    // StoreLoadバリア
    int b = a; // Normal Load
    // StoreExitバリア
} // MonitorExit
// ExitEnterバリア (StoreLoadバリア相当)
synchronized(this){ // MonitorEnter
    // EnterLoadバリア
    a = 1; // Normal Store
    // StoreExitバリア
} // MonitorExit

ExitEnterは、StoreLoad相当なので、次のモニターが開始するより前に、モニターが終了することを保証するのではないかと思います。MonitorExitで処理が終了する場合は、その後にExitEnterバリアが発生しないと思いますが、多くの場合は、MonitorExitの後にMonitorEnterが存在するのでMonitorExit後にはExitEnterバリアが発行されると考えてよいと思います。

  • EnterEnterについて

The other types are specializations that are unlikely to play a role in compilation (see below) and/or reduce to no-ops on current processors. For example, EnterEnter is needed to separate nested MonitorEnters when there are no intervening loads or stores. Here's an example showing placements of most types:
もう一つのタイプは,コンパイル中で役割を果たしそうになく(下の例を参照),そして現在のプロセッサ上では no-opsに縮小される特殊化である.例えば,インターリーブされたロードとストアが無い時に,複数のネストされた MonitorEnterを分離するのに EnterEnterが必要である.以下に,ほとんどのパターンを含む(?)例を示す.

// 以下の処理を複数のスレッドで実行していると想定
synchronized(this) { // MonitorEnter
	// EnterEnterバリア
	synchronized(this) { // MonitorEnter
		// EnterExitバリア
	} // MonitorExit
	// ExitExitバリア
} // MonitorExit

EnterEnterバリアはLoadLoadバリア相当なので、次のモニターの開始よりも前にモニターの開始が保証されるということではないかと思います。

モニターの可視性について

ここまでの説明を読んで、モニターの可視性が気になったかもしれません。ちょっとおさらいしておきたいとおもいます。
以前のエントリで、モニターの可視性について以下のように説明していました。

固有ロック(synchronized)はモニターという同期化の仕組みを利用していますが、そのモニターの機能の一部として、「synchronizedのブロックに出る際に、ローカルキャッシュをメインメモリに吐き出すことを要求し、同期化ブロックに入る際にはローカルキャッシュを無効化し、メインメモリを見にいく」動作を行うようです。つまり、JMMのメモリバリアのことですね。

実はこれはモニターの開始より前にモニターが終了することが保証されるExitEnterバリアのことです。
これは当然といえば当然ですが、これが可視性に関係しています。以下の「Java言語仕様3版」の「17.4.5 先行発生の順序」の一文を思い出してみてください。

モニタのアンロックは,後に続くすべての該当モニタに対するロックよりも先行発生する。

Java言語仕様 第3版 (The Java Series)

Java言語仕様 第3版 (The Java Series)


つまり、モニターの終了までに行われたことが、次のモニターの開始時には可視になるということです。
Java並行処理プログラミング」の「16-1 メモリモデルとは何か? なぜ必要か?」P385 の 「Fig16.2 Javaのメモリモデル(JMM)における事前発生関係の図解」が分かりやすいです。
Java並行処理プログラミング ―その「基盤」と「最新API」を究める―

Java並行処理プログラミング ―その「基盤」と「最新API」を究める―


モニターの終了時には何が行われるかというと、StoreExitバリアでメモリにストアされます。その変更内容が次のモニターの開始時には見えるということでしょう。

では、The JSR-133 Cookbookに掲載があるサンプルですが、これも分かりにくいので同じようなものを作ってみました。

public class Sample {
	private int a;
	private volatile int v;

        // 以下のメソッドを複数のスレッドで実行していると想定
	public void func() {  
		int i; 
		synchronized(this) { // MonitorEnter (1)
			// EnterLoadバリア : [MonitorEnter (1), Normal Load (2)]
			// EnterStoreバリア : [MonitorEnter (1), Normal Store (3)]
			i = a; // Normal Load (2)
			a = i; // Normal Store (3)
			// LoadExitバリア : [Normal Load (2), MonitorExit (4)]
			// StoreExitバリア : [Normal Store (3), MonitorExit (4)]
		} // MonitorExit (4)
		// ExitEnterバリア : [MonitorExit (4), MonitorEnter (5)]
		synchronized(this) { // MonitorEnter (5)
			// EnterEnterバリア : [MonitorEnter (5), MonitorEnter (6)]
			synchronized(this) { // MonitorEnter (6)
				// EnterExitバリア : [MonitorEnter (6), MonitorExit (7)]
			} // MonitorExit (7)
			// ExitExitバリア : [MoniorExit (7), MonitorExit (8)]
		} // MonitorExit (8)
		// ExitEnterバリア : [MonitorExit (8), MonitorEnter (10)]
		// ExitLoadバリア : [MonitorExit (8), Volatile Load (9)]
		i = v; // Volatile Load (9) 
		// LoadStoreバリア : [Volatile Load (9), Volatile Store (12)]
		// LoadEnterバリア : [Volatile Load (9), MonitorEnter (10)]
		synchronized(this) { // MonitorEnter (10)
			// EnterExitバリア : [MonitorEnter (10), MonitorExit (11)]
		} // MonitorExit (11)
		// ExitEnterバリア : [MonitorExit (11), MonitorEnter (13)]
		// ExitStoreバリア : [MonitorExit (11),  Volatile Store (12)]
		v = i; // Volatile Store (12)
		synchronized(this) { // MonitorEnter (13)
			// EnterExitバリア : [MonitorEnter (13), MonitorExit (14)]
		} // MonitorExit (14)
	}
}

かなり複雑ですが、これもじっくり読めばわかるはずです。
大事なことは、メモリモデルはスレッド毎のセマンティクスを持っていますが、複数のスレッドで上記のようなインタースレッド動作を行う場合はJVMがバリア命令を随所に挿入し、可視性と、大域のメインメモリとのI/Oを保証するというところではないかと思います。
大まかな説明として以上ですが、バリア命令の挿入に関する補足説明や、冗長なバリア命令の除去ルールなどについてはThe JSR-133 Cookbook - Recipes(レシピ)を読んでおくと参考になると思います。

問題の答え

冒頭の問題の答えは、「[Normal Store, Normal Load]の間にメモリバリアが挿入されないので、スレッドごとのローカルキャッシュなどへの読み書きとなるために、スレッドが停止しない」です。

public class StopThreadTest {
    private static volatile boolean stopRequested;
    // (*)のバリアはThe JSR-133 CookbookのInserting Barriers(バリアの挿入)にあるルールによって挿入されると考えられるバリア命令
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                // whileループの繰り返し毎に呼ばれる↓
                // LoadLoadバリア -- 繰り返しLoadする場合はLoadLoadバリアが挿入されるはず。大域のメインメモリの最新のロードが保証される。
                // LoadStoreバリア (*) -- 後続にStoreがないのでno-opではないかと思いますが...
                while (!stopRequested) {
                    i++;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        // StoreStoreバリア (*) -- 前方にStoreがないのでno-opではないかと思いますが...
        stopRequested = true;
        // StoreLoadバリア (*) -- volatileの場合は書込みが先行発生するのここで大域のメインメモリにストアされるはず。[JLS 17.4.5]とThe JSR-133 CookbookのInserting Barriersを参照のこと。
    }

}

Cookbookの表を見れば答えは一目瞭然ですが、並行処理におけるメモリの可視性保証についてで説明したように、固有ロックを掛けるか、volatile修飾子をstopRequestedに付ければ、大域のメインメモリへの読み書きとなるため、期待通りの動作になります。
stopRequestedフィールドへの読み書きはアトミックなので、この場合はvolatileにした方がロック争奪のコストを軽減できます。

まとめ

長々と説明してきましたが、Javaのメモリモデルは並行処理プログラムを設計する上で根幹となる仕様だと思います。難しいので敬遠しがちなのですが、これからはそうもいかないと思います。
前述しましたが、単一のプロセッサ環境では、コンテキストスイッチを伴うコンカレントな環境では、メモリモデルを無視しても問題が表面化しないかもしれません*6。しかし、マルチコアを含めたマルチプロセッサ環境では、アウト・オブ・オーダー実行などで並列で命令を実行できるので、メモリモデルによって投機的なリオーダーが発生するかもしれません。そうなった場合は、メモリモデルの仕様を想定していない並行処理プログラムは思わぬ不具合が発生する可能性があります。
並行処理に対する安全性に確信を持つにはJavaのメモリモデルの仕様に少なくとも従う必要があると思います。その意味では、Javaのメモリモデルは、マルチコア時代に備えて本気で理解しておきたい仕様のひとつです。ここでは触れていない仕様もありますので、興味がある方はJava言語仕様3版やThe JSR-133 Cookbookなどを参照してみてください。
ということで仕様重要です。

*1:詳しくは「Effective Java第二版」の「項目66 共有された可変データへのアクセスを同期する」を参照してください

*2:和訳のタイトルが"メモリアクセスに対するバリア同期"となっていますが、本文読むと"同期バリア"ではないと言っているので、この訳は間違いかもしれません。

*3:ローカルキャッシュなどと表現するのは、メモリモデルにはレジスタコンパイラなどの最適化が含まれるため一概にキャッシュと表現できないため

*4:ここで挙げている例ではアトミック性を保証していませんので、それに起因するスレッド間で起こる問題は言及していませんのでご容赦ください。

*5:間に別の命令が入ってもセマンティクスとして[Volatile Store (1), Volatile Store (3)]の関係が成立するはずなので、StoreStoreバリア命令が必要なはずです。

*6:今時、単一のプロセッサってあるのかなって感じですが...。