Blogikirjoitus -

Jos tykkäät monimutkaisesta, älä siirry Java 8:aan

Arto Santala, Valmennuskonsultti

Jep, provosoivaan otsikkoon vähän sisältöä. Olen jo vuoden päivät opiskellut Java 8 version tulevia muutoksia. Kyseessä on tosiaan iso pläjäys joka koodaajaa hyödyntävää tavaraa. Ne summautuvat yhteen tehokkuudeksi, sillä monissa paikoissa voi nyt tehdä koodaustyöt siten että koodirivejä syntyy vähemmän. Koodi on myös luettavampaa ja näin ollen kalliita huolimattomuusvirheitä on vähemmän. Kaiken lisäksi uudella tapaa tehty koodi voi skaalautua moniydinprosessoireissa ihan uudella tapaa. Turha odotella paria vuotta kun kaikesta tästä voi nauttia NYT. Koodaajille tätä ei ole tarpeen paljoa perustella, mutta koko projektin kannalta näkyy jo selvää rahaa kun siirtyy käyttämään fiksumpia uusin piirtein varustettuja alustoja. Jos pyörität vielä Java versioita 5 tai 6, on peiliin katsomisen aika. ;)

Java 1.8 julkaistaan näillä näkymin 18.3.2014 lopullisena versiona. Siitä on jo toimivaa Release Candidate versiota tarjolla. Jokainen kehitysympäristö tukee jo enemmän tai vähemmän sen käyttöä. Ja mikä tärkeintä, Tieturilla on tarjolla heti koulutusta jossa saat kickstartin uusiin piirteisiin ohjatusti ja vinkkejä parhaista käytännöistä ja myös sudenkuopista joita voi tulla vastaan.

Java 8 uudet piirteet (toteutus huhti- ja toukokuussa)

Tärkeimmät tulossa olevat uutuudet ovat tietysti Lambdat, joka toimii kevyenä porttihuumeena, ja sitten Streamit, joissa piilee se kova kama. Sen ohella vietellään kovasti kohden funktionaalista ohjelmointia joka on testattavampaa ja skaalautuvampaa. Vanha kunnon isoisän Javahan alkaa tässä näyttämään ihan Scalalta! Kun soppaan lisätään vielä uusi päivämääräkirjasto, joka on Jodatimen mukaan tehty ja ensimmäistä kertaa alkaa olla kohtuullisen mehukas – on aika painavat syyt päivittää itsensä ja ympäristönsä ASAP.

Tein vähän mallikoodia omaan käyttöön testatakseni piirteitä. Lähtötilanteena oli käydä läpi vähän tietokannastamme löytyvää kurssitarjontaa, ja tehdä siihen erilaisia hakuja. Otin tahallisesti laajan otoksen kaikesta mitä löytyi, joten datan laadussa on hyvin tyypillisiä heikkouksia, mm. puuttuvia kenttiä, ja tein siihen liittyvää perinteistä hyvin rumaa mutta myös hyvin tyypillistä imperatiivista koodia. Kuvaavaa on että koodin monimutkaisuuden vuoksi ehdin tehdä parikymmentä kertaa jonkin virheen joka testatessa kävi ilmi. Huolimattomuusvirheet voivat isommassa projektissa tulla äkkiä hyvin kalliiksi etenkin jos testaus ei ole automatisoitua, kattavaa, säännöllistä  ja nopeaa. Lähdin alunperin testailemaan suorituskykyä, mutta matkalla löytyikin muuta. Katsotaanpa mitä tapahtui.

Lähtötilanne: Kannasta on haettu tauludataa, joka on talletettu JSON olioihin. Pääohjelma lataa ne levyltä ja prosessoi ne läpi, tulostaen lopuksi käsitellyt tiedot. Pääohjelma on tämän näköinen:

public static void main(String[] args)
    throws IOException, JAXBException {
  List<Course> courses = loadCourses("test.json");
  long begin = System.nanoTime();
  List<Course> validPublicTrainings = filterAndFixCourses(courses);
  long end = System.nanoTime();

  // Print values out to control how it works 
  for (Course course : validPublicTrainings) { 
    System.out.println(course);
  }

  System.out.println("Number of filtered courses: " 
    + validPublicTrainings.size()); 
  System.out.println("Time spent: " + (end - begin) / 1000000.0 
    + "ms"); 
}

Melko yksinkertaista tavaraa, siis. Kaikki mehukas tapahtuu filterAndFixCourses() funktiossa, sen tehtävä on suodattaa kurssit jotka ovat julkisia, valideja, ja pistää ne päivämäärän mukaan järjestykseen. Ongelmia tuottaa, että osassa dataa arvoja puuttuu ja ne ovat null-arvoj (Kaiken tämän voisi tehdä kannassa mutta tämä ei ole kantakoodauksen artikkeli ;) ).

Tältä näyttää perinteiseen imperatiiviseen tyyliin kirjoitettu, varsin ruma ja optimoimaton (mutta myös varsin tyypillinen) esimerkki:

