Эта статья является первой подписанной статьей сообщества Nuggets, и ее перепечатка без разрешения запрещена.
Многоядерные машины сейчас очень распространены. Даже мобильный телефон оснащен мощным многоядерным процессором. Благодаря многопроцессорности и многопоточности несколько ЦП могут работать одновременно, чтобы ускорить выполнение задач.
Многопоточность — относительно сложная тема в программировании. Поскольку он включает в себя манипулирование общими ресурсами, он очень подвержен проблемам при кодировании. Параллельный пакет Java предоставляет множество инструментов, помогающих нам упростить синхронизацию этих переменных, но путь к обучению и применению по-прежнему полон поворотов.
В этой статье кратко представлены базовые знания о многопоточности в Java. Затем я остановлюсь на некоторых наиболее подверженных проблемам для новичков в многопоточном программировании, многие из которых — опыт крови и слез. Избежать этих ям равносильно тому, чтобы избежать 90% смертоносных ошибок многопоточности.
1. Основные понятия многопоточности
1.1 Облегченный процесс
В JVM нить на самом деле является легким процессом (LWP). Так называемый легкий процесс на самом деле является пользовательским процессом, вызывающим системное ядро, а также набор интерфейсов. На самом деле, он также хочет позвонить более основные потоки ядра (KLTS).
На самом деле создание, уничтожение и планирование потоков в JVM зависят от операционной системы. Если вы посмотрите на несколько функций в классе Thread, вы обнаружите, что многие из них являются родными и напрямую вызывают функции базовой операционной системы.
На следующем рисунке показана простая потоковая модель JVM в Linux.
Можно видеть, что когда разные потоки переключаются, они часто будут выполнять переходы между состояниями в пользовательском режиме и режиме ядра. Стоимость этого переключения относительно велика, именно его мы обычно и называем переключением контекста (Context Switch).
1.2 JMM
Прежде чем вводить синхронизацию потоков, необходимо ввести новый термин — модель памяти JVM JMM.
JMM не означает разделение памяти, такое как куча и метапространство, это совершенно другая концепция, относящаяся к модели памяти потоков времени выполнения Java, связанной с потоками.
Поскольку многие инструкции не являются атомарными при выполнении Java-кода, если порядок выполнения этих значений смещен, будут получены разные результаты. Например, действия i++ можно перевести в следующий байт-код.
getfield // Field value:I
iconst_1
iadd
putfield // Field value:I
Это только на уровне кода. Если вы добавите все уровни кеша для каждого ядра ЦП, этот процесс выполнения станет более деликатным. Если мы хотим выполнитьi++
После этого выполнитеi--
, что невозможно выполнить только с помощью элементарных инструкций байт-кода. Нам нужны какие-то средства синхронизации.
На картинке выше показана модель памяти JMM, которая делится на два типа: основная память и рабочая память. Обычно мы оперируем этими переменными в Thread, который на самом деле является копией основной памяти операции. Когда модификация завершена, ее необходимо обновить в основной памяти, чтобы другие потоки могли узнать об этих изменениях.
1.3 Общие методы синхронизации потоков в Java
Чтобы завершить работу JMM и синхронизировать переменные между потоками, Java предоставляет множество методов синхронизации.
- В объекте базового класса Java для завершения синхронизации между мониторами предоставляются примитивы ожидания и уведомления. Однако мы редко сталкиваемся с такого рода операциями в бизнес-программировании.
- Используйте synchronized для синхронизации метода или заблокируйте объект для синхронизации блока кода.
- Используйте повторные блокировки из параллельного пакета. Этот набор замков построен поверх AQS.
- Используйте ключевое слово volatile облегченной синхронизации, чтобы обеспечить видимость переменных в реальном времени.
- Используйте серию Atomic для завершения самовозрастания и самоуменьшения
- Используйте локальные переменные потока ThreadLocal для закрытия потока
- Используйте различные инструменты, предоставляемые пакетом concurrent, например LinkedBlockingQueue, для реализации потребителей-производителей. Суть по-прежнему AQS
- Используйте объединение потоков и различные методы ожидания для завершения последовательного выполнения параллельных задач.
Как видно из приведенного выше описания, в многопоточном программировании нужно учиться слишком многому. К счастью, есть много способов синхронизации, но не так много способов создания потоков.
Первый класс — это класс Thread. Мы все знаем, что есть две реализации. Первый — наследовать Thread, чтобы переопределить его метод запуска, второй — реализовать интерфейс Runnable и реализовать его метод запуска, а третий — создать поток через пул потоков.
На самом деле, в конце концов, есть только один способ начать — это Thread. Пулы потоков и Runnables — это просто инкапсулированные ярлыки.
Многопоточность настолько сложна и подвержена проблемам, что существуют общие проблемы, как мы можем их избежать? Ниже я представлю 10 высокочастотных ям и приведу решения.
2. Руководство по предотвращению ям
2.1 Пул потоков взрывает машину
Во-первых, давайте поговорим об очень-очень низкоуровневой ошибке многопоточности, которая имеет серьезные последствия.
Обычно мы создаем потоки тремя способами: Thread, Runnable и пул потоков. Благодаря популярности Java 1.8 наиболее часто используемым методом является пул потоков.
Однажды наш онлайн-сервер был мертв, и даже удаленный ssh не мог войти в систему, поэтому нам пришлось беспомощно перезагружаться. Все обнаружили, что это происходит в течение нескольких минут после запуска приложения. Наконец-то нашел несколько строчек нелепого кода.
Студент, не знакомый с многопоточностью, использует пул потоков для асинхронной обработки сообщений. Обычно мы будем использовать пул потоков как статическую переменную класса или переменную-член. Но этот одноклассник поместил его внутрь метода. То есть каждый раз, когда приходит запрос, будет создаваться новый пул потоков. Когда количество запросов увеличивается, системные ресурсы истощаются, что в конечном итоге приводит к зависанию всей машины.
void realJob(){
ThreadPoolExecutor exe = new ThreadPoolExecutor(...);
exe.submit(new Runnable(){...})
}
Как избежать этой проблемы? Только через код-ревью. Поэтому код, связанный с многопоточностью, даже если это очень простое ключевое слово синхронизации, следует передать для написания опытным людям. Даже без этого условия просмотрите код очень внимательно.
2.2 Запираемые замки
По сравнению с монопольной блокировкой, добавляемой ключевым словом synchronized, блокировка в параллельном пакете обеспечивает большую гибкость. Вы можете выбирать между справедливыми и нечестными блокировками, блокировками чтения и записи по мере необходимости.
Однако после того, как блокировка израсходована, она должна быть закрыта, т. е. блокировка и разблокировка должны появляться парами, в противном случае легко происходит утечка блокировки, в результате чего другие потоки никогда не получат блокировку.
Как и в следующем коде, после того, как мы вызвали блокировку, возникает исключение, логика выполнения в попытке будет прервана, и у блокировки никогда не будет возможности выполниться. В этом случае ресурс блокировки, полученный потоком, никогда не будет освобожден.
private final Lock lock = new ReentrantLock();
void doJob(){
try{
lock.lock();
//发生了异常
lock.unlock();
}catch(Exception e){
}
}
Правильный способ — поместить функцию разблокировки в блок finally, чтобы гарантировать, что она всегда может быть выполнена.
Поскольку блокировка также является обычным объектом, ее можно использовать как параметр функции. Если вы передаете блокировки туда и обратно между функциями, также будет путаница в логике синхронизации. В обычном кодировании также необходимо избегать такой ситуации использования блокировки в качестве параметра.
2.3 дождитесь, пока вас обернут в два слоя
Объект, как базовый класс Java, предоставляет четыре метода.wait
wait(timeout)
notify
notifyAll
, используемый для решения проблем с синхронизацией потоков, вы можете увидеть, насколько высок статус таких функций, как ожидание. В обычной работе студенты, пишущие бизнес-код, имеют относительно небольшую вероятность использования этих функций, поэтому после их использования легко вызвать проблемы.
Но использование этих функций имеет очень большую предпосылку, то есть оно должно быть обернуто синхронизированным, иначе будет выброшено исключение IllegalMonitorStateException. Например, следующий код сообщит об ошибке при выполнении.
final Object condition = new Object();
public void func(){
condition.wait();
}
Подобные методы, а также объект Condition в параллельном пакете также должны появляться между функциями блокировки и разблокировки при использовании.
Зачем нам нужно синхронизировать этот объект перед ожиданием? Поскольку JVM требует, чтобы при выполнении ожидания поток должен был удерживать монитор этого объекта, очевидно, что ключевое слово синхронизации может выполнить эту функцию.
Однако просто сделать это недостаточно.Функция ожидания обычно помещается в цикл while.JDK сделал четкие комментарии в коде.
Важный момент: это потому, что ожидание означает, что при уведомлении он может выполнять логику вниз. Но во время уведомления условие этого ожидания может больше не выполняться, потому что условие могло измениться в течение периода ожидания, и необходимо сделать другое суждение, поэтому это простой способ записать его в цикле while.
final Object condition = new Object();
public void func(){
synchronized(condition){
while(<条件成立>){
condition.wait();
}
}
}
Подождать и уведомить, если условия упакованы в два уровня, один уровень синхронизирован, а другой — пока, что является правильным использованием таких функций, как ожидание.
2.4. Не перезаписывайте объекты блокировки
При использовании ключевого слова synchronized, если оно добавляется к обычному методу, блокировкой является объект this; если оно загружается в статический метод, блокировкой является класс. В дополнение к использованию в методах, синхронизация также может напрямую указывать объекты, которые должны быть заблокированы, блокировать блоки кода и обеспечивать детальное управление блокировкой.
Что, если объект этой блокировки будет перезаписан? Как этот ниже.
List listeners = new ArrayList();
void add(Listener listener, boolean upsert){
synchronized(listeners){
List results = new ArrayList();
for(Listener ler:listeners){
...
}
listeners = results;
}
}
Приведенный выше код, потому что по логике принудительно дать блокировкуlisteners
Объект переназначается, что вызовет путаницу или аннулирование блокировки.
Чтобы быть в безопасности, мы обычно объявляем объект блокировки как final.
final List listeners = new ArrayList();
Или напрямую объявите выделенный объект блокировки и определите его как обычный объект Object.
final Object listenersLock = new Object();
2.5 Обработка исключений в циклах
Обработка некоторых задач с синхронизацией в асинхронных потоках или пакетная обработка с очень длительным временем выполнения — это часто встречающееся требование. Я не раз видел, что программы маленьких друзей останавливались после части исполнения.
Было обнаружено, что основной причиной этих прерываний является проблема с одной из строк данных, что приводит к гибели всего потока.
Давайте еще раз взглянем на шаблон кода.
volatile boolean run = true;
void loop(){
while(run){
for(Task task: taskList){
//do . sth
int a = 1/0;
}
}
}
В функции цикла выполните нашу реальную бизнес-логику. При выполнении задачи возникло исключение. В это время поток не будет продолжать работать, а выдаст исключение и завершится напрямую. При написании обычных функций мы все знаем такое поведение программы, но как только будет достигнута многопоточность, многие студенты забудут эту ссылку.
Стоит отметить, что даже незахватывающие типыNullPointerException
, также приведет к прерыванию потока. Поэтому очень хорошая привычка всегда помещать выполняемую логику в try catch.
volatile boolean run = true;
void loop(){
while(run){
for(Task task: taskList){
try{
//do . sth
int a = 1/0;
}catch(Exception ex){
//log
}
}
}
}
2.6 Правильное использование HashMap
HashMap в многопоточной среде будет проблема с бесконечным циклом. Эта проблема широко популяризировалась, потому что может иметь очень серьезные последствия: ЦП переполнен, код не может быть выполнен, а представление jstack блокируется на методе get.
Что касается того, как повысить эффективность HashMap и когда преобразовать красно-черное дерево в список, это тема в мире багу.
В Интернете есть подробные статьи, описывающие сценарии проблемы бесконечного цикла, в основном потому, что HashMap формирует циклическую цепочку при выполнении повторного хеширования. Определенные запросы на получение будут ходить по этому кольцу. JDK не считает это ошибкой, хотя ее последствия весьма неприятны.
Если вы решите, что ваш класс коллекции будет использоваться несколькими потоками, вместо этого вы можете использовать потокобезопасный ConcurrentHashMap.
HashMap также имеет проблему безопасного удаления, которая имеет мало общего с многопоточностью, но выдает исключение ConcurrentModificationException, что выглядит как проблема многопоточности. Давайте посмотрим на это вместе.
Map<String, String> map = new HashMap<>();
map.put("xjjdog0", "狗1");
map.put("xjjdog1", "狗2");
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if ("xjjdog0".equals(key)) {
map.remove(key);
}
}
Приведенный выше код вызовет исключение из-за механизма Fail-Fast HashMap. Если мы хотим безопасно удалить определенные элементы, мы должны использовать итераторы.
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, String> entry = iterator.next();
String key = entry.getKey();
if ("xjjdog0".equals(key)) {
iterator.remove();
}
}
2.7 Объем защиты резьбовой безопасности
Если используются потокобезопасные классы, должен ли написанный код быть потокобезопасным? ответ отрицательный.
Поточно-ориентированный класс отвечает только за свои внутренние методы, которые являются потокобезопасными. Если мы обернем его снаружи, то необходимо пересмотреть, может ли он достичь эффекта безопасности потоков.
Например, в приведенном ниже случае мы используем потокобезопасный ConcurrentHashMap для хранения счетчика. Хотя сам ConcurrentHashMap является потокобезопасным, проблема бесконечного цикла больше не возникает. Но функция addCounter явно некорректна, ее нужно обернуть синхронизируемой функцией.
private final ConcurrentHashMap<String,Integer> counter;
public int addCounter(String name) {
Integer current = counter.get(name);
int newValue = ++current;
counter.put(name,newValue);
return newValue;
}
Это одна из ям, на которую часто наступают разработчики. Чтобы достичь безопасности потоков, вам нужно взглянуть на область безопасности потоков. Если в логике больших размерностей есть проблемы с синхронизацией, то даже использование потокобезопасных коллекций не даст нужного эффекта.
2.8. Роль volatile ограничена
Ключевое слово volatile решает проблему видимости переменных, позволяя немедленно читать ваши изменения другими потоками.
Хотя об этом много спрашивали во время интервью, в том числе об оптимизации volatile в эскадрилье ConcurrentHashMap. Но при обычном использовании вы можете касаться только изменения значения логических переменных.
volatile boolean closed;
public void shutdown() {
closed = true;
}
Никогда не используйте его для подсчета или синхронизации потоков, как показано ниже.
volatile count = 0;
void add(){
++count;
}
Этот код неточен в многопоточной среде. Это связано с тем, что volatile гарантирует только видимость, а не атомарность, а многопоточные операции не гарантируют его правильность.
Насколько хорошо использовать класс Atomic или ключевое слово синхронизации напрямую, вас действительно волнует разница в наносекундах?
2.9 Будьте осторожны с датами
Во многих случаях обработка даты также может пойти не так. Это связано с тем, что используются глобальный Calendar, SimpleDateFormat и т. д. Когда несколько потоков одновременно выполняют функцию форматирования, возникает путаница данных.
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
Date getDate(String str){
return format(str);
}
В целях улучшения мы обычно ставим SimpleDateFormat в ThreadLocal, по одной копии на поток, что позволяет избежать некоторых проблем. Конечно, теперь мы можем использовать потокобезопасный DateTimeFormatter.
static DateTimeFormatter FOMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss");
public static void main(String[] args) {
ZonedDateTime zdt = ZonedDateTime.now();
System.out.println(FOMATTER.format(zdt));
}
2.10 Не запускайте потоки в конструкторах
Нет ничего плохого в том, чтобы начать новый поток в конструкторе или в статическом блоке кода. Однако делать это настоятельно не рекомендуется.
Поскольку в Java есть наследование, если вы сделаете это в конструкторе, то поведение подкласса станет очень волшебным. Кроме того, этот объект может быть использован в другом месте до его создания, что приведет к непредсказуемому поведению.
Поэтому лучше поместить начало потока в общий метод, такой как start. Это может снизить вероятность возникновения ошибок.
End
ждать и уведомлять очень легко ошибиться,
Требования к формату кодирования очень строгие. Синхронизированное ключевое слово относительно простое, но есть еще много моментов, о которых следует помнить при синхронизации блоков кода. Эти возможности по-прежнему полезны в различных API-интерфейсах, предоставляемых параллельным пакетом. Нам также приходится иметь дело с различными аномальными проблемами, возникающими в многопоточной логике, чтобы избежать прерываний и взаимоблокировок. Чтобы избежать этих ям, в основном написание многопоточного кода является точкой входа.
Многие java-разработчики плохо знакомы с многопоточной разработкой, и в обычной работе они широко не используются. Если вы работаете с грубой бизнес-системой, у вас будет меньше времени на написание многопоточного кода. Но всегда есть исключения, ваша программа становится очень медленной, или для устранения проблемы вы будете непосредственно участвовать в многопоточном кодировании.
Наши различные инструменты и программное обеспечение также используют многопоточность. От Tomcat до различного промежуточного программного обеспечения, до различных кешей пулов соединений с базами данных и т. д. — везде полно многопоточного кода.
Даже опытные разработчики во многом запутываются多线程
ловушка. Поскольку асинхронность вызовет путаницу во времени, синхронизация данных должна быть достигнута принудительно. Многопоточная работа, в первую очередь для обеспечения точности используйте потокобезопасные коллекции для хранения данных, также для обеспечения эффективности, ведь цель использования многопоточности именно в этом.
Я надеюсь, что эти практические примеры в этой статье подняли ваше понимание многопоточности на более высокий уровень.
Эта статья является первой подписанной статьей сообщества Nuggets, и ее перепечатка без разрешения запрещена.