1 сопутствующие симптомы проблемы
1.1 Многопоточность может привести к бесконечному циклу
В прошлом наш Java-код почему-то использовал HashMap, но программа в то время была однопоточной, и все было нормально. Позже возникла проблема с производительностью нашей программы, поэтому нам нужно было стать многопоточной.Поэтому после того, как стала многопоточной, мы вышли в интернет и обнаружили, что программа часто загружала 100% ЦП.Проверить стек , и вы обнаружите, что программа зависает в HashMap. Метод .get() включен, и проблема исчезает после перезапуска программы. Но через какое-то время снова придет. Кроме того, эту проблему может быть трудно воспроизвести в тестовой среде.
Мы просто смотрим на наш собственный код, мы знаем, что HashMap управляется несколькими потоками. В документации Java говорится, что HashMap не является потокобезопасным, и следует использовать ConcurrentHashMap. Но здесь мы можем посмотреть, почему. Простой код выглядит следующим образом:
package com.king.hashmap;
import java.util.HashMap;
public class TestLock {
private HashMap map = new HashMap();
public TestLock() {
Thread t1 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.put(new Integer(i), i);
}
System.out.println("t1 over");
}
};
Thread t2 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.put(new Integer(i), i);
}
System.out.println("t2 over");
}
};
Thread t3 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.put(new Integer(i), i);
}
System.out.println("t3 over");
}
};
Thread t4 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.put(new Integer(i), i);
}
System.out.println("t4 over");
}
};
Thread t5 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.put(new Integer(i), i);
}
System.out.println("t5 over");
}
};
Thread t6 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.get(new Integer(i));
}
System.out.println("t6 over");
}
};
Thread t7 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.get(new Integer(i));
}
System.out.println("t7 over");
}
};
Thread t8 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.get(new Integer(i));
}
System.out.println("t8 over");
}
};
Thread t9 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.get(new Integer(i));
}
System.out.println("t9 over");
}
};
Thread t10 = new Thread() {
public void run() {
for (int i = 0; i < 50000; i++) {
map.get(new Integer(i));
}
System.out.println("t10 over");
}
};
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
t6.start();
t7.start();
t8.start();
t9.start();
t10.start();
}
public static void main(String[] args) {
new TestLock();
}
}
То есть запустить 10 потоков и непрерывно помещать/получать содержимое в непоточно-безопасный HashMap.Содержимое put очень простое, а ключ и значение представляют собой целые числа, которые увеличиваются с 0 (содержимое этого put не очень хорошо сделано, в итоге это потом мешало мне думать об анализе проблемы). Выполняя параллельные операции записи в HashMap, я думал, что это будет генерировать только грязные данные, но многократно запуская эту программу,会出现线程t1、t2被hang住的情况,多数情况下是一个线程被hang住另一个成功结束,偶尔会10个线程都被hang住
.
产生这个死循环的根源在于对一个未保护的共享变量 — 一个"HashMap"数据结构的操作
. Когда я добавил "синхронизировал" в методы всех операций, все пришло в норму. Это ошибка JDK? Следует сказать, что нет, об этом явлении сообщалось давно.Sun的工程师并不认为这是bug,而是建议在这样的场景下应采用"ConcurrentHashMap"
.
CPU利用率过高一般是因为出现了出现了死循环,导致部分线程一直运行,占用cpu时间。问题原因就是HashMap是非线程安全的,多个线程put的时候造成了某个key值Entry key List的死循环,问题就这么产生了。
Когда другой поток получает ключ бесконечного цикла Entry List, get всегда будет выполняться. Конечным результатом является то, что все больше и больше потоков находятся в бесконечном цикле, что в конечном итоге приводит к зависанию сервера. Обычно мы думаем, что когда HashMap повторно вставляет значение, оно перезапишет предыдущее значение, и это правильно.但是对于多线程访问的时候,由于其内部实现机制(在多线程环境且未作同步的情况下,对同一个HashMap做put操作可能导致两个或以上线程同时做rehash动作,就可能导致循环键表出现,一旦出现线程将无法终止,持续占用CPU,导致CPU使用率居高不下),就可能出现安全问题了
.
Используйте инструмент jstack для создания дампа информации о стеке сервера с проблемой.死循环的话,首先查找RUNNABLE的线程
, найдите код проблемы следующим образом:
java.lang.Thread.State:RUNNABLE в java.util.HashMap.get(HashMap.java:303) на com.sohu.twap.service.logic.TransformTweeter.doTransformTweetT5(TransformTweeter.java:183) Всего 23 раза. java.lang.Thread.State:RUNNABLE в java.util.HashMap.put(HashMap.java:374) на com.sohu.twap.service.logic.TransformTweeter.transformT5(TransformTweeter.java:816) появился 3 раза.
Уведомление: Неразумное использование HashMap приводит к бесконечному циклу, а не к взаимоблокировке.
Более 1,2 потока, установленные время, может привести к потере элементов
Основная проблема заключается в методе Addentry New Entry
1.3 После помещения ненулевого элемента get становится нулевым
Код в методе передачи выглядит следующим образом:
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry e = src[j];
if (e != null) {
src[j] = null;
do {
Entry next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
В этом методе присвоить старый массив src, пройтись по src, когда элемент src не нулевой, установить элемент в src равным null, то есть установить элемент в старом массиве равным null, то есть это предложение:
if (e != null) {
src[j] = null;
В это время, если есть метод get для доступа к ключу, он по-прежнему получает старый массив и, конечно же, не может получить соответствующее ему значение.
Резюме: когда HashMap не синхронизирован, в параллельных программах будет много тонких проблем, и трудно найти причину на поверхности. Таким образом, использование HashMap противоречит здравому смыслу и может быть вызвано параллелизмом.
2 Структура данных HashMap
HashMap通常会用一个指针数组(假设为table[])来做分散所有的key,当一个key被加入时,会通过Hash算法通过key算出这个数组的下标i,然后就把这个<key, value>插到table[i]中,如果有两个不同的key被算在了同一个i,那么就叫冲突,又叫碰撞,这样会在table[i]上形成一个链表。
Мы знаем, что если таблица [] размера маленькая, скажем, только два, если вы хотите поставить 10 клавиш, то столкновение очень часто, поэтому A (1) алгоритм поиска, он становится связанным обходом в списке, изменение производительности O (n), который является дефектным хэш-таблицей.
Поэтому размер и емкость хеш-таблицы очень важны. Вообще говоря, когда в контейнере хеш-таблицы есть данные для вставки, он проверяет, превышает ли емкость установленный порог.Если он превышает, размер хэш-таблицы необходимо увеличить, но таким образом элементы во всем Хэш-таблица должна быть пересчитана. Это называется перефразировать, и стоимость довольно велика.
2.1 Перефразируйте исходный код HashMap
Далее давайте взглянем на исходный код Java HashMap. Поместите пару Key, Value в таблицу Hash:
public V put(K key, V value)
{
......
//算Hash值
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
//如果该key已被插入,则替换掉旧的value(链接操作)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//该key不存在,需要增加一个结点
addEntry(hash, key, value, i);
return null;
}
Проверьте, не превышает ли емкость стандарт:
void addEntry(int hash, K key, V value, int bucketIndex)
{
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
//查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
if (size++ >= threshold)
resize(2 * table.length);
}
Создайте новую хеш-таблицу большего размера, а затем перенесите данные из старой хеш-таблицы в новую хеш-таблицу.
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
Перенесенный исходный код, обратите внимание на основные моменты:
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
Что ж, этот код относительно нормальный. И нет проблем.
2.2 Обычный процесс ReHash
Нарисуйте картинку для демонстрации.
- Предположим, что наш алгоритм хеширования состоит в том, чтобы просто использовать модификатор ключа для просмотра размера таблицы (то есть длины массива).
- Верхний — старая хэш-таблица Размер хеш-таблицы равен 2, поэтому ключи = 3, 7 и 5. После мода 2 все они конфликтуют с таблицей[1].
- Следующие три шага — это процесс изменения размера хэш-таблицы до 4, а затем повторный хеширование всех
.
2.3 ОСОБЕННОЕ ПРОЦЕСС
(1) Предположим, у нас есть два потока. Я отметил его красным и голубым цветом. Давайте вернемся и посмотрим на эту деталь в нашем коде передачи:
do {
Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
И наше выполнение второго потока завершено. Итак, у нас есть что-то вроде этого ниже.
Примечание. Поскольку e для Thread1 указывает на ключ (3), а next указывает на ключ (7), после повторного хеширования потока 2 он указывает на связанный список после повторного хеширования потока 2. Мы видим, что порядок связанного списка обратный.
(2) После запланированного возвращения потока для выполнения.
- Сначала выполните newTalbe[i] = e.
- Тогда E = Далее, в результате чего указательный ключ E (7).
- И next = e.next в следующем цикле приводит к тому, что next указывает на ключ (3).
(3) Все хорошо.Нити работают одна за другой. Снимите ключ (7), поместите его в первый из newTable[i], а затем переместите e и следующий вниз.
(4) Появится кольцевая ссылка.
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了
.
Итак, когда наш поток вызывает HashTable.get(11), происходит трагедия — бесконечный цикл.
3 три решения
3.1 Hashtable заменяет HashMap
Hashtable синхронизируется, но итератор возвращается итератором и возвращается всеми "методами представления коллекции" Hashtable.Collection 的 listIterator 方法都是快速失败的
: после создания Iterator, если Hashtable структурно изменена,除非通过 Iterator 自身的移除或添加方法,否则在任何时间以任何方式对其进行修改,Iterator 都将抛出 ConcurrentModificationException
. Таким образом, перед лицом одновременных модификаций итератор быстро полностью выйдет из строя, не рискуя произвольным недетерминированным поведением в какой-то неопределенное время в будущем. Перечисления, возвращаемые методами ключа и значения Hashtable, не являются отказоустойчивыми.
Уведомление,迭代器的快速失败行为无法得到保证
, потому что, как правило, невозможно дать какие-либо жесткие гарантии того, будут ли происходить несинхронизированные одновременные модификации.快速失败迭代器会尽最大努力抛出 ConcurrentModificationException
. Поэтому было бы ошибкой написать программу, которая опирается на это исключение, чтобы улучшить правильность таких итераторов: Fail-быстрое поведение итераторы следует использовать только для обнаружения программных ошибок.
3.2 Collections.synchronizedMap обертывает HashMap
返回由指定映射支持的同步(线程安全的)映射
. Чтобы гарантировать доступ по порядку, все обращения к базовой карте должны выполняться через возвращенную карту.在返回的映射或其任意 collection 视图上进行迭代时,强制用户手工在返回的映射上进行同步
:
Map m = Collections.synchronizedMap(new HashMap());
...
Set s = m.keySet(); // Needn't be in synchronized block
...
synchronized(m) { // Synchronizing on m, not s!
Iterator i = s.iterator(); // Must be in synchronized block
while (i.hasNext())
foo(i.next());
}
Несоблюдение этой рекомендации приведет к неопределенному поведению. Если указанная карта является сериализуемой, возвращаемая карта также будет сериализуемой.
3.3 ConcurrentHashMap заменяет HashMap
Поддержка получения полной параллельной и обновленной хеш-таблицы. Такое соответствие той же функциональной спецификации, что и у HashTable, и включает версию каждого метода, соответствующую HashTable. но,尽管所有操作都是线程安全的,但检索操作不必锁定,并且不支持以某种防止所有访问的方式锁定整个表
. Такая программа может быть полностью совместима с Hashtable, в зависимости от ее потокобезопасности, и синхронизирована с посторонними деталями.
Восстановление операций (включая GET), как правило, не блокируют и, следовательно, могут перекрываться с операциями обновления (включая поставку и удалить).检索会影响最近完成的更新操作的结果
. Для некоторых агрегатных операций, таких как putAll и clear, параллельное извлечение может влиять только на вставку и удаление определенных записей. Точно так же итераторы и перечисления возвращают элементы, влияющие на состояние хеш-таблицы в момент времени, когда итератор/перечисление создан, или позже. Они не вызывают ConcurrentModificationException. Однако итераторы предназначены для одновременного использования только одним потоком.