Модель памяти Java или Java Memory Model (JMM) описывает поведение программы в многопоточной среде. Она объясняет возможное поведение потоков и то, на что должен опираться программист, разрабатывающий приложение.
В этой статье дальше приведено достаточно большое количество терминов. Думаю, что большая часть из них пригодится вам только на собеседованиях, но представлять общую картину того, что такое Java Memory Model всё-таки полезно.
- Разделяемые переменные
- Действия
- Program order
- Synchronization order
- Happens-before
- Final
- Word tearing
Java может работать на разных процессорах и разных операционных системах, что приводит к затруднению синхронизации между потоками. Многие современные процессоры имеют несколько ядер, могут выполнять команды не в той последовательности, в которой они записаны, а также компиляторы могут менять последовательность команд для оптимизации.
Неправильно синхронизированные программы могут приводить к неожиданным результатам.
Например, программа использует локальные переменные
r1 и
r2 и общие переменные
A и
B. Первоначально
A == B == 0.
Thread 1 | Thread 2 |
1: r2 = A; | 3: r1 = B; |
2: B = 1; | 4: A = 2; |
Может показаться, что результат r2 == 2 и r1 == 1 невозможен, так как либо инструкция 1 должна быть первой, либо инструкция 3 должна быть первой. Если инструкция 1 будет первой, то она не сможет увидеть число 2, записанное в инструкции 4. Если инструкция 3 будет первой, то она не сможет увидеть результат инструкции 2.
Если какое-то выполнение программы привело бы к такому поведению, то мы бы знали, что инструкция 4 была до инструкции 1, которая была до инструкции 2, которая была до инструкции 3, которая была до инструкции 4, что совершенно абсурдно.
Однако современным компиляторам разрешено переставлять местами инструкции в обоих потоках в тех случаях, когда это не затрагивает исполнение одного потока не учитывая другие потоки. Если инструкция 1 и инструкция 2 поменяются местами, то мы с лёгкостью сможем получит результат
r2 == 2 и
r1 == 1.
Thread 1 | Thread 2 |
B = 1; | r1 = B; |
r2 = A; | A = 2; |
Для некоторых программистов подобное поведение может оказаться ошибочным, но здесь нужно сделать замечание, что этот код неверно синхронизирован:
- у нас есть запись из одного потока;
- мы читаем ту же переменную из другого потока;
- чтение и запись не синхронизированы, что не гарантирует правильный порядок.
Ситуация, описанные в примере выше, называется «состоянием гонки» или Data Race.
Переставлять команды может Just-In-Time компилятор или процессор. Более того, каждое ядро процессора может иметь свой кеш. А значит, у каждого процессора может быть своё значение одной и той же переменнной, что может привести к аналогичным результатам.
Модель памяти описывает, какие значения могут быть считаны в каждый момент программы. Поведение потока в изоляции должно быть таким, каким описано в самом потоке, но значения, считываемые из переменных определяются моделью памяти. Когда мы ссылаемся на это, то мы говорим, что программа подчиняется intra-thread semantic, то есть семантики однопоточного приложения.
Разделяемые переменные
Память, которая может быть совместно использована разными потоками, называется куча (shared memory или heap memory).
Все переменные экземпляров, статические поля, массивы элементов хранятся в куче. Дальше в этой статье я буду называть их всех просто переменными.
Локальные переменные, параметры конструкторов и методов, а также параметры блока
catch никогда не разделяются между потоками.
Два доступа к одной переменной называются конфликтующими, если хотя бы один их доступов меняет значение переменной (другой может как менять, так и считывать текущее значение).
Действия
Inter-thread action (термин такой, не знаю, как перевести, может, межпоточное действие?) — это действие внутри одного потока, которое может повлиять или быть замечено другим потоком. Существует несколько типов inter-thread action:
- Чтение (нормальное, не volatile). Чтение переменной.
- Запись (нормальная, не volatile). Запись переменной.
- volatile read. Чтение volatile переменной.
- volatile write. Запись volatile переменной.
- Lock. Взятие блокировки монитора.
- Unlock. Освобождение блокировки монитора.
- (синтетические) первое и последнее действие в потоке.
- Действия по запуску нового потока или обнаружения остановки потока.
- Внешние действия. Это действия, которые могут быть обнаружены снаружи выполняющегося потока, например, взаимодействия с внешним окружением.
- Thread divergence actions. Действия потока, находящегося в бесконечном цикле без синхронизаций, работы с памятью или внешних действий.
Program order
Program order (лучше не переводить, чтобы не возникло путаницы) — общий порядок потока, выполняющего действия, который отражает порядок, в котором должны быть выполнены все действия с соответствии с семантикой intra-thread semantic потока.
Действия называются sequentially consistent (лучше тоже не переводить), если все действия выполняются в общем порядке, который соответствует program order, а также каждое чтение переменной видит последнее значение, записанное туда до этого в соответствии с порядком выполнения.
Если в программе нет состояния гонки, то все запуски программы будут sequentially consistent.
Synchronization order
Synchronization order (порядок синхронизации, но лучше не переводить) — общий порядок всех действий по синхронизации в выполнении программы.
Действия по синхронизации вводят связь synchronized-with (синхронизировано с):
- Действие освобождения блокировки монитора synchronizes-with все последующие действия по взятию блокировки этого монитора.
- Присвоение значения
volatile переменной synchronizes-with все последующие чтения этой переменной любым потоком. - Действие запуска потока synchronizes-with с первым действием внутри запущенного потока.
- Присвоение значения по умолчанию (0, false, null) каждой переменной synchronizes-with с первым действием каждого потока.
- Последнее действие в потоке synchronizes-with с любым действием других потоков, которые проверяют, что первый поток завершился.
- Если поток 1 прерывает поток 2, то прерывание выполнения потока 2 synchronizes-with с любой точкой, где другой поток (и прерывающий тоже) проверяет, что поток 2 был прерван (
InterruptedException,
Thread.interrupted,
Thread.isInterrupted).
Happens-before
Happens-before («выполняется прежде» или «произошло-до») — отношение порядка между атомарными командами. Оно означает, что вторая команда будет видеть изменения первой команды, и что первая команды выполнилась перед второй. Рекомендую ознакомиться с многопоточностью в Java, перед продолжением чтения.
Happens-before возникает:
- Освобожение монитора happens-before любого последующего взятия блокировки этого монитора.
- Присвоение значение
volatile полю happens-before любого последующего чтения значения этого поля. - Запуск потока happens-before любых действий в запущенном потоке.
- Все действия внутри потока happens-before любого успешного завершения
join() над этим потоком. - Инициализация по умолчанию для любого объекта happens-before любых других действий программы.
Работа с final полями
Все
final поля должны быть инициализированы либо конструкциями инициализации, либо внутри конструктора. Не стоит внутри конструкторов обращаться к другим потокам. Поток увидит ссылку на объект только после полной инициализации, то есть по окончании работы конструктора. Так как
final полям присваивается значение только один раз, то просто не обращайтесь к другим потоком внутри конструкторов и блоков инициализации и проблем возникнуть не должно.
Однако
final поля могут быть изменены через Java Reflection API, чем пользуются, например, десериализаторы. Просто не отдавайте ссылку на объект другим потокам и не читайте значение
final поля до его обновления и всё будет нормально.
Word tearing
Некоторые процессоры не позволяют записывать один байт в ОЗУ, что приводит к проблеме, называемой word tearing. Представьте, что у нас есть массив байт. Один поток записывает первый байт, а второй поток пытается записать значение в рядом стоящий байт. Но если процессор не может записать один байт, а только целое машинное слово, то запись рядом стоящего байта может быть проблематичной. Если просто считать машинное слово, обновить один байт и записать обратно, то мы помешаем другому потоку.
В JVM нет проблемы word tearing. Два потока, пишущие рядом стоящие байты не должны мешать друг другу.
Одним из соображений при реализации виртуальной машины Java является то, что каждое поле и элемент массива считаются отдельными; обновления одного поля или элемента не должны взаимодействовать с чтениями или обновлениями любого другого поля или элемента. В частности, два потока, которые обновляют смежные элементы массива байтов по отдельности, не должны мешать или взаимодействовать и не нуждаются в синхронизации для обеспечения последовательной согласованности.
Некоторые процессоры не предоставляют возможность записи в один байт. Было бы незаконно реализовывать обновления массива байтов на таком процессоре, просто считывая все слово, обновляя соответствующий байт, а затем записывая все слово обратно в память. Эта проблема иногда известна как разрыв слова (word tearing), и на процессорах, которые не могут легко обновить отдельный байт, потребуется другой подход.
Пример — обнаружение разрыва слов
Следующая программа представляет собой тестовый пример для обнаружения разрывов слов:
public class WordTearing extends Thread {
static final int LENGTH = 8;
static final int ITERS = 1000000;
static byte[] counts = new byte[LENGTH];
static Thread[] threads = new Thread[LENGTH];
final int id;
WordTearing(int i) {
id = i;
}
public void run() {
byte v = 0;
for (int i = 0; i < ITERS; i++) {
byte v2 = counts[id];
if (v != v2) {
System.err.println("Word-Tearing found: " +
"counts[" + id + "] = "+ v2 +
", should be " + v);
return;
}
v++;
counts[id] = v;
}
}
public static void main(String[] args) {
for (int i = 0; i < LENGTH; ++i)
(threads[i] = new WordTearing(i)).start();
}
}
Это указывает на то, что байты не должны перезаписываться записью в соседние байты.
Читайте также:
- Спецификация Java 11: 17.4.5. Порядок происходит-до (happens-before)
- Спецификация Java 11: 17.5. Семантика final полей
- Спецификация Java 11: 17.5. Чтение, изменение final полей
Канал в Телеграм
Under what circumstances is it unsafe to have two different threads simultaneously writing to adjacent elements of the same array on x86? I understand that on some DS9K-like architectures with insane memory models this can cause word tearing, but on x86 single bytes are addressable. For example, in the D programming language real
is an 80-bit floating point type on x86. Would it be safe to do something like:
real[] nums = new real[4]; // Assume new returns a 16-byte aligned block.
foreach(i; 0..4) {
// Create a new thread and have it do stuff and
// write results to index i of nums.
}
Note: I know that, even if this is safe, it can sometimes cause false sharing problems with the cache, leading to slow performance. However, for the use cases I have in mind writes will be infrequent enough for this not to matter in practice.
Edit: Don’t worry about reading back the values that are written. The assumption is that there will be synchronization before any values are read. I only care about the safety of writing in this way.
asked Oct 22, 2009 at 13:51
dsimchadsimcha
67.2k52 gold badges213 silver badges332 bronze badges
1
The x86 has coherent caches. The last processor to write to a cache line acquires the whole thing and does a write to the cache. This ensures that single byte and 4 byte values written on corresponding values are atomically updated.
That’s different than «its safe». If the processors each only write to bytes/DWORDS «owned» by that processor by design, then the updates will be correct. In practice, you want one processor to read values written by others, and that requires
synchronization.
It is also different than it is «efficient». If several processors can each write to different places in the cache line, then the cache line can ping-pong between CPUs and that’s a lot more expensive than if it the cache line goes to a single CPU and stays there.
The usual rule is to put processor-specific data in its own cache line.
Of course, if you are only going to write to just that one word, just once, and
the amount of work is significant compared to a cache-line move, then
your performance will be acceptable.
answered Oct 22, 2009 at 14:06
Ira BaxterIra Baxter
93k22 gold badges171 silver badges338 bronze badges
1
I might be missing something, but I don’t foresee any issues. x86 architecture writes only what it needs, it doesn’t do any writing outside the specified values. Cache-snooping handles the cache issues.
answered Oct 22, 2009 at 14:00
Brian KnoblauchBrian Knoblauch
20.5k15 gold badges61 silver badges92 bronze badges
You are asking about x86 specifics, yet your example is in some high-level language. Your specific question about D can only be answered by the people who wrote the compiler you are using, or perhaps the D language specification. Java for example requires that array element access must not cause tearing.
Regarding x86, atomicity of operations is specified in Section 8.1 of Intel’s Software Developer’s Manual Volume 3A. According to it, atomic store operations include: storing a byte, storing word-aligned word and dword-aligned dword on all x86 CPUs. It also specifies that on P6 and later CPUs unaligned 16-, 32- and 64-bit access to cached memory within a cache line is atomic.
answered Oct 30, 2009 at 3:08
Tuure LaurinolliTuure Laurinolli
3,9371 gold badge20 silver badges20 bronze badges
Добавил:
Upload
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз:
Предмет:
Файл:
java_language_specification_7.pdf
Скачиваний:
5
Добавлен:
21.03.2016
Размер:
3.11 Mб
Скачать
17.6 Word Tearing
One consideration for implementations of the Java virtual machine is that every field and array element is considered distinct; updates to one field or element must not interact with reads or updates of any other field or element. In particular, two threads that update adjacent elements of a byte array separately must not interfere or interact and do not need synchronization to ensure sequential consistency.
Some processors do not provide the ability to write to a single byte. It would be illegal to implement byte array updates on such a processor by simply reading an entire word, updating the appropriate byte, and then writing the entire word back to memory. This problem is sometimes known as word tearing, and on processors that cannot easily update a single byte in isolation some other approach will be required.
Example 17.6-1. Detection of Word Tearing
The following program is a test case to detect word tearing:
public class WordTearing extends Thread {
static |
final int LENGTH = |
8; |
|
static |
final int ITERS |
= |
1000000; |
static |
byte[] counts |
= |
new byte[LENGTH]; |
static |
Thread[] threads = |
new Thread[LENGTH]; |
|
final int id; |
|||
WordTearing(int i) { |
|||
id |
= i; |
||
} |
|||
public |
void run() { |
byte v = 0;
for (int i = 0; i < ITERS; i++) { byte v2 = counts[id];
if (v != v2) {
System.err.println(«Word-Tearing found: » + «counts[» + id + «] = «+ v2 + «, should be » + v);
return;
}
v++;
counts[id] = v;
}
}
public static void main(String[] args) { for (int i = 0; i < LENGTH; ++i)
(threads[i] = new WordTearing(i)).start();
}
}
589
17.7 |
Non-atomic Treatment of double and long |
THREADS AND LOCKS |
This makes the point that bytes must not be overwritten by writes to adjacent bytes.
17.7 Non-atomic Treatment of double and long
For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
Writes and reads of volatile long and double values are always atomic.
Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java virtual machine is free to perform writes to long and double values atomically or in two parts.
Implementations of the Java virtual machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.
590
Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]
- #
- #
- #
- #
- #
- #
- #
- #
- #
- #
- #