Память хранится в системе кеша в единицах, известных как строки кеша. Строки кэша имеют степень 2 смежных байта, размер которых обычно составляет 32-256. Наиболее распространенный размер строки кэша составляет 64 байта. Ложный общий доступ — это термин, который применяется, когда потоки невольно влияют на производительность друг друга, изменяя независимые переменные, совместно использующие одну и ту же строку кэша. Конфликт записи в строках кэша является единственным наиболее ограничивающим фактором для достижения масштабируемости для параллельных потоков выполнения в системе SMP. Я слышал, что ложный обмен описан как тихий убийца производительности, потому что это далеко не очевидно при взгляде на код.
Чтобы добиться линейной масштабируемости с количеством потоков, мы должны обеспечить, чтобы два потока не записывали в одну переменную или строку кэша. Два потока, записывающих в одну и ту же переменную, могут отслеживаться на уровне кода. Чтобы узнать, разделяют ли независимые переменные одну и ту же строку кэша, нам нужно знать структуру памяти, или мы можем получить инструмент, который скажет нам. Intel VTune — такой инструмент профилирования. В этой статье я объясню, как распределяется память для объектов Java и как мы можем заполнить наши строки кэша, чтобы избежать ложного совместного использования.
Фигура 1. |
Рисунок 1. выше иллюстрирует проблему ложного обмена. Поток, работающий на ядре 1, хочет обновить переменную X, тогда как поток на ядре 2 хочет обновить переменную Y. К сожалению, эти две горячие переменные находятся в одной строке кэша. Каждый поток будет бороться за право владения строкой кэша, чтобы они могли ее обновить. Если ядро 1 получает владение, подсистема кэша должна будет сделать недействительной соответствующую строку кэша для ядра 2. Когда Ядро 2 получит право собственности и выполнит свое обновление, ядру 1 будет приказано сделать недействительной свою копию строки кэша. Это будет пинг-понг туда и обратно через кэш-память третьего уровня, что сильно повлияет на производительность. Эта проблема будет еще более усугублена, если конкурирующие ядра находятся на разных сокетах и дополнительно должны пересечь межсоединение сокетов.
Макет памяти Java
В JVM Hotspot все объекты имеют заголовок из 2 слов. Во-первых, это слово «метка», которое состоит из 24 битов для хэш-кода и 8 битов для флагов, таких как состояние блокировки, или его можно поменять местами для объектов блокировки. Второе — это ссылка на класс объекта. Массивы имеют дополнительное слово для размера массива. Каждый объект выровнен по 8-байтовой границе гранулярности для производительности. Поэтому, чтобы быть эффективными при упаковке, поля объекта переупорядочиваются из порядка объявления в следующий порядок на основе размера в байтах:
- двойной (8) и длинный (8)
- целые (4) и поплавки (4)
- шорты (2) и чулки (2)
- логические значения (1) и байты (1)
- ссылки (4/8)
С этим знанием мы можем заполнить строку кэша между любыми полями с 7 long. В
Disruptor мы добавляем строки кэша вокруг
курсора
RingBuffer и последовательностей BatchEventProcessor .
Чтобы показать влияние на производительность, давайте возьмем несколько потоков, каждый из которых обновляет свои независимые счетчики. Эти счетчики будут
нестабильны в течение долгого времени, чтобы мир мог видеть их прогресс.
public final class FalseSharing
implements Runnable
{
public final static int NUM_THREADS = 4; // change
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
static
{
for (int i = 0; i < longs.length; i++)
{
longs[i] = new VolatileLong();
}
}
public FalseSharing(final int arrayIndex)
{
this.arrayIndex = arrayIndex;
}
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 FalseSharing(i));
}
for (Thread t : threads)
{
t.start();
}
for (Thread t : threads)
{
t.join();
}
}
public void run()
{
long i = ITERATIONS + 1;
while (0 != --i)
{
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong
{
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; // comment out
}
}
Результаты
Запустив приведенный выше код, увеличив количество потоков и добавив / удалив заполнение строк кэша, я получаю результаты, показанные на рисунке 2. ниже. Это измерение продолжительности тестовых прогонов на моем 4-ядерном Nehalem дома.
Фигура 2. |
Влияние ложного обмена отчетливо видно по увеличению времени выполнения, необходимого для завершения теста. Без конфликта строк кэша мы достигаем почти линейного масштабирования с потоками.
Это не идеальный тест, потому что мы не можем быть уверены, где
VolatileLongs будут размещены в памяти. Они являются независимыми объектами. Однако опыт показывает, что объекты, размещенные в одно и то же время, имеют тенденцию быть совмещенными.
Так что у вас есть это. Ложный обмен может быть тихим убийцей производительности.
От http://mechanical-sympathy.blogspot.com/2011/07/false-sharing.html