1. 擬似共有とキャッシュライン
1.CPUキャッシュアーキテクチャ
CPU はコンピュータの心臓部であり、すべての操作とプログラムは最終的に CPU によって実行されます。
メイン メモリ (RAM) はデータが保存される場所で、メイン メモリへの直接アクセスでも非常に遅いため、CPU とメイン メモリの間にはいくつかのレベルのキャッシュがあります。
CPU の速度はメモリの速度よりもはるかに高速です。この問題を解決するために、CPU は 3 レベルのキャッシュを導入しています: L1、L2、L3。L1 が CPU に最も近く、L2 が 2 番目、L3 CPUから一番遠いのがL3、その次がメインメモリです。速度はL1>L2>L3>メインメモリです。CPUに近づくほど容量は小さくなります。CPUはデータを取得すると、3次キャッシュから順番に検索します。
CPUはデータを読み込む場合、まず1次キャッシュから検索し、見つからない場合は2次キャッシュから検索し、それでも見つからない場合はさらに検索します。 3次キャッシュまたはメモリから。一般に、各レベルのキャッシュのヒット率は約 80% です。これは、総データ量の 80% が 1 次キャッシュで見つかり、総データ量の 20% だけを 1 次キャッシュから取得する必要があることを意味します。 2 次キャッシュ、レベル 3 キャッシュ、またはメモリからの読み取りレベル 1 キャッシュが CPU キャッシュ アーキテクチャ全体の最も重要な部分であることがわかります。
2. 擬似共有とは何ですか?
コンピュータシステムのメインメモリとCPUの実行速度のギャップを解決するために、CPUとメインメモリの間に1つ以上のレベルのキャッシュメモリ(Cache)が追加され、通常、このキャッシュはCPUに組み込まれています。 、そのため、CPU キャッシュとも呼ばれます。次の図は 2 レベルのキャッシュ構造です。
キャッシュは内部的に行単位で格納されます。各行はキャッシュ ラインと呼ばれます。キャッシュ ラインは、キャッシュとメイン メモリ間のデータ交換の単位です。キャッシュ ラインのサイズは、通常 2 バイトのべき乗です。
CPU が特定の変数にアクセスするとき、まずその変数が CPU キャッシュに存在するかどうかを確認し、存在する場合はそこから直接取得します。そうでない場合は、メイン メモリから変数を取得してキャッシュします。変数が配置されているメモリ領域に変数がコピーされ、メモリがキャッシュにコピーされます (キャッシュ ラインは、キャッシュとメイン メモリ間のデータ交換の単位です)。単一の変数ではなくメモリのブロックがキャッシュ ラインに格納されるため、複数の変数が 1 つのキャッシュ ラインに格納される可能性があります。複数のスレッドがキャッシュライン内の複数の変数を同時に変更する場合、そのキャッシュラインを同時に操作できるスレッドは 1 つだけであるため、各変数を 1 つのキャッシュラインに配置する場合よりもパフォーマンスが低下します (擬似共有)。
上の図に示すように、変数 x と y は、CPU の 1 次キャッシュと 2 次キャッシュに同時に配置されます。スレッド 1 が CPU1 を使用して変数 x を更新するとき、最初にキャッシュ ラインを変更します。 cpu1 の 1 次キャッシュ変数 x のキャッシュ整合性 このとき、プロトコルにより cpu2 の変数 x に対応するキャッシュ ラインが無効になり、スレッド 2 が変数 を書き込むときに、キャッシュの整合性が失われます。最悪の場合、CPU が一次キャッシュしか持たない場合、メイン メモリへの直接アクセスが頻繁に発生することになります。
3. 疑似共有はなぜ発生するのですか?
疑似共有は、複数の変数が 1 つのキャッシュ ラインに配置され、複数のスレッドがキャッシュ ライン内の異なる変数に同時に書き込むために発生します。では、なぜ複数の変数が 1 つのキャッシュ ラインに入れられるのでしょうか? 実は、キャッシュとメモリ間のデータ交換の単位がキャッシュであるためで、CPUがアクセスしたい変数がキャッシュにヒットしない場合、プログラム動作の局所性原理に従い、その変数はキャッシュとして保存されます。キャッシュラインのサイズを持つメモリ内のライン。
4. Java での疑似共有
疑似共有を解決する最も直接的な方法はパディングです。たとえば、次の VolatileLong では、long は 8 バイトを占有し、Java オブジェクト ヘッダーは 8 バイト (32 ビット システム) または 12 バイト (64 ビット システム、デフォルト) を占有します。オブジェクト ヘッダー圧縮をオンにします。オンにしない場合は 16 バイトを占有します)。キャッシュ ラインは 64 バイトなので、6 つのロング (6 * 8 = 48 バイト) を埋めることができます。
ここで、JVM オブジェクトのメモリ モデルについて学びます。すべての Java オブジェクトには 8 バイトのオブジェクト ヘッダーがあり、最初の 4 バイトはオブジェクトのハッシュ コードとロック ステータスの保存に使用され、最初の 3 バイトはハッシュ コードの保存に使用され、最後のバイトはロックの保存に使用されます。ステータスでは、オブジェクトがロックされると、これらの 4 バイトがオブジェクトから取得され、ポインタにリンクされます。残りの 4 バイトは、オブジェクトが属するクラスへの参照を格納するために使用されます。配列の場合、配列のサイズ (4 バイト) を保持する変数もあります。各オブジェクトのサイズは 8 バイトの倍数に調整され、8 バイト未満の部分は埋める必要があります。効率を確保するために、Java コンパイラは、Java オブジェクトをコンパイルするときに、次の表に示すように Java オブジェクトのフィールドをフィールド タイプごとに並べ替えます。
したがって、任意のフィールド間に長整数変数を入力することで、異なるキャッシュ ライン内のホット変数を分離でき、誤った同期を減らすことで、マルチコア CPU の効率を大幅に向上させることができます。
最も簡単な方法
/**
* 缓存行填充父类
*/
public class DataPadding {
//填充 6个long类型字段 8*4 = 48 个字节
private long p1, p2, p3, p4, p5, p6;
//需要操作的数据
private long data;
}
JDK1.7以降はコードが自動的に最適化され、無駄なコードが削除されるため、JDK1.7以降のバージョンでは効果がありません。
継承方法
/**
* 缓存行填充父类
*/
public class DataPadding {
//填充 6个long类型字段 8*4 = 48 个字节
private long p1, p2, p3, p4, p5, p6;
}
キャッシュフィルクラスの継承
/**
* 继承DataPadding
*/
public class VolatileData extends DataPadding {
// 占用 8个字节 +48 + 对象头 = 64字节
private long data = 0;
public VolatileData() {
}
public VolatileData(long defValue) {
this.data = defValue;
}
public long accumulationAdd() {
//因为单线程操作不需要加锁
data++;
return data;
}
public long getValue() {
return data;
}
}
JDK1.8でも使用可能です
@競合アノテーション
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
String value() default "";
}
競合アノテーションはタイプと属性に使用でき、このアノテーションを追加すると、仮想マシンは誤った共有を避けるために自動的にアノテーションを埋めます。このアノテーションは、Java8 ConcurrentHashMap、ForkJoinPool、および Thread クラスで使用されます。Java 8 の ConcurrentHashMap で Contended アノテーションを使用して擬似共有問題を解決する方法を見てみましょう。以下で説明する ConcurrentHashMap はすべて Java8 バージョンです。
注: **@sun.misc.Contended は、擬似共有を回避するために Java 8 で提供されています。実行時に JVM 起動パラメータ -XX:-RestrictContended**を設定する必要があります。そうしないと、有効にならない可能性があります。
キャッシュライン充填の威力
/**
* 缓存行测试
*/
public class CacheLineTest {
/**
* 是否启用缓存行填充
*/
private final boolean isDataPadding = false;
/**
* 正常定义的变量
*/
private volatile long x = 0;
private volatile long y = 0;
private volatile long z = 0;
/**
* 通过缓存行填充的变量
*/
private volatile VolatileData volatileDataX = new VolatileData(0);
private volatile VolatileData volatileDataY = new VolatileData(0);
private volatile VolatileData volatileDataZ = new VolatileData(0);
/**
* 循环次数
*/
private final long size = 100000000;
/**
* 进行累加操作
*/
public void accumulationX() {
//计算耗时
long currentTime = System.currentTimeMillis();
long value = 0;
//循环累加
for (int i = 0; i < size; i++) {
//使用缓存行填充的方式
if (isDataPadding) {
value = volatileDataX.accumulationAdd();
} else {
//不使用缓存行填充的方式 因为时单线程操作不需要加锁
value = (++x);
}
}
//打印
System.out.println(value);
//打印耗时
System.out.println("耗时:" + (System.currentTimeMillis() - currentTime));
}
/**
* 进行累加操作
*/
public void accumulationY() {
long currentTime = System.currentTimeMillis();
long value = 0;
for (int i = 0; i < size; i++) {
if (isDataPadding) {
value = volatileDataY.accumulationAdd();
} else {
value = ++y;
}
}
System.out.println(value);
System.out.println("耗时:" + (System.currentTimeMillis() - currentTime));
}
/**
* 进行累加操作
*/
public void accumulationZ() {
long currentTime = System.currentTimeMillis();
long value = 0;
for (int i = 0; i < size; i++) {
if (isDataPadding) {
value = volatileDataZ.accumulationAdd();
} else {
value = ++z;
}
}
System.out.println(value);
System.out.println("耗时:" + (System.currentTimeMillis() - currentTime));
}
public static void main(String[] args) {
//创建对象
CacheLineTest cacheRowTest = new CacheLineTest();
//创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(3);
//启动三个线程个调用他们各自的方法
executorService.execute(() -> cacheRowTest.accumulationX());
executorService.execute(() -> cacheRowTest.accumulationY());
executorService.execute(() -> cacheRowTest.accumulationZ());
executorService.shutdown();
}
}
キャッシュラインを埋めないテスト
/**
* 是否启用缓存行填充
*/
private final boolean isDataPadding = false;
出力
100000000
耗时:7960
100000000
耗时:7984
100000000
耗时:7989
キャッシュライン充填によるテスト
/**
* 是否启用缓存行填充
*/
private final boolean isDataPadding = true;
出力
100000000
耗时:176
100000000
耗时:178
100000000
耗时:182
同じ構造でも50倍近い速度差があります。
要約する
複数のスレッドが共有キャッシュ ラインに同時に書き込む場合、キャッシュ システムのキャッシュ一貫性原理により、疑似共有の問題が発生します。一般的な解決策は、キャッシュ ラインのサイズに応じて共有変数を補足的に調整することです。キャッシュにロードされると、排他的なキャッシュ ラインを持つことができ、他の共有変数と同じキャッシュ ラインに格納されることを回避できます。