#java #multithreading #caching #multiprocessing
#java #многопоточность #кэширование #многопроцессорная обработка
Вопрос:
Допустим, у меня есть класс X с двумя переменными.
class X {
Integer a;
Y b;
Integer c;
}
Класс Y
class Y {
Integer y1;
String y2;
}
Скажем, у нас есть 4 потока: T1, T2, T3 и T4.
T1 работает a
, T2 работает b.y1
(делает что-то вроде x.getB().setY1()
), T3 работает b.y2
, и T4 работает c
.
Я не буду читать ни одно из «самых глубоких» значений ( a, y1, y2, c
) в любом потоке, пока все они не будут выполнены (однако T2 и T3 сделают x.getB()
это).
Столкнусь ли я с какой-либо из типичных проблем, связанных с многопоточностью?
Мои вопросы
- Я думаю, что я не мог бы столкнуться с каким-либо условием гонки в отношении a и c, учитывая, что они не читаются потоками, отличными от «их» потока. Правильно ли это рассуждение?
- Как насчет
x.getB()
T2 и T3? - Как насчет кэширования процессорами в многоядерной среде? Кэшируют ли они весь объект? Или они кэшируют только то поле, которое они изменяют? Или они кэшируют все это, но обновляют только поле, которое они изменили?
- Они даже распознают объекты и поля? Или они просто работают с кусками памяти? В этом случае Java сообщает им адрес памяти, который им нужно будет кэшировать?
Когда процессоры согласовывают свой кэш с основной памятью после завершения обработки, обновляют ли они только тот фрагмент памяти, который они изменили, или они перезаписывают основную память всем блоком памяти, который они кэшировали?
Например, скажем, изначально и a, и c имеют значения a = 1
, и c = 1
. P1 и P4 кэшируют эти значения ( a=1
и c=1
). T1 изменяет его на a = 2
, T4 изменяет на c = 2
.
Теперь значения в кэше C1 равны a=2, c=1
; в C2, a=1, c=2
.
Итак, при обратной записи в основную память, скажем, сначала завершается P1, а затем обновляется основная память. Итак, теперь значения a=2, c=1
.
Теперь, когда P4 завершается, обновляет ли он значение only c
, потому что оно только изменилось c
? Или он просто перезаписывает основную память значением в своем кэше, создавая a=1, c=2
?
Или они просто кэшируют значения, которые они будут читать или записывать, то есть T1 никогда не будет кэшировать значение c
, а T4 никогда не будет кэшировать значение a
.
Комментарии:
1. Извините за очень длинный вопрос… Просто хотел донести намерение.
2. Поскольку нет барьера памяти, нет обещания видимости между потоками. До тех пор, пока не произойдет условие, аппаратное обеспечение может кэшировать его на одном ядре и быть крайне непоследовательным. Итак, есть много ответов, поскольку он недетерминирован.
3. @BenManes Я думаю, меня не беспокоит видимость между потоками, поскольку я не буду читать какое-либо значение, измененное другими потоками. Однако меня беспокоит, когда кэш синхронизируется с основной памятью? Перезаписывает ли он в основной памяти только те значения, которые изменил поток, или он просто сбрасывает содержимое кэша, перезаписывая таким образом значения, которые он даже не изменил. Я предполагаю, что они будут перезаписывать только «грязные» значения… но я не знаю, действительно ли это так
4. Размер блока кэша процессора довольно большой, обычно 64 байта. Он не записывает этот блок вместе, если изменяется одна ссылка (2 или 4 байта). Барьеры памяти гарантируют, что блок остается согласованным между ядрами (см.
false sharing
Проблему). Перекос записи произойдет только тогда, когда размер шины памяти превысит размер типа данных, например, 32-разрядные процессоры хранят 64-разрядную версию длиной в две операции с памятью. В настоящее время 64-разрядная версия является нормой и имеет наибольший размер поля (примитивный или ссылочный), поэтому это будет одна операция с памятью только с этим адресом памяти.5. @jubatus здесь очень сложно сказать «да» или «нет», поскольку вы не показываете, где находятся общие экземпляры и кто и как их обновляет. И эти детали имеют значение. Но в целом, поскольку вы вводите код
java
,JLS
это единственное место, которое гарантирует, что будет видно и когда. Кэш, процессор, согласованность не являются частью спецификации и никогда не будут. Я хотел сказать: у вас нет какой -либо синхронизации, поскольку такой JLS вообще не гарантирует никакого результата.
Ответ №1:
Столкнусь ли я с какой-либо из типичных проблем, связанных с многопоточностью?
Вы только читаете, поэтому, конечно, нет. Ваш вопрос даже не актуален. Модель памяти Java описывает, как изменения в полях распространяются на другие потоки. Сначала это требует фактических изменений.
обновляют ли они только тот фрагмент памяти, который они изменили, или они перезаписывают основную память всем блоком памяти, который они кэшировали?
Только то, что они изменили.
Как насчет кэширования процессорами в многоядерной среде? Кэшируют ли они весь объект? Или они кэшируют только то поле, которое они изменяют?
Ваш вопрос не имеет смысла. Вы вообще не можете помещать объекты в поля или переменные, это невозможно. Единственное, что вы можете вставить в поля / переменные / параметры, — это ссылки на объекты. В String x = "foo"
, вы не вставили «foo» x
. «foo» живет где-то в куче. Вы убедились, что он существует в куче, и вы назначили ссылку на него x
. Эта ссылка довольно простая, обычно 64-разрядная и атомарная.
Единственное, что вы можете разделить между потоками, где актуальны обновления, — это поля. методы не могут быть изменены (вы не можете изменить метод экземпляра чего-либо; java не похожа на python или javascript, вы не можете написать someRef.flibbledyboo = thingie;
, где ‘flibbledyboo’ — это то, что вы только что придумали. Локальные переменные (которые включают параметры) не могут быть совместно использованы с другими потоками; java передается по значению во всех вещах, поэтому, если вы это сделаете, внутри метода someOtherMethod(variable);
вы передаете копию, указывая на «что произойдет, если я изменю свою переменную, и SomeOtherMethod передаст ее другимдругой поток?’ не имеет значения.
Казалось бы, вы можете совместно использовать локальный var с потоком, если создаете лямбда- или локальный класс, но java откажется компилировать это, если var не является окончательным или фактически окончательным. Если это (фактически) окончательное, точка является спорной — ее нельзя изменить, поэтому вопрос «что произойдет, если один поток обновит это значение, когда другой поток увидит обновление» не имеет значения.
Таким образом: речь идет только о полях, а поля могут содержать только примитивные значения или ссылки. Ссылки — это простые вещи. Если вы знакомы с C, они являются указателями, но это ругательное слово, поэтому java называет их ссылками. Потайто, Потайто. То же самое.
Любое поле (будь то примитивное или ссылочное) может быть кэшировано любым потоком или нет, по выбору дилера. Они могут «синхронизировать» это обратно в основную память в любое время, когда пожелают. Если ваш код изменяет способ выполнения в зависимости от этого, вы написали труднодоступную ошибку. Старайтесь этого не делать 🙂
Они даже распознают объекты и поля? Или они просто работают с кусками памяти?
Опять же бессмысленный вопрос, согласно предыдущему пункту: это значения, которые вы ищете: примитивы и ссылки. Вот что такое JMM (а не «куски памяти»). объекты не могут находиться в полях. Только ссылки. Ссылка идет на объект, но этот объект — просто еще один пакет полей. Это поля до самого низа.
Представьте, что поток A делает: foo.getX().setY()
и поток B делает: foo.getX().getY()
. предполагая foo
, что никогда не меняется, то, по-видимому foo.getX()
, также никогда не меняется. Это всего лишь ссылка, а ‘.’ — это java-ese для: следуйте ей, найдите там пакет полей и работайте с ними. Итак, оба потока находят один и тот же объект и поля с сумками, которые есть на самом деле. Теперь поток A изменил одно из найденных там полей, а B читает одно из них. Это проблема — это поля. потоки могут кэшировать их, выбор дилера. Вам необходимо установить отношения HB / HA, или вы написали здесь ошибку.
Теперь, когда P4 завершается, обновляет ли он значение только c, потому что он изменил только c? Или он просто перезаписывает основную память значением в своем кэше, делая a = 1, c = 2?
Нет; но это не кажется особенно актуальным. Несвязанный поток без отношения HBHA (происходит до / после) может юридически соблюдать a = 1 / c = 1, a = 2 / c = 1 или a = 1 / c = 2. Однако, если они каким-то образом наблюдали a = 2 / c = 1, то впоследствии они будут продолжать наблюдать a = 2. Он не вернется к 1 из-за перезаписи стиля «перезаписать весь блок».
Или они просто кэшируют значения, которые они будут читать или записывать, то есть T1 никогда не будет кэшировать значение c, а T4 никогда не будет кэшировать значение a .
Выбор дилера. JMM лучше всего понимать следующим образом:
Каждый раз, когда какой-либо поток когда-либо обновляет какое-либо поле (а значения всегда являются примитивами или ссылками), он переворачивает злую монету. Если при перевороте выпадают заголовки, он обновляет это значение в своем локальном кэше и не «распространяет» его на любой другой код, который взаимодействует с этим полем, если только этот поток не имеет установленного правила HBHA. В tails он обновляет произвольный выбор кэшей других потоков.
Всякий раз, когда поток считывает какое-либо поле, он снова переворачивает монету. В heads он просто продолжает работу со своим кэшем. В хвостах он обновляется с центрального значения.
Монета — зло: это не выстрел 50/50. Фактически, сегодня на вашем ноутбуке при написании этого кода каждый раз появляются хвосты — даже если вы повторяете тест 1 миллион раз. На вашем сервере CI такая же сделка. хвосты. Затем в производстве — хвосты, каждый раз. Затем на следующей неделе, когда придет этот важный клиент, и вы покажете демонстрацию? Много головок.
Таким образом:
- Трудно обнаружить, что вы создали код, выполнение которого зависит от coinflip.
- Тем не менее, если вы пишете код, выполнение которого зависит от flip, вы потерпели неудачу. Это ошибка.
Обычно решение состоит в том, чтобы полностью забыть о потоках таким образом и выполнять межпоточное взаимодействие либо «по каналам», либо заранее и впоследствии.
Канал связи
Существуют системы каналов связи, которые подходят гораздо лучше. Например, базы данных: не обновлять поля; отправлять запросы к БД. используйте transactions и isolationlevel.СЕРИАЛИЗУЕМЫЙ, с фреймворками, поддерживающими RetryException (например, JDBI или JOOQ — не создавайте свой собственный, не используйте JDBC напрямую). Вы получаете точный контроль над каналом данных.
Другими вариантами являются шины сообщений, такие как rabbitmq.
заранее / после
Используйте такие фреймворки, как fork / join и friends, или что-нибудь еще, что, например, следует модели map / reduce. Они настраивают некоторые структуры данных, только затем запускают ваш поток (или, скорее, у них есть пул em, и они будут выполнять ваш код в одном потоке пула, передавая вам структуру данных). Ваш код просто просматривает эту структуру данных, а затем возвращает что-то. Он вообще не затрагивает никаких других полей. Фреймворк создает данные и интегрирует то, что вы возвращаете. Тогда доверяйте фреймворку; у них, вероятно, нет ошибок модели памяти.
Я действительно хочу изменять потоки в многопоточной среде.
Господи, здесь драконы.
Если необходимо, найдите «происходит-до / происходит-после»: для любых 2 строк кода, если существует связь HB / HA (например, согласно правилам JMM, одна строка гарантированно произошла раньше другой), тогда любые обновления полей предыдущей строкивызвавший гарантированно будет виден на более поздней полосе — никаких злых переворотов.
Очень краткий обзор:
- В пределах одного потока любая более поздняя выполняемая строка «выполняется после». Это очевидный факт — java является обязательным. Что вы можете наблюдать из кода, так это то, что каждая строка в одном потоке выполняется одна за другой.
- синхронизированный: когда у вас есть 2 потока, и один поток попадает в блок кода, защищенный
synchronized(X)
, а затем выходит из этого блока, а другой поток позже входит в блок, защищенный синхронизацией по той же ссылке, тогда точка выхода потока A гарантированно «произойдет до» точки входа B: независимоA, измененный внутри, вы увидите в B, гарантировано. - volatile — аналогичные правила, но volatile сложен.
- Запуск потока:
someThread.start()
имеет связь HB / HA с кодом в этом потоке. - Конструкторы и настройка конечных полей более или менее гарантированно работают (настройка поля «выполняется до» возврата конструктора, даже если затем вы передаете объект ref, который вы получили, вызвав этот конструктор в другой поток без защиты HB / HA, и они каким-то образом получают его, потому что злая монета приземлилась орлами).
- система загрузки классов никогда не будет загружать один и тот же класс дважды в одном и том же загрузчике классов. Это очень быстрый и простой способ создания безопасных одиночек.
Если какой-то код X обновляет поле, а какой-то другой код Y считывает это поле, а X и Y не имеют отношения HB / HA, вы полностью закрыты. Вы написали ошибку, и ее будет довольно сложно проверить, и тест не будет надежным.
Комментарии:
1. Правильно… Я должен был сформулировать вопрос лучше… Что я имел в виду под «объектом», так это то, что при кэшировании будет ли он кэшировать все поля в классе или просто кэшировать конкретные поля, к которым я обращаюсь в этом потоке. Это что-то предсказуемое? Или это зависит от ОС, оборудования, алгоритмов кэширования и т. Д.
2. Я также понял, что мой второй вопрос был бессмысленным… Поскольку я не меняю то, на что указывает b, ссылка всегда будет одинаковой… Это не зависит от значений полей, которые содержит b
3. Меня беспокоит то, что, когда поток завершил выполнение и хочет синхронизироваться с основной памятью, перезаписывает ли он поля, которые он не изменил? Я думаю, ответ отрицательный? Если ответ отрицательный, то я могу избежать механизмов HB / HA, таких как синхронизация и volatile, и связанных с ними снижений производительности, поскольку мои потоки пишут разные поля, и они также не считывают измененные поля. Я прав здесь?
4. Это не так, нет. Однако, похоже, вы решаете дилемму подбрасывания злой монеты, говоря: «Я напишу код, который приведет к подбрасыванию злой монеты, но мой код будет действовать правильно, независимо от того, как он приземляется». Это творческое решение, но не рекомендуемое. Тем не менее — поля, которые поток никогда не обновлял, не «синхронизируются с основной памятью» при обновлении кэша.
5. Только основная ошибка: если вы напутаете и напишете ошибку в состоянии гонки, то на самом деле невозможно написать тест, который надежно ее обнаружит — и это то, что легко испортить. Проблема заключается в самом стиле: практически невозможно укрепить веру в то, что ваш код действительно делает то, что вы хотите, надежно. Вы ищете стили написания кода, которые дают вам тестируемый код и дают понять, что вы не сталкиваетесь с какими-либо проблемами. «агрессивная блокировка каждый раз, когда это поле используется для чего-либо» — это один из способов. «использовать базу данных» — это другое.
Ответ №2:
Ваш вопрос затрагивает ряд интересных тем. Я постараюсь переформулировать ваши вопросы и ответить на них по порядку.
На ваш первый вопрос: если разные потоки изменяют только разные объекты, может ли это создавать проблемы с согласованностью?
Вам нужно проводить различие между изменением объекта (или «записью») и тем, чтобы такие изменения были видны другим потокам. В представленном вами случае ваши различные потоки обрабатывают различные объекты независимо друг от друга и никогда не нуждаются в «чтении» других объектов. Так что да, это нормально.
Однако, если потоку необходимо прочитать значение переменной, которая могла быть изменена другим потоком, вам необходимо ввести некоторую синхронизацию, чтобы изменение этой переменной происходило до того, как ее прочитает первый поток (синхронизированный блок / доступ к изменяемой переменной / семафоры и т. Д.). Я не могу рекомендовать достаточно эту статью, исправляющую модель памяти Java.
На ваш второй вопрос:
Тот же ответ на ваш первый вопрос: пока ни один поток не изменяет член b
вашего X
экземпляра, причин для беспокойства нет; оба потока T2
и T3
получат один и тот же объект.
На ваш третий и четвертый вопрос, как насчет согласованности кэша?
То, как виртуальная машина Java обрабатывает выделение памяти, немного неясно с точки зрения программиста. То, что вас беспокоит, называется ложным разделением. Виртуальная машина Java позаботится о том, чтобы то, что хранится в памяти, соответствовало вашей программе. Вам не нужно беспокоиться о том, что плохой кэш перезаписывает изменения, внесенные другим потоком.
Однако, если в элементах достаточно конфликтов, вы можете столкнуться со снижением производительности. К счастью, вы можете уменьшить это влияние, используя @Contended
аннотацию к элементам, которые создают проблемы, чтобы указать виртуальной машине Java, что они должны быть размещены в разных строках кэша.
Комментарии:
1. Правильно… Меня в первую очередь беспокоит это … перезаписывает ли синхронизация кэша основную память даже для полей, которые поток не изменил… Итак, в моем примере здесь, если T4 перезаписывает только значение
c
, потому что оно изменило только значениеc
, тогда все хорошо… Однако, если он сбрасывает все, что у него есть в своем кэше, тогда старое значениеa
также будет сброшено… Предполагая, что T1 уже завершил выполнение и изменил основную память, тогда мы, наконец, получим устаревшее значениеa
, о чем я беспокоюсь2. Возможно, я должен был указать это более убедительно в своем ответе, но виртуальная машина Java гарантирует, что такие обновления из разных потоков не «сбрасывают старое значение a», как вы выразились в вашем примере. Выполняются проверки, чтобы гарантировать, что синхронизация кэша не перезаписывает основную память для поля, которое поток не изменил .
3. Извините, что заставил вас уточнить это еще раз… Я не был полностью уверен, понял ли я вашу точку зрения… Извините за это… Не могли бы вы порекомендовать какие-либо ресурсы для более глубокого изучения этого?
4. Спецификация языка Java является обязательной для прочтения, если вы действительно хотите получить глубокое погружение. Вот ссылка на Java8 / Глава 17 , в которой рассматриваются потоки и блокировка. Он поставляется с очень хорошими примерами, что облегчает понимание. Это должно представлять для вас особый интерес. Я не уверен в отношении конкретного ресурса в отношении согласованности кэша.