27 февраля 2013 г.
А почему компилятор не синхронизирует код за меня?
26 февраля 2013 г.
Нет никакой ложки...
15 февраля 2013 г.
Квест по внутренностям JMM: solution
Хорошо, давайте разберем предыдущий пост. Для доказательства нам понадобится всего ничего:
13 февраля 2013 г.
4 февраля 2013 г.
Задача с собеседования: продолжение
31 января 2013 г.
Задача с собеседования
28 января 2013 г.
А что мы измеряем, когда измеряем "производительность CAS-а"?
intcementAndGet
(==lock xadd
) показывает лучшие результаты на процессорах архитектуры Nehalem, нежели на процессорах более свежей (и, вообще говоря, почти везде более производительной) архитектуры Sandy Bridge. Бенчмарк был совершенно тривиальный — сколько-то потоков, тупо делающих __sync_add_and_fetch()
одному счетчику — поэтому результаты, конечно, выглядели странно. И вот, больше чем через год, наконец-то странность объяснилась, и объяснение очень интересное — я не могу удержаться, и не разобрать его.10 ноября 2012 г.
Для тех, кто не решился написать ответы к предыдущему посту
В самом деле, JMM определяет порядок не для инструкций, а для событий. А событие состоит в том, что какая-то инструкция выполнилась (есть несколько синтетических событий, нужных для замкнутости модели -- но в первом приближении об этом можно забыть) в каком-то запуске кода.
Другими словами, не существует никаких ребер happens-before между строками кода в программе — ребра существуют в конкретном сценарии выполнения этого кода (в оригинале звучит как execution — сценарий, или маршрут выполнения. Но однажды я услышал термин "трасса над кодом", и он подкупил меня своей образностью, так что я часто использую его). Нет HB между какой-то волатильной записью и каким-то волатильным чтением, есть HB между событием волатильной записи некоторого значения, и событием волатильного чтения его же (из той же переменной, понятно).
А вот вопрос немного сложнее:
Thread 1 | Thread 2 |
---|---|
volatile int va; int a; | |
va = 1; a = 10; va = 1; | if( va == 1 ){ int la = a; } |
7 ноября 2012 г.
Если вы знаете, что такое happens-before...
Thread 1 | Thread 2 |
---|---|
volatile int va; | |
va = 10; | int b = va; |
Вопросы (отвечать желательно по порядку, не забегая вперед):
- Какие здесь есть межпоточные ребра HB?
- Может ли в
b
оказаться что-то, кроме 10? - Может ли в
b
оказаться 0? - Изменится ли что-либо, если здесь заменить
volatile store
наlazySet
?
18 октября 2012 г.
Off-heap: троллинг 70-го уровня
DirectByteBuffer/Unsafe
используют для организации данных вне java heap. Предположительно, это должно радикально снизить нагрузку на GC, да и, в некоторых случаях, сам доступ к данным ускорить. Сам я к этой идее относился довольно скептически: на мой взгляд у нее довольно узкая область применимости, и усилиями разработчиков JVM она становится с каждым годом еще уже. Мне кажется, многие, кто смотрит в сторону off-heap просто еще не разобрались толком, что можно делать на чистой java. Но пока это были все теоретические предположения — с Unsafe
я проводил лишь один небольшой эксперимент, и, хоть предположения он, косвенно, и подтвердил, но одного эксперимента для выводов явно недостаточно. А сделать серьезный бенчмарк как-то было лениво. 14 октября 2012 г.
StampedLock
ReadWriteLock
-а, с возможностью ультра-оптимистичных чтений практически без contention. Он дает ограниченную функциональность обычного ReadWriteLock
(в частности, нет reentrancy), но добавляет методы tryOptimisticRead()/validate(stamp)
, и tryConvertToWriteLock(stamp)
. По-сути, это попытка сделать версионную структуру данных: каждый захват блокировки возвращает уникальный (ну, почти — это все-таки обычный long, который через пару лет непрерывного использования может начать повторяться) идентификатор. Этот идентификатор можно рассматривать как номер текущей версии данных, защищаемых данной блокировкой. И, используя этот идентификатор можно делать очень дешевое спекулятивное чтение:9 октября 2012 г.
Видео с jug.ru
Посмотреть видео на сайте Лекториума
5 октября 2012 г.
Thread affinity binding
Меня спрашивали, как я задаю affinity потокам в яве в бенчмарках. Начнем с того, что из чистой явы это сделать нельзя. Но если нельзя, а очень хочется — то таки можно. Долгое время я пользовался на скорую руку сварганенным методом, подсмотренным где-то на stackoverflow. Потом какое-то время участвовал в разработке библиотеки Java-Thread-Affinity, от Питера Лоурея. В принципе, она довольно неплоха, и я могу ее вполне рекомендовать.
15 сентября 2012 г.
AtomicFieldUpdater optimized
AtomicXXXFieldUpdater
— чудесные классы, открывающие значительную часть магии Unsafe
без проблем с безопасностью и переносимостью. К сожалению, есть некоторая цена. Вот цитата из AtomicLongFieldUpdater.CASUpdater
:public void lazySet(T obj, long newValue) { if (obj == null || obj.getClass() != tclass || cclass != null) fullCheck(obj); unsafe.putOrderedLong(obj, offset, newValue); } private void ensureProtectedAccess(T obj) { if (cclass.isInstance(obj)) return; throw new RuntimeException ( new IllegalAccessException("Class " + cclass.getName() + " can not access a protected member of class " + tclass.getName() + " using an instance of " + obj.getClass().getName() ) ); } private void fullCheck(T obj) { if (!tclass.isInstance(obj)) throw new ClassCastException(); if (cclass != null) ensureProtectedAccess(obj); }
Выглядит все довольно страшно, хотя на практике в большинстве случаев все закончится на первых трех проверках: (
obj == null || obj.getClass() != tclass || cclass != null
). Эффект от них невелик, в моих бенчмарках замена AtomicUpdater
на Unsafe
давала 15-20% максимум в очень нагруженном конкурентном коде. По-видимому, дело в том, что результат этих проверок в правильно написанном коде всегда одинаков (false), поэтому предсказатель ветвлений в процессоре довольно быстро сообразит, какая из веток реализуется со 100% вероятностью, и будет спекулятивно выполнять код дальше. Тем не менее, иногда не хочется терять и этих процентов. Хорошая новость состоит в том, что
AtomicLongFieldUpdater
— абстрактный класс, и никто не мешает вам написать свою реализацию. Например, взять за основу AtomicLongFieldUpdater.CASUpdater
, и просто выбросить все "лишние" проверки. Результат будет практически drop-in-replacement для большинства сценариев использования.Разумеется, у меня сразу возникла мысль сделать какую-то свою фабрику, на замену
AtomicLongFieldUpdater.newUpdater(...)
, которая по-умолчанию делегировала бы к AtomicLongFieldUpdater.newUpdater
, а при указании специального свойства -Djava.concurrent.run-faster
начинала бы использовать оптимизированную версию. Плохая новость состоит в том, что так не получается: в конструкторе AtomicFieldUpdater
-а проверяется, что вы можете создать AtomicUpdater
только для того поля, которое вы и так можете видеть из создающего кода. Если я собираюсь обновлять private поле, то AtomicUpdater
для него я смогу создать только изнутри этого же класса. И значит выбор реализации AtomicUpdater
-а придется делать внутри каждого класса, его использующего.
4 августа 2012 г.
HotSpot OSR
Клифф рассказывает чем отличается "обычная" компиляция метода, и компиляция в режиме On-the-stack-Replacement (OSR), и почему эту разницу стоит иметь в виду.
Обычная компиляция случается когда JVM обнаруживает, что какой-то метод достаточно горячий, чтобы его имело смысл скомпилировать. Для этого с каждым методом связывается счетчик, который считает число вызовов метода, и число обратных переходов (backedge) (т.е. переходов "назад" по потоку инструкций) внутри метода (sic!). Вторая часть может показаться непонятной, но это просто способ считать итерации циклов, если они в методе есть. Таким образом метод будет считаться горячим не только если он часто вызывается, но и если сам он вызывается не очень часто, но внутри него есть достаточно "тяжелые" циклы.
Когда счетчик перевалит за какой-то порог (скажем, 10 000) — последовательность байт-кодов метода ставится в очередь на компиляцию. При первой же возможности JIT до нее доберется, скомпилирует, и заменит ссылку в таблице методов на оптимизированную версию. И при очередном, 11 042-м вызове исполнение пойдет уже в скомпилированный и оптимизированный метод.
Здесь все круто, но есть одно "но": чтобы подменить интерпретируемую версию компилированной нужно "выйти и снова зайти" — выполнение метода должно завершиться, и тогда очередной вызов этого метода будет уже выполнять скомпилированный код. А что делать, если внутри метода тяжелые циклы — ждать пока он в интерпретируемом режиме завершится? "Клинический" случай это когда я прямо в main написал какой-то сложный алгоритм с тройной вложенности циклом — получается, у меня вообще нет шансов увидеть его скомпилированным.
Чтобы JIT в таких ситуациях не ударял в грязь лицом, ему нужно уметь заменять версии метода на лету, прямо по ходу его выполнения. Именно такая махинация и называется подменой "на стеке", On the Stack Replacement, OSR.
Надо сразу сказать, что задача эта в общем случае очень нетривиальна, ведь скомплированная версия должна полностью "принять в наследство" от интерпретируемой текущее состояние выполнения. Но скомпилированная и интерпретируемая версии кода могут (и почти наверняка будут) очень по-разному это состояние хранить. Клифф приводит в пример простейший цикл вычисления хэш-кода строки:
hash = hash*31 + ary[i];
— состояние интерпретатора будет состоять из hash, arr, i
, в то время как JIT скорее всего будет хранить что-то вроде hash, p = A+12+i*2
, и инкрементить p+=2
на каждой итерации. Если здесь переход вам кажется несложным, то представьте, что будет если JIT еще и развернет (unroll) цикл? Получается, для OSR мало сгенерировать сам код метода — нужно еще и сгенерировать код-"адаптер", который сможет преобразовать состояние интерпретатора в тот вид, который нужен скомпилированной версии. Но генерация такого адаптера для произвольной точки кода будет очень сложной. Проще заранее фиксировать некий набор safepoint-ов, в которых только и можно "переключаться" — тогда задача становится обозримой.Собственно, "обычный" (не-OSR) компилятор использует в качестве таких safepoint-ов границы между методами. OSR же добавляет возможность переключаться на backedge-ах циклов.
До сих пор была довольно общеизвестная информация, ее давно и много где можно встретить. А вот что интересно: поскольку обычная и OSR компиляция стартуют с разных точек в коде, то для одного и того же метода в режиме обычной и OSR компиляции JIT получает на вход разные последовательности байткодов. OSR получает как бы "вывернутую" изнутри наружу версию кода. И результат компиляции может довольно сильно отличаться. Клифф сначала приводит простой пример: вот так выглядит код метода с простым циклом
i=0; loop: if(P) goto done; S3; A[i++]; goto loop; // <<--- OSR HERE done: S2;А так он будет выглядеть для JIT-а в случае OSR компиляции:
A=value on entry from interpreter; i=value on entry from interpreter; goto loop; loop: if(P) goto done; S3; A[i++]; goto loop; done:Здесь пока все довольно прозрачно: это все еще очевидно цикл прохода по массиву с шагом 1, и все оптимизации к нему все еще применимы. Но вот если взять вложенный цикл все становится сложнее:
loop1: if(P1) goto done1; S1; i=0; loop2: if(P2) goto done2; S3; A[i++]; goto loop2; // <<--- OSR HERE done2: S2; goto loop1; done1:Если представить себе, как будет выглядеть этот цикл с точки зрения OSR (это требует некоторого напряжения):
A=...value on entry from interpreter... i=...value on entry from interpreter... goto loop2; loop2: if(P2) goto done2; S3; A[i++]; goto loop2; done2: S2; goto loop1; loop1: if(P1) goto done1; S1; i=0; goto loop2; done1: ....Здесь уже не так-то просто разглядеть структуру цикла по массиву. Чуть упростив
A=...value on entry from interpreter... i=...value on entry from interpreter... loop2: if(P2) { S2; if(P1) goto done1; S1; i=0; } else { S3; A[i++]; } goto loop2; done1: ....В чем здесь проблема? — проблема в том, что эвристики компилятора уже не узнают здесь "итерацию по массиву". А значит у нас не будет range check elimination, и, возможно, еще каких-то вкусных плюшек. Производительность этого кода может быть раза в 2-3 хуже, чем не-OSR версии.
Что можно сделать, чтобы избежать такой ситуации? Клифф рекомендует вместо "цикл внутри цикла внутри цикла" организовывать код как "цикл внутри функции внутри цикла внутри функции" -- т.е. выделять каждый потенциально тяжелый цикл в отдельный метод. Во-первых, так увеличивается вероятность, что внутренний цикл будет скомпилирован в не-OSR режиме, а во-вторых, метод с одним циклом, даже вывернутый наизнанку OSR-ом, все равно остается достаточно простым для компилятора.
Мне здесь кажется важным вот что: совет Клиффа, если его самого чуть упросить, говорит просто "не делайте слишком много работы в одном методе". То есть требования производительности здесь идут рука об руку с требованиями чистоты и простоты кода. Пишите простой, достаточно мелко-гранулированный код — и, чаще всего, он и работать будет быстро.
31 июля 2012 г.
Disruptor 3.0
Если честно, мне сложно представить, чем еще можно ускорить простейший single publisher single subscriber случай. Но одно предположение у меня есть. Когда я гонял бенчмарки перед JavaOne, я пробовал заменять
AtomicLongFieldUpdater.lazySet()
на Unsafe.putOrderedLong()
. Это то же самое по смыслу, но отсутствуют различные проверки доступа и type-safety. Разница была довольно существенной. Правда, не вдвое, процентов на 20-25. Но, поскольку больше возможностей я не вижу, то я ставлю на то, что в третьей версии Disruptor появится магический класс sun.misc.Unsafe
.Хотя будет гораздо интереснее, если я ошибусь, и будет что-то новенькое :)
28 июля 2012 г.
Синтаксический сахарочек
Например, есть у нас такой класс-мэппер
public class Mapping{ public String map(final String arg1, final String arg2); }И есть какая-то реализация, скажем
SimpleImmutableMapping
, и хочется сделать для его конфигурирования фабрику-билдер, типа вот такой:public class MappingBuilder{ public MappingBuilder map( final String arg1 ){...} public MappingBuilder with( final String arg2 ){...} public MappingBuilder to( final String mapping ){...} public Mapping build(){...} } ... final Mapping mapping = new MappingBuilder() .map( "A" ).with( "B" ).to("ab") .map( "C" ).with( "D" ).to("cd") .map( "E" ).with( "F" ).to("ef") .build();Что мне здесь не нравится? — мне не нравится дуракоустойчивость.