public static List<Course> filterAndFixCourses(
    List<Course> courseList) {
  // Get all trainings that are public and valid
  List<Course> validPublicTrainings = new ArrayList<>();
  for (Course course : courseList) {
    if (course.getType() != null && course.getType() == 3 
        && course.getStatus() != null && course.getStatus() == 3) {
      if (course.getBeginDate() != null 
          && course.getEndDate() != null) {
        // Fix dates by adding +10 hours, 36000ms
        course.setBeginDate(new Date(
           course.getBeginDate().getTime() + 36000));
        course.setEndDate(new Date(
           course.getEndDate().getTime() + 36000));
      }
     validPublicTrainings.add(course);
    }
  }

  // Sort by begindate and enddate
  // Descending order for dates, meaning that newest dates go first
  // Null values go to bottom of list
  Comparator<Course> c = new Comparator<Course>() {
    @Override
      public int compare(Course o1, Course o2) {
        if (o1.getBeginDate() == null 
            && o2.getBeginDate() == null) {
          return 0;
        } else if (o1.getBeginDate() == null) {
          return 1;
        } else if (o2.getBeginDate() == null) {
          return -1;
        } else if (o1.getEndDate() == null 
            && o2.getEndDate() == null) {
          return 0;
        } else if (o1.getEndDate() == null) {
          return 1;
    } else if (o2.getEndDate() == null) {
      return -1;
    } else if (o1.getBeginDate().before(o2.getBeginDate())) {
      return 1;
    } else if (o1.getBeginDate().after(o2.getBeginDate())) {
      return -1;
    } else if (o1.getEndDate().before(o2.getEndDate())) {
      return 1;
    } else if (o1.getEndDate().after(o2.getEndDate())) {
      return -1;
    } else {
      return 0;
    }
   }
  };
  Collections.sort(validPublicTrainings, c);
  return validPublicTrainings;
}

Rumaa? Jep, elämä on. Tuota voisi stilisoida moninkin tavoin mutta totuus on, että tuollaista koodia löytyy tuotantojärjestelmistä ympäri maailman. Kuten Venkat Subramaniam sanoi luennoissaan, kun tuota kirjoittelee päivän, tuntee olonsa likaiseksi ja kaipaa suihkua.

Miltäpä tämä sitten näyttäisi Java 8 avulla? Kokeillaanpa uudestaan. Ei mikromanageroida ulkopuolelta mitä pitäisi tehdä, vaan käytetään Lambdoja ja Streameja, ja annetaan algoritmit suoraan Streamille, näin:

public static List<Course> filterAndFixCourses(
    List<Course> courseList) {
  return courseList.stream()
    .filter(c -> c.getType() != null)
    .filter(c -> c.getType() == 3)
    .filter(c -> c.getStatus() != null)
    .filter(c -> c.getStatus() == 3)
    .sorted((o1, o2) -> {
       if (o1.getBeginDate() == null 
            && o2.getBeginDate() == null) {
         return 0;
       } else if (o1.getBeginDate() == null) {
         return 1;
       } else if (o2.getBeginDate() == null) {
         return -1;
       } else if (o1.getEndDate() == null 
           && o2.getEndDate() == null) {
         return 0;
       } else if (o1.getEndDate() == null) {
         return 1;
       } else if (o2.getEndDate() == null) {
         return -1;
       } else if (o1.getBeginDate().before(o2.getBeginDate())) {
         return 1;
       } else if (o1.getBeginDate().after(o2.getBeginDate())) {
         return -1;
       } else if (o1.getEndDate().before(o2.getEndDate())) {
         return 1;
       } else if (o1.getEndDate().after(o2.getEndDate())) {
         return -1;
       } else {
         return 0;
       }
     })
     .map((c) -> fixDate(c))
     .collect(Collectors.toList());
}

Parempi? Mmmhmm… Tavallaan, imperatiivisen tyylin mikromanagerointi on poistunut, ja voisi sanoa että koodi on alkuun jopa ymmärrettävämpää. Mutta monimutkaiset asiat ovat edelleen monimutkaisia ja sotkuisia, vaikeasti ymmärrettäviä. Mutta meillä on käsissämme funktionaalinen ohjelmointityyli. Mitäpä jos.. käyttäisimme funktioita hoitamaan likaiset työt? Sama uusiksi funktioreferensseillä Java 8 tapaan:

public static List<Course> filterAndFixCourses(
    List<Course> courseList) {
  return courseList.stream()
    .filter(App::typeThreeIsValid)
    .filter(App::statusThreeIsValid)
    .sorted(App::sortByDate)
    .map(App::fixDate)
    .collect(Collectors.toList());
}

Aaaaaivan! Koodirivien määrä rupsahti 53:sta kuuteen – tai jos tarkkoja ollaan niin yhteen, tuohan on kaikki samaa return-lauseketta, eli one-lineri. Tietysti osa koodia siirtyi vain funktioihin joka aina kannattaisi tehdä. Kuten yllä näkyy, on hyvä idea nimetä funktiot dokumentatiiviseen tapaan, vähän kuin hyvissä yksikkötesteissä.

Mutta luettavuuden voisi väittää parantuneen. Kun ylläolevaa koodia lukee, sitä voi jopa ymmärtää. Kenties jopa kuukauden päästä palatessaan asiaan.

