Статьи

Что означает безопасность потоков в Java?

Что на самом деле означает быть «потокобезопасным»?

Безопасность потоков в Java означает, что методы класса являются атомарными или неактивными. Так что же означает атомное и что означает покой? И почему в Java нет других типов поточно-безопасных методов?


Вам также может понравиться:
7 методов для поточно-безопасных классов

Что значит быть «атомным»?

Метод является атомарным, когда вызов метода вступает в силу мгновенно. Таким образом, другие потоки либо видят состояние до или после вызова метода, но не имеют промежуточного состояния. Давайте посмотрим на неатомарный метод, чтобы увидеть, как атомарный метод делает класс потокобезопасным. Вы можете скачать исходный код из всех примеров на  GitHub .

public class UniqueIdNotAtomic {
    private volatile long counter = 0;
    public  long nextId() { 
        return counter++;   
    }   
}

Класс  UniqueIdNotAtomic создает уникальные идентификаторы с помощью счетчика переменных переменных. Я использую энергозависимое поле, строка 2, чтобы убедиться, что потоки всегда видят текущие значения, как описано более подробно здесь . Чтобы увидеть, является ли этот класс поточно-ориентированным, мы используем следующий тест:

public class TestUniqueIdNotAtomic {
    private final UniqueIdNotAtomic uniqueId = new UniqueIdNotAtomic();
    private long firstId;
    private long secondId;
    private void updateFirstId() {
        firstId  = uniqueId.nextId();
    }
    private void updateSecondId() {
        secondId = uniqueId.nextId();
    }
    @Test
    public void testUniqueId() throws InterruptedException {    
        try (AllInterleavings allInterleavings = 
                new AllInterleavings("TestUniqueIdNotAtomic");) {
        while(allInterleavings.hasNext()) { 
        Thread first = new Thread( () ->   { updateFirstId();  } ) ;
        Thread second = new Thread( () ->  { updateSecondId();  } ) ;
        first.start();
        second.start();
        first.join();
        second.join();  
        assertTrue(  firstId != secondId );
        }
        }
    }

}

Чтобы проверить, является ли счетчик потокобезопасным, нам нужны два потока, созданные в строках 16 и 17. Мы запускаем эти два потока, строки 18 и 19. И затем, мы ждем, пока оба не завершатся, используя соединение потоков, строки 20 и 21. После остановки обоих потоков мы проверяем, являются ли два идентификатора уникальными, как показано в строке 22.

Чтобы проверить все чередования потоков, мы помещаем полный тест в цикл while, итерируя по всем чередованиям потоков, используя класс  AllInterleavings из vmlens , строка 15.

Запустив тест, мы видим следующую ошибку:

java.lang.AssertionError: 
    at org.junit.Assert.fail(Assert.java:91)
    at org.junit.Assert.assertTrue(Assert.java:43)

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

Отчет от vmlens

В случае ошибки оба потока сначала считывают переменный счетчик параллельно. И затем оба создают один и тот же идентификатор. Чтобы решить эту проблему, мы делаем метод атомарным, используя синхронизированный блок:

private final Object LOCK = new Object();
public  long nextId() {
  synchronized(LOCK) {
    return counter++;   
  } 
}

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

Методы, которые не обращаются к общему состоянию, автоматически становятся атомарными. То же самое верно для классов с состоянием только для чтения. Поэтому классы без сохранения состояния и неизменяемые являются простым способом реализации потоково-безопасных классов. Все их методы автоматически являются атомарными.

Не все виды использования атомарных методов автоматически поточно-ориентированы. Объединение нескольких атомарных методов для одинаковых значений обычно приводит к гоночным условиям. Давайте посмотрим на атомарный метод получить и поставить,  ConcurrentHashMap чтобы понять, почему. Давайте использовать эти методы для вставки значения в карту, когда не существует предыдущего отображения:

public class TestUpdateTwoAtomicMethods {
    public void update(ConcurrentHashMap<Integer,Integer>  map)  {
            Integer result = map.get(1);        
            if( result == null )  {
                map.put(1, 1);
            }
            else    {
                map.put(1, result + 1 );
            }   
    }
    @Test
    public void testUpdate() throws InterruptedException    {
        try (AllInterleavings allInterleavings = 
           new AllInterleavings("TestUpdateTwoAtomicMethods");) {
        while(allInterleavings.hasNext()) { 
        final ConcurrentHashMap<Integer,Integer>  map = 
           new  ConcurrentHashMap<Integer,Integer>(); 
        Thread first = new Thread( () ->   { update(map);  } ) ;
        Thread second = new Thread( () ->  { update(map);  } ) ;
        first.start();
        second.start();
        first.join();
        second.join();  
        assertEquals( 2 , map.get(1).intValue() );
        }
        }
    }   
}

Тест похож на предыдущий тест. Опять же, мы используем два потока, чтобы проверить, является ли наш метод потоко-безопасным, строки 18 и 19. И снова, мы проверяем после того, как оба потока закончили, если результат правильный, строка 24. Выполняя тест, мы видим следующую ошибку:

java.lang.AssertionError: expected:<2> but was:<1>
    at org.junit.Assert.fail(Assert.java:91)
    at org.junit.Assert.failNotEquals(Assert.java:645)

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

Отчет от vmlens

В случае ошибки оба потока сначала получают значение параллельно. Затем оба создают одно и то же значение и помещают его в карту. Чтобы решить это условие гонки, нам нужно использовать один метод вместо двух. В нашем случае мы можем использовать единственный метод compute вместо двух методов get и put:

public void update() {
  map.compute(1, (key, value) -> {
    if (value == null) {
        return 1;
    } 
    return value + 1;
  });
}

Это решает условие гонки, так как метод вычисления является атомарным. В то время как все операции, которые работают с одним и тем же элементом,  ConcurrentHashMap являются атомарными, операции, которые работают с полной картой, например, размером, не работают. Итак, давайте посмотрим, что означает покой.

Что значит быть «спокойным»?

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

ConcurrentHashMap<Integer,Integer>  map = 
    new  ConcurrentHashMap<Integer,Integer>();
Thread first  = new Thread(() -> { map.put(1,1);});
Thread second = new Thread(() -> { map.put(2,2);});
first.start();
second.start();
first.join();
second.join();  
assertEquals( 2 ,  map.size());

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

Размер метод использует механизм также используется в классе   java.util.concurrent.atomic.LongAdderLongAccumulatorDoubleAdder, и  , DoubleAccumulator чтобы избежать конкуренции. Вместо того, чтобы использовать одну переменную для хранения текущего размера, он использует массив. Различные потоки обновляют разные части массива, что позволяет избежать конфликтов. Алгоритм объяснен более подробно в документе Java Striped64 .

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

Почему в Java нет других потоковобезопасных методов?

В теоретической информатике безопасность потоков означает, что структура данных удовлетворяет критерию корректности. Наиболее распространенный используемый критерий корректности является линеаризуемым, что означает, что методы структуры данных являются атомарными.

Для общих структур данных существуют доказуемые линеаризуемые параллельные структуры данных, см. Книгу «Искусство многопроцессорного программирования» Мориса Херлихи и Нира Шавита . Но чтобы сделать структуру данных линеаризуемой, необходим дорогой механизм синхронизации, такой как сравнение и своп, см. Статью Законы порядка: дорогостоящая синхронизация в параллельных алгоритмах, которую нельзя исключить,  чтобы узнать больше.

Таким образом, другие критерии правильности, такие как молчание Итак, я думаю, что вопрос не в том, «почему в Java нет других типов поточно-безопасных методов?». а точнее, когда в Java появятся другие типы безопасности потоков?

Заключение

Безопасность потоков в Java означает, что методы класса являются атомарными или неактивными. Метод является атомарным, когда вызов метода вступает в силу мгновенно. Quiescent означает, что нам нужно убедиться, что никакие другие методы в настоящий момент не работают, когда мы вызываем метод покоя.

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

Дальнейшее чтение

7 методов для многопоточных классов

Как я тестирую свои Java-классы на безопасность потоков

Глубокий параллелизм Java