Netty Memory Pool - eine ausführliche Erklärung des Prinzips von PoolChunk, siehe hier ~

PoolChunkNetty ist ein wichtiger Teil des Speicherpools. Seine Hauptfunktion besteht darin, einen großen Speicherblock aufrechtzuerhalten. Wenn mehr als 8 KB Speicher benötigt werden, wird dies aus der PoolChunkErfassung von. In diesem Artikel wird zunächst PoolChunkdie Gesamtstruktur, dann die Rolle der Hauptattribute und schließlich PoolChunkdie Realisierung der Anwendung und Freigabe großer Speicherblöcke aus der Sicht des Quellcodes erläutert .

1. PoolChunk-Gesamtstruktur

PoolChunkDie angewendete Standardspeichergröße beträgt 16 MB. In Bezug auf die Struktur wird der 16 MB-Speicher in einem ausgeglichenen Binärbaum organisiert. Die von jedem Knoten jeder Schicht des Binärbaums dargestellte Speichergröße ist gleich und jede Schicht des Knotens repräsentiert Die Summe der Gesamtspeichergröße beträgt 16 MB, die Gesamtzahl der Schichten des gesamten Binärbaums beträgt 12 und die Schichtnummer beginnt bei 0. Das Strukturdiagramm lautet wie folgt:

