介绍
Buddy Memory Allocation
是内存分配算法的一种,它假定内存的大小为2 N 2^N 2 N (N
为整数),并且总是以2的幂次方为单位分配或者释放内存。
算法
假设某个线程需要申请 m
字节内存,Buddy Memory Allocation
会先在当前所有的空闲空间中找到最小的空间满足2 k ≥ m 2^k \geq m 2 k ≥ m ,如果2 k 2^k 2 k 的一半依然大于等于 m
,说明当前分配的空间过大,则继续将2 k 2^k 2 k 对半分(分裂后的这两块内存区域就成为了互为兄弟关系(buddies
)),不断重复上述操作,直到找到最小的 p
(p ≤ k p \leq k p ≤ k )满足2 p − 1 < m ≤ 2 p 2^{p - 1} < m \leq 2^p 2 p − 1 < m ≤ 2 p 。
下图描述了从16字节中分配3字节的过程(假设系统总共只有16字节内存):
初始状态整个内存只有16字节,是可分配的最小空间;不过由于16字节的一半大于3字节,所以将16字节拆分为两个8字节
同理一个8字节的一半依然大于3字节,继续将其中一个8字节拆分为两个4字节
4字节的一半比3字节小,所以4字节就是可分配的最小内存空间
当某个线程需要释放2 k 2^k 2 k 的内存时,Buddy Memory Allocation
会尝试将这个内存空间及其相邻的兄弟空间一起合并得到一个2 k + 1 2^{k + 1} 2 k + 1 大小的空间,然后一直重复此操作,直到某块内存空间无法和其兄弟空间合并,无法合并的情况有三种:
当前分配的内存空间大小为整个内存空间的大小,所以也就没有兄弟空间
兄弟空间已全部分配
兄弟空间已局部分配
下图描述了从16字节中释放3字节的过程(假设系统总共只有16字节内存):
当前系统分配了一个2字节的空间和一个4字节的空间
此时需要回收被占用的2字节,由于它的兄弟空间没有被占用,所以两个2字节的空间合并为一个4字节的空间
合并后的4字节的空间的兄弟空间同样没有被占用,两个4字节的空间继续合并为1个8字节的空间
合并后的8字节的空间的兄弟空间存在部分占用,无法继续合并
实现
内存
首先定义一个 Memory
类来表示内存,其内部使用一个 byte
数组来存储数据,数组的索引就是内存地址:
1 2 3 4 5 6 7 8 9 10 11 public class Memory { private final byte [] memory; public Memory (int size) { if (size <= 0 ) { throw new IllegalArgumentException ("size should be greater than zero" ); } this .memory = new byte [size]; } }
同时,Memory
类还支持 bool
和 int32
类型的数据读写,从实现的简化考虑,bool
值的读写以一个 byte
为单位;而 int32
的读写以4个 byte
为单位:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public void setBool (int address, boolean value) { checkAddress(address); this .memory[address] = value ? (byte ) 1 : (byte ) 0 ; } public boolean getBool (int address) { checkAddress(address); return this .memory[address] == (byte ) 1 ; } public void setInt32 (int address, int value) { checkAddress(address); byte [] bytes = ByteBuffer.allocate(Constant.INT32_SIZE).putInt(value).array(); setByteArray(address, bytes); } public int getInt32 (int address) { checkAddress(address); if (address + Constant.INT32_SIZE > this .memory.length) { throw new IllegalArgumentException ("address overflow" ); } byte [] bytes = new byte [Constant.INT32_SIZE]; System.arraycopy(this .memory, address, bytes, 0 , Constant.INT32_SIZE); return ByteBuffer.wrap(bytes).getInt(); }
Block
定义 Block
表示系统所分配的内存块,其中 address
表示该 Block
的起始内存地址,同时 Block
借助 Memory
对内存实现读写操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Block { private final int address; private final Memory memory; public Block (int address, Memory memory) { if (address < 0 || address >= memory.getSize()) { throw new IllegalArgumentException ("invalid address" ); } Objects.requireNonNull(memory, "memory should not be null" ); this .address = address; this .memory = memory; } }
下图展示了一个 Block
在内存中的布局:
一个 Block
除了包含用户数据外还需要保存元数据,所以每个 Block
占据的内存会大于用户实际申请的内存;元数据中的第一个字节表示当前内存块是否被使用;第2到5字节表示 sizeClass
,用来计算当前内存块所占据的内存的大小,即2 s i z e C l a s s 2^{sizeClass} 2 s i z e C l a s s ;第6到9字节表示前一个空闲内存块的地址;第10到13字节表示后一个空闲内存块的地址;从第14字节开始就是用户数据。当然,这只是一种很粗犷的布局方式,实际应用中的布局必然比这个精炼。
这里需要前一个/后一个空闲内存块的地址是因为将相同大小的内存块通过双向链表的方式串联在一起,从而能快速找到以及删除某个指定大小的内存块。因为 Buddy Memory Allocation
始终以2 k 2^k 2 k 大小分配内存,假设系统的最大内存为2 N 2^N 2 N ,则可以建立 N
个双向链表,每个双向链表表示当前大小下可用的内存块,如下图所示:
Block
通过 Memory
类提供的 bool
,int32
数据的读写功能来实现对元数据的读写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public void setUsed () { this .memory.setBool(this .address, true ); } public boolean isUsed () { return this .memory.getBool(this .address); } public void setFree () { this .memory.setBool(this .address, false ); } public void setSizeClass (int sizeClass) { this .memory.setInt32(this .address + Constant.OFFSET_SIZE_CLASS, sizeClass); } public int getSizeClass () { return this .memory.getInt32(this .address + Constant.OFFSET_SIZE_CLASS); } public void setPrev (Block block) { this .memory.setInt32(this .address + Constant.OFFSET_PREV, block.getAddress()); } public Block getPrev () { int address = this .memory.getInt32(this .address + Constant.OFFSET_PREV); return address == -1 ? null : new Block (address, this .memory); } public void setNext (Block block) { this .memory.setInt32(this .address + Constant.OFFSET_NEXT, block.address); } public Block getNext () { int address = this .memory.getInt32(this .address + Constant.OFFSET_NEXT); return address == -1 ? null : new Block (address, this .memory); }
BlockList
BlockList
表示一个双向链表,用于存储某个 sizeClass
下的所有空闲内存块,为了实现方便,内部使用了一个哨兵头节点来作为双向链表的头节点,新节点的插入采用头插法的方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 package buddy;import java.util.Objects;public class BlockList { private final Block head; private final int sizeClass; public BlockList (int address, Memory memory, int sizeClass) { if (address < 0 || address >= memory.getSize()) { throw new IllegalArgumentException ("invalid address" ); } Objects.requireNonNull(memory, "memory cannot be null" ); if (sizeClass <= 0 ) { throw new IllegalArgumentException ("invalid sizeClass" ); } this .head = new Block (address, memory); this .head.setSizeClass(sizeClass); this .sizeClass = sizeClass; } public void clear () { this .head.setNext(this .head); this .head.setPrev(this .head); } public boolean isEmpty () { return this .head.getNext().equals(this .head); } public Block getFirst () { if (this .isEmpty()) { throw new RuntimeException ("list must not be empty" ); } return this .head.getNext(); } public void insertFront (Block block) { this .head.insertAfter(block); } public boolean hasAvailableBlock (int size) { return !this .isEmpty() && Block.getActualSize(this .sizeClass) >= size; } public int length () { int length = 0 ; Block block = this .head.getNext(); while (!block.equals(this .head)) { length += 1 ; block = block.getNext(); } return length; } }
由于需要通过哨兵头节点访问下一个可用的内存块,所以每个哨兵头节点就需要知道下一个 Block
的内存起始地址,因此同样需要将哨兵头节点的信息保存在内存中,对于内存大小为2 N 2^N 2 N 的系统来说,一共需要保存 N
个哨兵头节点的信息,这里将内存分为两部分,前一部分保存所有的哨兵头节点,后一部分保存所有的 Block
:
因此第一个 Block
的内存起始位置也就等于所有哨兵节点的大小之和。
内存管理
初始化
定义 Allocator
负责内存的分配和回收,本质上是对 Block
的管理,即 Block
的分裂和合并:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class Allocator { private final Memory memory; private final BlockList[] blockLists; private static final int MIN_SIZE_CLASS = 4 ; private static final int MAX_SIZE_CLASS = 16 ; public Allocator () { int allHeadSentinelSize = this .getMemoryOffset(); int maxMemorySize = (1 << MAX_SIZE_CLASS) + allHeadSentinelSize; this .memory = new Memory (maxMemorySize); this .blockLists = new BlockList [MAX_SIZE_CLASS]; for (int i = 0 ; i < MAX_SIZE_CLASS; i++) { int sizeClass = i + 1 ; int headSentinelAddress = Constant.HEAD_SENTINEL_SIZE * i; this .blockLists[i] = new BlockList (headSentinelAddress, this .memory, sizeClass); this .blockLists[i].clear(); } Block block = new Block (allHeadSentinelSize, this .memory); block.setSizeClass(MAX_SIZE_CLASS); block.setFree(); this .blockLists[MAX_SIZE_CLASS - 1 ].insertFront(block); } }
在这个例子中,我们假设系统最大能支持的内存大小为2 16 2^{16} 2 1 6 个字节,由于哨兵节点也需要占用一部分内存,所以在构造函数中初始化 Memory
的大小为所有哨兵节点占用的内存大小加上 2 16 2^{16} 2 1 6 个字节。同时,系统可分配的 Block
的大小分别为2 1 2^1 2 1 ,2 2 2^2 2 2 ,…,2 15 2^{15} 2 1 5 ,2 16 2^{16} 2 1 6 ,对应需要初始化16个双向链表,这里简单的使用数组来保存这16个双向链表,并初始化对应哨兵头节点的内存起始地址。同时,整个系统在初始状态只有一个 Block
,大小为2 16 2^{16} 2 1 6 。
内存分配
如前面所述,内存分配的第一步是找到满足用户内存需求的最小的 Block
,然后如果 Block
过大则继续将 Block
进行分裂:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 public int alloc (int size) { Block block = null ; for (int i = 0 ; i < MAX_SIZE_CLASS; i++) { BlockList blockList = this .blockLists[i]; if (!blockList.hasAvailableBlock(size)) { continue ; } block = blockList.getFirst(); block = this .split(block, size); block.setUsed(); break ; } if (block == null ) { throw new RuntimeException ("memory is full" ); } return block.getUserAddress(); } private Block split (Block block, int size) { int sizeClass = block.getSizeClass(); while (sizeClass > MIN_SIZE_CLASS && Block.getActualSize(sizeClass - 1 ) >= size) { int newSizeClass = sizeClass - 1 ; Block[] buddies = this .splitToBuddies(block, newSizeClass); block = buddies[0 ]; sizeClass = newSizeClass; } block.removeFromList(); return block; } private Block[] splitToBuddies(Block block, int sizeClass) { block.removeFromList(); Block[] buddies = new Block [2 ]; for (int i = 0 ; i < 2 ; i++) { int address = block.getAddress() + (1 << sizeClass) * i; buddies[i] = new Block (address, this .memory); buddies[i].setFree(); buddies[i].setSizeClass(sizeClass); } for (int i = 1 ; i >= 0 ; i--) { this .blockLists[sizeClass - 1 ].insertFront(buddies[i]); } return buddies; }
内存回收
应用程序要求释放内存时,提交的是用户数据的起始地址,需要先将其转为 Block
的起始地址(减去 Block
元数据的占用空间大小即可),然后尝试将 Block
和其兄弟合并,并将合并后的 Block
加入到空闲列表中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public void free (int userAddress) { Block block = Block.fromUserAddress(userAddress, this .memory); block.setFree(); this .merge(block); } public void merge (Block block) { int sizeClass = block.getSizeClass(); while (sizeClass < MAX_SIZE_CLASS) { Block buddy = this .getBuddy(block, sizeClass); if (buddy.isUsed() || buddy.getSizeClass() != sizeClass) { break ; } buddy.removeFromList(); if (block.getAddress() > buddy.getAddress()) { block = buddy; } sizeClass += 1 ; } block.setSizeClass(sizeClass); this .blockLists[sizeClass - 1 ].insertFront(block); }
这里有个关键的问题在于如何根据 block
的地址知道其兄弟 block
的地址?因为一个 block
会被分为左兄弟和右兄弟两个内存块,如果当前 block
是左兄弟,则右兄弟的地址为 block.getAddress() + 1 << sizeClass
,如果当前 block
是右兄弟,则左兄弟的地址为 block.getAddress() - 1 << sizeClass
。然而由于缺失位置信息我们并不能知道一个 block
是左兄弟还是右兄弟。
原作者在这里巧妙的在不引入额外的元数据的情况下解决了这个问题。首先,对于某个 sizeClass
为 k
的内存块来说,它的起始地址一定是C 2 k C2^k C 2 k ,其中 C
为整数。这里使用数学归纳法来证明,假设系统内存最多支持2 N 2^N 2 N 个字节,则初始状态下整个系统只有一个内存块,k
就等于 N
,该内存块的起始地址为0,满足C 2 k C2^k C 2 k ,取 C = 0
即可。假设某个 sizeClass
为 k
的内存块的起始地址满足C 2 k C2^k C 2 k ,则需要进一步证明分裂后的两个内存块的起始地址为C ′ 2 k − 1 C'2^{k - 1} C ′ 2 k − 1 。而分裂后的内存块的起始地址分别为C 2 k C2^k C 2 k 和C 2 k + 2 k − 1 C2^k + 2^{k - 1} C 2 k + 2 k − 1 ,又C 2 k = ( 2 C ) 2 k − 1 C2^k = (2C)2^{k - 1} C 2 k = ( 2 C ) 2 k − 1 ,C 2 k + 2 k − 1 = ( 2 C + 1 ) 2 k − 1 C2^k + 2^{k - 1} = (2C+ 1)2^{k - 1} C 2 k + 2 k − 1 = ( 2 C + 1 ) 2 k − 1 ,证明完毕。同时,由这些公式可以发现,对于左兄弟内存块来说,C
是偶数,而对于右兄弟内存块来说 C
是奇数。更进一步来说,左右兄弟内存块的地址差异仅在于从低位往高位数的第 k + 1
位不同。
因此,根据某个内存块的地址推算出兄弟内存块的地址只需要将当前内存块的地址从低位往高位数第 k + 1
位反转即可。这种涉及反转比特位的操作就可以使用异或运算,我们可以将内存块的地址和 1 << sizeClass
(也就是2 k 2^k 2 k )进行异或运算,得到的地址就是对应兄弟内存块的地址。
另外,由于哨兵头节点的存在,Memory
内部的数组大小不是严格的2 k 2^k 2 k ,在计算兄弟内存块的地址时,可以先将当前内存块的地址减去哨兵头节点的大小之和,计算出兄弟内存块的地址之后,再加回偏移量:
1 2 3 4 5 6 7 private Block getBuddy (Block block, int sizeClass) { int virtualAddress = block.getAddress() - this .getMemoryOffset(); int buddyVirtualAddress = virtualAddress ^ (1 << sizeClass); int buddyAddress = buddyVirtualAddress + this .getMemoryOffset(); return new Block (buddyAddress, this .memory); }
总结
以上仅作为 Buddy Memory Allocation
算法的示例,不具有实际应用意义,例如完全没有考虑线程安全。完整的代码可参考原作者的 代码 及 Java
版本的 buddy-memory-allocation 。
参考