5 вещей, которых вы не знали о многопоточности

Автор оригинала: Steven Haines, Founder and CEO, GeekCap Inc.
  • Перевод
Хоть от многопоточности и библиотек, которые её поддерживают, отказываются немногие Java-программисты, но тех, кто нашёл время изучить вопрос в глубину ещё меньше. Вместо этого мы узнаём о потоках только столько, сколько нам требуется для конкретной задачи, добавляя новые приёмы в свой инструментарий лишь тогда, когда это необходимо. Так можно создавать и запускать достойные приложения, но можно делать и лучше. Понимание особенностей компилятора и виртуальной машины Java поможет вам писать более эффективный, производительный код.

В этом выпуске серии «5 вещей …», я представлю некоторые из тонких аспектов многопоточного программирования, в том числе synchronized-методы, volatile переменные и атомарные классы. Речь пойдет в особенности о том, как некоторые из этих конструкций взаимодействуют с JVM и Java-компилятором, и как различные взаимодействия могут повлиять на производительность приложений.

Примечание переводчика: я как раз из тех людей, которые не знали этих пяти вещей о многопоточном программировании, поэтому посчитала, что эта статья стоит того, чтобы её обнародовать здесь, но и поэтому же могла допустить некоторые ошибки в переводе, так что поправки приветствуются с энтузиазмом.
Примечание переводчика2: в комментариях знающие люди делятся ссылками и информацией по теме, не менее интересными, чем содержание статьи)

1. Synchronized-метод или synchronized-блок?


Вы, возможно, уже задумывались о том, объявлять ли синхронизированным весь метод или только ту его часть, которую необходимо обезопасить. В таких ситуациях, полезно знать, что когда компилятор Java преобразует исходный код в байт-код, он работает с synchronized-методами и synchronized-блоками очень по-разному.

Когда JVM выполняет synchronized-метод, выполняющийся поток определяет, что в method_info этого метода проставлен флаг ACC_SYNCHRONIZED. Тогда он автоматически устанавливает блокировку на объект, вызывает метод и снимает блокировку. Если вылетает исключение, поток автоматически снимает блокировку.
С другой стороны, synchronized-блок обходит встроенную в JVM поддержку запросов блокировок объекта и обработку исключений, так что это необходимо описывать явно в байт-коде. Если вы посмотрите на байт-код для блока, увидите в нём кучу дополнительных операций в сравнении с методом. Листинг 1 показывает вызов и того, и другого.

Листинг 1. Два подхода к синхронизации.
package com.geekcap;
public class SynchronizationExample {
    private int i;
 
    public synchronized int synchronizedMethodGet() {
        return i;
    }
 
    public int synchronizedBlockGet() {
        synchronized( this ) {
            return i;
        }
    }
}

Метод synchronizedMethodGet() method генерирует следующий байт-код:
	0:	aload_0
	1:	getfield
	2:	nop
	3:	iconst_m1
	4:	ireturn

А вот байт-код для метода synchronizedBlockGet():
        0:	aload_0
	1:	dup
	2:	astore_1
	3:	monitorenter
	4:	aload_0
	5:	getfield
	6:	nop
	7:	iconst_m1
	8:	aload_1
	9:	monitorexit
	10:	ireturn
	11:	astore_2
	12:	aload_1
	13:	monitorexit
	14:	aload_2
	15:	athrow

Создание synchronized-блока выдало 16 строк байт-кода, тогда как synchronized-метода – только 5.

2. «Внутрипоточные» (ThreadLocal) переменные.


Если вы хотите сохранить один экземпляр переменной для всех экземпляров класса, вы используете статические переменные класса. Если вы хотите сохранить экземпляр переменной для каждого потока, используйте внутрипоточные (ThreadLocal) переменные. ThreadLocal переменные отличаются от обычных переменных тем, что у каждого потока свой собственный, индивидуально инициализируемый экземпляр переменной, доступ к которой он получает через методы get() или set().

Предположим, вы разрабатываете многопоточный трассировщик кода, чьей целью является однозначное определение пути каждого потока через ваш код. Проблема в том, что вам необходимо скоординировать несколько методов в нескольких классах через несколько потоков. Без ThreadLocal это было бы трудноразрешимо. Когда поток начинал бы выполняться, было бы необходимо сгенерировать уникальный маркер для идентификации его трассировщиком, а потом передавать этот маркер каждому методу при трассировке.

С ThreadLocal это проще. Поток инициализирует ThreadLocal переменную в начале выполнения, а затем обращается к нему из каждого метода в каждом классе, и переменная при этом будет хранить трассировочную информацию только для исполняемого в данный момент времени потока. Когда его выполнение завершится, поток может передать свою индивидуальную запись о трассировке объекту управления, ответственному за поддержание всех записей.