In Bezug auf das obige Bild müssen wir hauptsächlich die folgenden Punkte erklären:

  • Der von einem PoolChunk belegte Gesamtspeicher beträgt 16 MB. Er teilt den 16 MB Speicher gemäß der aktuellen Ebene des Binärbaums auf. Beispielsweise teilt die erste Schicht ihn in zwei 8 MB auf, die zweite Schicht teilt ihn in vier 4 MB auf usw. Der Binärbaum hat höchstens 12 Ebenen, sodass die Speichergröße jedes Blattknotens 8 KB beträgt, dh der Speicher, den PoolChunk zuweisen kann, beträgt mindestens 8 KB und höchstens 16 MB.
  • Es ist ersichtlich, dass der Binärbaum in der Figur 2 ^ 11 = 2048 Blattknoten enthält, sodass die Anzahl der Knoten im gesamten Baum 4095 beträgt. PoolChunk hat diese 4095-Knoten zu einem Array mit einer Länge von 4096 zusammengefasst. Das erste Bit speichert 0, das zweite und dritte Bit speichern 1, das vierte und siebte Bit speichern 2 und so weiter, das Ganze ist tatsächlich Das heißt, der durch die Ebenennummer dargestellte Binärbaum wird in einem Array gespeichert. Das Array hier ist die Tiefenkarte auf der linken Seite. Durch diesen Binärbaum kann die Anzahl der Ebenen schnell über den Index ermittelt werden. Beispielsweise beträgt der Wert von Position 2048 11, was bedeutet, dass er sich auf der 11. Ebene des Binärbaums befindet. Die Struktur von depthMap ist unten dargestellt:

  • Auf jedem Knoten des Binärbaums in der Abbildung haben wir eine Zahl für die Speichergröße markiert, die durch den aktuellen Knoten dargestellt wird. Diese Zahl stellt tatsächlich die Speichergröße dar, die der aktuelle Knoten zuweisen kann, z. B. 0 für 16 MB, 1 für 8 MB usw. . Diese Zahlen werden von der memoryMap gespeichert, die die Größe des zuweisbaren Speichers darstellt, der von jedem Knoten im Binärbaum dargestellt wird, und seine Datenstruktur entspricht genau der der depthMap. In der Abbildung entspricht die Größe des zuweisbaren Speichers, der von jedem übergeordneten Knoten dargestellt wird, der Summe der beiden untergeordneten Knoten. Wenn der Speicher eines untergeordneten Knotens zugewiesen wurde, wird der Knoten mit 12 markiert, was angibt, dass er zugewiesen wurde, und deren Der übergeordnete Knoten wird auf den Wert eines anderen untergeordneten Knotens aktualisiert, was angibt, dass der Speicher, den der übergeordnete Knoten zuweisen kann, die Summe des von seinen beiden untergeordneten Knoten bereitgestellten Speichers ist.
  • Für den Gesamtprozess der Anwendung von PoolChunk und der Freigabe des Speichers beschreiben wir die Anwendung für 9 KB Speicher:
    1. Zunächst wird 9 KB = 9126 auf einen Exponenten erweitert, der größer als die ersten 2 ist, dh 2 << 13 = 16384. Da der Blattknoten 8 KB = 2 << 12 ist, beträgt die entsprechende Anzahl von Schichten 11, also die Anzahl von Schichten, in denen sich 16384 befindet Es ist 10, was bedeutet, dass Sie einen nicht zugewiesenen Knoten der 10. Schicht aus dem Speicherpool finden können.
    2. Nachdem sich der Zielknoten in der 10. Schicht befindet, beginnt der Vergleich am Kopfknoten. Wenn der vom Kopfknoten gespeicherte Wert kleiner als 10 ist, bedeutet dies, dass er über genügend Speicher verfügt, um den Zielspeicher zuzuweisen, und dann wird er es tun Der linke untergeordnete Knoten wird mit 10 verglichen. Wenn der linke untergeordnete Knoten größer als 10 ist (im Allgemeinen wurde der linke untergeordnete Knoten zu diesem Zeitpunkt zugewiesen und sein Wert ist 12, also größer als 10), ist der rechte untergeordnete Knoten 10 zum Vergleich, zu diesem Zeitpunkt ist der rechte untergeordnete Knoten definitiv kleiner als 10, dann wird der obige Vergleich vom linken untergeordneten Knoten des rechten untergeordneten Knotens fortgesetzt;
    3. Im Vergleich zu einem bestimmten Moment ist die Nummer eines Knotens gleich 10, was bedeutet, dass dieser Ort der Speicherblock ist, den wir benötigen. Dann wird er als 12 markiert und der von seinem übergeordneten Knoten dargestellte Speicher wird rekursiv zurückverfolgt Der Wert wird auf den Wert eines anderen untergeordneten Knotens aktualisiert.

  • In Bezug auf die Zuweisung von Speicher ist das letzte Problem, das hier erklärt werden muss, dass wir durch die obige Berechnungsmethode einen Knoten als unseren zuzuordnenden Zielknoten finden können. Zu diesem Zeitpunkt müssen wir die Startadresse und die Länge des durch diesen Knoten dargestellten Speichers zurückgeben. . Da wir nur den Adresswert des 16M-Speichers haben, der vom gesamten PoolChunk angefordert wird, kann der Versatz des Knotens relativ zur Startadresse des gesamten Speicherblocks durch die Schichtnummer des Zielknotens und die Anzahl der Knoten in der Schicht berechnet werden. Auf diese Weise kann der Startadressenwert des Knotens erhalten werden. Über die vom Knoten belegte Speicherlänge kann ein intuitives Gefühl als eine Zuordnung verstanden werden, wie beispielsweise 11 für 8 KB Länge, 10 für 16 KB Länge und so weiter. Natürlich wird die Berechnung der Startadresse und des Offsets hier nicht direkt von PoolChunk über diesen Algorithmus implementiert, sondern durch effizientere Bitoperationen.

2. Die Rolle der Hauptattribute von PoolChunk

Ich glaube, dass die meisten Leser beim Lesen des Quellcodes des Netty-Speicherpools durch seine verschiedenen komplexen Attribute verwirrt sind, was das Verständnis erschwert. Hier listen wir seine Attribute separat auf, damit die Leser die Rolle jedes Attributs beim Lesen des Quellcodes schneller verstehen können.

// netty内存池总的数据结构,该类我们后续会对其进行讲解
final PoolArena<T> arena;
// 当前申请的内存块,比如对于堆内存,T就是一个byte数组,对于直接内存,T就是ByteBuffer,
// 但无论是哪种形式,其内存大小都默认是16M
final T memory;
// 指定当前是否使用内存池的方式进行管理
final boolean unpooled;
// 表示当前申请的内存块中有多大一部分是用于站位使用的,整个内存块的大小是16M+offset,默认该值为0
final int offset;
// 存储了当前代表内存池的二叉树的各个节点的内存使用情况,该数组长度为4096,二叉树的头结点在该数组的
// 第1号位,存储的值为0;两个一级子节点在该数组的第2号位和3号位,存储的值为1,依次类推。二叉树的叶节点
// 个数为2048,因而总节点数为4095。在进行内存分配时,会从头结点开始比较,然后比较左子节点,然后比较右
// 子节点,直到找到能够代表目标内存块的节点。当某个节点所代表的内存被申请之后,该节点的值就会被标记为12,
// 表示该节点已经被占用
private final byte[] memoryMap;
// 这里depthMap存储的数据结构与memoryMap是完全一样的,只不过其值在初始化之后一直不会发生变化。
// 该数据的主要作用在于通过目标索引位置值找到其在整棵树中对应的层数
private final byte[] depthMap;
// 这里每一个PoolSubPage代表了二叉树的一个叶节点,也就是说,当二叉树叶节点内存被分配之后,
// 其会使用一个PoolSubPage对其进行封装
private final PoolSubpage<T>[] subpages;

// 其值为-8192,二进制表示为11111111111111111110000000000000,它的后面0的个数正好为12,而2^12=8192,
// 因而将其与用户希望申请的内存大小进行“与操作“,如果其值不为0,就表示用户希望申请的内存在8192之上,从而
// 就可以快速判断其是在通过PoolSubPage的方式进行申请还是通过内存计算的方式。
private final int subpageOverflowMask;
// 记录了每个业节点内存的大小,默认为8192,即8KB
private final int pageSize;
// 页节点所代表的偏移量,默认为13,主要作用是计算目标内存在内存池中是在哪个层中,具体的计算公式为:
// int d = maxOrder - (log2(normCapacity) - pageShifts);
// 比如9KB,经过log2(9KB)得到14,maxOrder为11,计算就得到10,表示9KB内存在内存池中为第10层的数据
private final int pageShifts;
// 默认为11,表示当前你最大的层数
private final int maxOrder;
// 记录了当前整个PoolChunk申请的内存大小,默认为16M
private final int chunkSize;
// 将chunkSize取2的对数,默认为24
private final int log2ChunkSize;
// 指定了代表叶节点的PoolSubPage数组所需要初始化的长度
private final int maxSubpageAllocs;

// 指定了某个节点如果已经被申请,那么其值将被标记为unusable所指定的值
private final byte unusable;
// 对创建的ByteBuffer进行缓存的一个队列
private final Deque<ByteBuffer> cachedNioBuffers;

// 记录了当前PoolChunk中还剩余的可申请的字节数
private int freeBytes;

// 在Netty的内存池中,所有的PoolChunk都是由当前PoolChunkList进行组织的,
// 关于PoolChunkList和其前置节点以及后置节点我们会在后续进行讲解,本文主要专注于PoolChunk的讲解
PoolChunkList<T> parent;
// 在PoolChunkList中当前PoolChunk的前置节点
PoolChunk<T> prev;
// 在PoolChunkList中当前PoolChunk的后置节点
PoolChunk<T> next;

3. Implementierung des Quellcodes

In Bezug auf PoolChunkdie Funktionen erklären wir hauptsächlich den Prozess der Speicherzuweisung und -wiederherstellung.

3.1 Speicherzuordnung

PoolChunkDie Speicherzuweisung von liegt hauptsächlich in ihrer allocate()Methode, und die Gesamtbeschreibung der Zuweisung wurde bereits zuvor erläutert. Daher werde ich sie hier nicht wiederholen. Wir werden den Quellcode direkt eingeben, um Folgendes zu lesen:

boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
  final long handle;
  // 这里subpageOverflowMask=-8192,通过判断的结果可以看出目标容量是否小于8KB。
  // 在下面的两个分支逻辑中,都会返回一个long型的handle,一个long占8个字节,其由低位的4个字节和高位的
  // 4个字节组成,低位的4个字节表示当前normCapacity分配的内存在PoolChunk中所分配的节点在整个memoryMap
  // 数组中的下标索引;而高位的4个字节则表示当前需要分配的内存在PoolSubPage所代表的8KB内存中的位图索引。
  // 对于大于8KB的内存分配,由于其不会使用PoolSubPage来存储目标内存,因而高位四个字节的位图索引为0,
  // 而低位的4个字节则还是表示目标内存节点在memoryMap中的位置索引;
  // 对于低于8KB的内存分配,其会使用一个PoolSubPage来表示整个8KB内存,因而需要一个位图索引来表示目标内存
  // 也即normCapacity会占用PoolSubPage中的哪一部分的内存。
  if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
    // 申请高于8KB的内存
    handle = allocateRun(normCapacity);
  } else {
    // 申请低于8KB的内存
    handle = allocateSubpage(normCapacity);
  }

  // 如果返回的handle小于0,则表示要申请的内存大小超过了当前PoolChunk所能够申请的最大大小,也即16M,
  // 因而返回false,外部代码则会直接申请目标内存,而不由当前PoolChunk处理
  if (handle < 0) {
    return false;
  }

  // 这里会从缓存的ByteBuf对象池中获取一个ByteBuf对象,不存在则返回null
  ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null;
  // 通过申请到的内存数据对获取到的ByteBuf对象进行初始化,如果ByteBuf为null,则创建一个新的然后进行初始化
  initBuf(buf, nioBuffer, handle, reqCapacity);
  return true;
}

Es ist ersichtlich, dass für die Speicherzuweisung hauptsächlich beurteilt wird, ob sie größer als 8 KB ist. Wenn sie größer als 8 KB ist, wird sie direkt im Binärbaum von PoolChunk zugewiesen. Wenn sie kleiner als 8 KB ist, gilt sie direkt für einen 8 KB-Speicher und dann 8 KB Speicher Zur Wartung an eine PoolSubpage übergeben. Das Implementierungsprinzip von PoolSubpage wird später erläutert. Hier wird es nur kurz erläutert, damit die Leser das Konzept des Bitmap-Index besser verstehen. Wenn wir 8 KB Speicher aus dem Binärbaum von PoolChunk beantragen, wird dieser zur Wartung an eine PoolSubpage übergeben. In PoolSubpage wird die gesamte Speicherblockgröße in eine Reihe von 16-Byte-Größen unterteilt, hier sind es 8 KB, dh es wird in 512 = 8 KB / 16 Byte-Kopien unterteilt. Um festzustellen, ob jede Kopie belegt ist, verwendet PoolSubpage ein langes Array, um sie darzustellen. Der Name des Arrays lautet Bitmap, daher nennen wir es ein Bitmap-Array. Um anzuzeigen, ob 512 Datenelemente belegt sind oder nicht, hat ein Long nur 64 Bytes, daher werden hier 8 = 512/64 Longs zur Darstellung benötigt, sodass hier das Long-Array anstelle eines einzelnen Long-Felds verwendet wird. Der Leser hätte jedoch feststellen müssen, dass die hohen 32 Bits des Handles hier ein ganzzahliger Wert sind und die von uns beschriebene Bitmap ein langes Array ist. Wie zeigt eine Ganzzahl an, dass der aktuell angeforderte Speicher das Zahlenelement im Bitmap-Array ist? Und die Anzahl der Ganzzahlen 64 Bytes im Element. Dies wird tatsächlich durch die niedrigen 67 Bits der Ganzzahl dargestellt. Die 64. und 67. Bits werden verwendet, um die Anzahl der langen Elemente in dem aktuell belegten Bitmap-Array anzuzeigen , und die 1 64 Bits werden verwendet, um die Anzahl der langen Elemente in dem langen Element anzugeben. Wie viele sind von der aktuellen Anwendung belegt? Daher wird nur ein langes Integer-Handle benötigt, um die Position des aktuell angewendeten Speichers im gesamten Speicherpool und die Position auf der PoolSubpage anzugeben. Hier lesen wir zuerst allocateRun()den Quellcode der Methode:

private long allocateRun(int normCapacity) {
  // 这里maxOrder为11,表示整棵树最大的层数,log2(normCapacity)会将申请的目标内存大小转换为大于该大小的
  // 第一个2的指数次幂数然后取2的对数的形式,比如log2(9KB)转换之后为14,这是因为大于9KB的第一个2的指数
  // 次幂为16384,将其取2的对数后为14。pageShifts默认为13,这里整个表达式的目的就是快速计算出申请目标
  // 内存(normCapacity)需要对应的层数。
  int d = maxOrder - (log2(normCapacity) - pageShifts);
  // 通过前面讲的递归方式从先父节点,然后左子节点,接着右子节点的方式依次判断其是否与目标层数相等,
  // 如果相等,则会将该节点所对应的在memoryMap数组中的位置索引返回
  int id = allocateNode(d);
  // 如果返回值小于0,则说明在当前PoolChunk中无法分配目标大小的内存,这一般是由于目标内存大于16M,
  // 或者当前PoolChunk已经分配了过多的内存,剩余可分配的内存不足以分配目标内存大小导致的
  if (id < 0) {
    return id;
  }

  // 更新剩余可分配内存的值
  freeBytes -= runLength(id);
  return id;
}

Diese allocateRun()Methode berechnet zuerst die Anzahl der Binärbaumebenen, die dem Zielspeicher entsprechen, und ermittelt dann rekursiv, ob der Binärbaum einen entsprechenden Knoten enthält, und kehrt direkt zurück, wenn er gefunden wird. Hier schauen wir uns die allocateNode()Methode weiter an, um zu sehen, wie sie den Binärbaum rekursiv durchläuft:

private int allocateNode(int d) {
  int id = 1;
  int initial = -(1 << d);
  // 获取memoryMap中索引为id的位置的数据层数,初始时获取的就是根节点的层数
  byte val = value(id);
  // 如果更节点的层数值都比d要大,说明当前PoolChunk中没有足够的内存用于分配目标内存,直接返回-1
  if (val > d) {
    return -1;
  }

  // 这里就是通过比较当前节点的值是否比目标节点的值要小,如果要小,则说明当前节点所代表的子树是能够
  // 分配目标内存大小的,则会继续遍历其左子节点,然后遍历右子节点
  while (val < d || (id & initial) == 0) {
    id <<= 1;
    val = value(id);
    // 这里val > d其实就是表示当前节点的数值比目标数值要大,也就是说当前节点是没法申请到目标容量的内存,
    // 那么就会执行 id ^= 1,其实也就是将id切换到当前节点的兄弟节点,本质上其实就是从二叉树的
    // 左子节点开始查找,如果左子节点无法分配目标大小的内存,那么就到右子节点进行查找
    if (val > d) {
      id ^= 1;
      val = value(id);
    }
  }

  // 当找到之后,获取该节点所在的层数
  byte value = value(id);
  // 将该memoryMap中该节点位置的值设置为unusable=12,表示其已经被占用
  setValue(id, unusable);
  // 递归的更新父节点的值,使其继续保持”父节点存储的层数所代表的内存大小是未分配的
  // 子节点的层数所代表的内存之和“的语义。
  updateParentsAlloc(id);
  return id;
}

Die allocateNode()Hauptlogik der Methode besteht darin, den Indexindexwert in der memoryMap im Zielspeicher zu finden und den übergeordneten Knotenwert des angewendeten Knotens zu aktualisieren. Werfen wir einen Blick auf allocateSubpage()das Implementierungsprinzip:

private long allocateSubpage(int normCapacity) {
  // 这里其实也是与PoolThreadCache中存储PoolSubpage的方式相同,也是采用分层的方式进行存储的,
  // 具体是取目标数组中哪一个元素的PoolSubpage则是根据目标容量normCapacity来进行的。
  PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
  int d = maxOrder;
  synchronized (head) {
    // 这里调用allocateNode()方法在二叉树中查找时,传入的d值maxOrder=11,也就是说,其本身就是
    // 直接在叶节点上查找可用的叶节点位置
    int id = allocateNode(d);
    // 小于0说明没有符合条件的内存块
    if (id < 0) {
      return id;
    }

    final PoolSubpage<T>[] subpages = this.subpages;
    final int pageSize = this.pageSize;

    freeBytes -= pageSize;

    // 计算当前id对应的PoolSubpage数组中的位置
    int subpageIdx = subpageIdx(id);
    PoolSubpage<T> subpage = subpages[subpageIdx];
    // 这里主要是通过一个PoolSubpage对申请到的内存块进行管理,具体的管理方式我们后续文章中会进行讲解。
    if (subpage == null) {
      // 这里runOffset()方法会返回该id在PoolChunk中维护的字节数组中的偏移量位置,
      // normCapacity则记录了当前将要申请的内存大小;
      // pageSize记录了每个页的大小,默认为8KB
      subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
      subpages[subpageIdx] = subpage;
    } else {
      subpage.init(head, normCapacity);
    }

    // 通过PoolSubpage申请一块内存,并且返回代表该内存块的位图索引,位图索引的具体计算方式,
    // 我们前面已经简要讲述,详细的实现原理我们后面会进行讲解。
    return subpage.allocate();
  }
}

Wie Sie hier sehen können, besteht die allocateSubpage()Hauptmethode darin, den angeforderten 8-KB-Speicher zur Verwaltung auf eine PoolSubpage zu übertragen und den Bitmap-Index der Antwort zurückzugeben. Die Generierungsmethode des Handle-Parameters wurde hier erläutert . Das Prinzip allocate()des initBuf()Methodenaufrufs in der Methode ist relativ einfach. Im Wesentlichen berechnet es zuerst den Adresswert der Startposition des angewendeten Speicherblocks und die Länge des angewendeten Speicherblocks und dann Es wird auf ein ByteBuf-Objekt gesetzt, um es zu initialisieren, und sein Implementierungsprinzip wird hier nicht wiederholt.

3.2 Speicherfreigabe

In Bezug auf das Prinzip der Speicherfreigabe ist es relativ einfach. Durch die vorherige Erklärung können wir sehen, dass die Anwendung des Speichers darin besteht, den Speicherblock zu finden, der im Hauptspeicherblock angewendet werden kann, und dann seine Position wie die Schichtnummer oder den Bitmap-Index darzustellen Die Marke wird vergeben. Dann muss der Freigabeprozess hier tatsächlich zurückkehren und diese Flags dann zurücksetzen. Hier erklären wir den Quellcode der Speicherfreigabe mit dem direkten Speicherfreigabeprozess (ByteBuffer):

void free(long handle, ByteBuffer nioBuffer) {
  int memoryMapIdx = memoryMapIdx(handle);	// 根据当前内存块在memoryMap数组中的位置
  int bitmapIdx = bitmapIdx(handle);	// 获取当前内存块的位图索引

  // 如果位图索引不等于0,说明当前内存块是小于8KB的内存块,因而将其释放过程交由PoolSubpage进行
  if (bitmapIdx != 0) {
    PoolSubpage<T> subpage = subpages[subpageIdx(memoryMapIdx)];
    PoolSubpage<T> head = arena.findSubpagePoolHead(subpage.elemSize);
    synchronized (head) {
      // 由PoolSubpage释放内存
      if (subpage.free(head, bitmapIdx & 0x3FFFFFFF)) {
        return;
      }
    }
  }

  // 走到这里说明需要释放的内存大小大于8KB,这里首先计算要释放的内存块的大小
  freeBytes += runLength(memoryMapIdx);
  // 将要释放的内存块所对应的二叉树的节点对应的值进行重置
  setValue(memoryMapIdx, depth(memoryMapIdx));
  // 将要释放的内存块所对应的二叉树的各级父节点的值进行更新
  updateParentsFree(memoryMapIdx);

  // 将创建的ByteBuf对象释放到缓存池中,以便下次申请时复用
  if (nioBuffer != null && cachedNioBuffers != null &&
      cachedNioBuffers.size() < PooledByteBufAllocator
        .DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
    cachedNioBuffers.offer(nioBuffer);
  }
}

Es ist ersichtlich, dass die Freigabe des Speichers hier hauptsächlich dazu dient, festzustellen, ob er weniger als 8 KB beträgt. Wenn er weniger als 8 KB beträgt, wird er zur Verarbeitung an PoolSubpage übergeben, andernfalls wird er durch einen Binärbaum zurückgesetzt.

4. Zusammenfassung

In diesem Artikel wird zunächst die Gesamtstruktur von PoolChunk und das Realisierungsprinzip des ausgeglichenen Binärbaums in PoolChunk ausführlich erläutert. Anschließend wird jeder Attributwert in PoolChunk beschrieben, damit die Leser den Quellcode leichter verstehen können. Abschließend haben wir die beiden Hauptfunktionen von PoolChunk ausführlich erläutert: das Realisierungsprinzip der Speicherzuweisung und -freigabe.

Ich denke du magst

Origin blog.csdn.net/doubututou/article/details/109250066
Empfohlen
Rangfolge