美文网首页尚未看完java&python&node
【操作系统笔记】操作系统内存管理和jvm内存管理的对比和关系

【操作系统笔记】操作系统内存管理和jvm内存管理的对比和关系

作者: 程序员Anthony | 来源:发表于2020-05-14 16:39 被阅读0次

    在上一次的文章 【操作系统基础知识】内存管理 里学习了操作系统的内存管理的基础知识,今天做个延伸拓展,来学习从操作系统到jvm的内存管理。

    一 、物理内存与虚拟内存

    1、物理内存

    (1)RAM

    所谓物理内存就是我们通常所说的RAM(随机存储器)。

    (2)寄存器

    在计算机中,还有一个存储单元叫寄存器,它用于存储计算单元执行指令(如浮点、整数等运算时)的中间结果。寄存器的大小决定了一次计算可使用的最大数值。

    (3)地址总线

    连接处理器和RAM或者处理器和寄存器的是地址总线,这个地址总线的宽度影响了物理地址的索引范围,因为总线的宽度决定了处理器一次可以从寄存器或者内存中获取多少个bit。同时也决定了处理器最大可以寻址的地址空间,如32位地址总线可以寻址的范围为0x0000 0000~0xffff ffff。这个范围是232=4 294 967 296个内存位置,每个地址会引用一个字节,所以32位总线宽度可以有4GB的内存空间。通常情况下,地址总线和寄存器或者RAM有相同的位数,因为这样更容易传输数据。

    (4)内存地址

    运行程序需要向操作系统申请内存地址,操作系统按照进程来管理内存,每个进程拥有一段独立的地址空间,每个进程之间不会相互重合,即每个进程只能访问自己的内存空间。

    2、虚拟内存

    进程内存空间的独立是指逻辑上独立,这是由操作系统保证的,但真正的物理内存就不一定只能由一个进程来使用了。随着程序的复杂,物理内存无法满足其需求,这种情况下就有了虚拟内存的出现。

    虚拟内存的出现使得多个进程可以共享物理内存,这里的共享只是空间上共享,在逻辑上它们仍然是不能相互访问的。虚拟内存不但可以让进程共享物理内存、提高内存利用率,而且还能够扩展内存的地址空间,如一个虚拟内存地址可能被映射到一段物理内存、文件或者其它可以寻址的存储上。

    一个进程在不活动的情况下,操作系统将这个物理内存中的数据移到一个磁盘文件中即交换分区,而真正高效的物理内存留给正在活动的程序使用。在这种情况下,在我们重新唤醒一个很长时间没有使用的程序时,磁盘会吱吱作响,并且会有一个短暂的停顿得到印证,这时操作系统又会把磁盘上的数据重新交互到物理内存中。要避免这种情况的经常出现,因为操作系统频繁交互物理内存的数据和磁盘数据则效率将会非常低下,尤其是Linux服务器上。

    我们要关注Linux中swap的分区的活跃度,如果swap分区被频繁使用,系统会非常缓慢,很可能意味着物理内存已经严重不足或者某些程序没有及时释放内存。

    二、内核空间与用户空间

    一个计算机通常有一定大小的内存空间,比如4GB的地址空间,但是程序并不能完全使用这些地址空间,因为这些地址空间被划分为内核空间和用户空间。程序只能使用用户空间的内存,这里所说的使用是指程序能够给申请的内存空间,并不是程序真正访问的地址空间。

    内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者连接硬件资源的程序逻辑。之所以有内核空间和用户空间的划分,是为了系统的安全性和稳定性,但也牺牲了一部分效率。系统调用都是在内核空间有系统来发起的,比如:网络传输,通过网络传输的数据先从内核空间接收到远程主机的数据,然后再从内核空间复制到用户空间,供用户程序使用,每次这种系统调用都会存在两个内存空间的切换。但是现在已经出现了很多其它技术能够减少这种从内核空间到用户空间的数据复制方式,比如:Linux系统提供了sendfile文件传输方式。

    在当前的Windows 32位操作系统中默认内核空间和用户空间的比例是1:1(即2GB内核空间、2GB用户空间),而在32位Linux系统中默认的比例是1:3(1GB内核空间、3GB用户空间)。

    三、在Java中哪些组件需要使用内存

    1、Java堆

    Java堆是用于存储Java对象的内存区域,堆的大小在JVM启动时就一次向操作系统申请完成,通过-Xmx和-Xms两个选项来控制大小,Xmx表示堆的最大大小,Xms表示初始大小。一旦分配完成,堆的大小就将固定,不能在内存不够时再向操作系统重新申请,同时当内存空闲时也不能将多余的空间交换给操作系统。

    在Java堆中内存空间的管理由JVM来控制,对象创建由Java应用程序控制,但是对象所占的空间释放由管理堆内存的垃圾收集器来完成。根据垃圾收集(GC)算法的不同,内存回收的方式和时机也会不同。

    2、线程

    JVM运行实际程序的实体是线程,当然线程需要内存空间来存储一些必要的数据。每个线程创建时JVM都会为它创建一个堆栈,堆栈的大小根据不同的JVM实现而不同,通常在256KB~756KB之间。

    线程所占空间相比堆空间来说比较小。但是如果线程过多,线程堆栈的总内存使用量可能也非常大。当前有很多应用程序根据CPU的核数来分配创建的线程数,如果运行的应用程序的线程数量比可用于处理它们的处理器数量多,效率通常很低,并且可能导致比较差的性能和更高的内存占用率。

    3、类和类加载器

    在Java中的类和加载类的类加载器本身同样需要存储空间,在Sun JDK中它们也被存储在堆中,这个区域叫做永久代(PermGen区)。

    JVM是按需来加载类的,JVM只会加载那些在你的应用程序中明确使用的类到内存中。要查看JVM到底加载了哪些类,可以在启动参数上加上-verbose:class。

    理论上使用的Java类越多,需要占用的内存也会越多,还有一种情况是可能会重复加载同一个类。通常情况下,JVM只会加载一个类到内存一次,但是如果是自己实现的类加载器会出现重复加载的情况,如果PermGen区不能对已经失效的类做卸载,可能会导致PermGen区内存泄露。通常一个类能够被卸载,有如下条件需要被满足:

        (1)在Java堆中没有对表示该类加载器的java.lang.ClassLoader对象的引用。
    
        (2)Java堆中没有对表示类加载器加载的类的任何java.lang.Class对象的引用。
    
        (3)在Java堆上该类加载器加载的任何类的所有对象都不再存活(被引用)。
    

    需要注意的是:JVM所创建的3个默认类加载器Bootstrap ClassLoader、ExtClassLoader和AppClassLoader都不可能满足这些条件,因此,任何系统(如java.lang.String)或通过应用程序类加载器加载的任何应用程序类都不能在运行时释放。

    4、NIO

    Java在1.4版本后添加了新I/O类(NIO)类库,引入了一种基于通道和缓冲区来执行I/O的新方式。NIO使用java.nio.ByteBuffer.allocateDirect()方法分配内存,其使用的是本机内存而不是Java堆上的内存,每次分配内存会调用操作系统的os::malloc()函数。

    5、JNI

    JNI技术使得本机代码(如C语言程序)可以调用Java方法,也就是通常所说的native memory。实际上Java运行时本身也依赖于JNI代码来实现类库功能,如文件操作、网络I/O操作或者其它系统调用。所以JNI也会增加Java运行时的本机内存占用。

    四、JVM内存结构

    JVM是按照运行时数据的存储结构来划分内存结构的,JVM在运行Java程序时,将它们划分成几种不同格式的数据,分别存储在不同的区域,这些数据统一称为运行时数据(Runtime Data)。运行时数据包括Java程序本身的数据信息和JVM运行Java程序需要的额外数据信息,如要记录当前程序指令执行的指针(又称为PC指针)等。在Java虚拟机规范中将Java运行时数据划分为6种,下面会分别介绍。

    1、PC寄存器

    PC寄存器严格来说是一个数据结构,它用于保存当前正常执行的程序的内存地址。同时Java程序是多线程执行的,所以不可能一直都按照线性执行下去,当有多个线程交叉执行时,被中断线程的程序当前执行到哪条的内存地址必然要保存下来,以便于它被恢复执行时再按照被中断时的指令地址继续执行下去。

    2、Java栈

    Java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的Java栈,在这个Java栈中又会有多个栈帧(Frames),这些栈帧是与每个方法关联起来的,每运行一个方法就创建一个栈帧,每个栈帧会含有一些内部变量(在方法内定义的变量)、操作栈和方法返回值等信息。

    每当一个方法执行完成时,这个栈帧就会弹出栈帧的元素作为这个方法的返回值,并清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另一个方法时,与之对应的一个新的栈帧又被创建。这个新创建的栈帧又被放到Java栈的顶部,变为当前的活动栈帧。同样现在只有这个栈帧的本地变量才能被使用,当在这个栈帧中所有指令执行完成时这个栈帧移出Java栈,刚才的那个栈帧又变为活动栈帧,前面的栈帧的返回值又变为这个栈帧的操作栈中的一个操作数。如果前面的栈帧没有返回值,那么当前的栈帧的操作栈的操作数没有变化。

    由于Java栈是与Java线程对应起来的,这个数据不是线程共享的,所以我们不用关心它的数据一致性问题,也不会存在同步锁的问题。

    3、堆

    堆是存储Java对象的地方,它是JVM管理Java对象的核心存储区域,堆是Java程序员最应该关心的,因为它是我们的应用程序与内存关系最密切的存储区域。

    每一个存储在堆中的Java对象都会是这个对象的类的一个副本,它会赋值包括继承自它父类的所有非静态属性。

    堆是被所有Java线程所共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性。

    4、方法区

    JVM方法区是用于存储类结构信息的地方,比如:将一个class文件解析成JVM能识别的几个部分,这些不同的部分在这个class被加载到JVM时,会被存储在不同的数据结构中,其中的常量池、域、方法数据、方法体、构造函数,包括类中的专用方法、实例初始化、接口初始化都存储在这个区域。

    方法区这个存储区域也属于Java堆中的一部分,也就是我们通常所说的Java堆中的永久区,这个区域可以被所有的线程共享,并且它的大小可以通过参数来设置。

    5、运行时常量池

    在JVM规范中是这样定义运行时常量池这个数据结构的:Runtime Constant Pool代表运行时每个class文件中的常量表。它包括几种常量:编译期的数字常量、方法或者域的引用(在运行时解析)。Runtime Constant Pool的功能类似于传统编程语言的符号表,尽管它包含的数据比典型的符号表要丰富得多。每个Runtime Constant Pool都是在JVM的Method area中分配的,每个Class或者Interface的Constant Pool都是在JVM创建class或接口时创建的。

    运行时常量池是方法区的一部分,所以它的存储也受方法区的规范约束,如果常量池无法分配,同样会抛出OutOfMemoryError。

    6、本地方法栈

    本地方法栈是为JVM运行Native方法准备的空间,它和前面介绍的Java栈的作用是类似的,由于很多Native方法都是用C语言实现的,所以它通常又叫C栈,除了在我们的代码中包含的常规的Native方法会使用这个存储空间,在JVM利用JIT技术时会将一些Java方法重新编译为Native Code代码,这些编译后的本地代码通常也是利用这个栈来跟踪方法的执行状态的。

    在JVM规范中没有对这个区域的严格限制,它可以由不同的JVM实现者自由实现,但是它和其它存储区一样也会抛出OutOfMemoryError和StackOverflowError。

    五、JVM内存分配策略

    1、通常的内存分配策略

    在操作系统中将内存分配策略分为三种,分别是:

    (1)静态内存分配

    静态内存分配是指在程序编译时就能确定每个数据在运行时的存储空间需求,因此在编译时就可以给它们分配固定的内存空间。这种分配策略不允许在程序代码中有可变数据结构(如可变数组)的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间需求。

    (2)栈内存分配

    栈式内存分配也可称为动态存储分配,是由一个类似于堆栈的运行栈来实现的。在栈式内存方案中,程序对数据区的需求在编译时是完全未知的,只有到运行时才能知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能为其分配内存。栈式内存分配按照先进后出的原则进行分配。

    (3)堆内存分配

    在编写程序时除了在编译时能确定数据的存储空间和在程序入口处能知道存储空间外,还有一种情况就是当程序真正运行到相应代码时才会知道空间大小,在这种情况下我们需要堆这种分配策略。

    这几种内存分配策略中,很明显堆分配策略是最自由的,但是这种分配策略对操作系统和内存管理程序来说是一种挑战。另外,这个动态的内存分配是在程序运行时才执行的,它的运行效率也是比较差的。

    2、Java中的内存分配详解

    JVM内存分配主要基于两种,分别是堆和栈。

    (1)栈

    Java栈的分配是和线程绑定在一起的,当我们创建一个线程时,很显然,JVM就会为这个线程创建一个新的Java栈,一个线程的方法的调用和返回对应于这个Java栈的压栈和出栈。当线程激活一个Java方法时,JVM就会在线程的Java堆栈里新压入一个帧,这个帧自然成了当前帧。在此方法执行期间,这个帧将用来保存参数、局部变量、中间计算过程和其它数据。

    栈中主要存放一些基本类型的变量数据(int、short、long、byte、float、double、boolean、char)和对象句柄(引用)。存取速度比堆更快,仅次于寄存器,栈数据可以共享,缺点是存在栈中的数据大小与生存期必须是确定的,这也导致缺乏了其灵活性。

    (2)堆

    Java的堆是一个运行时数据区,这些对象通过new、newarray、anewarray和multianewarray等指令建立,它们不需要程序代码来显示地释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是由于要在运行时动态分配内存,存取速度较慢。

    每个Java应用都唯一对应一个JVM实例,每个实例唯一对应一个堆。应用程序在运行中所创建的所有类实例或数组都放在这个堆中,并由应用程序所有的线程共享。在Java中分配堆内存是自动初始化的,所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的,也就是说在建立一个对象时两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。

    六、JVM内存回收策略

    1、静态内存分配和回收

    在Java中静态内存分配是指在Java被编译时就已经能够确定需要的内存空间,当程序被加载时系统把内存一次性分配给它。这些内存不会在程序执行时发生变化,直到程序执行结束时内存才被回收。在Java的类和方法中的局部变量包括原生数据类型(int、long、char等)和对象的引用都是静态分配内存的。

    2、动态内存分配和回收

    所谓动态分配就是在程序执行时才知道要分配的存储空间大小,而不是在编译时就能够确定的。内存的分配是在对象创建时发生的,而内存的回收是以对象不再引用为前提的。动态内存的分配和回收是与Java中的一些数据类型关联的,而它们的回收是由垃圾收集器来解决的。

    3、如何检测垃圾

    垃圾收集器必须能够完成两件事情:一件是能够正确地检测出垃圾对象,另一件是能够释放垃圾对象占用的内存空间。其中如何检测出垃圾是垃圾收集器的关键所在。只要某个对象不再被其它活动对象引用,那么这个对象就可以被回收了。

    4、基于分代的垃圾收集算法

    该算法的设计思路是:把对象按照寿命长短来分组,分为年轻代和年老代,新创建的对象被分在年轻代,如果对象经过几次回收后仍然存活,那么再把这个对象划分到年老代。年老代的收集频度不像年轻代那么频繁,这样就减少了每次垃圾收集时所扫描的对象的数量,从而提高垃圾回收效率。

    JVM将整个堆划分为Young区、Old区和Perm区,分别存放不同年龄的对象。

    七 、操作系统内存管理和jvm内存管理之间的关系

    图片源自网络

    原来jvm的设计的模型其实就是操作系统的模型,基于操作系统的角度,jvm就是个该死的java.exe/javaw.exe,也就是一个应用,而基于class文件来说,jvm就是个操作系统,而jvm的方法区,也就相当于操作系统的硬盘区

    参考文章

    Java内存区域

    JVM 与 Linux 的内存关系详解

    java内存管理深入浅出

    jvm内存与操作系统内存之间的关系

    相关文章

      网友评论

        本文标题:【操作系统笔记】操作系统内存管理和jvm内存管理的对比和关系

        本文链接:https://www.haomeiwen.com/subject/sxntohtx.html