Поиск…


Pitfall: неправильное использование wait () / notify ()

Методы object.wait() , object.notify() и object.notifyAll() предназначены для использования очень определенным образом. (см. http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )

Проблема «потерянного оповещения»

Одна общая ошибка начинающего заключается в том, чтобы безоговорочно вызвать object.wait()

private final Object lock = new Object();

public void myConsumer() {
    synchronized (lock) {
        lock.wait();     // DON'T DO THIS!!
    }
    doSomething();
}

Причина, по которой это неверно, заключается в том, что в зависимости от какого-то другого потока можно вызвать lock.notify() или lock.notifyAll() , но ничто не гарантирует, что другой поток не сделал этот вызов перед потребительским потоком, называемым lock.wait() .

lock.notify() и lock.notifyAll() ничего не делать вообще , если какой -либо другой поток уже не дожидаясь уведомления. Поток, который вызывает myConsumer() в этом примере, будет зависать вечно, если слишком поздно поймать уведомление.

Ошибка «Недопустимое состояние монитора»

Если вы вызываете wait() или notify() на объект, не удерживая блокировку, тогда JVM будет IllegalMonitorStateException .

public void myConsumer() {
    lock.wait();      // throws exception
    consume();
}

public void myProducer() {
    produce();
    lock.notify();    // throws exception
}

(Конструкция wait() / notify() требует блокировки, потому что это необходимо для того, чтобы избежать системных условий гонки. Если бы можно было вызвать wait() или notify() без блокировки, тогда было бы невозможно реализовать основной пример использования этих примитивов: ожидание возникновения условия.)

Ожидание / уведомление слишком низкоуровневое

Лучший способ избежать проблем с wait() и notify() - не использовать их. Большинство проблем синхронизации можно решить, используя объекты синхронизации более высокого уровня (очереди, барьеры, семафоры и т. Д.), Которые доступны в пакете java.utils.concurrent .

Pitfall - расширение 'java.lang.Thread'

В javadoc для класса Thread показаны два способа определения и использования потока:

Использование пользовательского класса потоков:

 class PrimeThread extends Thread {
     long minPrime;
     PrimeThread(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeThread p = new PrimeThread(143);
 p.start();

Использование Runnable :

 class PrimeRun implements Runnable {
     long minPrime;
     PrimeRun(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeRun p = new PrimeRun(143);
 new Thread(p).start();

(Источник: java.lang.Thread javadoc .)

Подход к пользовательскому потоку классов работает, но у него есть несколько проблем:

  1. PrimeThread использовать PrimeThread в контексте, который использует классический пул потоков, исполнитель или инфраструктуру ForkJoin. (Это невозможно, потому что PrimeThread косвенно реализует Runnable , но использование пользовательского класса Thread как Runnable , безусловно, неудобно и может быть нецелесообразным ... в зависимости от других аспектов класса.)

  2. В других методах есть больше возможностей для ошибок. Например, если вы объявили PrimeThread.start() без делегирования Thread.start() , вы получите «поток», который запускался в текущем потоке.

Подход к тому, чтобы логика потока в Runnable избегала этих проблем. Действительно, если вы используете анонимный класс (Java 1.1 onwards) для реализации Runnable результат будет более кратким и более читаемым, чем приведенные выше примеры.

 final long minPrime = ...
 new Thread(new Runnable() {
     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }.start();

С выражением лямбда (Java 8 и далее) вышеупомянутый пример станет еще более элегантным:

 final long minPrime = ...
 new Thread(() -> {
    // compute primes larger than minPrime
     . . .
 }).start();

Pitfall - Слишком много потоков делает приложение медленнее.

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

  • Компьютер имеет фиксированное количество ядер (или гиперпотоков ).
  • Для запуска Java-потока необходимо назначить ядро или гиперпоточность.
  • Если есть больше запущенных потоков Java, чем (доступных) ядер / гиперпотоков, некоторые из них должны ждать.

Это говорит о том, что простое создание все большего количества потоков Java не может заставить приложение работать быстрее и быстрее. Но есть и другие соображения:

  • Для каждого потока требуется область памяти без кучи для стека потока. Типичный (по умолчанию) размер стека потоков составляет 512 Кбайт или 1 Мбайт. Если у вас есть значительное количество потоков, использование памяти может быть значительным.

  • Каждый активный поток будет ссылаться на несколько объектов в куче. Это увеличивает рабочий набор достижимых объектов, что влияет на сбор мусора и на использование физической памяти.

  • Накладные расходы на переключение между потоками нетривиальны. Обычно это приводит к переключению в пространство ядра ОС, чтобы принять решение о планировании потоков.

  • Накладные расходы на синхронизацию потоков и межпоточную сигнализацию (например, wait (), notify () / notifyAll) могут быть значительными.

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

Если ваше приложение создается для каждой новой задачи, неожиданное увеличение рабочей нагрузки (например, высокая скорость запроса) может привести к катастрофическому поведению.

Лучший способ справиться с этим - использовать ограниченный пул потоков, размер которого вы можете контролировать (статически или динамически). Когда требуется слишком много работы, приложение должно поставить в очередь запросы. Если вы используете ExecutorService , он позаботится об управлении пулом потоков и очереди задач.

Pitfall - создание темы относительно дорого

Рассмотрим эти два микро-теста:

Первый тест просто создает, запускает и соединяет потоки. Runnable потока не работает.

public class ThreadTest {
    public static void main(String[] args) throws Exception {
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Thread t = new Thread(new Runnable() {
                        public void run() {
                }});
                t.start();
                t.join();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ThreadTest 
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C

На типичном современном ПК под управлением Linux с 64-битным Java 8 u101 этот тест показывает среднее время, затрачиваемое на создание, запуск и объединение потоков между 33,6 и 33,9 микросекундами.

Второй контрольный показатель эквивалентен первому, но с использованием ExecutorService для отправки задач и Future для рандеву с окончанием задачи.

import java.util.concurrent.*;

public class ExecutorTest {
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newCachedThreadPool();
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Future<?> future = exec.submit(new Runnable() {
                    public void run() {
                    }
                });
                future.get();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C

Как вы можете видеть, средние значения составляют от 5,3 до 5,6 микросекунд.

Хотя фактическое время будет зависеть от множества факторов, разница между этими двумя результатами значительна. Очевидно, что быстрее использовать пул потоков, чтобы перерабатывать потоки, чем создавать новые потоки.

Pitfall: общие переменные требуют правильной синхронизации.

Рассмотрим этот пример:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

Цель этой программы состоит в том, чтобы запустить поток, позволить ему работать в течение 1000 миллисекунд, а затем заставить его остановиться, установив флаг stop .

Будет ли он работать по назначению?

Может быть да, может быть нет.

Приложение не обязательно останавливается, когда возвращается main метод. Если был создан другой поток, и этот поток не был помечен как поток демона, приложение продолжит работу после окончания основного потока. В этом примере это означает, что приложение будет продолжать работать до тех пор, пока дочерний поток не закончится. Это должно tt.stop если для параметра tt.stop установлено значение true .

Но это на самом деле не совсем верно. На самом деле, ребенок нить остановится после того, как он заметил stop со значением true . Это произойдет? Может быть да, может быть нет.

Спецификация языка Java гарантирует, что чтение и запись в памяти в потоке видимы для этого потока в соответствии с порядком инструкций в исходном коде. Однако, в общем, это НЕ гарантируется, когда один поток пишет, а другой поток (впоследствии) читает. Чтобы получить гарантированную видимость, должна существовать цепочка событий - до отношений между записью и последующим чтением. В приведенном выше примере для обновления флажка stop нет такой цепочки, и поэтому не гарантируется, что дочерний поток увидит, что stop изменится на true .

(Примечание для авторов: должна быть отдельная Тема на модели памяти Java, чтобы войти в глубокие технические детали.)

Как мы исправим проблему?

В этом случае есть два простых способа убедиться, что обновление stop видимо:

  1. Объявить stop чтобы она была volatile ; т.е.

     private volatile boolean stop = false;
    

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

  2. Используйте мьютекс для синхронизации следующим образом:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

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

Но не является ли атомарным присвоением?

Да, это!

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

Почему они это сделали?

Программисты, делающие многопоточное программирование в Java, впервые найдут модель памяти сложной задачей. Программы ведут себя неинтуитивно, потому что естественное ожидание состоит в том, что записи видны равномерно. Итак, почему дизайнеры Java проектируют модель памяти таким образом.

Это фактически сводится к компромиссу между производительностью и простотой использования (для программиста).

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

Это несоответствие устраняется путем реализации одного или нескольких уровней кэширования памяти между процессорными ядрами и основной памятью. Каждый ядро ​​доступа к ячейкам памяти через его кеш. Обычно чтение основной памяти происходит только в случае промаха в кеше, и запись основной памяти происходит только тогда, когда необходимо очистить строку кэша. Для приложения, в котором рабочий набор каждого ядра памяти помещается в его кеш, скорость ядра больше не ограничена основной скоростью / пропускной способностью памяти.

Но это дает нам новую проблему, когда несколько ядер считывают и записывают общие переменные. Последняя версия переменной может находиться в кеше одного ядра. Если это ядро ​​не сбрасывает строку кэша в основную память, а другие ядра аннулируют свою кешированную копию старых версий, некоторые из них могут видеть устаревшие версии переменной. Но если кеши были сброшены в память каждый раз, когда есть запись в кеш («на всякий случай» был прочитан другим ядром), который будет излишне потреблять пропускную способность основной памяти.

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

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

Это имеет значительные преимущества в производительности для многопоточных приложений, но недостатком является то, что писать правильные многопоточные приложения не так просто. Программист не должен понять , что он или она делает.

Почему я не могу воспроизвести это?

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

  1. Как объяснялось выше, следствие того, что проблема с видимостью памяти не связана с проблемами, обычно заключается в том, что ваше скомпилированное приложение неправильно обрабатывает кеширование памяти. Однако, как мы уже говорили выше, кэши памяти часто сбрасываются.

  2. При изменении аппаратной платформы характеристики кэшей памяти могут измениться. Это может привести к разному поведению, если ваше приложение не синхронизируется правильно.

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

  4. Запуск приложения под отладчиком приводит к тому, что компилятор JIT компилируется по-разному. Точки останова и однократный шаг усугубляют это. Эти эффекты часто изменят поведение приложения.

Эти вещи делают ошибки, которые из-за неадекватной синхронизации особенно трудно решить.



Modified text is an extract of the original Stack Overflow Documentation
Лицензировано согласно CC BY-SA 3.0
Не связан с Stack Overflow