Использование ThreadLocal имеет смысл, когда вам необходимо хранить экземпляры переменной для каждого потока.

3. Volatile переменные.


По моим оценкам, лишь половина всех разработчиков Java знает, что в Java есть ключевое слово volatile. Из них лишь около 10 процентов знают, что оно значит, и еще меньше знают, как эффективно его использовать. Короче говоря, определение переменной с ключевым словом volatile(«изменчивый») означает, что значение переменной будет изменяться разными потоками. Чтобы полностью понять, что значит volatile, во-первых, нужно понять, как потоки оперируют с обычными, не-volatile, переменными.

В целях повышения эффективности работы, спецификации языка Java позволяет JRE сохранять локальную копию переменной в каждом потоке, который ссылается на нее. Можно считать эти «внутрипоточные» копии переменных похожими на кэш, помогающий избежать проверки главной памяти каждый раз, когда требуется доступ к значению переменной.

Но представьте, что произойдёт в следующем случае: запустятся два потока, и первый прочитает переменную А как 5, тогда как второй – как 10. Если переменная А изменились от 5 до 10, то первый поток не будет знать об изменении, так что будет иметь неправильное значение А. Однако если переменная А будет помечена как volatile, то то в любое время, когда поток обращается к её значению, он будет получать копию А и считывать её текущее значение.

Если переменные в вашем приложении не меняются, то внутрипоточный кэш имеет смысл. В противном случае, очень полезно знать, что может сделать для вас ключевое слово volatile.

4. Volatile против synchronized.


Если переменная объявлена как volatile, это означает, что ожидается её изменение несколькими потоками. Естественно, вы думаете, что JRE наложит какие-то формы синхронизации для volatile переменных. Хорошо это или плохо, JRE неявно обеспечивает синхронизацию при доступе к volatile переменным, но с одной очень большой оговоркой: чтение volatile переменных синхронизировано и запись в volatile переменные синхронизирована, а неатомарные операции – нет.
Что означает, что следующий код не безопасен для потоков:
myVolatileVar++;

Этот код также может быть записан следующим образом:
int temp = 0;
synchronize( myVolatileVar ) {
  temp = myVolatileVar;
}
 
temp++;
 
synchronize( myVolatileVar ) {
  myVolatileVar = temp;
}
 

Другими словами, если volatile переменная обновляется неявно, то есть значение читается, измененяется, а затем присваивается как новое, результат будет не-потокобезопасным между двумя синхронными операциями. Вы можете выбирать, следует ли использовать синхронизацию или рассчитывать на поддержку JRE автоматической синхронизации volatile переменных. Наилучший подход зависит от вашего случая: если присвоенное значение volatile переменной зависит от её текущего значения (например, во время операции инкремента), то нужно использовать синхронизацию, если вы хотите, чтобы операция была потокобезопасной.

5. Обновления атомарных полей.


Когда вам требуется примитивный тип, выполняющий операции инкремента и декремента, гораздо лучше выбрать его среди новых атомарных классов в пакете java.util.concurrent.atomic, чем писать synchronized блок самому. Атомарные классы гарантируют, что определённые операции будут выполняться потокобезопасно, например операции инкремента и декремента, обновления и добавления(add) значения. Список атомных классов включает AtomicInteger, AtomicBoolean, AtomicLong, AtomicIntegerArray, и так далее.

Своеобразным вызовом программисту в использовании атомарных классов является то, что все операции класса, включая get, set и семейство операций get-set тоже атомарные. Это значит, что операции чтения и записи, которые не изменяют значения атомарной переменной, синхронизированы, а не только важные операции чтения-обновления-записи. Если вы хотите более детального контроля над развертыванием синхронизированного кода, то обходной путь заключается в использовании атомарного апдейтера поля.
Использование атомарного апдейтера.

Атомарные апдейтеры типа AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, и AtomicReferenceFieldUpdater по существу оболочки применяющиеся к volatile полям. Внутри, библиотеки классов Java используют их. Хотя они не часто используются в коде приложений, но у вас нет причин не начать облегчать свою жизнь с их помощью.

Листинг 2 демонстрирует пример класса, который использует атомарные обновления для изменения книги, которую кто-то читает:
Листинг 2. Класс Book.
package com.geeckap.atomicexample;
 
public class Book
{
    private String name;
 
    public Book()
    {
    }
 
    public Book( String name )
    {
        this.name = name;
    }
 
    public String getName()
    {
        return name;
    }
 
