воскресенье, 15 апреля 2012 г.

Уснуть и видеть сны

Продолжаем исследовать способы выстрелить себе в ногу на Java. Ещё одна головоломка с уже известного нам сайта.

Иногда бывает такое, что я просыпаюсь только для того, чтобы понять, что на самом-то деле я всё ещё сплю. Каждый раз, когда это случается, я чувствую себя несколько не в своей тарелке. Чтобы этого избежать, я начал считать уровни рекурсии, погружаясь в сон:
public class Sleeper {
    private int level;
    public synchronized int enter(Dream dream) {
        level++;
        try {
            dream.dream(this);
        } finally {
            level--;
        }
        return level;
    }
}
Выглядит надёжно, не так ли? Погружаясь в сон, я увеличиваю счётчик. Выходя из сна, я уменьшаю его. Благодаря блоку finally я уверен, что не забуду уменьшить счётчик, даже если на каком-то уровне рекурсии очередной сон выбросит исключение. Поскольку метод объявлен synchronized, я не боюсь, что в мой сон влезет какой-то другой поток.

Теперь я со спокойной душой ложусь спать следующим образом:
public class Main {
    public static void main(String[] args) {
        if (new Sleeper().enter(new Dream()) != 0) {
            // The goal is to reach this line
            System.out.println("Am I still dreaming?");
        }
    }
}
Есть ли ошибка в моих рассуждениях? Можно ли написать такой класс Dream, что он сломает мою программу, и она напечатать помеченную строку? Изменять классы Sleeper и Main нельзя.
public class Dream {
    public void dream(Sleeper s) {
        // TODO implement me
    }
}
Действуют те же ограничения, что и в задаче про клоунов. Вкратце, нельзя использовать reflection и манипулировать байт-кодом на лету. В остальном можете писать неограниченно грязный код, при условии, конечно, что компилятор его съест.

Решение
Никакими хитростями не получится заставить класс Sleeper выйти из метода enter(), не уменьшив счётчик, а потом напечатать нужную нам строку. С этой частью рассуждений всё правильно. Слабое место - утверждение, что благодаря объявлению метода synchronized можно забыть о проблеме нескольких потоков.

Дело в том, что код внутри блока synchronized может на какое-то время освободить монитор, не выходя из блока. Достаточно вызвать метод wait(), и наш поток отправится спать, а другие потоки получат возможность захватить монитор и что-нибудь сломать.

Например, класс Dream может запустить второй поток, который сначала зайдёт в сон, увеличив счётчик, а затем вернёт управление главному потоку. Осталось сделать так, чтобы второй поток никогда не вышел из метода dream(), и дело в шляпе.
public class Dream {
    public void dream(final Sleeper s) {
        Thread t = new Thread() {
            public void run() {
                s.enter(new Dream() {
                    public void dream(Sleeper s) {
                        s.notifyAll();
                        waitQuietly(s);
                    }
                });
            }
        };
        t.setDaemon(true);
        t.start();
        
        waitQuietly(s);
    }
    
    private static void waitQuietly(Object object) {
        try {
            object.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
Чтобы избежать таких проблем, нужно прятать монитор, делая его недоступным чужому коду:
public class Sleeper {
    private final Object lock = new Object();
    private int level;
    public int enter(Dream dream) {
        synchronized (lock) {
            level++;
            try {
                dream.dream(this);
            } finally {
                level--;
            }
            return level;
        }
    }
}

1 комментарий:

  1. Я следую таким принципам:
    1. Никогда не использовать рекурсивные мьютексы. К сожалению, в Java рекурсивные мьютексы - это дефолт.
    2. Не вызывать виртуальные методы под мьютексом.

    ОтветитьУдалить