Цикл не видит значение, измененное другим потоком без оператора печати

91

В моем коде есть цикл, который ожидает изменения какого-либо состояния из другого потока. Другой поток работает, но мой цикл никогда не видит измененное значение. Он ждет вечно. Однако, когда я помещаю System.out.printlnоператор в цикл, он внезапно срабатывает! Зачем?


Ниже приведен пример моего кода:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (pizzaArrived == false) {
            //System.out.println("waiting");
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        pizzaArrived = true;
    }
}

Пока выполняется цикл while, я вызываю deliverPizza()из другого потока, чтобы установить pizzaArrivedпеременную. Но цикл работает только тогда, когда я раскомментирую System.out.println("waiting");утверждение. В чем дело?

Боанн
источник

Ответы:

152

JVM разрешено предполагать, что другие потоки не изменяют pizzaArrivedпеременную во время цикла. Другими словами, он может поднять pizzaArrived == falseтест за пределы цикла, оптимизируя это:

while (pizzaArrived == false) {}

в это:

if (pizzaArrived == false) while (true) {}

который представляет собой бесконечный цикл.

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

volatile boolean pizzaArrived = false;

Создание переменной volatileгарантирует, что разные потоки увидят влияние изменений в ней друг друга. Это не позволяет JVM кэшировать значение pizzaArrivedили поднимать тест за пределы цикла. Вместо этого он должен каждый раз читать значение реальной переменной.

(Более формально volatileсоздает связь « происходит до» между обращениями к переменной. Это означает, что вся остальная работа, выполненная потоком перед доставкой пиццы, также видна потоку, получающему пиццу, даже если эти другие изменения не относятся к volatileпеременным.)

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

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (getPizzaArrived() == false) {}
        System.out.println("That was delicious!");
    }

    synchronized boolean getPizzaArrived() {
        return pizzaArrived;
    }

    synchronized void deliverPizza() {
        pizzaArrived = true;
    }
}

Эффект от печати заявления

System.outэто PrintStreamобъект. Методы PrintStreamсинхронизируются следующим образом:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

Синхронизация предотвращает pizzaArrivedкеширование во время цикла. Строго говоря, оба потока должны синхронизироваться на одном и том же объекте, чтобы гарантировать, что изменения переменной видны. (Например, вызов printlnпосле установки pizzaArrivedи повторный вызов перед чтением pizzaArrivedбудет правильным.) Если только один поток синхронизируется с конкретным объектом, JVM разрешено игнорировать его. На практике JVM недостаточно умен, чтобы доказать, что другие потоки не будут вызывать printlnпосле установки pizzaArrived, поэтому предполагается, что они могут. Следовательно, он не может кэшировать переменную во время цикла, если вы вызываете System.out.println. Вот почему такие циклы работают, когда у них есть оператор печати, хотя это неправильное исправление.

Использование System.out- не единственный способ вызвать этот эффект, но это тот, который люди обнаруживают чаще всего, когда пытаются отладить, почему их цикл не работает!


Большая проблема

while (pizzaArrived == false) {}это цикл "занято-ожидание". Это плохо! Пока он ожидает, он загружает процессор, что замедляет работу других приложений и увеличивает потребление энергии, температуру и скорость вращения вентилятора системы. В идеале мы хотели бы, чтобы поток цикла находился в режиме ожидания, пока он ожидает, чтобы он не загружал процессор.

Вот несколько способов сделать это:

Использование ожидания / уведомления

Низкоуровневое решение - использовать методы ожидания / уведомленияObject :

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        synchronized (this) {
            while (!pizzaArrived) {
                try {
                    this.wait();
                } catch (InterruptedException e) {}
            }
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        synchronized (this) {
            pizzaArrived = true;
            this.notifyAll();
        }
    }
}

В этой версии кода вызывается поток цикла wait(), который переводит поток в спящий режим. Во время сна он не будет использовать циклы ЦП. После того, как второй поток устанавливает переменную, он вызывает notifyAll()пробуждение любых / всех потоков, которые ожидали этого объекта. Это похоже на то, как если бы пицца звонила в дверь, чтобы вы могли сесть и отдохнуть в ожидании, вместо того, чтобы неловко стоять у двери.

При вызове wait / notify для объекта вы должны удерживать блокировку синхронизации этого объекта, что и делает приведенный выше код. Вы можете использовать любой объект, который вам нравится, если оба потока используют один и тот же объект: здесь я использовал this(экземпляр MyHouse). Обычно два потока не могут одновременно вводить синхронизированные блоки одного и того же объекта (что является частью цели синхронизации), но здесь это работает, потому что поток временно снимает блокировку синхронизации, когда он находится внутри wait()метода.

BlockingQueue

A BlockingQueueиспользуется для реализации очередей производитель-потребитель. «Потребители» берут товары в начале очереди, а «производители» проталкивают товары сзади. Пример:

class MyHouse {
    final BlockingQueue<Object> queue = new LinkedBlockingQueue<>();

    void eatFood() throws InterruptedException {
        // take next item from the queue (sleeps while waiting)
        Object food = queue.take();
        // and do something with it
        System.out.println("Eating: " + food);
    }

    void deliverPizza() throws InterruptedException {
        // in producer threads, we push items on to the queue.
        // if there is space in the queue we can return immediately;
        // the consumer thread(s) will get to it later
        queue.put("A delicious pizza");
    }
}