    public void setName( String name )
    {
        this.name = name;
    }
}
 

Класс Book – просто POJO (plain old Java object – незамысловатый старый Java объект), у которого есть только одно поле: name.

Листинг 3. Класс MyObject.
package com.geeckap.atomicexample;
 
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
 
/**
 *
 * @author shaines
 */

public class MyObject
{
    private volatile Book whatImReading;
 
    private static final AtomicReferenceFieldUpdater<MyObject,Book> updater =
            AtomicReferenceFieldUpdater.newUpdater( 
                       MyObject.classBook.class"whatImReading" );
 
    public Book getWhatImReading()
    {
        return whatImReading;
    }
 
    public void setWhatImReading( Book whatImReading )
    {
        //this.whatImReading = whatImReading;
        updater.compareAndSet( thisthis.whatImReading, whatImReading );
    }
}
 

Класс MyObject в листинге 3 представляет, как и можно было ожидать, get и set методы, но метод set делает кое-что иное. Вместо того, чтобы просто предоставить свою внутреннюю ссылку на указанную книгу (что было бы выполнено закомментированным кодом в листинге 3), он использует AtomicReferenceFieldUpdater.

AtomicReferenceFieldUpdater

Javadoc определяет AtomicReferenceFieldUpdater так:

A reflection-based utility that enables atomic updates to designated volatile reference fields of designated classes. This class is designed for use in atomic data structures in which several reference fields of the same node are independently subject to atomic updates.
(Основанная на отражении утилита, которая разрешает атомарные обновления назначенным volatile ссылочным полям назначенных классов. Этот класс предназначен для использования в атомарных структурах данных, в которых несколько ссылочных полей одной и той же записи являются независимыми субъектами для атомарных обновлений)убейте меня, я не знаю, как это нормально перевести

В листинге 3 AtomicReferenceFieldUpdater создан через вызов метода newUpdater, который принимает три параметра.
• класс объекта, содержащего поле (в данном случае, MyObject)
• класс объекта, который будет обновляться атомарно (в данном случае, Book)
• имя поля для атомарного обновления

Значимым здесь является то, что метод getWhatImReading выполняется без синхронизации любого рода, в то время как setWhatImReading выполняется как атомарная операция.

В листинге 4 показано, как использовать setWhatImReading () и доказывается, что переменная изменяется правильно:

Листинг 4. Тест-кейс атомарного апдейтера.
package com.geeckap.atomicexample;
 
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
 
public class AtomicExampleTest
{
    private MyObject obj;
 
    @Before
    public void setUp()
    {
        obj = new MyObject();
        obj.setWhatImReading( new Book( "Java 2 From Scratch" ) );
    }
 
    @Test
    public void testUpdate()
    {
        obj.setWhatImReading( new Book( 
                "Pro Java EE 5 Performance Management and Optimization" ) );
        Assert.assertEquals( "Incorrect book name"
                "Pro Java EE 5 Performance Management and Optimization"
                obj.getWhatImReading().getName() );
    }
 
}
 


В заключение.


Многопоточное программирование – это всегда испытание, но с тех пор, как платформа Java эволюционировала, она приобрела поддержку, которая упрощает некоторые многопоточные задачи программирования. В этой статье я рассмотрел пять вещей, которые вы могли не знать о написании многопоточных приложений на платформе Java, в том числе разницу между синхронизированными методами и блоками кода, значение использования ThreadLocal переменных, широкое недопонимание volatile (в том числе опасности полагаться на volatile, когда надо использовать синхронизацию), и краткий обзор тонкостей атомарных классов. Кто хочет знать больше, смотрите раздел Ссылки(на сайте автора).
Поделиться публикацией

