PHP-Hash-Tabellenprinzip

炎欲天舞
Freigeben: 2023-03-15 12:54:02
Original
1689 Leute haben es durchsucht

Einführung

Hash-Tabellen werden in fast jedem C-Programm verwendet. Da die C-Sprache nur die Verwendung von Ganzzahlen als Schlüsselnamen von Arrays zulässt, hat PHP eine Hash-Tabelle entworfen, um die Schlüsselnamen von string durch einen Hash-Algorithmus in ein Array begrenzter Größe abzubilden. Dies führt unweigerlich zu Kollisionen, und PHP verwendet eine verknüpfte Liste, um dieses Problem zu lösen.

Es gibt viele Möglichkeiten, Hash-Tabellen zu implementieren, von denen keine perfekt ist. Jedes Design konzentriert sich auf einen bestimmten Schwerpunkt, einige reduzieren die CPU-Auslastung, andere nutzen den Speicher rationaler und einige können eine Erweiterung auf Thread-Ebene unterstützen.

Der Grund für die Vielfalt bei der Implementierung von Hash-Tabellen liegt darin, dass jede Implementierungsmethode nur ihren eigenen Schwerpunkt verbessern, aber nicht alles abdecken kann.

Datenstruktur

Bevor wir mit der Einführung beginnen, müssen wir vorab etwas deklarieren:

Der Schlüsselname der Hash-Tabelle kann eine Zeichenfolge oder eine Ganzzahl sein. Wenn es sich um eine Zeichenfolge handelt, deklarieren wir den Typ als zend_string; wenn es sich um eine Ganzzahl handelt, deklarieren wir den Typ als zend_ulong.

Die Reihenfolge der Hash-Tabelle folgt der Einfügereihenfolge der Elemente in der Tabelle.

Die Kapazität der Hash-Tabelle wird automatisch erweitert und verkleinert.

Intern ist die Kapazität einer Hash-Tabelle immer ein Vielfaches von 2.

Jedes Element in der Hash-Tabelle muss Daten vom Typ Zval sein.

Das Folgende ist die Struktur von HashTable:

PHP

struct _zend_array {  
    zend_refcounted_h gc;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    flags,
                zend_uchar    nApplyCount,
                zend_uchar    nIteratorsCount,
                zend_uchar    reserve)
        } v;
        uint32_t flags;
    } u;
    uint32_t          nTableMask;
    Bucket           *arData;
    uint32_t          nNumUsed;
    uint32_t          nNumOfElements;
    uint32_t          nTableSize;
    uint32_t          nInternalPointer;
    zend_long         nNextFreeElement;
    dtor_func_t       pDestructor;
};
Nach dem Login kopieren

Diese Struktur belegt 56 Bytes.

Das wichtigste Feld ist arData, ein Zeiger auf Daten vom Typ Bucket. Die Bucket-Struktur ist wie folgt definiert:

typedef struct _Bucket {  
    zval              val;
    zend_ulong        h;                /* hash value (or numeric index)   */
    zend_string      *key;              /* string key or NULL for numerics */
} Bucket;
Nach dem Login kopieren

Zeiger wird nicht mehr verwendet in Bucket Ein Zeiger auf Daten vom Typ zval, der stattdessen die Daten selbst direkt verwendet. Denn in PHP7 verwendet zval keine Heap-Zuweisung mehr, da die Daten, die eine Heap-Zuweisung erfordern, als Zeiger in der zval-Struktur gespeichert werden. (z. B. PHP-Strings).

Das Folgende ist die Struktur der im Speicher gespeicherten arData:

Wir stellen fest, dass alle Buckets in der richtigen Reihenfolge gespeichert sind.

Elemente einfügen

PHP stellt sicher, dass die Elemente des Arrays in der Reihenfolge des Einfügens gespeichert werden. Auf diese Weise kann das Array bei Verwendung von foreach in einer Schleife in der Reihenfolge der Einfügung durchlaufen werden. Angenommen, wir haben ein Array wie dieses:

$a = [9 => "foo", 2 => 42, []];
var_dump($a);
 
array(3) {  
    [9]=>
    string(3) "foo"
    [2]=>
    int(42)
    [10]=>
    array(0) {
    }
}
Nach dem Login kopieren

Alle Daten liegen im Speicher nebeneinander.

Auf diese Weise wird die Logik des Umgangs mit Iteratoren der Hash-Tabelle recht einfach. Durchlaufen Sie einfach das arData-Array direkt. Durch das Durchlaufen benachbarter Daten im Speicher wird der CPU-Cache stark beansprucht. Da der CPU-Cache die gesamten arData lesen kann, erfolgt der Zugriff auf jedes Element auf Mikrosekundenebene.

size_t i;  
Bucket p;  
zval val;
 
