Вы наступали на эти потокобезопасные ямы на работе?

задняя часть

Статья сначала была опубликована в паблике, а затем синхронизирована с личным сайтом:xiaoflyfish.cn/

image.png

Статья длинная, можно лайкнуть и прочитать

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

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

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

проблема многопоточности

Прежде всего, каковы проблемы использования многопоточности?

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

В книге "Java Concurrent Programming in Practice" упоминаются три типа проблем многопоточности:Проблемы безопасности, проблемы с живучестью и проблемы с производительностью

проблемы с безопасностью

Например, есть очень простая операция вычета инвентарной функции, а именно:

public int decrement(){
 return --count;//count初始库存为10
}

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

--countВыглядит как операция, но на самом деле состоит из трех шагов (чтение-изменение-запись):

  • прочитать значение count
  • Уменьшить значение на единицу
  • Наконец, присвойте результат вычисления count

На следующем рисунке показан неправильный процесс выполнения: когда два потока 1 и 2 одновременно выполняют метод, они читают, что значение count равно 10, а окончательный результат возврата равен 9. Это означает, что два человека могут купить продукт. продается, но запас уменьшается только на 1, что неприемлемо для реальной производственной среды

Неправильные результаты из-за неподходящего времени выполнения, как в приведенном выше примере, являются очень распространенной проблемой безопасности параллелизма, которая называетсясостояние гонки

Вызывается метод decrement(), область кода, вызывающая состояние гонки.критическая секция

Чтобы избежать этой проблемы, необходимо обеспечитьчтение-изменение-записьЭта составная операцияатомарность

В Java есть много способов добиться этого, например, механизм блокировки с использованием встроенной блокировки синхронизации или явной блокировки ReentrantLock, использование потокобезопасных атомарных классов, использование CAS и т. д.

проблема активности

Проблемы живучести связаны с тем фактом, что операция не может продолжать выполняться из-за блокировки или зацикливания.

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

тупик

Самая распространенная проблема с живучестью — взаимоблокировка

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

Как предотвратить взаимоблокировку

1. Постарайтесь, чтобы последовательность блокировки была такой же

Например, есть три замка A, B и C.

  • Последовательность блокировки Thread 1: A, B и C.
  • Порядок блокировки потока 2 — A, C, чтобы не было взаимоблокировки.

Если порядок блокировки Thread2 — B, A или C, A, порядок несовместим, и возникает проблема взаимоблокировки.

2. Попробуйте использовать механизм тайм-аута, чтобы сдаться

Интерфейс блокировки обеспечиваетtryLock(long time, TimeUnit unit)метод, который может ожидать блокировки в течение фиксированного периода времени, поэтому поток может активно освобождать все блокировки, которые были получены ранее, после истечения времени ожидания блокировки. Может избежать проблем взаимоблокировки

живой замок

Livelock очень похож на deadlock, и программа никогда не ждет результата, но по сравнению с deadlock livelock живой, что это значит? Поскольку работающий поток не заблокирован, он всегда выполняется, но не может получить результаты.

голод

Голод означает, что когда потоку нужны определенные ресурсы, он не всегда может их получить, особенно ресурсы ЦП, из-за чего поток не может работать все время.

В Java существует понятие приоритета потока, в Java приоритет делится на 1-10, где 1 — самый низкий, а 10 — самый высокий.

Если мы установим приоритет потока равным 1, что является самым низким приоритетом, в этом случае потоку могут не выделяться ресурсы ЦП все время, что приводит к длительной невозможности запуска.

проблемы с производительностью

Создание самого потока и переключение между потоками потребляют ресурсы.Если частое создание потоков или время, затрачиваемое ЦП на планирование потоков, намного больше, чем время, затрачиваемое на выполнение потоков, использование потоков перевешивает выигрыш, и даже вызвать высокую загрузку ЦП или OOM.

Например

поток небезопасный класс

Дело 1

Для синхронизации с использованием потоково-небезопасных коллекций (ArrayList, HashMap и т. д.) лучше всего использовать потокобезопасные параллельные коллекции.

В многопоточной среде при работе с небезопасным для потоков обходом коллекции может возникнуть ошибкаConcurrentModificationExceptionисключение, о котором часто говорятfail-fastмеханизм

В следующем примере имитируется несколько потоков, одновременно работающих с ArrayList, поток t1 проходит по списку и печатает его, а поток t2 добавляет элементы в список.

