当前位置:首页 » 《关注互联网》 » 正文

疑难杂症:申请点内存为何这么耗时_Python爱好者的专栏

29 人参与  2021年04月15日 13:03  分类 : 《关注互联网》  评论

点击全文阅读


 

本次我们继续生产问题的疑难杂症排查系统的文章,在开始我们下一次集中讨论Redis的问题之前,还需要用两次博客来专门讲一下有关于内存的问题,本文先行讨论一下内存分配的时间复杂度,下一篇计划叫《疑难杂症:内存还够为何申请不到》讨论一下内存分配的空间碎片问题,这两篇博客与之前的《疑难杂症:系统雪崩到底是为什么》和《疑难杂症: 遇到一个杀不掉,追不到,找不着的进程怎么破?》共同作为Redis问题排查的前序铺垫,给诸位读者们普及一下基础知识。

本次这篇博客源自于今天上午《编程之美》的作者邹欣老师在群里发的一个网页截图,这是一个有关JAVA内存分配的问题,

 

问题的原形是用JAVA创建一个500*500的领接矩阵,结果不但用时超长而且所耗费的时间还大大超出预期。从而JAVA的效率被人嫌弃了,这里虽然笔者没看到题目中重要的限定词领接矩阵,不过这也不影响最后问题的解决与排查。这里先简要分享一下问题的排查与解决。

简要排查过程简述

因为这个问题在描述的过程中并没有贴代码,因此笔者只能用猜的方式来试验,为避免我所用的电脑配置过高所带来的问题,我选取了阿里云普通计算型2C8G的ECS作为实验平台,操作系统为Debian10。

1.首先我直接申请了一下500*500的整型数据,实验结果2毫秒级就结束了,速度非常快的了任务。这段代码就不放了。太简单了。

2.申请500*500的二维数据并遍历赋值,用时20ms,这个具体的代码如下:

public class TestArray {



public static void main (String[] args) {

double timeNow=System.currentTimeMillis();

int [][] intB=new int[500][500];

for(int i=0;i<intB.length;i++){

for(int j=0;j<intB[i].length;j++){



             intB[i][j]=1;

}

}

System.out.println(System.currentTimeMillis()-timeNow);



}

}

运行结果如下:

 

这个和800ms差得也太远了,相信问题的提出者肯定不是这么写的代码。

3.先申请一个500[]的数据,然后再将他们逐一初始化,耗时3ms

我先申请了一个500行,但是列不固定的二维数组,然后再将每列初始化

public class TestArray {



public static void main (String[] args) {

double timeNow=System.currentTimeMillis();

int [][] intB=new int[500][];

for(int i=0;i<intB.length;i++){



             intB[i]=new int[500];



}

System.out.println(System.currentTimeMillis()-timeNow);



}

}

 

 

说实话这个操作就有点接近于邻接矩阵的操作了,不过后面我会讲到由于这个内存的申请并没有超出JVM的初始值,JAVA并没有额外进行内存碎片的整理,因此这个JAVA程序运行也很快3ms结束。

4.申请一个10000*10000的数组,这回用了191ms,并且内存占用也上升到了387M

public class TestArray {



public static void main (String[] args) {

System.out.println("Total init memory  "+Runtime.getRuntime().totalMemory()/(1024*1024));

double timeNow=System.currentTimeMillis();

int [][] intB=new int[10000][10000];





System.out.println(System.currentTimeMillis()-timeNow);

System.out.println("Total init memory  "+Runtime.getRuntime().totalMemory()/(1024*1024));



}

}

 

5.先申请一个10000[]的数据,然后再将他们逐一初始化,耗时1009ms

public class TestArray {



public static void main (String[] args) {

System.out.println("Total init memory  "+Runtime.getRuntime().totalMemory()/(1024*1024));

double timeNow=System.currentTimeMillis();

//System.out.println("helloworld");

//int [][] intB=new int[500][500];

int [][] intB=new int[10000][];

for(int i=0;i<intB.length;i++){



             intB[i]=new int[10000];



}



System.out.println(System.currentTimeMillis()-timeNow);

System.out.println("Total memory at the end"+Runtime.getRuntime().totalMemory()/(1024*1024));



}

 

其实这个操作和领接矩阵就已经差不多了,接下来我们会详细的讲一下其中的原理。这里先定性的说一下如何避免这样的问题发生,如果在JAVA当中需要申请大的内存页,需要在启动参数中指定-XX:+UseLargePages,并将初始的JVM虚拟机所申请的内存大小固定下来,避免JAVA频繁向操作系统申请内存。因此可以使用如下命令行来运行领接矩阵程序。

      java -XX:+UseLargePages -Xmx512m -Xms512m -cp . org.kilik.perf.ClassicMatrixMulti

接下来我们详细分析一下内存申请时操作系统和JAVA虚拟机JVM到底都在作什么。

 

内存申请耗时的背后-数据局部性原理产生的循环

 我在《疑难杂症:系统雪崩到底是为什么》曾经介绍过,数据的访问往往都有局部性,比如内存单元A被访问了,那么他的邻居A’和A’’被访问到的可能性也会极大的增加,因此CPU的高速缓存、硬盘的缓存都会将这些集中数据访问进行优化,这种优化机制也强化了连续数据的访问性能。比如读取连续的磁盘空间通常性能能比随机读高三、四个数量级;内存也是同样,读取连续空间比读取非连续空间要快得多,其机制就是硬盘及CPU缓存一般会将要缓存单元的邻居也一并调用到缓存当中。

而要搞清我们刚刚所说的Java问题成因,还要把内存管理的模型以及物理内存分配的算法讲清楚。如果把计算机比成一个酒店,那内存就是客房,进程就是住户而CPU就是酒店的管家,从这个角度上理解逻辑地址、线性地址以及物理地址是最为简单的。

 逻辑地址我们刚刚所编写的JAVA程序,它所访问的地址其实就是逻辑地址逻辑地址的引入其实就是让进程之间彼此相互不会影响,都以为自己独享整个客房,而屏蔽了底层物理地址的硬件细节 。

线性地址(Linear Address)也叫虚拟地址(virtual address):这层的引入其实基本上是由于英特尔对于x86向前兼容的需要,按照原有的英特尔规划,线性地址是暴露给操作系统管理的,也就是应用所在的逻辑地址空间会映射到一个大的线性空间,方便操作系统统一调用管理。而像Linux等目前主流的操作系统内核,全部启用分页机制进行进程之间的内存隔离与保护,线性地址其实就是逻辑地址。

 物理地址,这就是真正的CPU地址总线访问内存使用的址了,由硬件电路控制(现在这些硬件是可编程的了)其具体含义。物理地址中很大一部分是留给内存条中的内存的,但也常被映射到其他存储器上(如显存、BIOS等)。

         在实际地址映射时,CPU要利用其段式内存管理单元,先将为个逻辑地址转换成一个线地址,再利用其分页功能,转换为最终物理地址。也就是进程访问的逻辑地址可能是相同的,但是最终他们访问到的物理地址完全不同。当然这个转换其实一次就够了。之所以这样冗余,正如前文所说完全是为了X86的向前兼容。

内存必须容纳操作系统和各种用户进程,因此必须尽可能有效得分配内存,在分配内存过程中,通常需要将多个进程放入内存中,前面提到过,我们需要每个进程的空间相互独立,而且我们必须保护每个进程的内存空间的独立性,如果不同的进程间需要通信,可以按照我们前面提到的通信方法进行通信,但是在此时,我们考虑内存空间独立性的实现。这就涉及到物理内存分配:

  我们将整个内存区域多个固定大小的分区,每个分区容纳一个进程,当一个分区空闲时,可以将内存调入内存,等待执行,这是最简单的内存分配方案,但是这种方案存在很多问题,我们并不知道每个进程需要多大的空间,如果空间过小,那么我们的进程就存不下,如果进程都很小,但是我们分区很大的话,那么会造成很大程度的浪费,这些在每个分区未被利用的空间,我们称之为碎片。

 

为了尽可能的减少碎片-伙伴算法正式登场

伙伴算法,简而言之,就是将内存分成若干块,然后尽可能以最适合的方式满足程序内存需求的一种内存管理算法,而针对于Linux操作系统还有针对更小块内存申请的slab分配机制,好在JAVA不涉及此部分。申请时,伙伴算法会给程序分配一个较大的内存空间,即保证所有大块内存都能得到满足。

其中大小为 b 的一对空闲伙伴块合并为一个大小为 2b 的单独块。满足以下条件的两个块称为伙伴:

  • 两个块具有相同的大小,记作 b
  • 它们的物理地址是连续的

 

下面通过一个简单的例子来说明该算法的工作原理:

假设系统中有 1MB 大小的内存需要动态管理,按照伙伴算法的要求:需要将这1M大小的内存进行划分。这里,我们将这1M的内存分为 64K、64K、128K、256K、和512K 共五个部分,如下图所示:

 

而内存申请的过程如下:

 

可以说想了解伙伴算法关键就是要多画画上面这张图,当然你不了解伙伴算法也没关系,以下几个结论记住就可以了。

有关伙伴算法需要仔细画一下图才能了解,这里只要先了解下面几个结论就好。

1操作系统会在进程申请或者释放内存的同时进行内存碎片的整理。

2在内存使用率比较高的情况下去申请或者释放内存都可能造成操作系统频繁进行内存页的合并或者切割,而这样的操作都是加锁保护的,一般会使系统整体的运行效率大幅下降。

问题的启示

那么这个问题的启示也非常明显,

首先操作系统和JAVA都没有开大内存页的功能,这造成每次申请都是小步快跑式的,无论对于JVM还是对操作系统都是极大的负担。

其次本质上讲JAVA是操作系统的一个进程,他只能看到逻辑空间,但是JAVA虚拟机JVM本身也具有操作系统的属性,也需要进行内存的分页管理,以尽量减少碎片的产生,并且JAVA还要进行垃圾回收,内存的管理对于JAVA本身压力就比C的程序大,而每次小规模内存的申请都要经过JVM与操作系统两道工序,这也极大的拖慢了速度。

以上就是个人对于邹老师上午提出JAVA内存管理小问题进行的一个初步归纳与总结,比较匆忙不当之处请指正!

 

 


点击全文阅读


本文链接:http://zhangshiyu.com/post/17251.html

内存  申请  地址  
<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1