for (i=0; i < ht->nTableSize; i++) {  
    p   = ht->arData[i];
    val = p.val;
    /* do something with val */
}
Nach dem Login kopieren

Wie Sie sehen können, werden die Daten sequentiell in arData gespeichert. Um eine solche Struktur zu implementieren, müssen wir den Standort des nächsten verfügbaren Knotens kennen. Diese Position wird im Feld nNumUsed in der Array-Struktur gespeichert.

Immer wenn neue Daten hinzugefügt werden, wird nach dem Speichern ht->nNumUsed++ ausgeführt. Wenn der nNumUsed-Wert den Maximalwert aller Elemente in der Hash-Tabelle (nNumOfElements) erreicht, wird der Algorithmus „Komprimierung oder Erweiterung“ ausgelöst.

Das Folgende ist ein einfaches Implementierungsbeispiel für das Einfügen von Elementen in eine Hash-Tabelle:

idx = ht->nNumUsed++; /* take the next avalaible slot number */  
ht->nNumOfElements++; /* increment number of elements */  
/* ... */
p = ht->arData + idx; /* Get the bucket in that slot from arData */  
p->key = key; /* Affect it the key we want to insert at */  
/* ... */
p->h = h = ZSTR_H(key); /* save the hash of the current key into the bucket */  
ZVAL_COPY_VALUE(&p->val, pData); /* Copy the value into the bucket&#39;s value : add operation */
Nach dem Login kopieren

Wir können sehen, dass beim Einfügen nur am eingefügt wird Ende des arData-Arrays. Gelöschte Knoten werden nicht gefüllt.

Element löschen

Wenn ein Element in der Hash-Tabelle gelöscht wird, skaliert die Hash-Tabelle nicht automatisch den tatsächlich gespeicherten Datenraum, sondern legt einen zval mit einem Wert von UNDEF fest Der aktuelle Knoten wurde gelöscht.

Wie in der folgenden Abbildung gezeigt:

Daher müssen Sie beim Schleifen von Array-Elementen den leeren Knoten speziell beurteilen:

size_t i;  
Bucket p;  
zval val;
 
for (i=0; i < ht->nTableSize; i++) {  
    p   = ht->arData[i];
    val = p.val;
    if (Z_TYPE(val) == IS_UNDEF) { /* empty hole ? */
        continue; /* skip it */
    }
    /* do something with val */
}
Nach dem Login kopieren

Auch wenn es sich um eine Zehner-Hash-Tabelle handelt, ist das Durchlaufen jedes Knotens und das Überspringen dieser gelöschten Knoten dank der Tatsache, dass die Knoten von arData immer nebeneinander im Speicher gespeichert sind, sehr schnell.

Hash-Standortelement

Wenn wir den Schlüsselnamen einer Zeichenfolge erhalten, müssen wir den Hash-Algorithmus verwenden, um den Hash-Wert zu berechnen, und ihn über den Hash-Wert-Index in arData finden können das entsprechende Element.

Wir können den Hash-Wert nicht direkt als Index des arData-Arrays verwenden, da es keine Garantie dafür gibt, dass die Elemente in der Einfügereihenfolge gespeichert werden.

Zum Beispiel: Wenn der Schlüsselname, den ich einfüge, zuerst foo und dann bar ist, wird davon ausgegangen, dass das Hash-Ergebnis von foo 5 und das Hash-Ergebnis von bar 3 ist. Wenn wir foo in arData[5] und bar in arData[3] speichern, bedeutet dies, dass das bar-Element vor dem foo-Element stehen muss, was genau das Gegenteil der Reihenfolge ist, in der wir es eingefügt haben.

所以,当我们通过算法哈希了键名后,我们需要一张 转换表,转换表保存了哈希后的结果与实际存储的节点的映射关系。

这里在设计的时候取了个巧:将转换表存储以 arData 起始指针为起点做镜面映射存储。这样,我们不需要额外的空间存储,在分配 arData 空间的同时也分配了转换表。

以下是有8个元素的哈希表 + 转换表的数据结构:

现在,当我们要访问 foo 所指的元素时,通过哈希算法得到值后按照哈希表分配的元素大小做取模,就能得到我们在转换表中存储的节点索引值。

如我们所见,转换表中的节点的索引与数组数据元素的节点索引是相反数的关系,nTableMask 等于哈希表大小的负数值,通过取模我们就能得到0到-7之间的数,从而定位到我们所需元素所在的索引值。综上,我们为 arData 分配存储空间时,需要使用 tablesize * sizeof(bucket) + tablesize * sizeof(uint32) 的计算方式计算存储空间大小。

在源码里也清晰的划分了两个区域:

#define HT_HASH_SIZE(nTableMask) (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))
#define HT_DATA_SIZE(nTableSize) ((size_t)(nTableSize) * sizeof(Bucket))
#define HT_SIZE_EX(nTableSize, nTableMask) (HT_DATA_SIZE((nTableSize)) + HT_HASH_SIZE((nTableMask)))
#define HT_SIZE(ht) HT_SIZE_EX((ht)->nTableSize, (ht)->nTableMask)
 
Bucket *arData;  
arData = emalloc(HT_SIZE(ht)); /* now alloc this */
Nach dem Login kopieren

我们将宏替换的结果展开:

(((size_t)(((ht)->nTableSize)) * sizeof(Bucket)) + (((size_t)(uint32_t)-(int32_t)(((ht)->nTableMask))) * sizeof(uint32_t)))
Nach dem Login kopieren

碰撞冲突

接下来我们看看如何解决哈希表的碰撞冲突问题。哈希表的键名可能会被哈希到同一个节点。所以,当我们访问到转换后的节点,我们需要对比键名是否我们查找的。如果不是,我们将通过 zval.u2.next 字段读取链表上的下一个数据。

注意这里的链表结构并没像传统链表一样在在内存中分散存储。我们直接读取 arData 整个数组,而不是通过堆(heap)获取内存地址分散的指针。

这是 PHP7 性能提升的一个重要点。数据局部性让 CPU 不必经常访问缓慢的主存储,而是直接从 CPU 的 L1 缓存中读取到所有的数据。

所以,我们看到向哈希表添加一个元素是这样操作的:

    idx = ht->nNumUsed++;
    ht->nNumOfElements++;
    if (ht->nInternalPointer == HT_INVALID_IDX) {
        ht->nInternalPointer = idx;
    }
    zend_hash_iterators_update(ht, HT_INVALID_IDX, idx);
    p = ht->arData + idx;
    p->key = key;
    if (!ZSTR_IS_INTERNED(key)) {
        zend_string_addref(key);
        ht->u.flags &= ~HASH_FLAG_STATIC_KEYS;
        zend_string_hash_val(key);
    }
    p->h = h = ZSTR_H(key);
    ZVAL_COPY_VALUE(&p->val, pData);
    nIndex = h | ht->nTableMask;
    Z_NEXT(p->val) = HT_HASH(ht, nIndex);
    HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);
Nach dem Login kopieren

同样的规则也适用于删除元素:

#define HT_HASH_TO_BUCKET_EX(data, idx) ((data) + (idx))
#define HT_HASH_TO_BUCKET(ht, idx) HT_HASH_TO_BUCKET_EX((ht)->arData, idx)
 
h = zend_string_hash_val(key); /* get the hash from the key (assuming string key here) */  
nIndex = h | ht->nTableMask; /* get the translation table index */
 
idx = HT_HASH(ht, nIndex); /* Get the slot corresponding to that translation index */  
while (idx != HT_INVALID_IDX) { /* If there is a corresponding slot */  
    p = HT_HASH_TO_BUCKET(ht, idx); /* Get the bucket from that slot */
    if ((p->key == key) || /* Is it the right bucket ? same key pointer ? */
        (p->h == h && /* ... or same hash */
         p->key && /* and a key (string key based) */
         ZSTR_LEN(p->key) == ZSTR_LEN(key) && /* and same key length */
         memcmp(ZSTR_VAL(p->key), ZSTR_VAL(key), ZSTR_LEN(key)) == 0)) { /* and same key content ? */
        _zend_hash_del_el_ex(ht, idx, p, prev); /* that&#39;s us ! delete us */
        return SUCCESS;
    }
    prev = p;
    idx = Z_NEXT(p->val); /* get the next corresponding slot from current one */
}
return FAILURE;
Nach dem Login kopieren

转换表和哈希表的初始化

HT_INVALID_IDX 作为一个特殊的标记,在转换表中表示:对应的数据节点没有有效的数据,直接跳过。

哈希表之所以能极大地减少那些创建时就是空值的数组的开销,得益于他的两步的初始化过程。当新的哈希表被创建时,我们只创建两个转换表节点,并且都赋予 HT_INVALID_IDX 标记。

#define HT_MIN_MASK ((uint32_t) -2)
#define HT_HASH_SIZE(nTableMask) (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))
#define HT_SET_DATA_ADDR(ht, ptr) do { (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); } while (0)
 
static const uint32_t uninitialized_bucket[-HT_MIN_MASK] = {HT_INVALID_IDX, HT_INVALID_IDX};
 
/* hash lazy init */
ZEND_API void ZEND_FASTCALL _zend_hash_init(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC)  
{
    /* ... */
    ht->nTableSize = zend_hash_check_size(nSize);
    ht->nTableMask = HT_MIN_MASK;
    HT_SET_DATA_ADDR(ht, &uninitialized_bucket);
    ht->nNumUsed = 0;
    ht->nNumOfElements = 0;
}
Nach dem Login kopieren