List<Integer> list = new ArrayList<>();
list.add(0); 
list.add(1); 
list.add(2);  //list: [0,1,2]
System.out.println(list);

//线程t1遍历打印list
Thread t1 = new Thread(() -> {
  for(int i : list){
    System.out.println(i);
  }
});  

//线程t2向list添加元素
Thread t2 = new Thread(() -> {
  for(int i = 3; i < 6; i++){
    list.add(i);
  }
});

t1.start();
t2.start();

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

При вызове метода next() итератора для получения следующего элемента он сначала пройдетcheckForComodification()проверка методаmodCountа такжеexpectedModCountРавен ли он, если нет, бросьте ConcurrentModificationException

modCount — это атрибут ArrayList, который представляет количество раз, когда структура коллекции была изменена (количество раз, когда длина списка изменялась), и modCount увеличивается на 1 каждый раз, когда вызывается такой метод, как добавление или удаление.

ожидаемыйModCount — это свойство итератора, которому перед обходом при создании экземпляра итератора присваивается значение, равное modCount (expectedModCount=modCount)

Таким образом, когда другие потоки добавляют или удаляют элементы коллекции, modCount будет увеличиваться, а затем, когда коллекция будет пройдена, expectModCount не равен modCount, и будет выдано исключение.

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

List<Integer> list = new ArrayList<>();
list.add(0); 
list.add(1); 
list.add(2);
System.out.println(list);

//线程t1遍历打印list
Thread t1 = new Thread(() -> {
  synchronized (list){   //使用synchronized关键字
    for(int i : list){
      System.out.println(i);
    }
  }
});  

//线程t2向list添加元素
Thread t2 = new Thread(() -> {
  synchronized (list){
    for(int i = 3; i < 6; i++){
      list.add(i);
      System.out.println(list);
    }
  }
});  

t1.start();
t2.start();

Как и в приведенном выше коде, используйте ключевое слово synchronized, чтобы заблокировать работу списка, и исключение не будет выдано. Однако использование synchronized эквивалентно сериализации заблокированного блока кода, что не дает преимущества в производительности.

Рекомендуются потокобезопасные инструменты параллелизма.

В JDK1.5 добавлено множество поточно-ориентированных классов инструментов, таких как CopyOnWriteArrayList, ConcurrentHashMap и другие параллельные контейнеры.

Рекомендуется использовать эти классы инструментов в повседневной разработке для реализации многопоточного программирования.

Случай 2

Не используйте SimpleDateFormat в качестве глобальной переменной

Класс SimpleDateFormat на самом деле является потоконебезопасным классом, основная причина которого заключается в том, что внутренняя реализация SimpleDateFormat не синхронизирует операции некоторых общих переменных.

public static final SimpleDateFormat SDF_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
  //两个线程同时调用SimpleDateFormat.parse方法
  Thread t1 = new Thread(() -> {
    try {
      Date date1 = SDF_FORMAT.parse("2019-12-09 17:04:32");
    } catch (ParseException e) {
      e.printStackTrace();
    }
  });

  Thread t2 = new Thread(() -> {
    try {
      Date date2 = SDF_FORMAT.parse("2019-12-09 17:43:32");
    } catch (ParseException e) {
      e.printStackTrace();
    }
  });

  t1.start();
  t2.start();
}

Рекомендуется использовать SimpleDateFormat в качестве локальной переменной или использовать ее с ThreadLocal.

Самый простой способ — использовать SimpleDateFormat в качестве локальной переменной.

Но если он используется в цикле for, будет создано много экземпляров, которые можно оптимизировать для использования с ThreadLocal.

//初始化
public static final ThreadLocal<SimpleDateFormat> SDF_FORMAT = new ThreadLocal<SimpleDateFormat>(){
  @Override
  protected SimpleDateFormat initialValue() {
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  }
};
//调用
Date date = SDF_FORMAT.get().parse(wedDate);

Рекомендуется использовать LocalDateTime и DateTimeFormatter Java8.

LocalDateTime и DateTimeFormatter — это новые функции, представленные в Java 8, они не только потокобезопасны, но и более удобны в использовании.

В реальной разработке рекомендуется заменить Calendar и SimpleDateFormat на LocalDateTime и DateTimeFormatter.

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime time = LocalDateTime.now();
System.out.println(formatter.format(time));

Правильная разблокировка замка

