从下往上看内存

1内存条、总线与DMA

计算机组成中内存或者叫主存是非常重要的部件。内存因为地位太重要,所以和CPU直接相连,通过数据总线进行数据传输,并通过地址总线来进行物理地址的寻址。

除了数据总线、地址总线还有控制总线、IO总线等。IO总线是用来连接各种外设的,例如USB全称就是通用串行总线。再比如PCIE是目前最常见的IO总线之一。这里放一张B站硬件茶谈的一张图。

图1-1硬件图

图中CPU和左侧内存条直接连,并通过PCIE总线与下方的PCIE插槽连接,在PCIE插槽上可以插显卡,网卡,声卡,硬盘等等。PCIE带宽是共享的,如果某个设备用了x1路带宽,则能用的就少一路,因为本质上每一路都是串行的。南桥和CPU之间也有PCIE通道,主要是提供给一些带宽占用很低的外设。

南桥芯片位于主板上,一般在右下角,有个被动散热下面压着。南桥中有个很重要的设备就是DMA控制器,或者叫DMAC。DMA直接内存访问,意思就是DMAC能够直接访问内存。即一般进行IO的时候,cpu会把总线完全交给DMAC(DMAC和CPU会分时掌控总线),DMAC访问设备如磁盘,将数据读到内存中,因为此时接管了总线,所以可以写内存。在这个过程中CPU可以进行其他的任务。这也是异步IO、非阻塞IO等理论的基础。

计算机常考题:

图1-2-1题目1

图1-2-2题目2

2操作系统内存管理与分类2.1虚拟内存(逻辑内存)

win32程序从程序上能操作的逻辑地址空间有4G这么大(虽然实际可能用不了那么多),4G的逻辑地址需要全部映射到物理内存上。映射的最小单位如果是字节的话,映射表将会非常大,且效率低下。提出page概念,即最小的映射单位是一个page,一页一般是4K这样的大小,我的机器是这样的,所以下面程序demo中页大小都是4K。

逻辑页是抽象的,需要映射到物理的页上,才能完成对内存的操作。我们把逻辑页叫页(page)物理页叫帧(pageframe)。页号-帧号的映射表叫页表(pagetable)。

图2-1页表映射

因为每个程序看到的逻辑地址空间都很大,所以程序变多了之后,程序使用的内存大于了物理内存,此时一般通过将部分"不着急使用"的页映射到磁盘的方式来解决。所以页表中映射项可能是磁盘。

图2-2页表映射

同时每个进程都有自己的专属页表,如下:

图2-3多进程的页表

一种实际情况,4G逻辑地址有32bit地址空间,假设pageSize=4K偏移量占12bit,因而页表的逻辑页号有20bit。再假设实际内存条只有256M28bit地址空间12bit偏移量16bit页号。

逻辑地址0x000011a3,去映射的时候00001就是逻辑页号,去查页表发现映射到真实页帧号00f3,然后偏移量不变还是1a3,最终就找到这个物理内存内容了。

图2-4页表的映射过程

这个过程中,可能会出现映射的帧号是disk,即映射到了磁盘上。此时会触发缺页异常,进入内核态,内核从磁盘中读取缺的这页内容,将其加载到物理内存中。但是物理内存的帧有可能所有帧都满了,此时就需要逐出不太"重要"的帧。

逐出的过程需要判断当前物理页(帧)是否是脏的(脏:与磁盘中内容不一致,即从磁盘加载到物理内存后被改过就是脏的),如果是脏的还需要更新磁盘中的内容保证一致。

逐出后就腾出了位置给从磁盘中读到的这页的数据,然后需要更新页表的这一项的映射关系,将磁盘改为帧号,然后重新进行查页表这一步。

2.2快表TLB、多级页表

上面提到了逻辑-物理页的映射,这就是页表,但是上面的页表其实除了简单的页号映射,还存储了其他一些属性:是否有效,读写权限,修改位,访问位(淘汰算法和TLB中用),是否是脏(被修改过就是脏的,因为他和硬盘上的数据不一致),是否允许被高速缓存等等。

