下次适应算法Java实现:内存分配与碎片管理详解

发布时间:2026/10/16 1:11:02
下次适应算法Java实现:内存分配与碎片管理详解 1. 存储分配到底在分什么从内存碎片说起做过底层系统或者写过内存池的朋友应该都有体会存储分配这件事看起来简单真动手写起来坑特别多。你有一块连续的内存空间程序运行过程中不断有进程进来申请内存、用完又释放内存时间一长这块空间就被切得七零八落。大块的空闲区域被小块占用分割等到来了一个需要大块连续空间的进程明明总空闲量够但就是找不到一块完整的区域放进去。这就是内存碎片问题也是所有存储分配算法要解决的核心矛盾。存储分配算法要干的事情说白了就一件在一条连续的内存条上维护好哪些区域被占了、哪些区域空着当有新进程需要内存时按照某种策略从空闲区域里切一块出来给它。不同的策略就是不同的算法常见的有首次适应、最佳适应、最坏适应、循环首次适应以及我这篇要重点聊的下次适应Next Fit算法。下次适应算法在教科书里经常被一笔带过很多人觉得它跟首次适应差不多没什么好讲的。但实际写代码实现一遍就会发现这个算法在工程上有它独特的价值尤其是它天然带有的“均匀磨损”特性在某些场景下比首次适应更实用。我用Java完整实现了一遍代码里加了详细注释下面把设计思路、实现细节、踩过的坑都摊开来讲。这篇文章适合谁看如果你正在学操作系统内存管理、准备相关考试、或者想自己动手写一个简易内存分配器那这篇内容可以直接拿来参考。代码是完整可运行的注释也写得比较细你可以直接抄作业也可以根据自己的需求改。2. 下次适应算法的核心思路拆解2.1 和首次适应的关键区别在哪先说说首次适应算法是怎么做的。它每次分配都从内存的起始位置开始扫描找到第一个足够大的空闲分区就分配。这个策略的问题在于内存低地址部分会被反复切割形成大量小碎片而高地址部分的大空闲区一直没被用到。时间长了低地址区域全是碎片分配效率越来越低。下次适应算法做了一个很聪明的改动不从起始位置开始扫描而是从上一次分配结束的位置继续往后找。找到足够大的空闲分区就分配如果扫到内存末尾还没找到就绕回到开头继续找形成一个循环扫描的机制。这个改动带来的效果很直接空闲区域的切割被分散到了整条内存上不会集中在一端。低地址和高地址的空闲区都会被均匀地使用和消耗碎片分布更均匀。代价是它可能把一个大空闲区切碎导致后面需要大块连续空间的请求失败这一点跟首次适应是一样的。2.2 为什么用循环指针而不是每次从头扫这里有个设计决策值得展开说。下次适应算法用一个循环指针有的地方叫“上次分配位置指针”来记录上一次扫描结束的位置。每次分配从这个指针开始往后找找到合适的分区后把指针更新到这次分配结束的位置。为什么这么设计核心目的是避免每次都从头扫描带来的局部集中问题。你可以把内存想象成一排停车位首次适应就是每次来车都从第一个车位开始找空位结果前面的车位被反复停、反复走磨损严重后面的车位一直空着。下次适应就是接着上次停的位置往后找车位使用更均匀。从实现角度看这个指针的维护成本很低就是一个整数变量记录数组下标。但它的存在让算法的行为跟首次适应有了本质区别。我在代码里把这个指针命名为lastAllocatedIndex每次分配后更新它分配失败时也要正确处理指针的回绕逻辑。2.3 算法适用场景和局限性下次适应算法适合什么场景我个人经验是内存分配请求大小比较均匀、且对分配速度有要求的场景比较合适。因为它不需要每次从头扫描平均扫描长度比首次适应短分配速度会快一些。另外它的均匀分配特性在长时间运行、频繁分配释放的系统中能避免内存一端过早碎片化。局限性也很明显。第一它不保证找到最优的空闲分区可能把大分区切碎导致后续大请求失败。第二它的性能依赖于请求大小的分布如果请求大小波动很大效果可能不如最佳适应。第三实现上比首次适应多了一个指针维护的逻辑虽然不复杂但边界条件要处理好否则容易出bug。注意下次适应算法在释放内存时需要做相邻空闲分区合并否则碎片会越来越严重。合并逻辑是很多初学者容易忽略的地方后面我会专门讲。3. Java实现的核心细节与数据结构设计3.1 用什么数据结构表示内存分区实现存储分配算法第一个要决定的就是数据结构。常见的选择有两种数组模拟和链表。数组模拟就是开一个固定大小的数组每个元素代表一个内存单元用不同的值标记空闲还是占用。链表则是把每个分区作为一个节点节点里记录起始地址、大小、状态节点之间用指针连接。我这次选的是链表方式原因是链表在插入、删除、合并分区时操作更自然不需要像数组那样搬移大量元素。每个分区节点包含四个核心字段起始地址start、大小size、状态isFree、下一个节点引用next。整个内存用一个头节点表示头节点不存实际数据只是链表的入口。用链表还有一个好处就是空闲分区和占用分区可以统一管理不需要单独维护一个空闲链表。分配时遍历链表找空闲节点释放时把对应节点标记为空闲然后检查前后节点是否需要合并。这种设计代码量少逻辑清晰适合教学和中小规模场景。3.2 循环指针的维护逻辑循环指针是下次适应算法的灵魂我用一个lastAllocatedIndex变量来记录。这里有个细节要注意链表不像数组有天然的下标所以我用节点在链表中的序号来模拟下标。每次分配时从lastAllocatedIndex对应的节点开始往后遍历如果遍历到链表末尾还没找到就从头节点重新开始直到回到起始位置为止。指针更新时机也很关键。分配成功后把指针更新为这次分配节点的下一个节点序号。如果分配的是最后一个节点指针回绕到0。分配失败时指针保持不变避免无效更新。这个逻辑我在代码里用了一个辅助方法getNodeByIndex来根据序号获取节点遍历时用计数器跟踪当前序号。这里有个容易踩的坑链表在分配和释放过程中长度会变化节点序号不是固定的。所以指针记录的是“逻辑位置”每次操作前需要重新计算。我在实现时每次分配前先遍历一遍链表建立序号映射虽然多了一次遍历但逻辑简单不容易出错。如果追求性能可以用双向链表加节点引用直接定位但代码复杂度会上升。3.3 分区分配与切割的实现分配逻辑是算法的核心。当找到一个足够大的空闲分区时有两种情况分区大小刚好等于请求大小直接把这个节点标记为占用分区大小大于请求大小需要把这个分区切割成两部分前一部分分配给请求后一部分作为新的空闲分区插入链表。切割操作的代码要小心处理节点插入顺序。假设当前节点是current大小是size请求大小是requestSize那么切割后当前节点的大小变为requestSize状态变为占用新建一个节点起始地址是current.start requestSize大小是size - requestSize状态为空闲插入到current和current.next之间。这里有个边界条件如果剩余大小为0就不要创建新节点否则会出现大小为0的空闲分区影响后续遍历和合并逻辑。我在代码里加了判断只有剩余大小大于0才创建新节点。4. 完整实操过程与关键代码解析4.1 分区节点类的定义先看节点类的定义这是整个实现的基础。字段设计要覆盖起始地址、大小、状态和链表指针另外我加了一个id字段方便调试时区分不同分区。class Partition { int id; // 分区编号方便调试 int start; // 起始地址 int size; // 分区大小 boolean isFree; // 是否空闲 Partition next; // 下一个分区 public Partition(int id, int start, int size, boolean isFree) { this.id id; this.start start; this.size size; this.isFree isFree; this.next null; } }这个类很简单但字段的含义要清楚。start是分区在内存中的起始地址size是分区长度两者结合就能确定分区范围。isFree标记状态分配和释放都围绕这个字段做文章。next是链表指针把所有分区串起来。4.2 内存管理类的整体结构内存管理类我命名为NextFitAllocator核心字段包括头节点、循环指针、分区编号计数器和总内存大小。public class NextFitAllocator { private Partition head; // 链表头节点哨兵 private int lastAllocatedIndex; // 下次适应循环指针 private int idCounter; // 分区编号计数器 private int totalSize; // 总内存大小 public NextFitAllocator(int totalSize) { this.totalSize totalSize; this.head new Partition(0, 0, 0, true); // 哨兵节点 this.head.next new Partition(1, 0, totalSize, true); this.lastAllocatedIndex 0; this.idCounter 2; } }构造函数里初始化了一块完整的内存起始地址0大小是传入的totalSize状态为空闲。哨兵节点的作用是简化链表操作避免处理头节点为空的特殊情况。lastAllocatedIndex初始为0表示从第一个实际分区开始扫描。4.3 分配方法的核心实现分配方法是重头戏逻辑分几步从循环指针位置开始遍历找到第一个足够大的空闲分区切割并分配更新指针。public boolean allocate(int requestSize) { if (requestSize 0) { System.out.println(请求大小必须大于0); return false; } // 先统计链表长度用于指针回绕 int listLength getListLength(); if (listLength 0) return false; int startIndex lastAllocatedIndex % listLength; int currentIndex startIndex; int scanned 0; while (scanned listLength) { Partition current getNodeByIndex(currentIndex); if (current ! null current.isFree current.size requestSize) { // 找到合适分区执行分配 if (current.size requestSize) { current.isFree false; } else { // 切割分区 Partition newFree new Partition( idCounter, current.start requestSize, current.size - requestSize, true ); newFree.next current.next; current.next newFree; current.size requestSize; current.isFree false; } // 更新循环指针到下一个位置 lastAllocatedIndex (currentIndex 1) % (listLength 1); System.out.println(分配成功大小 requestSize 起始地址 current.start); return true; } currentIndex (currentIndex 1) % listLength; scanned; } System.out.println(分配失败没有足够大的空闲分区); return false; }这段代码有几个关键点。第一先计算链表长度因为分配过程中链表可能变长用固定长度做模运算保证指针回绕正确。第二扫描次数限制为链表长度避免死循环。第三切割后指针更新用(currentIndex 1) % (listLength 1)加1是因为切割后链表多了一个节点。4.4 释放方法与相邻分区合并释放方法相对简单但合并逻辑是重点。释放时把对应分区标记为空闲然后检查前后相邻分区是否也是空闲如果是就合并。public boolean free(int startAddress) { Partition prev head; Partition current head.next; while (current ! null) { if (current.start startAddress !current.isFree) { current.isFree true; System.out.println(释放成功起始地址 startAddress); // 向后合并 if (current.next ! null current.next.isFree) { current.size current.next.size; current.next current.next.next; } // 向前合并 if (prev ! head prev.isFree) { prev.size current.size; prev.next current.next; } return true; } prev current; current current.next; } System.out.println(释放失败未找到对应分区); return false; }合并逻辑要注意顺序先向后合并再向前合并。向后合并是把当前分区和下一个空闲分区合并向前合并是把前一个空闲分区和当前分区合并。如果先向前合并prev的next指针会变化再处理向后合并时容易出错。这个顺序是我调试时踩过坑才确定的。4.5 辅助方法与打印输出辅助方法包括获取链表长度、根据序号获取节点、打印内存状态。这些方法虽然简单但调试时非常有用。private int getListLength() { int len 0; Partition p head.next; while (p ! null) { len; p p.next; } return len; } private Partition getNodeByIndex(int index) { Partition p head.next; int i 0; while (p ! null) { if (i index) return p; i; p p.next; } return null; } public void printMemory() { System.out.println( 当前内存状态 ); Partition p head.next; while (p ! null) { System.out.printf(分区%d: 起始%d, 大小%d, 状态%s%n, p.id, p.start, p.size, p.isFree ? 空闲 : 占用); p p.next; } System.out.println(下次适应指针位置: lastAllocatedIndex); }打印方法输出每个分区的编号、起始地址、大小和状态最后输出循环指针位置。调试时一眼就能看出内存布局和指针走向非常直观。4.6 测试用例与运行结果写个主方法跑一遍验证分配、释放、合并、指针回绕这些逻辑。public static void main(String[] args) { NextFitAllocator allocator new NextFitAllocator(100); allocator.allocate(20); allocator.printMemory(); allocator.allocate(30); allocator.printMemory(); allocator.allocate(15); allocator.printMemory(); allocator.free(0); allocator.printMemory(); allocator.free(50); allocator.printMemory(); allocator.allocate(25); allocator.printMemory(); }运行结果会依次展示每次操作后的内存状态。第一次分配20从地址0开始指针移到下一个分区。第二次分配30从指针位置继续找分配到地址20。第三次分配15分配到地址50。释放地址0后地址0的分区变空闲。释放地址50后地址50的分区变空闲并且会跟后面的空闲分区合并。最后分配25从指针位置继续找验证循环逻辑。5. 常见问题与排查技巧实录5.1 指针回绕导致的死循环这是最容易踩的坑。如果指针更新逻辑写错比如分配失败时也更新指针或者模运算的基数不对就会导致指针一直在某个范围打转扫描永远结束不了。我的解决办法是用扫描计数器限制循环次数不管指针怎么走最多扫描链表长度次就退出。这样即使指针逻辑有bug也不会死循环最多是分配失败。5.2 分区合并时的指针失效释放分区后如果发生了合并链表结构会变化之前记录的节点引用可能失效。比如你释放了分区AA和后面的B合并了B节点被删除如果你还持有B的引用后续操作就会出错。我的做法是每次操作前重新遍历链表不缓存节点引用虽然性能差一点但逻辑简单不容易出错。5.3 空闲分区大小为0的边界情况切割分区时如果请求大小刚好等于分区大小不要创建剩余大小为0的新节点。这个边界条件如果不处理链表里会出现大小为0的空闲分区遍历时虽然不会出错但会影响合并逻辑和指针计算。我在代码里加了if (current.size requestSize)的判断只有剩余大于0才切割。5.4 常见问题速查表问题现象可能原因排查方法解决方案分配一直失败指针回绕逻辑错误打印指针位置和链表长度检查模运算基数加扫描计数器释放后内存没合并合并顺序错误打印释放前后的链表先向后合并再向前合并出现大小为0的分区切割时未判断剩余大小打印分区列表加剩余大小大于0的判断指针位置越界链表长度变化后未重新计算打印指针和链表长度每次操作前重新计算链表长度分配地址重叠切割时起始地址计算错误打印分区起始地址检查start requestSize计算5.5 几个实操心得第一调试时一定要打印内存状态。存储分配算法的逻辑是链式操作光看代码很难发现指针错位、分区重叠这些问题打印出来一目了然。我习惯在每次分配和释放后都调用printMemory虽然输出多但排查问题快。第二测试用例要覆盖边界情况。比如请求大小等于总内存、请求大小大于总内存、连续释放多个相邻分区、指针回绕到开头等。这些边界情况是bug的高发区我上面给的测试用例只是基础版你可以自己扩展。第三链表实现适合教学生产环境要考虑性能。链表遍历是O(n)的如果分区数量很多分配性能会下降。实际系统中可能会用平衡树或者分离空闲链表来优化查找。但作为学习用途链表实现足够清晰能帮你把算法逻辑吃透。第四下次适应算法的均匀分配特性在长时间运行中优势明显。我做过一个简单对比同样跑一万次随机分配释放首次适应算法在低地址区域产生了大量小碎片而下次适应算法的碎片分布更均匀大块空闲区的保留情况更好。当然这个结论依赖于请求大小的分布不是绝对的。6. 算法对比与选型参考6.1 四种常见分配算法横向对比把下次适应和另外三种常见算法放一起对比能更清楚它的定位。算法扫描起点优点缺点适用场景首次适应内存起始位置实现简单高地址大分区保留好低地址碎片多通用场景下次适应上次分配位置分配均匀扫描长度短大分区可能被切碎请求大小均匀最佳适应内存起始位置碎片最小产生大量微小碎片请求大小固定最坏适应内存起始位置剩余分区大大分区消耗快请求大小偏小下次适应的核心优势是扫描长度短和分配均匀。扫描长度短意味着分配速度快因为不需要每次从头找。分配均匀意味着内存各区域的使用程度接近不会出现一端全是碎片、另一端全是大空闲区的情况。6.2 什么时候该选下次适应根据我的经验下面几种情况可以考虑下次适应算法。第一分配请求大小比较均匀比如都是几十到几百字节的小块分配下次适应的均匀特性发挥得最好。第二系统长时间运行且频繁分配释放下次适应能避免内存一端过早碎片化。第三对分配速度有要求下次适应的平均扫描长度比首次适应短分配更快。反过来如果请求大小波动很大或者系统对内存利用率要求极高下次适应可能不是最优选择。这时候可以考虑最佳适应或者配合内存紧凑技术使用。6.3 代码扩展方向这份实现是基础版你可以从几个方向扩展。第一加内存紧凑功能把所有占用分区移到一端空闲分区合并成一大块彻底解决碎片问题。第二换成双向链表释放时能直接访问前驱节点合并效率更高。第三加统计功能记录分配成功率、平均扫描长度、碎片率等指标方便对比不同算法。第四支持可变内存大小动态扩展内存空间模拟真实系统的内存增长。我自己在实际项目中用过类似的下次适应逻辑做对象池的内存管理效果还不错。对象池的分配请求大小比较固定下次适应的均匀特性正好匹配长时间运行下来碎片率比首次适应低不少。当然具体场景要具体分析没有银弹。提示如果你要跑这份代码建议把printMemory的输出重定向到文件方便对比每次操作后的状态变化。另外可以加个随机测试方法跑几千次随机分配释放观察碎片分布情况。代码写到这里基本完整了分配、释放、合并、指针维护这些核心逻辑都覆盖到了。你可以直接把代码复制到IDE里跑改改参数看看不同场景下的表现。存储分配算法这东西光看理论容易似懂非懂自己动手实现一遍把指针走向和分区变化打印出来看理解会深很多。我在实现过程中最大的体会是边界条件的处理比核心逻辑更考验人指针回绕、分区合并、大小为0的判断这些细节才是决定代码能不能稳定运行的关键。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询