Java Gym: Многопоточность в Android и Java, часть 2: синхронизация потоков в Java

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

Прежде всего, все типы, исключая int, float, double, byte — являются потомками класса Object(полное имя java.lang.Object). Класс Object и все его потоки имеют встроенную поточную синхронизацию. Эта синхронизация представлена двумя семействами методов: wait() и notify().

Методы из семейства wait() заставляют текущий поток остановиться, до получения этим объектом сигнала из семейства notify() или по истечению таймаута (если таймаут — 0, это все равно, что его нет).

    • wait(1000); /* ожидание в 1 секунду или 1000 миллисекунд или ранее, если будет получен notify() */
    • wait(1000, 1000); /* ожидание в 1000 миллисекунд и ещё 1000 наносекунд */
    • wait(); /* ожидание как оно есть, до получения сигнала notify() */

В семейство notify() входят следующие методы:

    • notify(); /* пробуждает один поток из числа ожидающих на wait() */
    • notifyAll(); /* пробуждает все ожидающие этот объект на wait() потоки */

На этих двух методах построена сама простая и в 90 % случаев достаточная синхронизация — монитор. Работает это так:

/*объект синхронизации находится в пространстве объекта (объявлен в глобальном или классовом пространстве имен, в пространстве класса куда более надёжно)*/

Object sync = new Object();
/* вход в монитор */
try {
sync.wait();
} catch (InterruptedException ex) {
} finally {
}
/* рабочий код */
/* выход из монитора */
sync.notify();

Теперь «рабочий код» синхронизирован, то есть внутри монитора два потока одновременно не появятся. Написано оно все с большего правильно, но очень не красиво. Нужно писать так:

synchronize (sync) {

/* рабочий код */
}

Эффект тотже, однако читать проще. Теперь посмотрим еще глубже, чем мы чаще всего синхронизируем и что мы чаще всего синхронизируем. Вскрытие показывает, что мы редко синхронизируем только кусочек метода, обычно метод синхронный целиком. Объект синхронизации мы тоже создаем не часто, чаще всего, если речь идет о классе с хорошей инкапсуляцией, то достаточно, чтобы в рамках одного объект были синхронизированы некоторые его методы. В данном случае лучший синхронизатор — сам объект.

Итак, первый шаг, синхронизируем кусок метода при помощи специального объекта.

public void doSomething() {

/* здесь незащищенный код */
synchronize (sync) {
/* здесь защищенный код */
}
/* здесь снова незащищенный код */
}

Теперь защитим содержимое метода при помощи экземпляра класса.

public void doSomething() {

synchronize (this) {
/* здесь защищенный код */
}
}

Этоже можно(нужно!) написать более элегантно.

public synchronized void doSomething() {

/* здесь защищенный код */
}

У такого подхода есть и свои издержки. Например, если класс содержит множество методов синхронизированных по объекту, то, если это было сделано не просто так, и имеют места такие вызовы, на android’е это может вызвать ANR, при условии, что вызов метода, который будет ждать, будет из потока пользовательского интерфейса.

Говоря о синхронизации в Java нельзя не упомянуть про семафоры. Семафор — это такой объект, который позволяет сделать доступным участок кода для n потоков, как правило, выбирается вариант, когда семафор даёт такую возможность только одному потоку.

Реализация семафора в Java — java.util.concurrent.Semaphore. Конструктор принимает количество потоков, которые одновременно могут пройти через семафор; опционально можно выставить режим fair (англ. справедливый), при котором потоки выходят из семафора в томже порядке как и зашли (FIFO, First In First Out, то есть первый зашел, первый вышел).

Я вот тут говорю про «вошел» и «вышел», эти методы называются: acquire() и release() соответственно. На что тут нужно обратить внимание: я уже написал, что семафор может пропускать некоторое количество потоков в участок кода или к определенному ресурсу, так вот в методы acquire() и release() можно передать целое число, которое будет засчитано, как количество вошедших и вышедших потоков соответственно. Вот пример правильного и безопасного использования семафора:

class MyThread extends Thread {

private Semaphore mSemaphore = null;
public MyThread(Semaphore semaphore) {
mSemaphore = semaphore;
assert(mSemaphore != null); /* семафор должен быть! */
}
@Override
protected void run() {
/* принимаем семафор */
try {
mSemaphore.acquire();
/* здесь рабочий код, который имеет доступ к защищаемому ресурсу */
} catch (InterruptedException ex) {
/* вот такое может произойти, если
поток например прервут thread.interrupt()
*/
} finally {
/* отдаем семафор */
mSemaphore.release();
}
}
}
class ThreadManager {
/* этот семафор может быть занят одним потоком */
private Semaphore mSemaphor = new Semaphor(1);
public void doTenTimes() {
/* запускаем десять наших потоков и знаем, что
одновременно они работать не будут. Зачем она нужна
такая многопоточность - это другой вопрос <img src='http://idev.by/wordpress/wp-includes/images/smilies/icon_smile.gif' alt=':-)' class='wp-smiley' /> */
for (int i = 0; i < 10; i++) {
MyTread th = new MyTread(mSemaphor);
th.start();
}
}
}

