Пять секретов... многопоточного Java-программирования

О тонкостях высокопроизводительной многопоточной обработки данных

Многопоточное программирование никогда не было простым, но понимание того, как JVM обрабатывает некоторые тонко различающиеся конструкции кода, может помочь делу. Пять советов Стивена Хайнса способствуют принятию более обоснованных решений при работе с синхронизированными методами, volatile-переменными и атомарными классами.

Стивен Хейнс, основатель и генеральный директор, GeekCap Inc.

Стивен Хайнс (Steven Haines) - технический архитектор ioko и основатель компании GeekCap. Написал три книги по Java-программированию и анализу производительности, а также несколько сотен статей и десяток официальных технических документов. Выступал на таких отраслевых конференциях, как JBoss World и STPCon; преподавал Java-программирование в Калифорнийском университете в Ирвине и в Learning Tree University. Проживает в пригороде Орландо (штат Флорида, США).



29.10.2012

Об этом цикле статей

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

Немногие Java™-разработчики могут позволить себе игнорировать многопоточное программирование и поддерживающие его библиотеки платформы Java, но еще меньше тех, кто располагает временем для глубинного изучения потоков. Вместо этого мы изучаем их спонтанно, добавляя в свой инструментарий новые рекомендации и приемы, когда нуждаемся в них. Это не лучший путь, хотя он и позволяет построить и запустить достойное приложение. Понимание потоковых механизмов компилятора Java и JVM поможет вам написать более эффективный, качественный и быстродействующий код Java.

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

1. Синхронизированный метод или синхронизированный блок?

Развить навыки по этой теме

Этот материал — часть knowledge path для развития ваших навыков. Смотри Стать Java-программистом

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

Когда JVM выполняет синхронизированный метод, исполняемый поток определяет, что в структуре method_info этого метода установлен флаг ACC_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() генерирует следующий байт-код:

	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

Для создания синхронизированного блока понадобилось 16 строк байт-кода, тогда как для синхронизации метода достаточно пяти.


2. Переменные ThreadLocal

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

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

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

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


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

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

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

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

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


4. Volatile- или синхронизированные переменные?

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

Это означает, что следующий код не является потокобезопасным:

myVolatileVar++;

Предыдущий оператор можно записать и так:

int temp = 0;
synchronize( myVolatileVar ) {
  temp = myVolatileVar;
}

temp++;

synchronize( myVolatileVar ) {
  myVolatileVar = temp;
}

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


5. Атомарные корректоры полей

При увеличении или уменьшении значения примитива в многопоточной среде гораздо выгоднее использовать один из новых атомарных классов из пакета java.util.concurrent.atomic, чем писать свой собственный синхронизированный блок кода. Атомарные классы гарантируют выполнение определенных операций, таких как увеличение и уменьшение, обновление или добавление значения, потокобезопасным способом. В перечень атомарных классов входят классы AtomicInteger, AtomicBoolean, AtomicLong, AtomicIntegerArray и т.п.

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

Использование атомарных обновлений

Атомарные корректоры полей, такие как 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 - это простой объект Java (POJO) с единственным полем: 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.class, Book.class, "whatImReading" );

    public Book getWhatImReading()
    {
        return whatImReading;
    }

    public void setWhatImReading( Book whatImReading )
    {
        //this.whatImReading = whatImReading;
        updater.compareAndSet( this, this.whatImReading, whatImReading );
    }
}

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

AtomicReferenceFieldUpdater

В Javadoc класс AtomicReferenceFieldUpdater определяется следующим образом:

Основанная на отражении (reflection-based) утилита, которая обеспечивает атомарное обновление указанных опорных 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 при решении задач синхронизации) и краткий экскурс в тонкости атомарных классов. Подробнее см. в разделе Ресурсы.

Ресурсы

  • Оригинал статьи: 5 things you didn't know about ... multithreaded Java programming.
  • Пять секретов...: цикл статей, содержащих полезные советы по Java-программированию.
  • Java Concurrency in Practice (Brian Goetz, et. al. Addison-Wesley, 2006): удивительная способность Брайана объяснять сложные концепции простым языком делает эту книгу необходимой в библиотеке любого Java-программиста.
  • Code Tracing (Steven Haines, InformIT, август 2010 г.): подробнее о трассировке кода с помощью переменных ThreadLocal.
  • Java bytecode: Understanding bytecode makes you a better programmer (Peter Haggar, developerWorks, июль 2001 г.): введение в малоизвестные области байткода, включая приведенный выше пример, иллюстрирующий разницу между синхронизированными методами и синхронизированными блоками.
  • Java theory and practice: Going atomic (Brian Goetz, developerWorks, ноябрь 2004 г.): о том, как атомарные классы позволяют разрабатывать высокомасштабируемые неблокирующие алгоритмы на языке Java.
  • Java theory and practice: Concurrency made simple (sort of) (Brian Goetz, developerWorks, ноябрь 2002 г.): экскурс в пакет java.util.concurrent.
  • 5 things you didn't know about ... java.util.concurrent, Part 1 (Ted Neward, developerWorks, май 2010 г.): о пяти классах параллельных коллекций, которые модифицируют стандартные классы коллекций для нужд параллельного программирования.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=843334
ArticleTitle=Пять секретов... многопоточного Java-программирования
publish-date=10292012