上面可以看到基于页表的寻址,需要两次访问主存(页表是存在主存的),效率低下。为了提高速度,引入了快表,快表是页表项的缓存,将最近一次的映射项存入快表,因为空间有限所以需要逐出最老的那一项。快表的设计是基于经验:程序经常访问的page一般就那几个,不会经常频繁的更换特别多的页。

另一个值得讨论的话题是页表占用空间太大,上面例子中(32位程序256M机器pageSize4K)页号有20bit即2百万个,所以需要有1百万条,每条大小如果只算逻辑页号(20bit)和物理页号(16bit)的话:

36bit*2^20=4.5MB

如果有64个这样的程序在运行后果可想而知。

一种很好的解决方法是多级页表,第一级页表用于寻找第二级页表的编号。20bit-16bit的单级映射可以改成10bit-10bit和10bit-6bit两级映射。此时占用内存为

20bit*2^10+16bit*2^20=2M
2.3分段

严格意义的分段是,每一段的虚拟地址都是从0开始。然后页表是段号+页号来映射帧号的。但是这种形式已经被废弃了,只有x8632位的intel的cpu还保留了这种段页结合的方式,即严格意义的分段已经用的很少。

那为什么还经常听到段的概念?现在所说的段一般是程序在逻辑层面保留的概念,对逻辑地址有个粗略的划分,便于程序编写,但是并不影响os的内存管理(还是分页管理)。

以32位程序为例,在逻辑空间中最高的0xc0000000-0xffffffff这1G的内存是给内核留出的,这部分是所有进程共享的。剩余3G内存从低到高分别是Text、Data、Heap、Lib、Stack。64位程序则远大于这里的值。

Heap是从低往高增长,Stack是从高往低增长,且有个最大限制。Data存储静态变量Text存储程序二进制码,Lib存储库函数需要占用的内存,多个程序如果都使用了相同的库,内存是共用的(共享内存)。各个部分的留有随机的一段偏移量,可以保护程序,这也使得每次重新执行程序的时候变量所在的内存地址总是不同的。

图2-532位系统下内存地址的组成

2.4cache

cpu的三级缓存扮演着缓存主存数据的作用,而cache在内存管理中的位置是怎样的呢?

PIPT,物理级cache,cpu分析完映射关系,先到cache找有没有该物理地址的cache。这样会非常的慢,但是所有进程可以共享cache。

VIVT,逻辑级cache,cpu直接通过逻辑地址找cache,miss后再查TLB页表这些。这样很快,但是逻辑地址只能对当期进程使用,其他进程完全不能复用,尤其是库函数这种共享的不能利用好cache。

VIPT,将两者结合,用逻辑地址查找cache,cache中数据部分前面添加一个对应物理地址的tag。这样拿到这个tag后到tlb、页表中查看下这个对应关系是否正确,如果正确就直接读cache。这样速度和共享性都是折中的。

以上三种方式各有优劣,在不同的cpu中可能使用的不一样。

2.5内存地址大小

很多人想当然的会认为32位系统的虚拟地址是32位,这是没错的,但是64位系统下真正的可用的虚拟地址却不到64位。

/(){int*a=(int*)mmap(NULL,100*4096,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);int*b=a;for(inti=0;i100;i++){b=(void*)a+(i*4096);*b=1;}while(1){sleep(1);}}

这里提交400K内存的申请,并且在每页中都进行内存的使用。可以看到不映射文件的话触发的是minflt次数是100次。

图3-3进程的内存minflt

这里是mmap内存的惰性加载,一开始mmap100页时其实都没有分配给进程,在用到的时候开始真正拿到内存,此时触发minflt缺页,因为不是映射的文件,不用从磁盘中调内存,所以是小错误。但是仍是消耗性能的。

如果mmap是映射的磁盘文件,也会惰性加载,在初次加载或者页被逐出后再加载的时候,也会缺页,这个时候就不是小错误minflt了,而是majflt。例如下面使用mmap来读文件。