Что будет, если я вызову в этой точке
.build()
? Очевидно, ничего хорошего. В лучшем случае уже во время выполнения вылетит IllegalStateException("Can't build mapping with supplied data: first argument is not enough")
. Это если я позаботился о том, как мой объект себя ведет, если его используют "неправильно". В более реальном случае будет что-нибудь вроде NullPointerException
из недр метода .build()
. В самом паршивом случае здесь все отработает без ошибок, а веселье начнется при использовании созданного Mapping
-а где-нибудь совсем в другом месте.Так вот, я раньше думал, что устроить в подобном Builder-е проверку времени выполнения — на яве не получится. А оказывается можно — правда так гемморойно
public class SimpleMapping { //код самого мэппера -- скучный и банальный ..... //а вот дальше начинается треш и содомия //уберите от монитора женщин и детей, и глубоко вдохните public interface FirstArgumentGivenState { public SecondArgumentGivenState with( final String arg2 ); } public interface SecondArgumentGivenState { public FullMappingGivenState to( final String mapping ); } public interface FullMappingGivenState { public Mapping build(); public FirstArgumentGivenState map( final String arg1 ); } private static class Builder implements FirstArgumentGivenState, SecondArgumentGivenState, FullMappingGivenState { public FirstArgumentGivenState map( final String arg1 ) { ... return this; } public SecondArgumentGivenState with( final String arg2 ) { ... return this; } public FullMappingGivenState to( final String mapping ) { ... return this; } public Mapping build() { ... } } public static FullMappingGivenState builder() { return new Builder(); } } .... final Mapping mapping = SimpleMapping.builder() .map( "A" ).with( "B" ).to( "ab" ) .map( "C" ).with( "D" ).to( "cd" ) .map( "E" ).with( "F" ).to( "ef" ) .build();



По названию интерфейсов можно понять, как эта конструкция масштабируется на более сложные системы — представляем Builder в виде конечного автомата, на каждое состояние заводим интерфейс в котором методы, соответствующие разрешенным переходам из этого состояния. И private класс-реализация реализует все эти интерфейсы.
По-правде, я не уверен, что придумать такое было хорошей идеей — встреть я такой адъ в чужом коде, матерился бы в голос. А ведь самому-то теперь будет сложно удержаться :)
Если серьезно, то мне кажется, делать такое в каждом Builder-е это over-engeneering. Сложность понимания "как, и зачем, черт подери, такое здесь наворочено" не окупает небольшого увеличения дуракоустойчивости. Но вот если какая-нибудь изолированная, часто используемая библиотека... Тогда к странным типам возвращаемых значений у методов Builder-а можно и привыкнуть, а вот compile time safety при широком использовании может вполне себя окупить.
Если что — похороны за ваш счет. Я предупреждал :)
UPD:Как мне подсказывает Александр в комментариях — это уже описано как паттерн Wizard. Было бы слишком оптимистично ожидать, что такую штуку еще никто не придумал. Статью рекомендую прочитать, примеры там забавные.
21 июля 2012 г.
Cache coherency #3: false sharing
False sharing
...Или ложное разделение. По-правде, не очень понятно, почему именно ложное — оно самое что ни на есть настоящее, только неожиданное. Что такое true sharing? — Это когда у нас есть ячейка памяти, используемая одновременно более чем одним потоком. В предыдущих статьях я уже рассказывал, что конкурентный доступ на чтение протоколы когерентности разруливают без особого труда, а вот конкурирующие чтение и запись, и особенно запись и запись заставляют их попотеть — здесь все понятно. А false sharing это когда ячейки памяти разные, но физически попадают на один и тот же cache line. С точки зрения программиста — никакого разделения данных нет, никакого конкурирующего доступа тоже. А с точки зрения подсистемы памяти каждый cache line — это одна ячейка, и операции доступа к разным частям одной строки кэша из разных ядер конкурируют друг с другом за эту ячейку-строку. И неожиданно оказывается, что обычная запись в какую-то локальную (казалось бы) переменную стоит в несколько раз дороже, чем должна была бы:int threadId = 0, 1, 2, ... THREADS_COUNT; int distance = 1..8; long[] area = new long[THREADS_COUNT*distance]; for(int i=0...Inf){ area[threadId*distance] = i; }(Готовый к исполнению код здесь). Если увеличивать
distance
, то можно наблюдать, как время на выполнение цикла уменьшается, вплоть до distance=8
— для интелловских архитектур, где cache line 64 байта. (Строго говоря, нужно еще убедиться, что все потоки сели на разные физические ядра, но как правило, в случае незагруженной машины системный планировщик их сам так и раскидает)Вообще, про false sharing не писал уже только ленивый. Я лично эту тему мусолил аж 4 раза, даже тэг для этого специальный завел. Поэтому я здесь хочу остановиться на другом вопросе, который мне как-то задали в комментариях: если все так легко может стать так плохо, почему это редко когда заметно? (Есть, конечно, вариант, что не замечают потому что и не смотрят — но это недостаточно сексуальный вариант)
У false sharing есть два ощутимых эффекта: увеличение в разы времени выполнения конкретной операции записи (memory latency), и увеличение нагрузки на шину. Чтобы заметить увеличение нагрузки на шину нужно либо специально ее мониторить, либо уже использовать шину близко к режиму насыщения. Но интелловский InterConnect имеет пропускную способность что-то вроде 20Гб/с в каждую сторону — а это 20 миллионов килобайтных сообщений в секунду (в нереальном случае 100% КПД). Не то, чтобы это было недостижимо круто, но не
А что насчет memory latency? Ну, проблема медленной памяти перед разработчиками процессоров стоит уже лет 15, за это время было придумано масса трюков, позволяющих ее как-то обойти. Часть из них — те, что про введение иерархии кэшей — в случае с false sharing не помогут, потому что они сами и являются причиной. Но на уровне процессора остается еще пара тузов в рукаве, связанных с асинхронным выполнением операций с памятью. Например, современные процессоры содержат блок префетчинга, который может предсказать доступ к памяти до того, как исполнение дойдет до соответствующей инструкции, и отправить запрос на упреждающее чтение нужного блока в кэш. Другой пример: инструкции записи тоже обычно не ждут, пока запись фактически будет выполнена — команда на запись просто сохраняется в т.н. store buffer-е, и исполнение идет дальше, а команды из буфера спускаются контроллеру памяти в асинхронном режиме.
В итоге получается, что процессор старается не ждать, пока контроллер памяти
И отсюда можно попробовать порасуждать на тему, в каких случаях false sharing имеет наибольшую вероятность стать заметным.
Во-первых, когда нам важно именно время выполнения конкретной (небольшой) операции (latency, response time), а не пропускная способность в целом (throughput). Если наша операция содержит всего-то пару сотен инструкций, и несколько обращений к памяти, то заметное удорожание даже одного из этих обращений все-таки будет заметно. Процессору просто не за чем будет спрятать замедлившуюся операцию. Да, он может параллельно начать выполнять инструкции хоть из другого потока (через hyper threading), тем самым удерживая на уровне общую пропускную способность — но время выполнения конкретной операции от этого не уменьшится. Мы можем получить такую картину: система по-прежнему выполняет N операций в секунду, только время одной операции выросло с 1мкс до 2мкс. Поскольку далеко не каждого волнует время выполнение конкретной операции (особенно, когда масштаб этого времени — микросекунды), то на этой стадии false sharing часто остается просто незамеченным.
Дальше идут ситуации, когда операции с false sharing составляют заметную долю всех операций (по крайней мере, на критическом маршруте). Это как раз пример выше. Почему false sharing там заметен? Потому что там, кроме самой записи, только обвязка цикла, которая едва тянет на пару тактов. И одна-единственная запись, которая contended (счетчик цикла скорее всего будет сидеть в регистре). Даже если запись идет в L1 (~5ns) — она здесь будет доминирующей, и никакие трюки этого не замаскируют, потому что маскировать нечем. И если запись станет в разы дороже — это будет просто бросаться в глаза.
Ну и последний очевидный вариант — это когда у вас шина памяти уже близка к насыщению, и тут еще добавляется false sharing, и все становится грустно.
Т.е. получается, что в первую очередь false sharing беспокоит тех, кто разрабатывает latency-critical системы, и уж во-вторую — тех, кто разрабатывает очень high throughput, где-то близко к пиковой производительности железа.
P.S. Есть у меня еще такая любопытна идея: кажется, что чем мягче аппаратная модель памяти, тем больше у процессора возможностей "прятать" memory latency. Т.е. и прятать false sharing у него тоже больше возможностей. Однако сравнивать разные процессоры сложновато — они обычно отличаются слишком много чем, не только моделью памяти (да и где бы их все взять для тестов-то). Но можно ужесточить аппаратную модель памяти программно — расставляя барьеры памяти. Если взять тот пример, выше, и вместо обычной записи в ячейку массива делать volatile store (придется заменить массив на
AtomicIntegerArray
, или дергать Unsafe.putIntVolatile
) — то сопутствующий lock xadd
скорее всего приведет к принудительному сбросу store buffer-а. А значит не будет возможности отложить запись на "попозже", ее придется делать синхронно. Кажется, что это сделать false sharing еще более заметным. Еще интереснее будет проверить это для Disruptor-а. На Java One меня спрашивали, почему padding в моих экспериментах никак себя не проявил — вот я сейчас думаю, что причина может быть как раз в том, что я экспериментировал с padding после того, как заменил volatile store для курсоров на lazySet (что эффект от оптимизаций некоммутативен — это не новость). Будет интересно, как появится возможность, проверить, что получится если сделать наоборот: вернуть volatile store, и поиграться с padding.
P.P.S. Вообще-то, эта статья планировалась как продолжение цикла про cache coherency. И про false sharing задумывалось как вступление и разминка. Но я что-то увлекся, и false sharing вылез на место главного героя, не оставив места запланированным атомикам. Ну я и подумал — пусть остается как есть, а атомики получат свою главную роль чуть позже :)
17 июня 2012 г.
Simple concurrent caches
Есть у нас доменный объект
CurrencyEntity
:public final class Entity { private final String isoCode; public Entity( final String isoCode ) { this.isoCode = isoCode; } @Override public boolean equals( final Object o ) { if( this == o ) { return true; } if( !(o instanceof Entity )) { return false; } final Entity entity = ( Entity ) o; if( !isoCode.equals( entity.isoCode ) ) { return false; } return true; } @Override public int hashCode() { return isoCode.hashCode(); } }(важно, что объект immutable). На вход (из сети, например) программа получает Entity в виде
String isoCode
, но внутри, понятно, хочется оперировать type-safe представлением. Однако создавать каждый раз новый экземпляр Entity не хочется -- зачем нагружать лишний раз GC? Кроме того, известно, что реально различных isoCode не так уж много -- ну, скажем, штук 100-200. Вот как бы так их кэшировать, чтобы кэш и конкурентным был, и накладные расходы минимальны (иначе кэшировать смысла нет -- создать/удалить такой мелкий объект чего-то стоит, но не так уж дорого)? Вариант решения:
public class FastConcurrentEntityCache{ private static final Object FREE = null; private final Entity[] entries; public FastConcurrentEntityCache( final int size ) { checkArgument( size > 1, "size[%s] must be >1", size ); entries = new Entity[size]; } public Entity get( final String isoCode ) { final int hash = isoCode.hashCode() & 0x7fffffff; final int length = entries.length; final int index = hash % length; Entity cur = entries[index]; if( cur == FREE ) { //cache miss return evaluateAndStore( isoCode, index ); } else if( cur.isoCode.equals( isoCode ) ) { //cache hit return cur; } else {// already FULL, must probe // compute the double hash final int probe = 1 + ( hash % ( length - 2 ) ); int probedIndex = index; for( int i = 0; i < entries.length; i++ ) { probedIndex -= probe; if( probedIndex < 0 ) { probedIndex += length; } cur = entries[probedIndex]; if( cur == FREE ) { //cache miss return evaluateAndStore( isoCode, probedIndex ); } else if( cur.isoCode.equals( isoCode ) ) { //cache hit return cur; } } //cache miss, and cache is full throw new IllegalStateException( "Cache[size:"+entries.length+"] is full: ["+isoCode+"]" ); } } private Entity evaluateAndStore( final String isoCode, final int index ) { final Entity entity = new Entity( isoCode ); entries[index] = entity; return entity; } public void purge() { Arrays.fill( entries, null ); } }
Собственно, весь кэш -- это хэш-таблица фиксированного размера, с открытой адресацией. Сам метод get()
по-большей части с любовью украден из gnu.trove.TObjectHash
-- логика несколько упрощена, поскольку нет удаления.
Подробности
Вопрос: Почему это работает? Ответ: А почему бы и нет? ;)Вообще-то стоило бы чуть подробнее остановиться на том, как именно оно работает. Обычно предполагается, что если объект уже есть в кэше, то при запросе именно он и вернется. Но в данном случае, из-за отсутствия синхронизации, состав кэша может разными потоками видиться по-разному, так что понятие "объект есть в кэше" становится не очень определенным. Так что работать это будет так: если объект есть в кэше, и виден текущему потоку -- вернется он. Если не виден (или вообще нет) -- будет создан новый экземпляр. То есть кэш не сохраняет идентичность (preserve identity) -- для одного и того же ключа isoCode
в программе могут существовать несколько разных экземпляров Entity
.
Второй важный момент это то, что Entity
-- immutable. В многопоточном окружении запись entries[index] = entity
-- это публикация ссылки на объект. Без дополнительной синхронизации (а где она? а нет ее!) это будет публикация через data race. И только особые гарантии JMM для immutable объектов дают нам возможность утверждать, что если другой поток увидел ссылку на Entity
, то он увидит через нее полностью инициализированный объект.
Собственно, здесь две ключевых идеи. Во-первых, ослабление семантики -- отказ от принципа один тюбик в одни зубы "для каждого ключа значение создается строго однажды". Во-вторых, использование immutable объектов для безопасной публикации через data race. Эти две идеи в сумме и позволяют обойтись без синхронизации.
Что еще можно с этим сделать?
Приведенная реализация подразумевает работу в стационарном режиме: через какое-то время все isoCode
будут программе знакомы, и соответствующие Entity
закэшированы, и видимы всем потокам единообразно. Т.е. через какое-то время новые экземпляры Entity
создаваться перестанут. При этом важно, чтобы мы изначально угадали с размером кэша.
Но это не единственный вариант использования. Бывает другая задача: множество объектов в принципе не ограничено, но известно, что чаще всего используются недавние объекты. Т.е. хочется LRU кэш. И такое тоже можно:
public class FastLRUEntityCache{ private static final Object FREE = null; private final Entity[] entries; public FastLRUEntityCache( final int size ) { checkArgument( size > 1, "size[%s] must be >1", size ); entries = new Entity[size]; } public Entity get( final String isoCode ) { final int hash = isoCode.hashCode() & 0x7fffffff; final int length = entries.length; final int index = hash % length; Entity cur = entries[index]; if( cur == FREE ) { //cache miss return evaluateAndStore( isoCode, index ); } else if( cur.isoCode.equals( isoCode ) ) { //cache hit return cur; } else {// already FULL, replace return evaluateAndStore( isoCode, index ); } } private Entity evaluateAndStore( final String isoCode, final int index ) { final Entity entity = new Entity( isoCode ); entries[index] = entity; return entity; } public void purge() { Arrays.fill( entries, null ); } }
Я убрал разрешение коллизий (probing). Теперь, если место для объекта уже занято -- мы просто затираем предыдущего жильца. Этот вариант, в отличие от предыдущего не страдает от запаздывающей синхронизации состояния кэша между потоками -- он ей наслаждается. В самом деле, разные потоки будут просто видеть свои LRU объекты!
Вопрос: А если у меня объект не immutable? Ответ: Если он не immutable, но фактически не меняется -- можно просто создать обертку
public class Freezer<T>{ public final T item; public Freezer(final T item){ this.item = item; } }
Разумеется, это добавит немного накладных расходов (особенно в случае LRU-кэша), так что нужно тщательно тестировать. Идею можно довольно сильно обобщить: вот тут у меня годичной давности примеры кода и бенчмарки к ним, можно поглядеть, что еще с этим можно делать.
Вопрос: А можно сделать кэш resizeable?
Ответ: Можно, конечно. Но придется делать поле entities volatile
-- что несколько увеличит стоимость обращения к кэшу. Я не стал с этим возиться.
Для пуристов, поясняю: да, сам по себе volatile load на широко используемых архитектурах ничего дополнительного не стоит, в сравнении с обычным load. Но он запрещает некоторые оптимизации. Например:
for( over 9000 ){ final String isoCode = ...; final Entity entity = cache.get(isoCode); ... }Если
entries
является final
(да и просто non-volatile
), то компилятор может вынести его чтение за цикл. Если же entries volatile
-- оно будет считываться на каждой итерации. Т.е. мы можем сэкономить что-то вроде (L1 load - register load) * (over 9000) тактов :)
Философское заключение про data race
Как мы могли увидеть, существование в програме data race сам по себе не является приговором "программа некорректная". Но существование гонки, как правило, резко увеличивает размер графа состояний программы, количество возможных сценариев выполнения. Если это не было предусмотрено -- есть близкая к 100% вероятность упустить какие-то сценарии, нарушающие заложенные в код инварианты. Но если распоряжаться гонками аккуратно, то можно осмысленно создать ситуацию, где граф состояний даже с гонкой остается обозримым. И тогда можно увидеть возможности для вот таких вот простых и эффективных решений.Это я так, типа, хвастаюсь :)
20 мая 2012 г.
Тестирование и документирование
Я тут осознал, что сильно недооценивал роль примеров (examples) для технической документации. Документация очень сильно выигрывает от хороших примеров. Продуманные примеры могут быстро сориентировать читателя – ввести в курс дела, и облегчить понимание более детального описания. Более того, хорошо подобранные примеры способны сходу предоставить читателю (= пользователю) решение конкретной проблемы, без необходимости вообще вникать в детали. Вообще, кажется, есть много сценариев, где наиболее практичная документация == правильно подобранный набор примеров с краткой аннотацией.
Конечно, не новость, что примеры нужны, важны, и полезны. Но эти полезные примеры нередко оказываются рассеяны среди большого количества текста. При том, что примеры часто гораздо полезнее этого текста, и могли бы заменить бОльшую его часть.