В приведенном коде нам безразлично, сколько потоков может пропустить через себя семафор, если он может пропустить хотя бы один. Но допустим, что нам нужно чтобы в системе единовременно работало: либо три потока с легкой задачей, либо один с тяжёлой. И вот тут появляется смысл в некоторых возможностях семафора.

class LightTask extends Thread {

private static final int LIGHT_WEIGH = 1;
private Semaphore mSemaphore = null;
public MyThread(Semaphore semaphore) {
mSemaphore = semaphore;
assert(mSemaphore != null); /* семафор должен быть! */
}
@Override
protected void run() {
/* принимаем семафор, занимаем одно место, таких задания сможет войти три */
try {
mSemaphore.acquire(LIGHT_WEIGH);
/* здесь рабочий код, который имеет доступ к защищаемому ресурсу */
} catch (InterruptedException ex) {
ex.printStackTrace();
} finally {
/* увеличиваем счётчик семафора на 1(на наш LIGHT_WEIGH), теперь еще
один легкий, или, когда выйдут все три, то и один тяжелый сможет зайти */
mSemaphore.release(LIGHT_WEIGH);
}
}
}
class HeavyTask extends Thread {
private static final int HEAVY_WEIGH = 3;
private Semaphore mSemaphore = null;
public MyThread(Semaphore semaphore) {
mSemaphore = semaphore;
assert(mSemaphore != null); /* семафор должен быть! */
}
@Override
protected void run() {
/* принимаем семафор, занимаем три места, такое задание
может выполнятся только в одиночку */
try {
mSemaphore.acquire(HEAVY_WEIGH);
/* здесь рабочий код, который имеет доступ к защищаемому ресурсу */
} catch (InterruptedException ex) {
ex.printStackTrace();
} finally {
/* увеличиваем счётчик семафора на 3(на наш HEAVY_WEIGH), теперь еще
три легких или один тяжёлый могут зайти */
mSemaphore.release(HEAVY_WEIGH);
}
}
}
class TaskManager {
/* этот семафор может быть занят максимум тремя потоками, с весом 1
или одним с весом 3! */
private Semaphore mSemaphor = new Semaphor(3);
public void doLight() {
LightTask th = new LightTask(mSemaphor);
th.start();
}
public void doHeavy() {
HeavyTask th = new HeavyTask(mSemaphor);
th.start();
}
}

Теперь посмотрим как можно использовать «справедливый» семафор. Семафор созданный нами в предыдущем примере — «несправедливый», так происходит по умолчанию, и это значит, что если мы делаем doLight() 3 раза, то, например первый поток может выйти из защищенной области даже, например, третьим; для этого достаточно чтобы он начал инициализировать ресурсы и стал ждать завершения инициализации, при этом разрешив другим идти дальше. Допустим так нельзя, то есть все должны выходить из участка кода в томже порядке, как и зашли. Тогда в предыдущем примере нам нужно исправить только одну строчку.

...

private Semaphore mSemaphor = new Semaphor(3, true);
...

Теперь сделаем предположение, что тяжёлый поток, который не успел пройти 3 секунды назад — уже не нужно выполнять (например система посылает такие потоки каждые 3 секунды и если мы будем их копить, то система скоро остановится). Тут нам поможет оболочка вокруг acquire() — tryAcquire(). Этот метод возвращает true если ему удалось занять семафор, также есть варианты этого метода, которые принимают таймаут. Так как это оболочка вокруг нормального acquire() ему также можно передать число занятых потоков. Итак, вся семья этих методов:

boolean tryAcquire() — вернёт true, только если удалось занять семафор прямо сейчас;

boolean tryAcquire(int permits) — как и предыдущий, только занимает семафор premits раз;

boolean tryAcquire(int permits,long timeout,TimeUnit unit) — в отличии от предыдущей, ждет возможности получить true в течении времени timeout, указанном в unit (это перечисление туда входят:SECONDS,MILLISECONDS,MICROSECONDS,NANOSECONDS);

boolean tryAcquire(int permits,long timeout,TimeUnit unit) — тоже самое, что и предыдущая, при permits равном 1.

Теперь переделываем класс HeavyTask:

class HeavyTask extends Thread {

private static final int HEAVY_WEIGH = 3;
private Semaphore mSemaphore = null;
public MyThread(Semaphore semaphore) {
mSemaphore = semaphore;
assert(mSemaphore != null); /* семафор должен быть! */
}
@Override
protected void run() {
/* пытаемся занять три места на семафоре, и ждем этого не более 3 секунд */
if (mSemaphore.tryAcquire(HEAVY_WEIGH, 3, TimeUnit.SECONDS)) {
/* здесь рабочий код */
/* увеличиваем счётчик семафора на 3(на наш HEAVY_WEIGH) */
mSemaphore.release(HEAVY_WEIGH);
} else {
/* Не удалось выполнить задачу, время истекло */
}
}
}

Сделаем ещё одно предположение, допустим, мы захотели увеличить количество тяжелых и легких потоков, которые выполняются одновременно, но семафор мы уже создали. Для этого достаточно вызвать reducePermits(int reduction), нужно понимать, что этот метод только увеличивает. То есть если был семафор с permits = 3, мы вызвали reducePermits(2), то теперь у нас семафор с permits = 5. Уменьшить permits, никак нельзя.

Один важный частный случай потоков в Java -Timer

Часто нужно выполнять какие-то действия через интервал времени. Можно сделать это при помощи потока и метода sleep(). Однако существует специализированная сущность, которая уже умеет все что нам нужно — это Timer(полное имя java.util.Timer). Timer умеет запускать ваши задания на выполнение, при этом задания представляют из себя потомков другого класса TimerTask (полное имя java.util.TimerTask). Эта пара классов представляет из себя специализацию для Thread и Runnable. Причем увидеть связь TimerTask и Runnable можно сразу, так как TimerTask реализует Runnable. Связь Timer и Thread менее очевидна.

Итак, как это работает. Сначала нужно определить потомка дляTimerTask, который будет выполнять периодическое задание, например писать в лог слово Hello.

class HelloTask extends TimerTask {

public static final String TAG = HelloTask.class.getName();
/**
* Ничего нового, тот же интерфейс Runnable. (не пишите такие
* комментарии в реальном коде, ну пожалуйста)
*/
@Override
public void run() {
super.run();
Log.i(TAG, “Hello”);
}
}

Теперь нужно создать таймер.

Timer t = new Timer();

И запустить задачу на выполнение, однако тут уже появляются варианты:

        • t.schedule(new HelloTask(), new Date()) — даёт нам запуск нашего задания прямо сейчас, так как я использовал просто ‘new Date()’, можно определить, что-то по конкретней, Но суть вызова — можно назначить момент единственного исполнения.
        • t.schedule(new HelloTask(), new Date(), 1000) — от сего момента, и каждую секунду(1000 миллисекунд). Сей момент можно определить любой. Тут важно понимать, что 1000 миллисекунд будет между удачными заверениями заданий, то есть если задание “опоздало”, например из-за вызова GC, то следующее тоже сдвинется по времени. У этого метода есть напарник, который вызывает задания именно через фиксированное время, не глядя на то, завершилось предыдущее или нет — это t.scheduleAtFixedRate(new HelloTask(), new Date(), 1000).
        • t.schedule(new HelloTask(), 60 * 1000) — как первый, но задается задержка в миллисекундах, в нашем случае — минута.
        • t.schedule(new HelloTask(), 60 * 1000, 1000) — похож на второй метод, но смещение для перовго вызова задания задается в миллисекундах а не типом Date. Аналогично предыдущему периодическому таймеру имеет напарника, который вызывает именно через интервалы от начала, а не от конца выполнения метода — это scheduleAtFixedRate, сигнатура аналогичная базовому методу.

Чтобы остановить таймер нужно вызвать cancel(), вызвать его можно один раз, посе этого таймер больше не работает.

Не стоит разводить много таймеров в одной программе, чаще всего это приводит к труднообнаружимым ошибкам. Мы вас предупредили!

В завершение скажу, что пользоваться многопоточностью и особенно синхронизацией нужно аккуратно, так как можно получить взаимное блокирование потоков. Чаще всего взаимное блокирование имеет корни в архитектуре приложения, а значит исправить его будет не просто, поэтому нужно писать сразу правильно и не пытаться синхронизировать слишком много разных участков кода без особой нужды. Обычно более 2 семафоров в одном классе — признак того, что что-то идет не так.

Всем удачи, продолжение следует…

Материал предоставлен компанией Softeq Development, FLLC

Java Gym: Многопоточность в Android и Java, часть 2: синхронизация потоков в Java

Рейтинг
( Пока оценок нет )
webnewsite.ru / автор статьи
Загрузка ...

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: