Потокобезопасность в Java

Thread Safety (Безопасность потоков) в Java – очень важная тема. Java обеспечивает поддержку многопоточной среды с помощью Java Threads. Мы знаем, что несколько потоков, созданных из одного объекта, разделяют переменные объекта, и это может привести к несогласованности данных, когда потоки используются для чтения и обновления общих данных.

Безопасность потоков

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

package com.journaldev.threads;

public class ThreadSafety {

    public static void main(String[] args) throws InterruptedException {
    
        ProcessingThread pt = new ProcessingThread();
        Thread t1 = new Thread(pt, "t1");
        t1.start();
        Thread t2 = new Thread(pt, "t2");
        t2.start();
        //ожидание окончания выполнения потоков
        t1.join();
        t2.join();
        System.out.println("Processing count="+pt.getCount());
    }

}

class ProcessingThread implements Runnable{
    private int count;
    
    @Override
    public void run() {
        for(int i=1; i < 5; i++){
            processSomething(i);
        	count++;
        }
    }

    public int getCount() {
        return this.count;
    }

    private void processSomething(int i) {
        //обработка некоторой задачи
        try {
            Thread.sleep(i*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

В приведенной выше программе в цикле for count увеличивается на 1 четыре раза, и так как у нас есть два потока, его значение должно быть равно 8 после завершения обоих потоков. Но при многократном запуске данной программы вы заметите, что значение count варьируется от 6 до 8. Это происходит потому, что даже если count++ кажется атомарной операцией, на самом деле она таковой не является и вызывает повреждение данных.

Потокобезопасность в Java

Потокобезопасность в Java – это процесс обеспечения безопасности нашей программы для использования в многопоточной среде. Существуют различные способы обеспечения потокобезопасности нашей программы.

  • Синхронизация является самым простым и широко используемым инструментом для обеспечения потокобезопасности в Java.
  • Использование обёрток Atomic из пакета java.util.concurrent.atomic. Например, AtomicInteger
  • Использование блокировок из пакета java.util.concurrent.locks.
  • Использование потокобезопасных коллекций, см. этот пост о использовании ConcurrentHashMap для обеспечения потокобезопасности.
  • Использование ключевого слова volatile с переменными, чтобы каждый поток считывал данные из памяти, а не из кэша потока.

Java synchronized

Синхронизация – это инструмент, с помощью которого мы можем достичь потокобезопасности; JVM гарантирует, что синхронизированный код будет выполняться только одним потоком за раз. Ключевое слово java synchronized используется для создания синхронизированного кода, и внутренне оно использует блокировки на объекте или классе, чтобы гарантировать, что только один поток выполняет синхронизированный код.

  • Java синхронизация работает на блокировке и разблокировке ресурса перед входом любого потока в синхронизированный код. Для этого поток должен получить блокировку на объект, и когда выполнение кода заканчивается, он разблокирует ресурс, который может быть заблокирован другими потоками. В то время как другие потоки находятся в состоянии ожидания блокировки синхронизированного ресурса.
  • Мы можем использовать ключевое слово synchronized двумя способами: сделать полный метод синхронизированным и создать синхронизированный блок.
  • Когда метод синхронизирован, он блокирует Object, если метод статический, то блокирует Class, поэтому всегда лучше использовать синхронизированный блок, чтобы заблокировать только те части метода, которые требуют синхронизации.
  • При создании синхронизированного блока необходимо указать ресурс, на котором будет выполнена блокировка. Это может быть XYZ.class или любое поле объекта класса.
  • synchronized(this) заблокирует объект перед входом в синхронизированный блок.
  • Следует использовать низший уровень блокировки, например, если в классе есть несколько синхронизированных блоков, и один из них блокирует объект, то другие синхронизированные блоки также не будут доступны для выполнения другими потоками. Когда мы блокируем объект, он получает блокировку на всех полях объекта.
  • Синхронизация в Java обеспечивает целостность данных за счет производительности, поэтому ее следует использовать только тогда, когда это абсолютно необходимо.
  • Java синхронизация работает только в том же JVM, поэтому, если вам нужно заблокировать какой-то ресурс в среде с несколькими JVM, это не сработает, и вам, возможно, придется заботиться о каком-то глобальном механизме блокировки.
  • Java синхронизация может привести к взаимным блокировкам, проверьте этот пост о взаимной блокировке в Java и способах их избежания.
  • Ключевое слово synchronized в Java не может использоваться для конструкторов и переменных.
  • Предпочтительнее создать фиктивный частный объект для использования в блоке synchronized, чтобы его ссылка не могла быть изменена другим кодом. Например, если у вас есть метод setter для объекта, на котором вы синхронизируетесь, его ссылка может быть изменена другим кодом, что приведет к параллельному выполнению синхронизированного блока.
  • Мы не должны использовать объект, который хранится в постоянном пуле, например, String не следует использовать для синхронизации, потому что если какой-то другой код также блокирует тот же самый String, он будет пытаться захватить блокировку на том же самом объекте-ссылке из пула строк, и хотя оба кода не имеют отношения друг к другу, они будут блокировать друг друга.

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

    // фиктивная переменная объекта для синхронизации
    private Object mutex=new Object();
    ...
    // используем блок synchronized для синхронного чтения, инкремента и обновления значения счетчика
    synchronized (mutex) {
            count++;
    }

Давайте посмотрим на некоторые примеры синхронизации и что мы можем из них извлечь.

public class MyObject {
 
  // Блокировки на мониторе объекта
  public synchronized void doSomething() { 
    // ...
  }
}
 
// Код хакера
MyObject myObject = new MyObject();
synchronized (myObject) {
  while (true) {
    // Неопределенно задерживаем myObject
    Thread.sleep(Integer.MAX_VALUE); 
  }
}

Обратите внимание, что код хакера пытается заблокировать экземпляр myObject, и как только это удается, он никогда не освобождает его, вызывая блокировку метода doSomething() в ожидании блокировки. Это приведет к взаимоблокировке системы и вызовет отказ в обслуживании (DoS).

public class MyObject {
  public Object lock = new Object();
 
  public void doSomething() {
    synchronized (lock) {
      // ...
    }
  }
}

//ненадежный код

MyObject myObject = new MyObject();
//изменить ссылку на объект блокировки
myObject.lock = new Object();

Обратите внимание, что объект блокировки является общедоступным, и изменяя его ссылку, мы можем выполнять синхронизированный блок параллельно в нескольких потоках. Аналогично, если у вас есть закрытый объект, но есть метод setter для изменения его ссылки.

public class MyObject {
  //блокировки на мониторе объекта класса
  public static synchronized void doSomething() { 
    // ...
  }
}
 
// код хакера
synchronized (MyObject.class) {
  while (true) {
    Thread.sleep(Integer.MAX_VALUE); // Indefinitely delay MyObject
  }
}

Обратите внимание, что код хакера получает блокировку на мониторе класса и не освобождает ее, что приведет к взаимной блокировке и DoS в системе. Вот еще один пример, когда несколько потоков работают с одним и тем же массивом строк и после обработки добавляют имя потока к значению массива.

package com.journaldev.threads;

import java.util.Arrays;

public class SyncronizedMethod {

    public static void main(String[] args) throws InterruptedException {
        String[] arr = {"1","2","3","4","5","6"};
        HashMapProcessor hmp = new HashMapProcessor(arr);
        Thread t1=new Thread(hmp, "t1");
        Thread t2=new Thread(hmp, "t2");
        Thread t3=new Thread(hmp, "t3");
        long start = System.currentTimeMillis();
        //запустить все потоки
        t1.start();t2.start();t3.start();
        //ожидать завершения потоков
        t1.join();t2.join();t3.join();
        System.out.println("Time taken= "+(System.currentTimeMillis()-start));
        //проверить значение общей переменной сейчас
        System.out.println(Arrays.asList(hmp.getMap()));
    }

}

class HashMapProcessor implements Runnable{
    
    private String[] strArr = null;
    
    public HashMapProcessor(String[] m){
        this.strArr=m;
    }
    
    public String[] getMap() {
        return strArr;
    }

    @Override
    public void run() {
        processArr(Thread.currentThread().getName());
    }

    private void processArr(String name) {
        for(int i=0; i < strArr.length; i++){
            //обработать данные и добавить имя потока
            processSomething(i);
            addThreadName(i, name);
        }
    }
    
    private void addThreadName(int i, String name) {
        strArr[i] = strArr[i] +":"+name;
    }

    private void processSomething(int index) {
        //обработка некоторой работы
        try {
            Thread.sleep(index*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
}

Вот результат выполнения вышеуказанной программы.

Time taken= 15005
[1:t2:t3, 2:t1, 3:t3, 4:t1:t3, 5:t2:t1, 6:t3]

Значения массива строк повреждены из-за общих данных и отсутствия синхронизации. Вот как мы можем изменить метод addThreadName(), чтобы сделать нашу программу потокобезопасной.

    private Object lock = new Object();
    private void addThreadName(int i, String name) {
        synchronized(lock){
        strArr[i] = strArr[i] +":"+name;
        }
    }

После этого изменения наша программа работает нормально, и вот правильный вывод программы.

Time taken= 15004
[1:t1:t2:t3, 2:t2:t1:t3, 3:t2:t3:t1, 4:t3:t2:t1, 5:t2:t1:t3, 6:t2:t1:t3]

Вот и всё, что касается безопасности потоков в Java. Надеюсь, вы узнали о безопасном программировании с использованием ключевого слова synchronized.

Source:
https://www.digitalocean.com/community/tutorials/thread-safety-in-java