Проблем със странно време/памет на 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
@Roman, ще го направя. Наистина имах предвид решение за нашия софтуер...   -  person bcothren    schedule 07.10.2015
comment
Това определено е грешка при оптимизиране на JIT. -XX:-TieredCompilation или -XX:-EliminateAllocations е вероятно да бъде приемливо решение, което не влошава значително производителността.   -  person apangin    schedule 07.10.2015


Отговори (2)


Актуализация: изглежда, че първоначалният ми отговор беше неправилен и OnStackReplacement току-що разкри проблема в този конкретен случай, но първоначалната грешка беше в кода за анализ на избягване. Escape анализът е подсистема на компилатор, която определя дали обектът излиза от дадения метод или не. Неекранираните обекти могат да бъдат скаларизирани (вместо разпределение на купчина) или напълно оптимизирани. В нашия тест анализът на избягване има значение, тъй като няколко създадени обекта със сигурност не избягват метода.

Изтеглих и инсталирах JDK 9 компилация 83 за ранен достъп и забелязах, че грешката изчезва там. Въпреки това в JDK 9 ранен достъп build 82 той все още съществува. дневник с промени между b82 и b83 показва само една подходяща корекция на грешка (поправете ме, ако греша): JDK-8134031 "Неправилна JIT компилация на сложен код с вграждане и escape анализ". Ангажираният тестов случай е донякъде подобен: голям цикъл, няколко кутии (подобно на едноелементни масиви в нашия тест), които водят до внезапна промяна на стойността вътре в кутията, така че резултатът става мълчаливо неправилен (без срив, без изключение, просто неправилна стойност ). Както в нашия случай се съобщава, че проблемът не се появява преди 8u40. въведена корекция е много кратко: само една промяна на ред в източника на анализ на изхода.

Според програмата за проследяване на грешки на OpenJDK, корекцията вече е репортирана към клона на JDK 8u72, който е планиран да бъде пуснат през януари 2016 г. Изглежда, че беше твърде късно да портирам тази корекция към предстоящия 8u66.

Предложеното заобиколно решение е да деактивирате escape анализа (-XX:-DoEscapeAnalysis) или да деактивирате елиминирането на оптимизацията на разпределенията (-XX:-EliminateAllocations). Така @apangin всъщност беше по-близо до отговора от мен.

По-долу е оригиналният отговор


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

За мен изглежда, че този конкретен проблем вероятно е грешка в OnStackReplacement механизъм (когато OSR се появи на ниво 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'ed метода. Тук се случва нещо лошо: вероятно за кратко време <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 VM мога да възпроизведа тази грешка само след като изпълня кода на 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