Mitäpä tapahtui suorituskyvylle? Kellotin vähän millisekunteja. Imperatiivinen koodiesimerkki rusikoi 100 000 tietueen massaa n. 27ms verran. Funktionaalinen Java 8 versio käytti samaan aikaa n. 54ms. Hetki, miten tässä näin kävi? Eikö tämän pitänyt olla nopeampaa? No ei. Skaalautuvampaa, kyllä. Nopeushävikkiä voi syntyä esim. immutable-olioiden kopioinnista, ja toisaalta tässä ovat operaatiot niin nopeita että ylimääräisten askelien overhead jää suuremmaksi kuin mikään tehosäästö.

Mutta tämänhän piti skaalautua mukavasti moniydinprosessoreille? Tässä koneessani on niitä kahdeksan, ajetaanpa testi uusiksi tällä koodilla:

// Best version of Java 8 features with parallel execution
 public static List<Course> filterAndFixCourses(
    List<Course> courseList) {
    return courseList.parallelStream()
       .filter(App::typeThreeIsValid)
       .filter(App::statusThreeIsValid)
       .sorted(App::sortByDate)
       .map(App::fixDate)
       .collect(Collectors.toList());
}

Kuten huomaat, ainoa mitä piti muuttaa oli korvata stream() parallelStream()-kutsulla. Nyt taustalla käytetään Fork&Joinia ja hajoitetaan ja hallitaan operaatioita, ajaen ne rinnakkain. Mitenkäs suuri nopeushyöty saadaan tästä?

Turpiin tuli. Nyt aikaa meni 90ms, eli lähes neljä kertaa hitaampaa. Mikä meni vikaan?

Kuten aiemminkin, overhead iski. Thread poolin pystytys ja töiden skedulointi vie suoritusaikaa. Tässä esimerkissä laskennallinen tehtävä on edelleen triviaalin nopea ja siksi sen tekeminen rinnakkain ei hyödytä tarpeeksi. Eli jos nopeutta hakee, tämä kannattaisi edelleen tehdä mutkattomammin imperatiivisesti. Ytimien lisääminenkään tuskin auttaisi, koska overhead.

Mutta entäpä jos työ olisi raskaampaa? Nykyään dataa haetaan usein verkon yli, tai tietokannasta useissa erissä. Edellämainitun koodin ongelmana alkoi olemaan muistin riittäminen, jos tietueita olisi vaikka miljoona. Looginen ratkaisu olisi hakea sitä sopivammissa viipaleissa kannasta tai verkon yli.

Entä jos siinä menisi esim. 10ms per kierros aikaa? Simuloidaan koodiin pientä verkkoviivettä, näin:

try {
 Thread.sleep(10);
 } catch (InterruptedException ex) { }

Joka kierroksella menee hetki aikaa, joko laskenta on raskasta tai haetaan jotain verkon yli tai kannasta. 18793 tehtävää (viive on vain suodatetuilla alkioilla), 10ms viivettä per tehtävä. Nyt tilanne muuttuu, imperatiiviselta koodilta kestää: 194 045ms tehdä työ. Ei kovin suuri yllätys, 100k kertaa 10 on miljoona. Entä miten suoriutuu parallel Stream Java 8 versio? Suoritusaika on 25272ms, eli imperatiivisen koodin aika jaettuna ytimien määrällä plus overhead. Kahdeksan ytimen kone ajaa operaatiot lähes 8x nopeammin, ja skaalautuu jos ytimiä onkin 16, 32, jne..

Yhteenveto: Java 8 uudet piirteet ovat uutta arsenaalia ohjelmoijan työkalupakissa. Jo selkeyden ja ongelmanratkaisun välineinä niitä ei ole varaa olla käyttämättä. Ne eivät automaattisesti ole ylivoimaisia suoritusnopeudessa – mutta tehtävissä joissa rinnakkaisuudesta voi olla hyötyä, ne tuovat ylivertaista ja ohjelmoijalle helppoa skaalautuvuutta. Ja vielä on yksi syy lisää opiskella tämä NYT: Ellet opiskele, pian voi pamahtaa silmille Java-koodia jota et enää pysty ymmärtämään. Muistatko miten kävi Java 5 kohdalla? ;)

Pari tärppiä vielä loppuun. Streamien debuggaus voi olla hankalaa – ja tuki vielä huteraa kehitysvälineissä. Apuun tulee peek()-funktio, jolla voit kurkata prosessoinnin sisään. Esim. näin:

return courseList.parallelStream()
&nbsp; .filter(App::typeThreeIsValid)
&nbsp; .filter(App::statusThreeIsValid)
&nbsp; .sorted(App::sortByDate)
&nbsp; .peek(System.out::println)
&nbsp; .map(App::fixDate)
&nbsp; .collect(Collectors.toList());

Ei siis hullumpaa uudistusta. Matkaa on vielä Scalan hienouksiin mutta nyt meillä on jotain käyttökelpoista joka taas inspiroi koodaamista vuosiksi eteenpäin.


Aiheet

  • Yritysvalmennus

Kategoriat

  • koulutus
  • tieturi

Liittyvä sisältö