SortShuffleWriterUnsafeShuffleWriter都提到了执行内存的申请,为了说明内存的申请过程,我们先看看内存管理器。

内存管理器

Executor启动的时候,就会创建一个内存管理器MemoryManager(默认UnifiedMemoryManager),MemoryManager对存储体系和内存计算所使用的内存进行管理。内存又分为堆外内存和堆内存,所以MemoryManager就有4个内存池,分别为堆内存的存储内存池onHeapStorageMemoryPool、堆外内存的存储内存池offHeapStorageMemoryPool、堆内存的执行计算内存池onHeapExecutionMemoryPool、堆外内存的执行计算内存池offHeapExecutionMemoryPool,每个内存池都记录着内存池的大小和已使用的大小。
下图把堆内和堆外的执行计算的内存和存储的内存并在一起,是因为他们之间并没有一个明显的界限,比如说堆内执行计算的内存不够用了,就会向堆内存储的内存“借”内存,堆内存储的内存也会向堆内执行计算的内存“借”内存。堆外执行计算的内存和存储的内存也是可以相互“借”内存。
image.png

内存消费者和任务内存管理器

Task的资源分配中已经知道,Driver会把task封装成TaskDescription,交给Executor去执行。Executor会把TaskDescription封装成TaskRunner并放入线程池中,如果需要外部排序的话,最后会用到UnsafeShuffleWriter的ShuffleExternalSorter或者SortShuffleWriter的ExternalSorter。这两个外部排序器其实都是内存消费者MemoryConsumer。
image.png
内存消费者仅仅定义了内存消费者的规范,实际上对内存的申请、释放是由任务内存管理器TaskMemoryManager来管理的。任务内存管理器实际上依赖于MemoryManager提供的内存管理能力,所以Executor同时有多个TaskRunner在执行的时候,就会有多个内存消费者,每个内存消费者都会通过任务内存管理器对内存管理器申请、释放内存。
image.png

申请内存

内存消费者为了排序性能的提高,会把RDD的数据集预先存放在内存中,这个内存是需要向内存管理器申请的。
我们假定内存模式是堆内存,那申请的内存就是堆内存的执行计算内存池,内存池的大小_poolSize为100。每个执行内存池中都维护着一个map,叫做memoryForTask,key是任务内存管理器的身份标识taskAttemptId,value是任务内存管理器已申请的内存大小。
此时,taskAttemptId为1的任务内存管理器去堆内存的执行计算内存池申请10内存,他发现memoryForTask里并没有taskAttemptId为1的key,于是就把key为1,value为0赋值给memoryForTask。
image.png
每个任务所能申请的内存范围是poolSize/2NmaxPoolSize/N之间,这个N是memoryForTask中key的数量,poolSize是当前内存池的大小,maxPoolSize是当前内存池可使用的最大大小,这个值是包括堆内存的存储内存池的部分甚至全部内存大小,也就是说,如果执行计算内存池不够用了,是可以从存储内存池借的。当然存储内存池也可以向执行计算内存池借内存。
目前执行计算内存池的内存能够满足taskAttemptId为1所需要的内存,所以直接给分配内存,并更新memoryForTask中taskAttemptId为1的值为10。
image.png
taskAttemptId为2的任务内存管理器去堆内存的执行计算内存池申请50内存,流程同上。此时线程池中,可用内存就剩下100-10-10-50=30了。
image.png
taskAttemptId为3的任务内存管理器去堆内存的执行计算内存池申请20内存,流程同上。此时线程池中,可用内存就剩下100-10-10-50-20=10了。
image.png
taskAttemptId为3的任务内存管理器去堆内存的执行计算内存池继续申请20内存,此时内存已经不够用了,执行计算内存池就会去存储内存池要回借出去的内存(如果有的话),并且向存储内存池借了10内存,taskAttemptId为3的值就变成了20+20=40。此时这4个任务内存管理器申请的内存已经超过了堆内存的执行计算内存池。
image.png
taskAttemptId为4的任务内存管理器去堆内存的执行计算内存池申请20内存,但是执行计算内存池和存储内存池借来的内存已经不满足20,或者比要求的内存最小值还小,当前线程处于等待状态。
image.png

内存释放

taskAttemptId为2的任务内存管理器此时释放了40内存,此时对应的value就变成了50-40=10,然后唤醒所有阻塞的线程,比如上面的taskAttemptId为4对应的线程。
image.png
taskAttemptId为4对应的线程被唤醒后,发现可以申请20,于是更新对应的值。
image.png
taskAttemptId为2的任务内存管理器此时继续释放了10内存,此时对应的value就变成了10-10=0,由于小于等于0了,所以这个key就删除掉,并且再唤醒所有阻塞的线程。
image.png

内存分配

上面讲了内存大小的申请,申请到内存大小后,根据内存模式,会从堆内存或者对外内存进行分配。

堆内存

堆内存模式下使用的内存分配器是HeapMemoryAllocator。HeapMemoryAllocator中维护着一个key为申请内存的大小,value为LinkedList<WeakReference<long[]>>的map,这个map叫bufferPoolsBySize。当要分配的内存大小大于等于1MB,就采用池化机制,也就是从map中的链表取值。
每次申请到内存后,就会封装成MemoryBlock,这个MemoryBlock就是之前提过的page,包含了obj、ofset、length三个属性。obj保存了对象在JVM堆中的地址,offset保存了Page的起始地址(即相对于所在对象在JVM堆中地址的偏移量),用于定位数据的具体位置,length保存了Page的页面大小(即从offset开始,连续内存空间的大小),为当前MemoryBlock的连续内存块的长度。
image.png
当TaskMemoryManager拿到了500k的内存时,就会通过HeapMemoryAllocator从堆内存中申请一个内存块,并把这个内存块的地址、连续内存空间的大小保存在MemoryBlock中,返回给TaskMemoryManager,TaskMemoryManager给这个MemoryBlock指定页号。
image.png
当拿到了1M的内存时,流程同上。
image.png
当释放500k的MemoryBlock时,就会把当前的页号设置为已释放的标志,并释放MemoryBlock。
image.png
当释放1M的MemoryBlock时,也会把当前的页号设置为已释放的标志,由于大小符合池化机制,所以将MemoryBlock的弱引用放入bufferPoolsBySize中。
image.png
如果TaskMemoryManager再申请一个1M内存的,此时就会从bufferPoolsBySize的弱引用中取MemoryBlock,而不用从堆内存中去申请内存资源。取来后,就会从bufferPoolsBySize中移除,当TaskMemoryManager释放内存时,这个MemoryBlock又会移到bufferPoolsBySize中。

堆外内存

堆外内存模式下使用的内存分配器是UnsafeMemoryAllocator。先用sun.misc.Unsafe的allocateMemory方法返回分配的内存地址,以及所要申请的大小构建一个MemoryBlock。
image.png
释放MemoryBlock的时候,根据offset来释放,并把当前的页号设置为已释放的标志,MemoryBlock的offset赋值给0。


大军
847 声望183 粉丝

学而不思则罔,思而不学则殆