Предположим, что есть такой псевдокод:

Lock lock = new ReentrantLock();
...  
try{
  lock.tryLock(timeout, TimeUnit.MILLISECONDS)
  //业务逻辑
}
catch (Exception e){
  //错误日志
  //抛出异常或直接返回
}
finally {
  //业务逻辑
  lock.unlock();
}
...

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

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

Поэтому перед снятием блокировки в предложении finally должна быть некоторая обработка для освобождения ресурсов (таких как блокировки, потоки ввода-вывода и т. д.), занятых текущим потоком.

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

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

Правильное использование пулов потоков

Дело 1

Не используйте пулы потоков в качестве локальных переменных

public void request(List<Id> ids) {
  for (int i = 0; i < ids.size(); i++) {
     ExecutorService threadPool = Executors.newSingleThreadExecutor();
  }
}

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

В этом случае по мере того, как будет поступать все больше и больше запросов, пул потоков будет занимать все больше и больше памяти, что приведет к частым fullGC или даже OOM. Неразумно создавать пул потоков для каждого вызова метода, потому что это ничем не отличается от частого создания и уничтожения потоков самостоятельно.Это не только не использует преимущества пула потоков, но и потребляет больше ресурсов, требуемых пулом потоков. .

Поэтому попробуйте использовать пул потоков как глобальную переменную.

Случай 2

Используйте статический метод пула потоков по умолчанию с осторожностью

Executors.newFixedThreadPool(int);     //创建固定容量大小的线程池
Executors.newSingleThreadExecutor();   //创建容量为1的线程池
Executors.newCachedThreadPool();       //创建一个线程池,线程池容量大小为Integer.MAX_VALUE

Точки риска трех вышеупомянутых пулов потоков по умолчанию:

Значения corePoolSize и maxPoolSize пула потоков, созданного newFixedThreadPool, равны, а используемая очередь блокировки — LinkedBlockingQueue.

newSingleThreadExecutor устанавливает для corePoolSize и maxPoolSize значение 1, а также использует LinkedBlockingQueue.

Емкость LinkedBlockingQueue по умолчанию составляетInteger.MAX_VALUE=2147483647, для реальных машин можно рассматривать как неограниченные очереди

  • Когда количество запущенных потоков newFixedThreadPool и newSingleThreadExecutor превышает corePoolSize, последующие запросы будут помещены в очередь блокировки для ожидания.Поскольку очередь блокировки установлена ​​слишком большой, запрос не может быстро завершиться и заблокироваться на долгое время, что может привести к пул потоков на стороне запрашивающей стороны.Будучи избитым, тянущим вниз весь сервис.

newCachedThreadPool устанавливает для corePoolSize значение 0, а для maxPoolSize значениеInteger.MAX_VALUE, SynchronousQueue используется блокирующей очередью, SynchronousQueue не сохраняет задачи, ожидающие выполнения

  • Таким образом, newCachedThreadPool предназначен для создания потока, который будет выполняться при поступлении задачи, а maxPoolSize эквивалентен бесконечному параметру, так что количество созданных потоков может заполнить машинную память.

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

Рекомендуемое количество потоков

Рекомендации по настройке количества пулов потоков corePoolSize:

1. Приложения, интенсивно использующие процессор

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

Общая формула:corePoolSize=CPU核数+1个线程. Количество ядер ЦП, на которых может работать JVM, можно определить поRuntime.getRuntime().availableProcessors()Проверить.

2. Приложения с интенсивным вводом-выводом

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

Справочная формула: оптимальное количество потоков = количество ЦП/(1-коэффициент блокировки); коэффициент блокировки = время ожидания потока/(время ожидания потока + время обработки ЦП).

Время обработки ЦП задач с интенсивным вводом-выводом часто намного меньше, чем время ожидания потока, поэтому коэффициент блокировки обычно считается равным 0,8–0,9. до 4/(1-0,9)=40. Разумеется, конкретные настройки зависят от различных показателей в реальной работе машины.

В конце концов

Нелегко писать статьи и рисунки.Если вам понравилось, надеюсь, вы поможете мне поставить лайк и переслать.Спасибо

Поиск в WeChat: летающие рыбы с луной, подружитесь

Ответьте 666 в фоновом режиме официального аккаунта, получите бесплатные электронные книги, обязательные к прочтению классические книги все здесь

Справочная литература:

  • Практика параллельного программирования на Java