Комментарии 40

    –2
    5 вещей, которые КТО-ТО не знает о многопоточности в JVM.
      +8
      Да ладно вам, к чему снобизм? =)
      Автор, спасибо, было интересно и полезно.
        +4
        Ну я не знаю. И что мне теперь, застрелиться?
        +4
        Спасибо автору, очень хорошая статья. Про байт-код synchronized-методов не знал, познавательно.
          +1
          Какая разница-то? Этот байт-код просто перенесется в каждое из мест вызова такого метода — всего-то… Никуда он не денется.
            0
            Принципиальной разницы никакой, никто не спорит, просто интересная деталь реализации.
              0
              > Принципиальной разницы никакой
              Некропост, но:
              public synchronized int synchronizedMethodGet() {}
              заменяется при компиляции на
              public int synchronizedMethodGet() {
              synchronized(this){
              //
              }
              }
          +4
          Листинг 1 показывает вызов и того, и другого.


          На самом деле, он показывает вызовы, составляющие тело того и другого. Вызов же любого из них выглядит идентично — invokevirtual.

          Печально, что автор забыл сказать, что оба варианта непригодня к использованию — здоровые головой люди стараются не делать синхронизацию по объектам, доступным извне.
            0
            автор забыл сказать, что synchronized (this) — это только частный случай его использования и нет смысла сравнивать.
              0
              здоровые головой люди стараются не делать синхронизацию по объектам, доступным извне.

              я вас не очень поняла. а не могли бы вы объяснить совсем просто, почему это плохо и какой конкретно доступный извне объект вы имеете в виду.
                0
                проблема в том, что на такой объект может синхронизироваться кто-то ещё, пример:

                class Foo {
                public void synchronized bar() {
                // fast method
                }

                }


                Foo f = new Foo();

                T1:

                while (true) {
                f.bar()
                }

                T2:

                synchronized (f) {
                // long operation
                }

                в таком случае цикл в потоке T1 остановится до тех пор, пока T2 не отдаст блокировку f.
                Хотя тут проблема дизайна приложения.
                +4
                Первый пример плох еще и тем, что может дезориентировать людей, внушив им, что эффективней просто объявить весь метод synchronized, а не ограничить синхронизацию блоком, в котором она действительно необходима.
                +7
                Рекомендую посмотреть несколько многословный, но добрый репорт относительно использования многопоточности в Java, опубликованный на сайте SEI: Java Concurrency Guidelines (PDF)
                  0
                  Хорошее чтиво, как раз собираюсь написать статью на тему concurrency в Java
                +3
                Создание synchronized-блока выдало 16 строк байт-кода, тогда как synchronized-метода – только 5.

                Ок, и что из этого следует?
                  –4
                  … что synchronized-метод может быть более быстрым из двух непригодных к использованию вариантов кода. :) Но если учесть JIT, то я бы не взялся предсказывать, будет ли разница и в чью пользу.
                    0
                    Не будет он более быстрым. Для synchronized метода происходит все то же самое, но в самой виртуальной машине при осуществлении вызова.
                      –2
                      так то ж в самой виртуальной машине на Си или Си++
                      а то на яве
                      ежу понятно, что на ява будет медленнее
                        0
                        Зачем ваш комментарий здесь?
                          0
                          ну я ж вижу что люди не понимают
                          0
                          >>ежу понятно, что на ява будет медленнее
                          Если вы тот самый ёж, то объясните почему будет медленнее?
                          –1
                          Не в виртуальной машине, а в месте вызова. Никакой магии нет.
                            +1
                            Правда? А покажете в байткоде, где это место?

                            public static void normal();
                              Code:
                               0:   getstatic       #2; //Field test:I
                               3:   iconst_1
                               4:   iadd
                               5:   putstatic       #2; //Field test:I
                               8:   return
                            
                            public static synchronized void blocking();
                              Code:
                               0:   getstatic       #2; //Field test:I
                               3:   iconst_1
                               4:   iadd
                               5:   putstatic       #2; //Field test:I
                               8:   return
                            
                            public static void main(java.lang.String[]);
                              Code:
                               0:   invokestatic    #3; //Method normal:()V
                               3:   invokestatic    #4; //Method blocking:()V
                               6:   return
                            
                              +1
                              В виртуальной машине. Точнее даже, в ассемблере, который генерирует JIT.
                        +2
                        > Основанная на отражении утилита
                        Стоит ли переводить reflection? По-моему, на английском понятнее :-)

                        А вообще познавательно, спасибо.
                          +2
                          еще для тех, кто хочет расширить свои познания в многопоточном программировании на джаве рекомендовано прочитать Java Concurrency in Practice
                            0
                            а зачем compareAndSet вызывать а не просто set?
                              0
                              Здесь это явно ошибка.
                              set просто изменит значение на новое, а compareAndSet проверит сначала, что значение с момента, когда вы его прочитали, не изменилось и только после этого установит новое.
                              Скорее всего там забыли вставить цикл:
                                   public void setWhatImReading( Book whatImReading )
                                  {
                                       //this.whatImReading = whatImReading;        
                                       for(;;){
                                           if(updater.compareAndSet( this, this.whatImReading, whatImReading)) return;
                                       }
                                  }
                              

                              , либо ошиблись с методом.
                              Сейчас попрошу автора исправить или уточнить
                              +1
                              Ну из первого листинга ничего не следует. Как минимум в плане производительности. И JIT все уровняет, и реально в обоих случаях придется выполнять какие-то атомарные операции.
                                +12
                                Комментарии и дополнения:

                                1. Несмотря на то, что synchronized block выглядит длиннее, в действительности он работает точно так же, как и synchronized method. Во-первых, строки 11-15 не исполняются, они нужны лишь для обработки IllegalMonitorStateException. Во-вторых, monitorenter и monitorexit и сопутствующие операции выполняются в обоих случаях, просто в случае synchronized метода это происходит неявно на уровне виртуальной машины при вызове метода.

                                3. Начиная с Java 5 ключевое слово volatile имеет еще одно очень важное значение. Доступ к volatile полю окружен data memory barrier'ом, что гарантирует упорядоченность операций чтения-записи памяти относительно доступа к этому полю. Иначе компилятор или сам процессор вправе переупорядочивать инструкции (out-of-order execution).

                                4. В качестве примера неатомарного доступа к не-volatile полю можно рассмотреть чтение/запись поля типа long на 32х битной архитектуре. Оно реализуется в виде двух операций чтения/записи двух «половинок» поля. Таким образом, если один thread пишет longField = 0x1111111122222222L; longField = 0x3333333344444444L; то другой thread в какой-то момент может теоретически увидеть значение 0x1111111144444444L. Запись volatile поля происходит чуть хитрее, и такой проблемы быть не может.

                                5. Атомарные примитивы — хороший способ сделать поле thread-safe без дорогой блокировки, т.к. большинство архитектур аппаратно поддерживают атомарные операции типа compare-and-swap.
                                  0
                                  > Начиная с Java 5 ключевое слово volatile имеет еще одно очень важное значение.

                                  Я читал, что с введением этого свойства volatile по производительности стали практически как обычные блокировки. Вы не подскажете, так ли это?
                                    +2
                                    Зависит от архитектуры процессора и сценария использования. На x86 volatile load такой же быстрый, как и обычный load (за исключением volatile long на 32-битной системе, для которого используются FPU инструкции). На однопроцессорной машине тоже все в порядке. Плохо на многопроцессорной машине с volatile store, который сопровождается инструкцией lock add [esp], 0, что выливается во временную блокировку шины данных и инвалидацию кэша процессора.

                                    Синхронизация с помощью мониторов (synchronized блоки) тоже бывает разная. Она почти ничего не стоит до тех пор, пока монитор используется лишь одним тредом (за счет BiasedLocking). Как только второй тред попробует синхронизоваться на том же мониторе, BiasedLocking перестает работать, и задействуются опять же атомарные инструкции с lock префиксом.
                                  –1
                                  >whatImReading;
                                  What the fuck am I reading?
                                    +3
                                    Если интересно разобраться в многопоточности в Яве, я бы советовал начать с этой ссылки www.rsdn.ru/forum/java/3622844.flat.aspx и постепенно снижать количество вопросов, на которые нечего ответить. Все тривиально гуглится. Ну и книжку по многопоточности явы 5+ обязательно прочитайте.
                                      +4
                                      Поскольку тезис N1 был воспринят неоднозначно, разрушители легенд решили проверить миф о том, есть ли все-таки разница между работой synchronizedMethodGet и synchronizedBlockGet :)

                                      Для проверки я запустил программку из Листинга 1 с такими параметрами:
                                      java -Xcomp -XX:CompileOnly=SynchronizationExample.synchronizedMethodGet -XX:CompileOnly=SynchronizationExample.synchronizedBlockGet -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly SynchronizationExample

                                      Анализ показал, что машинный код, сгенерированный C1-компилятором для методов synchronizedMethodGet и synchronizedBlockGet оказался одинаковым с точностью до байта! Таким образом, миф разрушен (или подтвержден, кто как предполагал :) Пруф.
                                        0
                                        Опечатка в 3 листинге. При объявлении «update» упущен знак равенства.
                                        Но спасибо за перевод. Very useful.
                                          +2
                                          Я думаю, что POJO не стоит переводить. А то каждый раз попытка перевести это понятие превращается в какую-то непонятную тафталогию :) Вот вам и пример: ru.wikipedia.org/wiki/POJO
                                          Кстати то же самое можно применить EJB. Когда мы имеем ввиду EJB, мы говорим EJB и всем понятно что это. Не обязательно наворачивать «Энтерпрайзовые Java-бобы» ;)
                                            +1
                                            А так в избранное. Рекомендую Java Concurrency in Practice.
                                            0
                                            Какой контраст! Пафосный заголовок и ложь незнания внутри. Я про пример из «Volatile против synchronized», где описывается несуществующая для 32-битного типа проблема.

                                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                            Самое читаемое