Recentemente, examinei o código-fonte relevante do hashmap na Internet e descobri que o conhecimento básico foi introduzido, mas alguns lugares ainda não são completos o suficiente, então pretendo compartilhar meu entendimento com você.
1. Introdução aos conhecimentos básicos
Todo mundo sabe que HashMap é uma estrutura de dados comum em java e tem uma ampla gama de usos. Por exemplo, pode ser usado como contêiner para passagem de parâmetros e também pode ser usado para gerenciamento de beans na primavera. Ele herda a classe AbstractMap e é descendente de map, portanto, muitas vezes podemos usar upcasting para criar instâncias.
como:
Map<Type1, Type2> map = new HashMap<>();
Além disso, os parâmetros também podem ser passados entre colchetes. O significado deste parâmetro é o initCapacity (capacidade inicial) do hashmap, que geralmente é elevado à potência de 2 (por que esse valor é considerado será discutido mais tarde), e o o padrão é 16.
como:
Map<Type1, Type2> map = new HashMap<>(16);
ps: A capacidade aqui também pode ser preenchida com outros números, como 10, 14, etc., mas será automaticamente convertida para uma potência de 2 não inferior ao número durante a inicialização. Como fazer isso será explicado mais tarde.
Se você quiser armazenar dados nele posteriormente, basta escrever assim:
map.put(key1,value1);
map.put(key2,value2);
Para buscar dados, escreva isto:
map.get(key1); //value1
map.get(key2); //value2
Além disso, o elemento de entrada no mapa suporta travessia cíclica, o código é o seguinte:
for(Map.Entry<Type1,Type2> entry: map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
// 后续操作
...
}
Também pode ser percorrido da seguinte maneira, o código é o seguinte
for(Type1 key: map.keySet()) {
String value = map.get(key);
// 后续操作
...
}
O mapa também pode ser percorrido através de iteradores, o código é o seguinte:
Iterator it = map.entrySet().iterator();
while(it.hasNext()) {
Map.Entry<Type1,Type2> entry = it.next();
//对entry进行处理
String key = entry.getKey();
String value = entry.getValue();
...
}
map pode remover elementos:
map.remove(key1);
Mas lembre-se de não usar o método put ou remove para modificar o nó nos dois loops acima, caso contrário, um erro java.util.ConcurrentModificationException será relatado, porque no código-fonte, há uma variável expectModCount no iterador da entrada em no mapa, e no mapa Há também uma variável chamada modCount. Tome o método remove como exemplo. Quando o método remove no mapa é chamado, o modCount no mapa será acumulado, mas o esperadoModCount no iterador não foi alterado. Isso é implementado no iterador EntryIterator para obter o próximo. Um erro será relatado durante a operação do nó, o código é o seguinte:
//1.该函数出现在HashMap的HashIterator这个抽象类中
class HashMap<K.V> extends AbstractMap<K.V> implements Map<K,V>, Cloneable, Serializable {
...
transient int modCount;//HashMap结构改变的次数
...
abstract class HashIterator {
Node<K,V> next;
Node<K,V> current;
int expectedModCount //迭代器改变的次数
int index;
...
final HashMap.Node<K,V> nextNode() {
HashNode node2 = this.next;
//关键代码
if(hashMap.this.modCount != this.expectedModCount) {
throw new ConcurrentModificationException();
}
...
}
...
}
//2.而在map中则通过内部EntryIterator类实现了这个抽象类
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() {
return nextNode(); }
}
//3.在Map.Entry中创建EntryIterator实例
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() {
return size;
}
public final void clear() {
HashMap.this.clear();
}
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
...
}
}
No entanto, se o método remove no iterador for usado, o esperadoModCount no iterador e o modCount no mapa serão atualizados automaticamente de forma síncrona, evitando assim o relatório de erros.
map também pode substituir elementos
map.replace (chave Type1, Type2 oldValue, Type2 newValue)
2. Introdução aos Princípios de Armazenamento
A estrutura básica do hashmap é mostrada na figura:
sua estrutura de dados de nível inferior é uma matriz de listas vinculadas, que é uma matriz composta por listas vinculadas, ou seja, tabela Node<Type1, Type2>[]. Node é uma classe que implementa a interface Map.Entry (era chamada de Entry antes de 1.8, e era chamada de Node depois de 1.8, e o nome foi alterado), que é usada para armazenar pares de valores-chave. Seu código de composição básico é o seguinte:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //hash值用于确定下标位置
final K key;
final V value;
final Node<K,V> next; //链表下一个元素
// 注意这里是package的访问修饰符,也就是说外部无法通过
//Map.Node的形式获取该元素,而是要通过自带的static函数
// map.entrySet()来获取
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final String toString() {
return key + "=" + value;
}
public final int hashCode(){
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
}
Amigos cuidadosos devem ter descoberto que o método de construção aqui é pacote, que só pode ser acessado no mesmo pacote, então o exterior não pode obter este elemento através do formulário Map.Node, mas através do método embutido map.entrySet() obter.
Portanto, a questão é: como sabemos que a camada inferior do hashmap é uma matriz de lista vinculada, como ela armazena dados? Como é calculado o valor do hash no nó e qual a sua utilidade?
A comida deve ser comida pedaço por pedaço e os problemas devem ser resolvidos um por um. Primeiro, observe o processo de armazenamento de dados: agora queremos armazenar alguns dados nele: {"nome": "zhangsan", "idade": "16", "sexo", "masculino"}, obviamente isso é uma peça de dados de informações pessoais, também podemos armazená-los no hashmap na forma de pares de valores-chave.
- Etapa 1: chame o método put(key,value)
- Etapa 2: No método put(key,value), calcularemos o valor hash deste par chave-valor, e o método de cálculo é o seguinte:
//JDK1.7
static int hash(Object key){
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//JDK1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
Tomando JDK1.8 como exemplo, a operação de deslocamento para a direita não assinado é usada aqui (o bit superior é preenchido com 0 e o bit inferior é deslocado para a direita), e os 16 bits superiores e o valor hash da própria chave são XORed (a operação & ou | não é usada porque as duas probabilidades da operação são tendenciosas para 0 ou 1 e a distribuição não é suficientemente uniforme). A vantagem disso é que o bit baixo mantém as características do bit alto, reduzindo a possibilidade de duplicação no bit baixo, tornando o valor do hash o mais uniforme possível e reduzindo a possibilidade de conflitos repetidos na determinação da posição do primário. chave mais tarde . A função hash usada aqui é uma função hash, que permite que entradas de comprimentos diferentes obtenham resultados do mesmo comprimento . Mas a forma de implementar a função hashCode específica pode exigir a ajuda de pequenos parceiros.
- Etapa 3: Determine o local de armazenamento do par chave-valor no hashmap de acordo com o valor do hash. Isso é índice. Em relação a este ponto,
existe uma função chamada indexFor(int h, int length) no JDK1.7, (JDK1.8 foi ajustado mas o princípio é o mesmo), o código fonte é o seguinte
static int indexFor(int h, int length) {
return h & (length-1);
}
Deixe-me explicar aqui, h - indica o resultado do cálculo da função hash, comprimento indica o comprimento da matriz hashmap, mas por que o comprimento deveria ser reduzido em 1?
Antes de explicar este ponto, vou compensar as armadilhas anteriores. Deixe-me primeiro falar sobre por que o comprimento padrão da matriz é uma potência de 2 (o padrão é 16): a característica de um comprimento de matriz de uma potência de 2
é que é reduzido em 1. Cada bit após o bit mais alto do valor é 1, e os dados dos bits mais baixos do hash obtidos após realizar uma operação AND (&) com qualquer número são o resultado.
Quais são os benefícios de fazer isso? A vantagem é que a operação de bits é rápida , e a operação AND (não operação OR ou XOR) é realizada entre o valor hash e o número binário com 1 bit, o que reduz a possibilidade de conflito e aumenta a uniformidade de distribuição .
Por exemplo, faça a operação AND: pegue 1101 (hash) e 0111 (comprimento 1 é 8-1) e faça a operação AND para obter 0101 (5) Pegue 1111 (
hash) e 0111 (comprimento 1 é 8-1) para Operação AND obtém 0111 (7) Mas se você fizer a operação OR: pegue 1101 (hash) e
0111 (comprimento-1 é 8-1) Faça a operação OR para obter 0111 (7) Pegue 1111 (hash) e 0111 (comprimento-1 é 8-1)
e o resultado é 0111 (7) Isso entra em conflito.
Resumindo, o processo de armazenamento de hash é o seguinte: put(key,value) -> hash(key) -> indexFor(hash) -> index
Aí vem a pergunta novamente, essa distribuição uniforme é impossível de evitar a possibilidade de conflitos repetidos, como lidar com o hashmap neste caso?
Por que deveria ser armazenado posteriormente na forma de uma árvore rubro-negra? Por que você escolheu um valor de corte de 8?
Aprendemos a estrutura de dados e devemos saber que o comprimento de pesquisa da árvore rubro-negra é O (logn) e a lista vinculada é O (n/2).Quando o comprimento é <8, a diferença entre logn e n
/ 2 não é grande e é necessário gerar uma árvore rubro-negra. O overhead adicional é, portanto, armazenado na forma de uma lista vinculada, mas quando o comprimento é maior que 8 (16, 32, 64... porque a expansão do hashmap é expandido por um fator de 2), a diferença entre logn e n/2 se tornará maior, o que significa que às vezes as árvores rubro-negras são sem dúvida melhores.
3. Introdução ao princípio da expansão
– 2022.1.21 Estou de volta depois de mais de meio mês de trabalho -----------------
A expansão do hashmap na verdade não é complicada se for complicada, e não é simples se for simples. Sem mais delongas, vamos direto ao assunto. Como implementar o mecanismo de expansão do hashmap?
Primeiro, vamos dar uma olhada em um trecho do código-fonte:
/**
* tableSizeFor 是为了实现hashmap扩容而建立的函数,用于
* 对数组进行不小于cap的2的幂次方扩容
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
Ok, vamos analisar o código-fonte agora. O limite aqui obviamente se refere ao comprimento do array no hashmap a ser alocado. Mas o que significa a seguinte operação AND e deslocamento para a direita sem sinal? Por que o limite deve ser reduzido primeiro em um e depois adicionado em um (se não exceder o comprimento máximo)?
Primeiro definimos cap para qualquer número, como 31. (Um número primo relativamente clássico)
Então 31 - 1 = 30, convertido em binário é
00000000 00000000 00000000 00011110 (int é 4 bits ou 32 bits)
Podemos ver que o bit mais alto que não é 0 é o quinto bit (da direita para a esquerda )
Então n >>> 1 Sim (deslocamento sem sinal para a direita em um bit, ou seja, todos mudam para a direita em um bit e 0 é adicionado à esquerda
)
Bit, neste momento, se a operação OR , o quarto e o quinto dígitos devem ser 1. (Enquanto houver um número em uma posição que seja 1, o resultado será 1), o resultado de n |= n >>> 1 é o seguinte: 00000000 00000000 00000000 00011111 Depois disso, o resultado acima é sem sinal deslocado para
a
direita por dois bits novamente para obter
00000000 00000000 00000000 00000111
O 1 original no quarto e quinto dígitos é movido para o segundo e terceiro dígitos e então a operação OR é executada, ou seja, o resultado de n |= n >>> 2 é:
00000000 00000000 00000000 00011111
Os resultados após o mesmo raciocínio podem ser provados.
Pode-se observar que após o código acima, olhando da direita para a esquerda, a parte inferior é toda 1, e a parte superior é toda 0, mais 1 é a potência de 2. Este algoritmo não é maravilhoso!
Então, qual é o propósito deste cálculo? Onde está a engenhosidade deste algoritmo?
O objetivo deste algoritmo é converter um número que não seja uma potência de 2 em um número que não seja menor que uma
potência de 2 do número. A combinação engenhosa de operação e deslocamento à direita sem sinal reduz a quantidade de operação, e o resultado pode ser obtido executando apenas 5 vezes. Incrível!
E por que o limite precisa ser subtraído primeiro por um e depois adicionado por um (se não exceder o comprimento máximo)? Vamos dar outro
exemplo, vamos considerar o limite como 32. Se você não subtrair um primeiro,
32 é convertido para binário como
00000000 00000000 00000000 Depois de 00100000
n |= n >>> 1, há
00000000 00000000 00000000 00110000
e então n |= n >>> 2:
00000000 00000000 000 00000 00111100
e assim por diante
...
Finalmente + 1 vai obtenha 64 em vez de 32, ou seja,
0 0000000 00000000 00000000 01000000 ,
então esse número não é menor que 32 elevado a 2 (deve ser 32)
Com o conhecimento básico acima, podemos agora falar sobre o princípio da expansão da capacidade:
Obrigado novamente ao chefe da lkforce pelo compartilhamento, o link é o seguinte:
https://blog.csdn.net/lkforce/article/details/89521318Primeiro
de all Dê uma olhada no código fonte:
// jdk1.7
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* 从原hashmap数组中迁移数据到新数组中.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; //step1
if (rehash) {
// 重新计算hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); //step2
e.next = newTable[i]; // step3
newTable[i] = e; //断点
e = next;
}
}
}
Para jdk1.7, a expansão segue principalmente as seguintes etapas:
- Alocar novo espaço de memória
- copiar dados
- O limite de atualização (limite) geralmente é o fator de carga * nova capacidade do array
Aqui falarei principalmente sobre a função de transferência, que usa uma lista vinculada para consultar do início ao fim e usa o método de inserção head para inserir.
- Primeiro, na etapa 1, salve o nó sucessor do nó atual no próximo.
- Na etapa 2, a posição do índice é obtida novamente de acordo com o valor do hash atual. (Conforme mencionado anteriormente na função indexFor, sua posição é avaliada de acordo com os n bits inferiores)
- Na etapa 3, defina o nó sucessor do nó atual como newTable[i] para facilitar a reorganização da lista vinculada, ou seja, aponte o nó sucessor do nó atual para a i-ésima posição na newTable no novo variedade.
- A etapa 4 é substituir o valor de newTable[i] pelo nó atual,
- A etapa 5 é continuar procurando o próximo nó na matriz original.
Amigos que se sentem confusos aqui podem conferir o link abaixo. https://blog.csdn.net/lkforce/article/details/89521318
Nota: Quando este método é usado em multithreading, haverá links mortos (listas vinculadas circulares) e perda de dados. Para obter detalhes, consulte:
https://blog.csdn.net/XiaoHanZuoFengZhou/article/details/105238992
Expansão jdk1.8:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首次初始化后table为Null
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//默认构造器的情况下为0
int newCap, newThr = 0;
if (oldCap > 0) {
//table扩容过
//当前table容量大于最大值得时候返回当前table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//table的容量乘以2,threshold的值也乘以2
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//使用带有初始容量的构造器时,table容量为初始化得到的threshold
newCap = oldThr;
else {
//默认构造器下进行扩容
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//使用带有初始容量的构造器在此处进行扩容
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({
"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
// help gc
oldTab[j] = null;
if (e.next == null)
// 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
// 扩容都是按照2的幂次方扩容,因此newCap = 2^n
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof HashMap.TreeNode)
// 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// preserve order
// 把当前index对应的链表分成两个链表,减少扩容的迁移量
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 扩容后不需要移动的链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 扩容后需要移动的链表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
// help gc
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// help gc
hiTail.next = null;
// 扩容长度为当前index位置+旧的容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}