Blog

Blog from July, 2008

A synchronized mítosz

Java programozók körében elterjedt tévhit, hogy a synchronized módosítóval ellátott metódusok vagy blokkok által bezárt programsorokat csak egy szál futtathatja egy időben. A valóság az, hogy a kérdéses osztály példányaira vonatkozik a synchronized, ha az osztályból van kettő példányunk, amelyek egy közös static változót használnának, akkor mit sem ér a synchronized... Hogy ne a levegőbe beszéljek, nézzünk egy konkrét példát, vegyünk például egy osztályt, amelynek egy statikus változója és két metódusa van:

Test.java
public class Test
{
  private static Integer counter=0;
 
  public synchronized void addToCounter(Integer number)
  {
    counter += number;
  }
 
  public Integer getCounter()
  {
    return counter;
  }
}

Láthatjuk, hogy az addToCounter metódus szinkronizált blokkban van, így azt a counter nevű változóhoz több szál nem férhet hozzá:

ThreadA.java
public class ThreadA implements Runnable
{
  @Override
  public void run()
  {
    try
    {
      Test test = new Test();
      for (int count = 0; count < 100; count++)
      {
        test.addToCounter(1);
        Thread.sleep(7);
      }
      System.out.println(test.getCounter());
    } catch (InterruptedException ex)
    {
      Logger.getLogger(ThreadA.class.getName()).log(Level.SEVERE, ex.toString(), ex);
    }
  }
}

Ahogy fentebb látszik, létrehoztunk egy ThreadA (és egy ThreadB) osztályt, amelyekkel több szálon tudjuk futtatni a Test osztály "szálbiztos" addToCounter metódusát:

Main.java
public class Main
{
  public Main()
  {
    ThreadA a=new ThreadA();
    ThreadB b=new ThreadB();
   
    new Thread(a).start();
    new Thread(b).start();
  }
 
  public static void main(String[] args)
  {
    new Main();
  }
}

Az eremény meglepő lehet:

197
198

Elvileg az eremény 200 kellene lennie az utolsónak lefutó szál esetén, hiszen minden hozzáadás szinkronizált volt... azonban a synchronized két külön példányra volt értelmezett, így a statikus változót két szál akár egyidőben is módosíthatta. A fent vázolt probléma abból a tényból adódik, hogy a synchronized módosítóval jelölt metódus (lásd fentebb) az alábbi kóddal lesz azonos:

public void addToCounter(Integer number)
{
  synchronized (this)
  {
    counter += number;
  }
}

Ahogy a kódból is kiolvasható: a kód szálzárása példány szintű lesz (this), s ebből adódóan csak akkor jön elő a probléma, ha statikus változót akarunk egy nem statikus - de szinkronizált metódusból elérni. A megoldás egyszerű, kell készítenünk egy statikus és szinkronizált metódust, vagy osztály szintű szálzárást állítunk be:

public void addToCounter(Integer number)
{
  synchronized (getClass())
  {
    counter += number;
  }
}

Érdemes figyelni ilyen apróságokra, hiszen egy többszálú programban hibát keresni nem egyszerű, s egy tévhit még nehezebbé teheti a hiba okának felderítését.

Minden programozási nyelven vannak olyan trükkök, amelyek a nyelv határait feszegetik, vagyis teljesen legális nyelvi eszközökkel érnek el meglepő viselkedést. Ilyen lehet egy olyan rövid program, amely kiírja a klasszikus "Hello, World!" szöveget, de nincs benne main metódus, ahol ezt megtennénk. Mégis lehetséges... A megoldás egyszerű, a ritkán használt statikus inicializátor blokkba kell tennünk a "Hello, World!" kiírást. Ez a blokk akkor hajtódik végre egyszer, amikor az osztályt a ClassLoader betölti, olyan, mint egy konstruktor: szokták statikus konstruktornak is hívni. Ez a blokk még azelőtt hívódik meg, mielőtt a VM meghívná a main metódust, ezért - megelőzve a VM hibaüzenetét a main metódus hiányára - a statikus blokk végén ki is lépünk:

StaticTest.java
public class StaticTest
{
  static
  {
    System.out.println("Hello, World!");
    System.exit(0);
  }
}

Ha lefordítjuk és futtatjuk, akkor az eredmény egy "Hello, World!" felirat a képernyőn... :)

A StringBuffer mítosz

A senior Java programozók többségébe az évek tapasztalata mélyen beleégette az általános szabályt, hogy két String típusú változót ne a + operátorral fűzzünk össze, hanem a StringBuffer append metódusával. Ennek az alapja, hogy a String típus speciálisan kezelendő, ha két szöveget összefűzünk, akkor a memóriában három szövegünk lesz, ha ehhez hozzáfűzünk egy negyediket, akkor már öt: a másolgatás lassít és a GC is nehezményezi a többletmunkát. Az idők és a Java verziók változnak, érdemes kissé körüljárni a problémát. A StringBuilder előnyeit nem lehet elvitatni, egy rövid kis tesztprogrammal hamar be lehet bizonyítani, hogy a szövegek összefűzésére nem alkalmas a + operátor. Nézzük a programot:

StringTest.java
public class StringTest
{
  private static final int MAX=500000;

  public long doString()
  {
    long start=System.currentTimeMillis();

    String a="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

    for (int count=0;count<MAX;count++)
    {
      String aggregated="";
      aggregated+=a;
// ... összesen 16 összefűzés
      aggregated+=a;
    }

    return System.currentTimeMillis()-start;
  }

  public long doStringBuffer()
  {
    long start=System.currentTimeMillis();

    String a="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

    for (int count=0;count<MAX;count++)
    {
      String aggregated="";
      StringBuffer sb=new StringBuffer();
      sb.append(a);
// ... összesen 16 összefűzés
      sb.append(a);
      aggregated=sb.toString();
    }

    return System.currentTimeMillis()-start;
  }

  public StringTest()
  {
    System.out.println("doString: "+doString()+"ms");
    System.out.println("doStringBuffer: "+doStringBuffer()+"ms");
  }

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

A programot futtatva azt kapjuk, hogy a StringBuffer append metódusa ebben az esetben tízszer gyorsabb, mint a + operátor. Persze ezen lehet még gyorsítani, ha a StringBuffer megfelelő kezdő bufferméretet kap - a legjobb, ha ez pont egyezik az összefűzött szöveg méretével, ezért még biztosabban kijelenthetjük: mindig használjunk StringBuffer-t. Módosítsuk kissé a teszteléshez szükséges programot az alábbiak szerint:

public long doString()
{
  long start=System.currentTimeMillis();

  String a="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

  for (int count=0;count<MAX;count++)
  {
    String aggregated=a+a+a+a+a+a+a+a+a+a+a+a+a+a+a+a;
  }
 
 return System.currentTimeMillis()-start;
}

Mint látható, egyszerűen annyit csináltunk, hogy egymás mögé vontuk össze a szövegek összefűzését, s meglepő módon a két metódus közel azonos idő alatt futott le... feltéve, hogy Java 1.5 futtatókörnyezetre fordítottuk, ez alatt ugyanis megmarad a tízszeres különbség, ezzel egyező vagy nagyobb verziószámú célra fordítva azonban a szöveget összefűzése is gyors lesz. Mi lehet az oka? Nézzük csak meg a fordítot .class fájlt egy Java Decompiler programmal.

Java 1.4 fordítás
public long doString()
{
  long l = System.currentTimeMillis();

  String str1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

  for (int i = 0; i < 500000; ++i)
  {
    String str2 = str1 + str1 + str1 + str1 + str1 + str1 + str1 + str1 +
                  str1 + str1 + str1 + str1 + str1 + str1 + str1 + str1;
  }

  return (System.currentTimeMillis() - l);
}
Java 1.5 fordítás
public long doString()
{
  long l = System.currentTimeMillis();
  String s = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
  for(int i = 0; i < 0x7a120; i++)
  {
    String s1 = (new StringBuilder()).append(s).append(s).append(s).append(s).
                                      append(s).append(s).append(s).append(s).
                                      append(s).append(s).append(s).append(s).
                                      append(s).append(s).append(s).append(s).toString();
  }

  return System.currentTimeMillis() - l;
}

Mint látni, a fordító kicserélgeti magától a szövegek összefűzését StringBuilder összefűzésre, és ezáltal pont olyan gyors lesz, mintha a StringBuilder append metódusát használnák, mivel pontosan arra fordul, amit a másik metódusban írtunk.

A fentiek fényében, ha egy - a fordító számára összefüggő - sorban használjuk a + operátort, akkor a fordító ezt a kódba már append hívássá fogja fordítani. Az eredmény olvashatóbb forráskód és azonos teljesítmény.