Нечетное время/память в Java 8

Я столкнулся с довольно странной проблемой, которую я могу создать при запуске Java 8. Проблема проявляется так, как будто в самой JVM происходит какая-то временная ошибка. Он носит прерывистый характер, но легко воспроизводим (по крайней мере, в моих тестовых средах). Проблема в том, что явно установленное значение массива уничтожается и заменяется на 0.0 при определенных обстоятельствах. В частности, в приведенном ниже коде array[0] оценивается как 0,0 после строки new Double(r.nextDouble());. Затем, если вы немедленно снова посмотрите на содержимое array[0], оно теперь показывает, что значение является правильным значением 1,0. Пример вывода при выполнении этого тестового примера:

claims array[0] != 1.0....array[0] = 1.0
claims array[0] now == 1.0...array[0] = 1.0`

Я использую 64-битную Windows 7 и могу воспроизвести эту проблему как из Eclipse, так и при компиляции из командной строки с JDK 1.8_45, 1.8_51 и 1.8_60. Я не могу создать проблему с запуском 1.7_51. Те же результаты были продемонстрированы на другой машине с 64-битной Windows 7.

Эта проблема возникла в большой, нетривиальной части программного обеспечения, но мне удалось сократить ее до нескольких строк кода. Ниже приведен небольшой тестовый пример, демонстрирующий проблему. Это довольно странно выглядящий тестовый пример, но кажется, что он необходим для возникновения ошибки. Использование Random не требуется — я могу заменить все r.nextDouble() любым двойным значением и продемонстрировать проблему. Интересно, что если someArray[0] = .45; заменить на someArray[0] = r.nextDouble();, я не смог воспроизвести проблему (хотя в .45 нет ничего особенного). Отладка Eclipse также бесполезна - она ​​меняет время настолько, что этого больше не происходит. Даже правильно размещенное заявление System.err.println() приведет к тому, что проблема больше не будет появляться.

Опять же, проблема носит периодический характер, поэтому для ее воспроизведения может потребоваться запустить этот тестовый пример несколько раз. Я думаю, что максимум, что мне пришлось запускать, это около 10 раз, прежде чем я получу вывод, показанный выше. В Eclipse я даю ему секунду или две после запуска, а затем убиваю его, если этого не произошло. Из командной строки тоже самое - запустить, если не получается CTRL+C выйти и попробовать еще раз. Оказывается, если это и произойдет, то довольно быстро.

Я сталкивался с подобными проблемами в прошлом, но все они были связаны с потоками. Я не могу понять, что здесь происходит - я даже посмотрел на байт-код (который, кстати, был идентичен между 1.7_51 и 1.8_45).

Любые идеи о том, что здесь происходит?

import java.util.Random;

public class Test { 
    Test(){
        double array[] = new double[1];     
        Random r = new Random();

        while(true){
            double someArray[] = new double[1];         
            double someArray2 [] = new double [2];

            for(int i = 0; i < someArray2.length; i++) {
                someArray2[i] = r.nextDouble();
            }

            // for whatever reason, using r.nextDouble() here doesn't seem
            // to show the problem, but the # you use doesn't seem to matter either...

            someArray[0] = .45;

            array[0] = 1.0;

            // commented out lines also demonstrate problem
            new Double(r.nextDouble());
            // new Float(r.nextDouble();
            // double d = new Double(.1) * new Double(.3);
            // double d = new Double(.1) / new Double(.3);
            // double d = new Double(.1) + new Double(.3);
            // double d = new Double(.1) - new Double(.3);

            if(array[0] != 1.0){
                System.err.println("claims array[0] != 1.0....array[0] = " + array[0]);

                if(array[0] != 1.0){
                    System.err.println("claims array[0] still != 1.0...array[0] = " + array[0]);
                }else {
                    System.err.println("claims array[0] now == 1.0...array[0] = " + array[0]);
                }

                System.exit(0);
            }else if(r.nextBoolean()){
                array = new double[1];
            }
        }
    }

    public static void main(String[] args) {
        new Test();
    }
}

person bcothren    schedule 07.10.2015    source источник
comment
ты проверял на плохую память?   -  person wero    schedule 07.10.2015
comment
Я не могу воспроизвести это. Здесь работает как положено.   -  person marstran    schedule 07.10.2015
comment
double по своей сути не является точным. Вы уверены, что это не ваша проблема?   -  person PM 77-1    schedule 07.10.2015
comment
@wero маловероятно, что у нескольких машин плохая память (я могу воспроизвести на 3 разных 64-битных машинах Win7). @marstran твоя установка, как я описал? @ PM 77-1 да, но это не должно влиять на оценку if.   -  person bcothren    schedule 07.10.2015
comment
@bcothren маловероятно, но все же возможно   -  person wero    schedule 07.10.2015
comment
@wero правда, я проверю память   -  person bcothren    schedule 07.10.2015
comment
Я предлагаю вам зарегистрировать ошибку в JDK, если вы можете воспроизвести это. Я могу только догадываться, что это как-то связано с запуском JIT. Из интереса: нужен ли new Double(...)? Это то, чего я не ожидал найти в реальном коде. @ PM77-1: Сохранение интегрального значения 1.0 в double никогда не должно приводить к таким проблемам, поскольку его можно представить без потери точности. Если == дает ложные результаты, я скорее ожидаю, что по какой-то причине используется коробочное значение (из-за ошибки в JVM).   -  person Axel    schedule 07.10.2015
comment
@Axel в реальном коде на самом деле выполнял некоторые арифметические действия, я просто свел их к созданию нового Double в попытке придумать простейший тестовый пример. Вы можете воспроизвести проблему? Может быть преждевременно подавать отчет об ошибке?   -  person bcothren    schedule 07.10.2015
comment
Я могу воспроизвести это (oracle java 1.8.0_60 на 64-битной Linux-машине).   -  person Roman    schedule 07.10.2015
comment
@bcothren Я застрял на JDK 7 на работе...   -  person Axel    schedule 07.10.2015
comment
@Axel, ваш комментарий о JIT имеет смысл. Я собираюсь попытаться воспроизвести проблему после отключения JIT.   -  person bcothren    schedule 07.10.2015
comment
Похоже на stackoverflow.com/a/32986956/5032339 (также не должно возникать проблем). После того, как я исключил ваш код из JIT-компиляции, он заработал нормально.   -  person Roman    schedule 07.10.2015
comment
Двойники @ PM77-1 могут быть неточными, но 1.0d == 1.0d все равно должно быть правдой. Всегда.   -  person assylias    schedule 07.10.2015
comment
Должно быть, потребовалось много работы, чтобы отследить проблему от производственного кода до этого небольшого примера…   -  person Holger    schedule 07.10.2015
comment
@ PM77-1 Существует значительное количество целых чисел, которые могут быть представлены без ошибки округления в двойном значении, так что == работает без проблем.   -  person laune    schedule 07.10.2015
comment
@Axel @Roman Отключение JIT (я сделал большой шаг и просто использовал -Xint) действительно устраняет проблему. Мне не нравится это как решение, но, по крайней мере, это причина проблемы.   -  person bcothren    schedule 07.10.2015
comment
Решение, как сказал Аксель, состояло бы в том, чтобы зарегистрировать ошибку.   -  person Roman    schedule 07.10.2015
comment
@ Роман, я так и сделаю. Я действительно имел в виду решение для нашего программного обеспечения...   -  person bcothren    schedule 07.10.2015
comment
Это определенно ошибка оптимизации JIT. -XX:-TieredCompilation или -XX:-EliminateAllocations, скорее всего, будет приемлемым обходным решением, которое не приведет к значительному снижению производительности.   -  person apangin    schedule 07.10.2015


Ответы (2)


Обновление: кажется, что мой первоначальный ответ был неверным, и OnStackReplacement только что выявил проблему в этом конкретном случае, но исходная ошибка была в коде анализа побега. Escape-анализ — это подсистема компилятора, которая определяет, экранируется ли объект данным методом или нет. Неэкранированные объекты могут быть масштабированы (вместо выделения в куче) или полностью оптимизированы. В нашем тесте анализ побега имеет значение, так как несколько созданных объектов наверняка не ускользнут от метода.

Я скачал и установил JDK 9 для раннего доступа, сборка 83 и заметил, что ошибка исчезла. Однако в сборке 82 раннего доступа JDK 9 он все еще существует. журнал изменений между b82 и b83 показывает только одно соответствующее исправление ошибки (поправьте меня, если я ошибаюсь): JDK-8134031 "Неправильная JIT-компиляция сложного кода с встраиванием и анализом экранирования". Зафиксированный тестовый набор чем-то похож: большой цикл, несколько ящиков (аналогично одноэлементным массивам в нашем тесте), которые приводят к внезапному изменению значения внутри ящика, поэтому результат становится молча неверным (ни сбоев, ни исключений, просто неверное значение ). Как и в нашем случае сообщается, что проблема не появляется до 8u40. введено исправление очень короткий: изменение всего одной строки в исходном коде escape-анализа.

Согласно системе отслеживания ошибок OpenJDK, исправление уже перенесено в ветку JDK 8u72, выпуск которого запланирован на январь 2016 года. было слишком поздно переносить это исправление в готовящуюся версию 8u66.

Предлагаемый обходной путь — отключить анализ выхода (-XX:-DoEscapeAnalysis) или отключить оптимизацию устранения выделений (-XX:-EliminateAllocations). Таким образом, @apangin на самом деле был ближе к ответу, чем я.

Ниже оригинальный ответ


Во-первых, я не могу воспроизвести проблему с JDK 8u25, но могу с JDK 8u40 и 8u60: иногда он работает правильно (зависает в бесконечном цикле), иногда выводит и завершает работу. Поэтому, если для вас приемлемо понижение версии JDK до 8u25, вы можете подумать об этом. Обратите внимание, что если вам нужны более поздние исправления в javac (многие вещи, особенно связанные с лямбда-выражениями, были исправлены в 1.8u40), вы можете скомпилировать с более новым javac, но запустить на более старой JVM.

Мне кажется, что эта конкретная проблема, вероятно, является ошибкой в ​​OnStackReplacement механизм (когда ЛРН происходит на уровне 4). Если вы не знакомы с OSR, вы можете прочитать этот ответ. OSR, безусловно, происходит в вашем случае, но немного странным образом. Вот -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+TraceNMethodInstalls для неудачного запуска (% означает OSR JIT, @ 28 означает позицию байт-кода OSR, (3) и (4) означает уровень уровня):

...
     91   37 %     3       Test::<init> @ 28 (194 bytes)
Installing osr method (3) Test.<init>()V @ 28
     93   38       3       Test::<init> (194 bytes)
Installing method (3) Test.<init>()V 
     94   39 %     4       Test::<init> @ 16 (194 bytes)
Installing osr method (4) Test.<init>()V @ 16
    102   40 %     4       Test::<init> @ 28 (194 bytes)
    103   39 %     4       Test::<init> @ -2 (194 bytes)   made not entrant
...
Installing osr method (4) Test.<init>()V @ 28
    113   37 %     3       Test::<init> @ -2 (194 bytes)   made not entrant
claims array[0] != 1.0....array[0] = 1.0
claims array[0] now == 1.0...array[0] = 1.0

Таким образом, OSR на уровне 4 возникает для двух разных смещений байт-кода: смещения 16 (которое является точкой входа в цикл while) и смещения 28 (которое является точкой входа вложенного цикла for). Похоже, что во время передачи контекста между обеими версиями вашего метода, скомпилированными OSR, возникает некоторое состояние гонки, что приводит к нарушению контекста. Когда выполнение передается методу OSR, он должен передать текущий контекст, включая значения локальных переменных, таких как array и r, в метод OSR. Тут происходит что-то нехорошее: видимо недолго работает <init>@16 версия OSR, потом ее заменяют на <init>@28, но контекст обновляется с небольшой задержкой. Вполне вероятно, что передача контекста OSR мешает оптимизации «устранения выделений» (как отметил @apangin, отключение этой оптимизации помогает в вашем случае). Моего опыта не хватает, чтобы копать здесь дальше, возможно, @apangin может прокомментировать.

В противоположность этому при обычном запуске создается и устанавливается только одна копия метода OSR уровня 4:

...
Installing method (3) Test.<init>()V 
     88   43 %     4       Test::<init> @ 28 (194 bytes)
Installing osr method (4) Test.<init>()V @ 28
    100   40 %     3       Test::<init> @ -2 (194 bytes)   made not entrant
   4592   44       3       java.lang.StringBuilder::append (8 bytes)
...

Получается, что в этом случае никакой гонки между двумя версиями OSR не происходит и все работает отлично.

Проблема также исчезнет, ​​если вы переместите тело внешнего цикла в отдельный метод:

import java.util.Random;

public class Test2 {
    private static void doTest(double[] array, Random r) {
        double someArray[] = new double[1];
        double someArray2[] = new double[2];

        for (int i = 0; i < someArray2.length; i++) {
            someArray2[i] = r.nextDouble();
        }

        ... // rest of your code
    }

    Test2() {
        double array[] = new double[1];
        Random r = new Random();

        while (true) {
            doTest(array, r);
        }
    }

    public static void main(String[] args) {
        new Test2();
    }
}

Также ручное развертывание вложенного цикла for устраняет ошибку:

int i=0;
someArray2[i++] = r.nextDouble();
someArray2[i++] = r.nextDouble();

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

Альтернативное решение — полностью отключить OSR с помощью -XX:-UseOnStackReplacement. Это редко помогает в производственном коде. Счетчики циклов все еще работают, и если ваш метод с многократным циклом вызывается как минимум дважды, второй запуск все равно будет JIT-компилирован. Кроме того, даже если ваш метод с длинным циклом не скомпилирован JIT из-за отключенного OSR, любые методы, которые он вызывает, все равно будут скомпилированы JIT.

person Tagir Valeev    schedule 08.10.2015
comment
Отличная работа. Пожалуйста, включите это в отчет об ошибке, так как это может помочь разработчикам JDK устранить проблему. Я бы поставил +2, если бы мог... :-) - person Axel; 08.10.2015
comment
Да, замена в стеке помогает, если у вас есть большие длительные методы, код которых важен для производительности, что является шаблоном, который соответствует не типичному коду приложения, а типичному коду искусственного теста. - person Holger; 08.10.2015
comment
Вау, отличная работа! Я уже отправил отчет об ошибке, и он все еще находится на рассмотрении. Предполагая, что я могу добавить к нему информацию, когда/если он будет принят, я обязательно включу это. Еще раз спасибо! - person bcothren; 12.10.2015
comment
@bcothren, я отредактировал ответ. Похоже проблема была несколько в другом и она уже исправлена. - person Tagir Valeev; 13.10.2015

Я могу воспроизвести эту ошибку в Zulu (сертифицированная сборка OpenJDK) с кодом, размещенным по адресу http://www.javaspecialists.eu/archive/Issue234.html

С виртуальной машиной Oracle я могу воспроизвести эту ошибку только после запуска кода в Zulu. Похоже, Zulu загрязняет общий кэш поиска. Решением в этом случае является запуск кода с параметром -XX:-EnableSharedLookupCache.

person Wim De Rammelaere    schedule 01.12.2015
comment
У Azul есть 2 JVM Zulu и Zing. Судя по предоставленной вами ссылке (которая не работает), кажется, что вы имеете в виду Zulu, а не Zing. Zulu — это сборка OpenJDK, полностью состоящая из кода OpenJDK, но протестированная и поддерживаемая. Он должен демонстрировать такое же поведение для сопоставимой версии. Zing — это совсем другой зверь. - person Nitsan Wakart; 08.12.2016