(){sleep(4);intfd=open("./1.txt",O_RDONLY,S_IRUSR|S_IWUSR);structstatsb;if(fstat(fd,sb)==-1){perror("cannotgetfilesize\n");}printf("filesizeis%ld\n",_size);char*file_in_memory=mmap(NULL,_size,PROT_READ,MAP_PRIVATE,fd,0);for(inti=0;_size;i++){printf("%c",file_in_memory[i]);}munmap(file_in_memory,_size);close(fd);}

下图是线程监听的结果,为了方便观察我在开始读之前sleep4s。可以看到红框第一行,有一次majflt,这是第一次去读文件,直接触发了缺页异常,且指向磁盘。是最耗时的错误。

图3-3-2进程mmap读文件引发majflt

read和mmap都可以读文件,前者有状态转换和多次拷贝,但是后者有缺页中断。在单纯读磁盘文件场景,两者其实没法在孰优孰劣上有定论。

3.4共享内存

共享内存是进程间通信的一种方式,(管道信号信号量套接字也是进程通信的方式)。共享内存的例子比比皆是,windows下最明显,比如这个上传文件的对话框就是共享内存里的,同一时间windows下不会弹出两个该对话框。再比如动态链接库,也是共享内存中的,多个进程可以共享,两个进程mmap相同文件的方式可以实现共享内存,shmget则是更广泛的共享内存的系统调用。

图3-4共享内存的典型例子

共享内存原理就是两个进程中页,映射到了相同的帧。代码这里不写了,直接参考geeks这篇的代码。

4java中的内存4.1java内存概述

jvm内存结构主要如图4-1.本文不想对“常考”的知识点再次进行讲解,网上有大量的文章来讲内存结构各自的用途和GC相关的内容,这里我就不展开讲了。下面几节会讲一些比较"冷门"的知识。

图4-1java的内存五区

4.2对象头与指针压缩

在另一篇讲计算java对象大小的文章中提到,java对象是由对象头,对象内容组成,并且是8字节对齐的。其中对象头有以下三部分组成:

MarkWord(64bits)当前对象一些运行时数据如锁

KlassWord(开压缩32bits,不开64bits)类型指针,指向类元数据Klass地址

arraylength(32bits)数组对象才有

我们这里来看下Klass,有没有想过我们反射的时候操作的都是Class对象而不是这里的Klass,两者关系是:

Klass是C++对象InstanceKlass,里面有个_java_mirror字段指向对应的Class对象。

图4-2java对象头指向metaspace

这里还提到了指针压缩,64位系统,如果jvm堆内存小于32GB是可以开启指针压缩的,此时Klass指针只需要4个字节,同时对象指针也只需要4个字节。这里会衍生出两个问题:

第一个就是4字节最多表示2^32个地址,每个地址里住的是一个字节,所以只能表示4GB,怎么还说32G下都能压缩呢?

因为:上面提到对象都是8字节对齐,所以每个地址里住的是8字节,所以可以表示32GB,实际地址移3位。

第二个问题就是普通对象指针压缩CompressedObjectPointers(“CompressedOops”),压缩的是java堆上的对象的指针(引用)大小,而对象头指向的是Klass,这是个C++的结构,这个指针也压缩了吗?

是的,CompressOops和CompressKlass是相伴而生,默认同时开启的,Klass这部分需要连续的4G的内存,因为是C++结构,没有8字节对齐限制,所以4字节只能在4G内存上寻址,默认大小是1G。

4.3metaspace

metaspace存储的是类的元数据信息,上面提到的Klass就是在metaspace中的,一般开启压缩的metaspace有CompressClassSpace和NonClassSpace两部分组成,其中前者内存占用较少,是后者的5-100分之一,前者又叫压缩类空间,实际上这部分内存本身并没有压缩,只是对象头中记录的指向这里的指针进行了压缩。

图4-3metaspace两部分:非类区和压缩类空间

压缩类空间中Klass是c++的对象有着很多元数据字段,vtable是记录虚方法指针,itable是接口方法指针。Non-class中则记录了更详细的元数据信息。开启指针压缩后,如果设置MaxMetaspaceSize参数实际上是限定的Non-class部分的大小,而不包括压缩类空间。通过Jprofile中也能发现Metaspace只包括Non-class部分,那为什么我上来说Metaspace有两部分呢,主要是从概念上讲两者都是元数据,在国外很多文章中也都归为了Metaspace。这里只需要注意这个小细节就可以了。设置MaxMetaspaceSize参数也可以对压缩类空间起到间接的限制,因为前面说了Non-class部分是class部分的n倍。

图4-4指针压缩开启时非堆

将压缩类空间和非类空间分开的原因之一,就是压缩类空间是对象关联的,只有4G上限,而将更多其他元数据剥离出去后,元空间可以远超过4G。而如果不开启指针压缩,其实两者就没必要分开了。关闭指针压缩后,-XX:-UseCompressedOops两部分会合为一个。统称Metaspace

图4-5指针压缩关闭时非堆

Q1:元空间内存什么时候分配?Q2:元空间什么时候释放内存?Q3:metaspace溢出会不会导致OOM?
depencygroupIdcglib/groupIdartifactIdcglib//version/depency
//设置metaspace大小:-XX:MaxMetaspaceSize=200mpublicclassT{publicstaticvoidmain(String[]args){while(true){Enhancerenhancer=newEnhancer();();(false);((FixedValue)()-":)");();}}}

监视会发现压缩类空间和非类空间都在增大,后者在200M上有道红线,在2分钟左右溢出,程序挂掉,这个程序中压缩类空间大概是分类的六分之一。

图4-5a压缩类空间

图4-5b非类空间

4.4堆外内存

上面的CodeCache和Metaspace毫无疑问是jvm管理下的堆外空间。但是除了这些常规的堆外空间,jvm还可以使用一些native方法,直接申请堆外内存。

例如做这么个demo,我们设置一个简单的java程序的堆大小是10M,此时用jprofile查看内存堆提交了10M实际使用9M多,堆外提交了12M实际使用11M左右。所以算下来是20M+。直接查看进程内存会略大于这个值,因为这个20M是虚拟机内部的内存,本身运行还是需要一些额外内存的,进程提交的内存有90M,实际使用内存47M

图4-6进程的提交内存和实际内存

接下来我们使用Unsafe申请1G堆外内存(也可以用NIO中的())

publicstaticvoidmain(String[]args)throwsInterruptedException,IllegalAccessException,NoSuchFieldException{Fieldf=("theUnsafe");(true);Unsafeus=(Unsafe)(null);longaddr=(1024*1024*1024);("HelloWorld");(addr);while(true){(1000L);}}

可以看到提交的内存1G多,实际使用内存也是47M。

图4-7进程的提交内存和实际内存2

我甚至可以调整申请65G的内存,要知道我的电脑也只有64G的内存,但这仍不会报错,可以看到提交的内存已经超过了物理内存上限,但是得益于前面讲的虚拟内存的管理模式,使得应用申请了超过物理大小的内存,而如果真的使用起来的话,会有页置换来协调。

图4-8进程可以提交超过现实存在的内存

上面的提交内存很大但是实际使用内存却并不大:

图4-9任务管理器此时的状态

Unsafe是很危险的一个类,不建议使用。但是可以帮助我们理解有些框架是如何工作的。比如前一阵子看的Ehcache就提供了堆外缓存就是用类似Unsafe申请的。堆外缓存需要自己实现序列化,因为Unsafe设置内存只能设置01字节码不能设置为java对象。

堆外缓存的好处:缓存一般是短时间不需要清理的,如果在堆上则肯定会进入老年代,占用固定的一大块空间,使得触发fullGC的门槛降低了,很容易到了那个门限值。而且GC过程中还要去遍历这些对象,效率较低。

堆外内存的坏处:序列化需要自己实现,清理也需要自己实现,访问速度比heap要慢。

版权声明:本站所有作品(图文、音视频)均由用户自行上传分享,仅供网友学习交流,不声明或保证其内容的正确性,如发现本站有涉嫌抄袭侵权/违法违规的内容。请举报,一经查实,本站将立刻删除。

相关推荐