Java Concurrency Synchronization

12 Mar 2018

1. При параллельном взаимодеиствии нескольких потоков с обшими ресурсами, возникает множество проблем:

  • Race Conditions - Случается когда разные потоки несинхронно модифицируют один и тот же ресурс, что приводит к тому что модифицируемый ресурс попадает в неправильное состояние:
if (a == 10.0)
{
  b = a / 2.0;
}
  • Data Races - Случается когда несколько потоков пишут данные в один ресурс без синхронизации:
private static Parser parser;

public static Parser getInstance()
{
  if (parser == null)
  parser = new Parser();
  return parser;
}
  • Cached Variables - Из соображений производительности, значение переменной может храниться не в оперативной памяти а в регистрах процессора, в таком случае у разных потоков значение одной и той же переменной может отличаться.

2. Все эти проблемы можно решить при помощи синхронизации.

Синхронизировать можно отделыные блоки кода.

В этом случае нужно указать какой объект следует использовать в качестве лока:

class IdGenerator {
  private Integer id = 0;
  
  public Integer getId() {
    synchronized(id) {
      id++;
    }
    return id;
  }
}

А можно и методы целиком:

В таком случае лок будет привязан к объекту в котором вызван метод:

class IdGenerator {
  private Integer id = 0;
  
  public synchronized Integer getId() {
    id++;
    return id;
  }
}  

3. Однако синхронизация не может решить всех проблем взаимодействия поток, напротив, она подеидывает нам еще несколько новых:

  • Deadlock - Поток А ждет пока поток Б разблокирует один ресурс, в то время как поток Б ждет пока поток А разблокирует другой ресурс. В результате ни один из поток ничего не делает.
  • Livelock - Поток пытается выполнить операцию, которая постоянно выдает ошибку. В результате поток ничего не делает.
  • Starvation - Поток с низким приоритетом не запускается, потому что все ресурсы забрали потоки с большим приоритетом, и планировщик задач никак не запустит поток с низким приоритетом.

Стоит правда отметить, что в двух последних сучаях синхронизация ни при чем.

Но давайте вернемся к dealock’у. Эта проблема возникает из-за неразумной оверсинхронизации. Вот вам классический пример:

public class DeadlockDemo
{
   private final Object lock1 = new Object();
   private final Object lock2 = new Object();

   public void instanceMethod1()
   {
      synchronized(lock1)
      {
         System.out.println("A :: lock1 hodl");
         synchronized(lock2)
         {
            System.out.println("A :: lock2 hodl");
            // critical section guarded first by
            // lock1 and then by lock2
         }
         System.out.println("A :: lock2 release");
      }
      System.out.println("A :: lock1 release");
   }

   public void instanceMethod2()
   {
      synchronized(lock2)
      {
         System.out.println("B :: lock2 hodl");
         synchronized(lock1)
         {
            System.out.println("B :: lock1 hodl");
            // critical section guarded first by
            // lock2 and then by lock1
         }
         System.out.println("A :: lock1 release");
      }
      System.out.println("A :: lock2 release");
   }

   public static void main(String[] args)
   {
      final DeadlockDemo dld = new DeadlockDemo();

      Runnable r1 = () -> {
         while(true)
         {
            dld.instanceMethod1();
            try
            {
               Thread.sleep(50);
            }
            catch (InterruptedException ie)
            {
            }
         }
      };

      Runnable r2 = () -> {
         while(true)
         {
            dld.instanceMethod2();
            try
            {
               Thread.sleep(50);
            }
            catch (InterruptedException ie)
            {
            }
         }
       };

      Thread thdA = new Thread(r1);
      Thread thdB = new Thread(r2);
      thdA.start();
      thdB.start();
   }
}

/*
  Sample output:

  B :: lock2 hodl
  A :: lock1 hodl
*/

Из примера прекрасно видно, что поток-Б захватывает лок2, после чего поток-А захватывает лок1 … и ВСЁ! Больше никто никуда не идёт. После этого приложение попадает в состояние deadlock’a и его выполнение прекращается.

Примерно такаяже ситуация у Молдовы с Приднестровьем, у Украины с Донбассом, у Кипра с Северным Кипром, у северных корейцев с южными, у России с Японией Курилы и далее везде. Поэтому deadlock очень жизненная ситуация. И для того чтобы исправить эту ошибку тебе придется изрядно попотеть, друг мой.

4. Volatile

Поверь мне на слово, что каждое ядро процессора может сожержать копию значения переменной, и иногда может возникнуть ситуация при которой изменение значения переменной в одном потоке, не отразится в другом. Потому что у каждого ядра закешированы свои значения переменной.

В таком случае необходимо использовать или синхронизацию или ключевое слово volatile.

Например, следующий код может не сработать:

public class ThreadStopping
{
  public static void main(String[] args)
  {
    class StoppableThread extends Thread
    {
      private boolean stopped; // defaults to false
      
      @Override
      public void run()
      {
        while(!stopped)
        System.out.println("running");
      }

      void stopThread()
      {
        stopped = true;
      }
    }

    StoppableThread thd = new StoppableThread();
    thd.start();

    try
    {
      Thread.sleep(1000); // sleep for 1 second
    }
    catch (InterruptedException ie) {}

    thd.stopThread();
  }
}

Есть вероятность такого исхода, из-за того что изменение значения переменной из потока main может не отразиться в потоке thd, или произойдет с опозданием.

В свою очередь volatile заставит JVM хранить переменную в общей памяти, что приведет к эффекту мягкой синхронизации.

Имей ввиду что volatile переменная не может быть final, кроме того не стоит на 32-битной JVM использовать volatile c long и double переменными, так как это небезопасно из-за того что в таком случае потребуется две опеации для доступа или изменения переменной. В таком случае предпочтительно использовать синхронизацию.