注意到这里不需要使用堆分配内存,而是使用静态的内存区域,这样更轻量。

然后,当第一个元素插入时,我们会完整的初始化哈希表,这时我们才创建所需的转换表的空间(如果不确定数组大小,则默认是8个元素)。这时,我们将使用堆分配内存。

#define HT_HASH_EX(data, idx) ((uint32_t*)(data))[(int32_t)(idx)]
#define HT_HASH(ht, idx) HT_HASH_EX((ht)->arData, idx)
 
(ht)->nTableMask = -(ht)->nTableSize;
HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT));  
memset(&HT_HASH(ht, (ht)->nTableMask), HT_INVALID_IDX, HT_HASH_SIZE((ht)->nTableMask))
Nach dem Login kopieren

HT_HASH 宏能够使用负数偏移量访问转换表中的节点。哈希表的掩码总是负数,因为转换表的节点的索引值是 arData 数组的相反数。这才是C语言的编程之美:你可以创建无数的节点,并且不需要关心内存访问的性能问题。

以下是一个延迟初始化的哈希表结构:

哈希表的碎片化、重组和压缩

当哈希表填充满并且还需要插入元素时,哈希表必须重新计算自身的大小。哈希表的大小总是成倍增长。当对哈希表扩容时,我们会预分配 arBucket 类型的C数组,并且向空的节点中存入值为 UNDEF 的 zval。在节点插入数据之前,这里会浪费 (new_size – old_size) * sizeof(Bucket) 字节的空间。

如果一个有1024个节点的哈希表,再添加元素时,哈希表将会扩容到2048个节点,其中1023个节点都是空节点,这将消耗 1023 * 32 bytes = 32KB 的空间。这是 PHP 哈希表实现方式的缺陷,因为没有完美的解决方案。

编程就是一个不断设计妥协式的解决方案的过程。在底层编程中,就是对 CPU 还是内存的一次取舍。

哈希表可能全是 UNDEF 的节点。当我们插入许多元素后,又删除了它们,哈希表就会碎片化。因为我们永远不会向 arData 中间节点插入数据,这样我们就可能会看到很多 UNDEF 节点。

举个例子来说:

重组 arData 可以整合碎片化的数组元素。当哈希表需要被重组时,首先它会自我压缩。当它压缩之后,会计算是否需要扩容,如果需要的话,同样是成倍扩容。如果不需要,数据会被重新分配到已有的节点中。这个算法不会在每次元素被删除时运行,因为需要消耗大量的 CPU 计算。

以下是压缩后的数组:

压缩算法会遍历所有 arData 里的元素并且替换原来有值的节点为 UNDEF。如下所示:

Bucket *p;  
uint32_t nIndex, i;  
HT_HASH_RESET(ht);  
i = 0;  
p = ht->arData;
 
do {  
    if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) {
        uint32_t j = i;
        Bucket *q = p;
        while (++i < ht->nNumUsed) {
            p++;
            if (EXPECTED(Z_TYPE_INFO(p->val) != IS_UNDEF)) {
                ZVAL_COPY_VALUE(&q->val, &p->val);
                q->h = p->h;
                nIndex = q->h | ht->nTableMask;
                q->key = p->key;
                Z_NEXT(q->val) = HT_HASH(ht, nIndex);
                HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(j);
                if (UNEXPECTED(ht->nInternalPointer == i)) {
                    ht->nInternalPointer = j;
                }
                q++;
                j++;
            }
        }
        ht->nNumUsed = j;
        break;
    }
    nIndex = p->h | ht->nTableMask;
    Z_NEXT(p->val) = HT_HASH(ht, nIndex);
    HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
    p++;
} while (++i < ht->nNumUsed);
Nach dem Login kopieren

结语

到此,PHP 哈希表的实现基础已经介绍完毕,关于哈希表还有一些进阶的内容没有翻译,因为接下来我准备继续分享 PHP 内核的其他知识点,关于哈希表感兴趣的同学可以移步到原文。

Das obige ist der detaillierte Inhalt vonPHP-Hash-Tabellenprinzip. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Verwandte Etiketten:
Quelle:php.cn
Erklärung dieser Website
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn
Beliebte Tutorials
Mehr>
Neueste Downloads
Mehr>
Web-Effekte
Quellcode der Website
Website-Materialien
Frontend-Vorlage
Über uns Haftungsausschluss Sitemap
Chinesische PHP-Website:Online-PHP-Schulung für das Gemeinwohl,Helfen Sie PHP-Lernenden, sich schnell weiterzuentwickeln!