Примечание. Методы putи могут генерировать s, которые являются отмеченными исключениями, которые необходимо обработать. В приведенном выше коде для простоты исключения повторно генерируются. Вы можете предпочесть перехватить исключения в методах и повторить вызов put или take, чтобы убедиться, что он завершится успешно. Не считая этого уродства, пользоваться им очень просто.takeBlockingQueueInterruptedExceptionBlockingQueue

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

Исполнители

Executors похожи на готовые BlockingQueues, которые выполняют задачи. Пример:

// A "SingleThreadExecutor" has one work thread and an unlimited queue
ExecutorService executor = Executors.newSingleThreadExecutor();

Runnable eatPizza = () -> { System.out.println("Eating a delicious pizza"); };
Runnable cleanUp = () -> { System.out.println("Cleaning up the house"); };

// we submit tasks which will be executed on the work thread
executor.execute(eatPizza);
executor.execute(cleanUp);
// we continue immediately without needing to wait for the tasks to finish

Более подробную информацию см в док для Executor, ExecutorServiceи Executors.

Обработка событий

Зацикливание в ожидании, пока пользователь что-то щелкнет в пользовательском интерфейсе, неправильно. Вместо этого используйте функции обработки событий набора инструментов пользовательского интерфейса. В Swing , например:

JLabel label = new JLabel();
JButton button = new JButton("Click me");
button.addActionListener((ActionEvent e) -> {
    // This event listener is run when the button is clicked.
    // We don't need to loop while waiting.
    label.setText("Button was clicked");
});

Поскольку обработчик событий работает в потоке отправки событий, длительная работа в обработчике событий блокирует другое взаимодействие с пользовательским интерфейсом, пока работа не будет завершена. Медленные операции могут быть запущены в новом потоке или отправлены в ожидающий поток с помощью одного из вышеуказанных методов (ожидание / уведомление, a BlockingQueueили Executor). Вы также можете использовать SwingWorker, который предназначен именно для этого и автоматически предоставляет фоновый рабочий поток:

JLabel label = new JLabel();
JButton button = new JButton("Calculate answer");

// Add a click listener for the button
button.addActionListener((ActionEvent e) -> {

    // Defines MyWorker as a SwingWorker whose result type is String:
    class MyWorker extends SwingWorker<String,Void> {
        @Override
        public String doInBackground() throws Exception {
            // This method is called on a background thread.
            // You can do long work here without blocking the UI.
            // This is just an example:
            Thread.sleep(5000);
            return "Answer is 42";
        }

        @Override
        protected void done() {
            // This method is called on the Swing thread once the work is done
            String result;
            try {
                result = get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
            label.setText(result); // will display "Answer is 42"
        }
    }

    // Start the worker
    new MyWorker().execute();
});

Таймеры

Для выполнения периодических действий вы можете использовать файл java.util.Timer. Его проще использовать, чем писать собственный цикл отсчета времени, и его проще запускать и останавливать. Эта демонстрация печатает текущее время один раз в секунду:

Timer timer = new Timer();
TimerTask task = new TimerTask() {
    @Override
    public void run() {
        System.out.println(System.currentTimeMillis());
    }
};
timer.scheduleAtFixedRate(task, 0, 1000);

У каждого java.util.Timerесть свой фоновый поток, который используется для выполнения запланированных TimerTasks. Естественно, поток «спит» между задачами, поэтому он не загружает процессор.

В коде Swing также javax.swing.Timerесть похожий элемент , но он выполняет прослушиватель в потоке Swing, поэтому вы можете безопасно взаимодействовать с компонентами Swing без необходимости вручную переключать потоки:

JFrame frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
Timer timer = new Timer(1000, (ActionEvent e) -> {
    frame.setTitle(String.valueOf(System.currentTimeMillis()));
});
timer.setRepeats(true);
timer.start();
frame.setVisible(true);

Другие способы

Если вы пишете многопоточный код, стоит изучить классы в этих пакетах, чтобы узнать, что доступно:

Также см. Раздел «Параллелизм» в руководствах по Java. Многопоточность - это сложно, но есть много помощи!

Боанн
источник
Очень профессиональный ответ, после прочтения этого у меня не осталось заблуждений, спасибо
Хумоюн Ахмад
1
Отличный ответ. Я довольно долго работаю с потоками Java и все же кое-что узнал ( wait()снимает блокировку синхронизации!).
brimborium
Спасибо, Боанн! Отличный ответ, это как полная статья с примерами! Да, еще понравился «wait () снимает блокировку синхронизации»
Кирилл Иванов
java public class ThreadTest { private static boolean flag = false; private static class Reader extends Thread { @Override public void run() { while(flag == false) {} System.out.println(flag); } } public static void main(String[] args) { new Reader().start(); flag = true; } } @Boann, этот код не поднимает pizzaArrived == false тест за пределы цикла, и цикл может видеть флаг, измененный основным потоком, почему?
gaussclb
1
@gaussclb Если вы имеете в виду, что вы декомпилировали файл класса, поправьте. Компилятор Java почти не оптимизирует. Подъем выполняется JVM. Вам нужно разобрать собственный машинный код. Попробуйте: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann