- 一、项目介绍
- 简介
- 1.什么是内存池
- 1.池化技术
- 2.内存池
- 2.内存碎片问题
- 二、具体实现
- 开胃菜--初步认识定长内存池
- 思路
- 实现细节
- 总体思路
- 1.thread cache层
- 申请内存
- 释放内存
- 2.central cache层
- 申请内存
- span
- 申请
- 释放内存
- 3.page cache层
- 申请内存
- 释放内存
- 三、高并发内存池的优化
- 四、多线程竞争问题
- 五、高并发内存池优点
- 六、内存池不足
- gitee源码链接
由于malloc在多线程并发,且连续不断申请内存的环境下,运行效率并不高
但基于Google开源TcMalloc项目,该内存池能够极大的减少在上述环境下的消耗。优点:性能卓越,大幅减少外碎片,减少向系统申请空间的消耗和不必要的时间浪费。
所谓的“池化技术”,就是程序先向内存申请过量的资源,自我管理,以备不时之需。之所以需要申请过量内存来进行自我管理,是因为每一次申请资源会有过大的消耗,当提前申请好时,能够减少很多消耗
内存池经常使用在除了内存池,还有连接池,线程池,对象池等等。以线程池为例,其主要思想:先启动若干线程,令其处于睡眠状态,当接收到客户端的请求时,唤醒线程池中的某个线程让它去chu’li客户端的请求,处理完后,线程再进入睡眠状态
内存池是指内存池先向系统申请一大块内存,当程序中需要申请内存的时候,各个线程各自独立向内存池申请空间,而不是直接向 *** 作系统申请。同样,程序释放内存的时候,内存并不是返回给 *** 作系统,而是返回内存池。当满足一定的条件后,再返回给系统(后面会将)
2.内存碎片问题1.内碎片
内碎片是什么呢?内碎片就是由于一些对齐的需求,而导致一些被系统分配出去的内存无法被利用,从而产生了内碎片问题
2.外碎片
那外碎片又是什么呢?外碎片是由于一些空闲的连续内存区域太小,这些内存空间并不是连续的,从而导致无法满足一些的内存分配申请需求
简单介绍:
先向系统申请一大块的内存,当进程需要内存时,直接从大块内存中拿走一块即可,这可以避免我们使用new/delete或malloc/free,可以在特定环境下,从而减少分配和释放的效率,接着我们再分配给进程一块定长的内存,并且可以极大的减少内存碎片的问题
优点:简单粗暴,分配和释放的效率高,在特定的使用环境下十分有效
缺点: 功能单一,只能解决定长的内存需求
同样,我们给出两个接口,分别为New和Delete函数来进行申请和释放
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage*(1<<12),MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
template<class T>
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
// 如果自由链表有对象,直接取一个
if (_freeList)
{
obj = (T*)_freeList;
_freeList = *((void**)_freeList);
}
else
{
if (_leftBytes < sizeof(T))
{
_leftBytes = 128 * 1024;
//_memory = (char*)malloc(_leftBytes);
_memory = (char*)SystemAlloc(_leftBytes);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) :
sizeof(T);
_memory += objSize;
_leftBytes -= objSize;
}
// 使用定位new调用T的构造函数初始化
new(obj)T;
return obj;
}
void Delete(T* obj)
{
// 显示调用的T的析构函数进行清理
obj->~T();
// 头插到freeList
*((void**)obj) = _freeList;
_freeList = obj;
}
private:
char* _memory = nullptr; // 指向内存块的指针
int _leftBytes = 0; // 内存块中剩余字节数
void* _freeList = nullptr; // 管理还回来的内存对象的自由链表
};
思路
对于该内存池,我们是通过链表来进行管理的,在链表为空时先向系统申请内存,当进程归还内存时,直接使用头插挂到链表当中。在后续的进程不断向内存池的申请中,内存池直接在链表上给出已分割好的内存
在上面的代码中,值得我们注意的是,我们并没有使用malloc函数来向系统进行申请,而是使用在Windows环境下的VirtualAlloc函数直接向堆空间进行申请,从而可以减少很多申请和释放的消耗
对于Delete函数,我们并没有进行空间的释放,而是将其挂回链表上去
我们通过一块分割好的内存的头上四个字节(32位下)或八个字节(64位下)来进行保存下一块内存的地址(即指针),在归还时,直接将小块内存挂回链表中
总体思路
不太理解的小伙伴可以先到centralcache那理解一下span哦
申请内存
1、对象申请内存时,先向线程缓存(thread cache)申请内存,当对应的哈希桶中(即申请的内存大小所对应的数组下的链表中(具体对应方式后面会说)),比如8bytes,它里面的span的_freelist链表下,每一块的span的freelist都不存在小内存时,便向中心缓存(central cache)申请小内存块。
2、同样当中心缓存对应的哈希桶中不存在span时,直接向页缓存(page cache)进行申请。
3、首先会有对应的映射表(后面会讲)把中心缓存所需要的内存大小转换成页数,接着由于页缓存和前面两个桶不一样,它是按照页数作为数组的下标,所以直接遍历大于等于申请页数的数组开始向后遍历,寻找到后,把page数组上挂的大块内存按头切法切下,剩下的继续挂回再数组中。
4、而切下的内存块按照中心缓存桶的对齐字节,把大块内存切为多个小块,最后再分配给中心缓存
5、接着中心缓存把线程缓存需要的小块内存的数量分配完后,剩下的内存块挂在自己桶上面
释放内存
6、线程的对象用完内存块后,把内存块还给线程缓存,即挂会对应的哈希桶上,此时我们可以给线程缓存给定一个条件,当满足条件时,线程把对应的桶上挂的内存块还给中心缓存
7、在中心缓存中,我们需要将分散的,不连续的小内存块合并为大块内存再还给页缓存,此时我们可以给span一个计数器,用了计算span下面挂的小内存块是否换回来,若全部还回来后,我们再把它归还给页缓存
8、在页缓存也是同样道理,当我们把页归还后,我们会有一个映射表,能够实现该大块内存的前后大块内存是否被使用,如果没有被使用则合并,当合并到大于128页时,则归还给系统
注:当申请的大小大于128页时,直接向系统申请,小于等于128页则通过tcmalloc申请
释放也是同样道理
thread cache的函数方法
class ThreadCache
{
public:
void* Allocate(size_t size);//申请内存
void DelAllocate(void* ptr, size_t size);//释放内存
void* FetchFromCentralCache(size_t index, size_t size);//当对应的线程缓存没有内存时,向中心缓存要
void ListTooLong(FreeList& list, size_t size);//释放内存时,释放freelist上的内存块
private:
FreeList _freelist[FREELIST_BUCKETSIZE];//哈希桶,FreeList可以理解为链表
};
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
//能够多线程互不干扰进行申请内存
申请内存
我们可以将thread cache称为线程缓存,各个线程的对象申请内存时,首先向线程缓存进行申请。因为每次对象申请的大小总是不同的,所以我们需要用一种对齐映射的方式来分配内存。另外,每个线程的对象在自己桶内申请时,都是互不干扰的
// 整体控制在最多10%左右的内碎片浪费
// [1,128] 8byte对齐 freelist[0,16)
// [128+1,1024] 16byte对齐 freelist[16,72)
// [1024+1,8*1024] 128byte对齐 freelist[72,128)
// [8*1024+1,64*1024] 1024byte对齐 freelist[128,184)
// [64*1024+1,256*1024] 8*1024byte对齐 freelist[184,208)
//这里我们可以进行简单的理解,当对象申请的内存大小在1~128字节时,我们按照8字节的大小来
//进行对齐,比如申请39字节时,则可以认为分配5个8字节大小的内存块,并挂在freelist[4]下
这时我们就需要有方法来进行字节大小的对齐,和寻找桶的下标了
//下面函数为对齐字节的函数
static inline size_t _RoundUp(size_t bytes, size_t align)//align是对齐数
{
return (((bytes)+align - 1) & ~(align - 1));//解释在下面
}//大神写法
static inline size_t RoundUp(size_t size)
{
if (size <= 128)
{
return _RoundUp(size, 8);
}
else if (size <= 1024)
{
return _RoundUp(size, 16);
}
else if (size <= 8 * 1024)
{
return _RoundUp(size, 128);
}
else if (size <= 64 * 1024)
{
return _RoundUp(size, 1024);
}
else if (size <= 256 * 1024)
{
return _RoundUp(size, 8*1024);
}
else
{
return _RoundUp(size, 1 << PAGE_SHIFT);
}
}
//寻找对应字节大小的桶的下标
static inline size_t Index(size_t bytes)
{
static int bucket_num[4] = { 16,56,56,56 };
if (bytes <= 128)
return _Index(bytes, 3);
else if (bytes <= 1024)
return _Index(bytes - 128, 4) + bucket_num[0];//加上bucket_num是为了取在16字节对齐下桶的位置
else if (bytes <= 8 * 1024)
return _Index(bytes - 1024, 7) + bucket_num[0] + bucket_num[1];
else if (bytes <= 64 * 1024)
return _Index(bytes - 8 * 1024, 10) + bucket_num[0] + bucket_num[1] + bucket_num[2];
else if (bytes <= 256 * 1024)
return _Index(bytes - 64 * 1024, 13) + bucket_num[0] + bucket_num[1] + bucket_num[2] + bucket_num[3];
else
assert(false);
return -1;
}
static inline size_t _Index(size_t bytes, size_t align_shift)//对齐后的字节大小,移位数
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;//返回当前对齐字节大小的桶的位置
//比如1~ 8返回0,9 ~ 16返回1
}
对_RoundUp的简单理解:当align为8时,align-1意思为加上需要对齐数的最大值(0111),此时bytes不论为多少,总是在一个大小为8的区间里,比如bytes在1~ 8之间,左边(bytes)+align - 1的值为8 ~ 15(二进制:1000 ~ 1111)此时和~7(… 1000)(最小三位数为0)进行与运算,结果为8,别的同样如此
对_Index的简单理解:bucket_num数组为不同对齐数的桶的个数。比如bytes为1~8之间,则align_shift为3,则(bytes + (1 << align_shift) - 1) 为8 ~ 15之间(1000 ~1111),此时向右移3为变成1,再-1,此时对应的就是freelist[0]的桶了,和_RoundUp函数一样,按照8字节对齐的大小来分配桶
完成了以上的步骤后,我们可以开始在对应的_freelist[k]对应的桶下的每一个span中的freelist,寻找内存块,当桶中所以的span都没有内存块时,则进入到中心缓存中去
释放内存我们把对象释放的内存找对应的自由链表桶,将内存插入进去即可。
但我们需要有一个把线程缓存的内存块还给中心缓存的调件
//当链表长度大于一次批量申请的内存时,就开始还一段list给central cache
//伪代码
if (_freelist[index].Size() >= _freelist[index].MaxSize())
{
ListTooLong(_freelist[index], size);
}
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
void* start = nullptr;
void* end = nullptr;
list.PopRange(start, end, list.MaxSize());//start和end是输入输出型参数,把list的start和end传出来
CentralCache::GetsInst()->ReleaseListToSpans(start, size);//把内存还给central
}
ListTooLong函数是获取当前桶下小内存块的起始地址和末尾地址
MaxSize是一个线程缓存向中心缓存要内存块,要的数量的”上限“
慢开始反馈调节算法,通过使用MaxSize,一开始treadcache不会向centralcache要太多,多了可能会用不完,如果你不断的有size大小内存的需求,那么MaxSize就会不断的增长,直到上限,当链表上挂的内存块大于这个上限时,则归还给中心缓存
central cache的函数方法
这里我们使用了单例模式,不懂的小伙伴可以去查一下资料哦
//单例模式,饿汉
class CentralCache
{
public:
static CentralCache* GetsInst()
{
return &_sInst;
}
//从中心缓存获取一定数量的对象给threadcache
size_t FetchRangeObj(void*& strat, void*& end, size_t batchNum, size_t size);
//获取一个非空的span
Span* GetOneSpan(SpanList& list, size_t size);
//将一定数量的对象释放到span跨度
void ReleaseListToSpans(void* start, size_t byte_size);
private:
SpanList _spanlists[FREELIST_BUCKETSIZE];
private:
CentralCache()//单例模式
{}
CentralCache(const CentralCache&) = delete;
static CentralCache _sInst;
};
申请内存
span
在申请内存前,我们需要先了解一下Span类
struct Span//管理多个连续页的大块内存跨度结构
{
Span* _prev = nullptr;//双向链表结构
Span* _next = nullptr;
size_t _n = 0;//页的数量
PAGE_ID _pageId = 0;//大块内存起始页的页号
size_t _objSize = 0;//切好的小对象的大小(经过三层缓存,会在centralcache中切分)或者是大块内存的值(在pagecache中申请)
size_t _useCount = 0;//切好小块内存,被分配给thread cache计数
void* _freeList = nullptr;//切好的小块内存的自由链表
bool _isUse = false;//该Span是否在被使用
};
在page cache前我们可以先了解部分内容
1.首先Span是一个color对内存块进行管理的类,这一认知我们需要理解到位。
2.他是一个双向链表的方式对内存进行管理,这样方便对前后的内存块进行查找。
3.学过 *** 作系统的小伙伴都知道,系统内对页的管理都是8kb大小的,给出的地址也都是8kb的倍数,所以我们把系统申请内存的地址向右移13位,我们便可以知道该内存的起始地址了
4.而_n则是该内存块的大小范围,假如该内存块起始地址为5,而_n为8,则该内存块的大小为13<<13(右移13位,5<<13 ~ 13<<13的范围)
5._useCount是切好小块内存,被分配给thread cache计数,在threadcache的释放内存里提到的
6.对于_freeList,由于页缓存给的是按页大小的大块内存,所以我们需要对其进行切分,切分下来的小内存块则挂在这个对象上,在把他分配给线程缓存
以上是对span的介绍
先调用FetchRangeObj函数,通过GetOneSpan函数不断的获取central cache里的span,把span里的小内存给thread cache,直到满足thread cache的条件,但如果span已被使用完,则从page cache里获取大块内存(页),之后再按对齐的字节大小将其切分成多个小内存块(切分方法和定长内存池的头上4字节方法一样),把然后在挂到_freeList上,最后再把新出的span给给FetchRangeObj函数。
注:上面两种方法都是一样,将给出去了多少的小内存块,则需要对_useCount进行++,只有当_useCount为0时,才能说明所有的小内存块都归还回来,此时才可以还给page cache了
以下为部分代码展示
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)//size是对齐的字节大小
Span* span = GetOneSpan(_spanlists[index], size);
assert(span);
assert(span->_freeList);
//从Span中获取batchNum个对象
//如果不够batchNum个对象,则有多少拿多少
start = span->_freeList;
end = start;
size_t i = 0;
size_t actualNum = 1;
while (i < batchNum - 1 && NextObj(end) != nullptr)
{
end = NextObj(end);
++i;
++actualNum;
}
span->_freeList = NextObj(end);
NextObj(end) = nullptr;
span->_useCount += actualNum;
return actualNum;
}
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)//size是对齐的字节大小
{
Span* it = list.Begin();
while (it != list.End())
{
if (it->_freeList != nullptr)
{
return it;
}
else
{
it = it->_next;
}
}
char* start = (char*)(span->_pageId << PAGE_SHIFT);//这里究竟是怎么切的?(_pageId存了起始页号,左移之后可以获取内存的地址)
size_t bytes = span->_n << PAGE_SHIFT;
char* end = start + bytes;
//把大块内存切成自由链表挂起来
span->_freeList = start;//_freeList获取的就是大块内存start的地址,并用头上4或8个字节存下一个内存块的地址,将其切分
start += size;
void* tail = span->_freeList;
int i = 1;
while (start < end)
{
NextObj(tail) = start;
tail = NextObj(tail);
start += size;
++i;
}
NextObj(tail) = nullptr;
}
释放内存
对于central cache释放内存的逻辑很简单,上面也多次提到。只要thread cache归还给central cache的小内存块,令_useCount–,直到_useCount为0时,说明该span下所有的内存块全部还回,此时便可以向page cache进行归还了
if (span->_useCount == 0)
{
_spanlists[index].Erase(span);
span->_freeList = nullptr;//这里的freeList可以直接置空是因为,虽然freeList链表里的
//小内存虽然是乱的,但是本身的整块内存都已经回来了,所以直接置空即可
span->_next = nullptr;
span->_prev = nullptr;
}
但是!!!重要的是,我们如何通过小内存块的地址,寻找到对应的span呢???
这个我们可以看page cache的MapObjectToSpan函数
page cache的函数方法
class PageCache
{
public:
static PageCache* GetInstance()
{
return &_sInst;
}
//获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
//释放空闲span回到Page cache,并合并相邻的span
void ReleaseSpanToPageCache(Span* span);
Span* NewSpan(size_t k);
std::mutex _pageMtx;
private:
SpanList _spanList[NPAGES];
std::unordered_map<PAGE_ID, Span*> _idSpanMap;
ObjectPool<Span> _spanPool;//这里我们使用定长内存池来对span类进行创建对象
PageCache()
{}
PageCache(const PageCache&) = delete;
static PageCache _sInst;
};
申请内存
首先,我们可以先来讲讲MapObjectToSpan函数
Span* PageCache::MapObjectToSpan(void* obj)
{
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);
std::unique_lock<std::mutex> lock(_pageMtx);//出了作用域自动解锁
auto ret = _idSpanMap.find(id);
if (ret != _idSpanMap.end())
{
return ret->second;
}
else
{
assert(false);//不存在找不到的可能,出现了说明有问题
return nullptr;
}
}
从上面的代码中,很容易看出来,我们通过使用unordered_map容器(_idSpanMap),利用起始地址寻找到对应的span,这是为什么呢??
首先每一个span都是8kb的倍数,即页号,在NewSpan函数中,我们会对每一个进行了切分的页进行ID和span的映射(可以理解为,span的起始地址和范围大小进行映射),对应span中的小块内存,比如在0~8191(地址)中的,当我们把他右移13位时,最终的结果都为0,此时在这个范围里的小内存块,我们都可以找到对应span,并将其进行归还
PS:具体分割可以看NewSpan函数
对应NewSpan函数,我们需要判断我们申请的内存大小是否大于128页,若满足则直接向系统申请。首先我们先在第k页的桶中找是否有内存,如果有,则需要对这块内存的每一页都进行id和span的映射,为了便于central cache的归还
//先检查第k个桶里有没有span
if (!_spanList[k].Empty())
{
//发现问题:在这里面没有进行缓存
Span* kSpan = _spanList[k].PopFront();
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;//意思是,你Span里每一页都对应着同一个span
}
return kSpan;
}
如果不存在,则开始逐一寻找后面的桶
找到后,对大块内存进行头切,切下我们需要的页数内存,并把剩下的挂回对应的页桶中,注意!!!对于切下来的这块内存,我们需要把他的头尾地址进行映射,这里是为了便于page中页与页的合并 ,另外同上面一样,这块内存的每一页都进行id和span的映射
for (size_t i = k+1; i < NPAGES; ++i)
{
if (!_spanList[i].Empty())
{
Span* nSpan = _spanList[i].PopFront();
//Span* kSpan = new Span;
Span* kSpan = _spanPool.New();
//在nSpan页的头部切一个k下来
//k页span返回
//nSpan再挂到相映的位置上去
kSpan->_pageId = nSpan->_pageId;
kSpan->_n = k;
nSpan->_pageId += k;
nSpan->_n -= k;
_spanList[nSpan->_n].PushFront(nSpan);
//存储nSpan的首尾页号跟nSpan映射,方便page cache回收内存时,进行合并查找
_idSpanMap[nSpan->_pageId] = nSpan;
_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
//建立id和span的映射,方便central cache回收小块内存时,查找对应的span
for (PAGE_ID i = 0; i < kSpan->_n; ++i)
{
_idSpanMap[kSpan->_pageId + i] = kSpan;//意思是,你Span里每一页都对应着同一个span
//(即存在一个span里有多个页,这样同样方便threadcache还给centralcache和central还给pagecache)
}
return kSpan;
}
}
释放内存
对于page cache的内存释放,同样道理,一开始先判断如果页数大于128,则直接归还给系统
接下来,我们需要对central归还的span进行管理合并。由于我们在对大块内存进行切分的时候,对切分下来的内存进行了头尾的映射,所有合并时,我们只需要判断三种条件:1、对当前内存的前一块内存地址和后一块内存地址进行_isUse的判断,如果被使用,则不合并 2、前一块内存和后一块内存地址不存在,不合并 3、合并超出128页的span没办法管理,也不合并
如果在128页以内的,再重新挂回桶上,当然,对合并后的内存重新进行映射
//部分代码
auto ret = (Span*)_idSpanMap.get(nextId);
if (ret == nullptr)
{
break;
}
Span* nextSpan = ret;
if (nextSpan->_isUse == true)
{
break;
}
if (nextSpan ->_n + span->_n > NPAGES - 1)//合并超出128页的span没办法管理,也不合并
{
break;
}
三、高并发内存池的优化
该项目有两个优化的方案
第一:因为我们是为了脱离malloc的使用,而new,delete这两个本质仍然是在使用malloc的,所以使用了定长内存池来对span类进行空间申请,这样避免了malloc的使用
第二:上述的代码我们可以发现我们对span和ID的映射,使用的是unordered_map,因为unordered_map本质使用的是哈希表,这导致了我们在查找的时候效率十分的低下,所以我使用了基数树,基数树可以直接通过ID和span一对一的映射,而不是哈希表的随机存储,这样在我们使用find()函数进行查找的时候十分的方便
这里我简单说明一下,该项目tcmalloc,是每一个线程在向thread cache申请内存时都是各自独立的,当thread cache向central cache申请时,central cache的桶内会进行上锁,即桶锁,此时只能有一个线程对同一个桶(可以理解为某个数组)进行 *** 作,但是central cache向page cache进行申请时,thread cache可以对central归还内存。
然后central cache向page cache进行申请时,会对整个page上锁,即只能有一个桶向page申请内存
注:对于MapObjectToSpan函数,我们可以任何时候对齐进行使用,因为不会造成任何的影响
所以,实际上该项目很多时候资源和时间的浪费都是用在了锁的开锁解锁上去
五、高并发内存池优点我们可以和malloc比较
malloc的查找机制,是将一块内存用链表连接,然后从头开始查找满足对象申请内存大小的内存块,分配后再进行切分,但是这样很容易造成很多的外碎片,可能会出现空余碎片满足对象申请内存的大小,但是由于不连续而不发分配的情况
但是tcmalloc很方便的解决了这种外碎片的情况,他按照哈希桶和字节对齐的映射,并且能够将归还的内存简单合并成一个连续的内存块,使得malloc产生外碎片的情况进行解决,并且由于对齐数的映射规则,我们可以很好将内碎片的浪费控制在10%左右
六、内存池不足一、产生一个问题,线程结束时,当thread cache中未被回收到central cache的内存块如何进行回收?因为如果无法进行回收话,则central cache该桶对应下标的数组则进入死锁状态,在极端情况下,所有的桶都被锁住时,则会崩溃,同样,这里也造成了内存泄漏问题
解决方案(不确定):在向central归还内存时,我们可以使用一次回调函数,检查当前桶内是否仍有小内存块残留,如果有,则再进行归还
二、可以进行的优化,当thread cache中内存量占用过多该如何解决?(即thread cache 的哈希桶中,每一个size都没满足maxsize的条件,此时会造成很多内存块闲置问题)
解决方案:在每一次向central cache申请内存之前,我们可以对thread的哈希桶进行完整闲置内存块的计算,假如闲置占比超过某个值,我们可以把桶内全部的内存块进行归还,无需满足size>maxsize的条件
链接: 高并发内存池源码链接.
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)