jvm内存模型浅谈

  

#     一、类执行机制

       线程创建后,jvm会为每个线程分配堆栈空间, 且每个线程创建后,每个线程都会产生 程序计数器(PC)和 栈(stack),
       程序计数器存放下一条需要执行的指令在方法内的偏移量, 栈中存放一个个的栈帧,每个栈帧对应着每个方法的每次调用,
       而每个栈帧有分为 局部变量区 和 操作数栈 两部分,局部变量区用于存放 方法的局部变量和参数,
       操作数栈 用于存放方法执行过程中产生的中间结果。如下图


       JVM内存结构分为:方法区(method),栈内存(stack),堆内存(heap),本地方法栈(java中的jni调用),结构图如下所示:
 

- [ ] ###       (1) 堆内存(heap)

-        所有通过new创建的对象的内存都在 堆 中分配,其大小可以通过 -Xms 和 -Xms 来控制。
         操作系统有一个记录空闲内存地址的 链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,
         然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录
         本地分配的大小,这样代码中的delete语句才能正确的释放本内存空间。但由于找到的堆结点的大小不一定正好等于申请的大小,
         系统会自动的将多余的那部分重新放入空闲链表中。这时由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。
         堆内存是向高地址扩展的数据结构,是不连续的内存区域。由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的
         遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存,所以,堆获得的空间比较灵活,也比较大。
         
        
          

###  (2)栈内存(stack)

    在Windows下, 栈是向低地址扩展的数据结构,是一块连续的内存区域。这句话的意思是栈顶的地址和栈的最大容量是
    系统预先规定好的,在WINDOWS下,栈的大小是固定的(是一个编译时就确定的常数),如果申请的空间超过栈的剩余
    空间时,将提示overflow。因此,能从栈获得的空间较小。只要栈的剩余空间大于所申请空间,系统将为程序提供内存
    ,否则将报异常提示栈溢出。 由系统自动分配,速度较快。但程序员是无法控制的。
    
    堆内存与栈内存需要说明:
    
    基础数据类型直接在栈空间分配,方法的形式参数,直接在栈空间分配,当方法调用完成后从栈空间回收。
    引用数据类型,需要用new来创建,既在栈空间分配一个地址空间,又在堆空间分配对象的类变量 。
    方法的引用参数,在栈空间分配一个地址空间,并指向堆空间的对象区,当方法调用完成后从栈空间回收。
    局部变量new出来时,在栈空间和堆空间中分配空间,当局部变量生命周期结束后,栈空间立刻被回收,堆空间区域等待GC回收。
    方法调用时传入的literal参数,先在栈空间分配,在方法调用完成后从栈空间收回。字符串常量、static在DATA区域分配,
    this在堆空间分配。数组既在栈空间分配数组名称,又在堆空间分配数组实际的大小。

### 


     上面的图中看到的是JVM中栈有两个,但是堆只有一个,每一个线程都有自已的线程栈【线程栈的大小可以通过设置JVM的-xss参数进行配置,
     32位系统下,JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K】,线程栈里面的数据属于该线程私有,但是所有的线程都
     共享一个堆空间,堆中存放的是对象数据,什么是对象数据,排除法,排除基本类型以及引用类型以外的数据都将放在堆空间中。
     其中方法区和堆是所有线程共享的数据区
     


     1.程序计数器
      在CPU的寄存器中有一个PC寄存器,存放下一条指令地址,这里,虚拟机不使用CPU的程序计数器,自己在内存中设立一片区域
        来模拟CPU的程序计数器。只有一个程序计数器是不够的,当多个线程切换执行时,那就单个程序计数器就没办法了,
        虚拟机规范中指出,每一条线程都有一个独立的程序计数器。注意,Java虚拟机中的程序计数器指向正在执行的字节码地址,而不是下一条。

     2. Java虚拟机栈 
      Java虚拟机栈也是线程私有的,虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的时候都会创建一个栈帧,
        用于存放局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直到执行完成的过程都对应着一个栈帧
        在虚拟机中的入栈到出栈的过程。我们平时把内存分为堆内存和栈内存,其中的栈内存就指的是虚拟机栈的局部变量表部分。
        局部变量表存放了编译期可以知道的基本数据类型(boolean、byte、char、short、int、float、long、double),
        对象引用(可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置),
        和返回后所指向的字节码的地址。其中64 位长度的long 和double 类型的数据会占用2个局部变量空间(Slot),
        其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,
        这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
        当递归层次太深时,会引发java.lang.StackOverflowError,这是虚拟机栈抛出的异常。

     3. 本地方法栈 
      在HotSpot虚拟机将本地方法栈和虚拟机栈合二为一,它们的区别在于,虚拟机栈为执行Java方法服务,
        而本地方法栈则为虚拟机使用到的Native方法服务。

     4. Java堆
      Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。这个区域是用来存放对象实例的,
        几乎所有对象实例都会在这里分配内存。堆是Java垃圾收集器管理的主要区域(GC堆),垃圾收集器实现了对象的自动销毁。
        Java堆可以细分为:新生代和老年代;再细致一点的有Eden空间,From Survivor空间,To Survivor空间等。
        Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。可以通过-Xmx和-Xms控制
        
     5. 方法区 
      方法区也叫永久代。在过去(自定义类加载器还不是很常见的时候),类大多是”static”的,很少被卸载或收集,
        因此被称为“永久的(Permanent)”。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),
        目的应该是与Java 堆区分开来。同时,由于类class是JVM实现的一部分,并不是由应用创建的,所以又被认为是“非堆(non-heap)”内存。
        HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。
        对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

      永久代也是各个线程共享的区域,它用于存储已经被虚拟机加载过的类信息,常量,静态变量(JDK7中被移到Java堆),
        即时编译期编译后的代码(类方法)等数据。这里要讲一下运行时常量池,它是方法区的一部分,用于存放编译期生成的各
        种字面量和符号引用(其实就是八大基本类型的包装类型和String类型数据(JDK7中被移到Java堆))
    
      在JDK1.7中的HotASpot中,已经把原本放在方法区的字符串常量池移出。

        将interned String移到Java堆中
        将符号Symbols移到native memory(不受GC管理的内存)
          从JDK7开始永久代的移除工作,贮存在永久代的一部分数据已经转移到了Java Heap或者是Native Heap。
        但永久代仍然存在于JDK7,并没有完全的移除:符号引用(Symbols)转移到了native heap;字面量(interned strings)
        转移到了java heap;类的静态变量(class statics)转移到了java heap。随着JDK8的到来,JVM不再有PermGen。
        但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory)中。

        在JVM中共享数据空间划分如下图所示

 
        上图中,刻画了Java程序运行时的堆空间,可以简述成如下2条

        1.JVM中共享数据空间可以分成三个大区,新生代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation),
          其中JVM堆分为新生代和老年代

        2.新生代可以划分为三个区,Eden区(存放新生对象),两个幸存区(From Survivor和To Survivor)(存放每次垃圾回收后存活的对象)

        3.永久代管理class文件、静态对象、属性等
        
        4.JVM垃圾回收机制采用“分代收集”:新生代采用复制算法,老年代采用标记清理算法。

        作为操作系统进程,Java 运行时面临着与其他进程完全相同的内存限制:操作系统架构提供的可寻址地址空间和用户空间。

        操 作系统架构提供的可寻址地址空间,由处理器的位数决定,32 位提供了 2^32 的可寻址范围,也就是 4,294,967,296 位,
        或者说 4GB。而 64 位处理器的可寻址范围明显增大:2^64,也就是 18,446,744,073,709,551,616,或者说 16 exabyte(百亿亿字节)。

        地址空间被划分为用户空间和内核空间。内核是主要的操作系统程序和C运行时,包含用于连接计算机硬件、调度程序以及提供联网和
        虚拟内存等服务的逻辑和基于C的进程(JVM)。除去内核空间就是用户空间,用户空间才是 Java 进程实际运行时使用的内存。

        默认情况下,32 位 Windows 拥有 2GB 用户空间和 2GB 内核空间。在一些 Windows 版本上,通过向启动配置添加 /3GB 
        开关并使用 /LARGEADDRESSAWARE 开关重新链接应用程序,可以将这种平衡调整为 3GB 用户空间和 1GB 内核空间。
        在 32 位 Linux 上,默认设置为 3GB 用户空间和 1GB 内核空间。一些 Linux 分发版提供了一个hugemem内核,
        支持 4GB 用户空间。为了实现这种配置,将进行系统调用时使用的地址空间分配给内核。通过这种方式增加用户
        空间会减慢系统调用,因为每次进行系统调用时,操作系统必须在地址空间之间复制数据并重置进程地址-空间映射。    


        对复制算法进一步优化:使用Eden/S0/S1三个分区

        平均分成A/B块太浪费内存,采用Eden/S0/S1三个区更合理,空间比例为Eden:S0:S1==8:1:1,有效内存(即可分配新生对象的内存)是总内存的9/10。

        算法过程:

        1. Eden+S0可分配新生对象;
        2. 对Eden+S0进行垃圾收集,存活对象复制到S1。清理Eden+S0。一次新生代GC结束。
        3. Eden+S1可分配新生对象;
        4. 对Eden+S1进行垃圾收集,存活对象复制到S0。清理Eden+S1。二次新生代GC结束。
        5. goto 1。

        默认Eden:S0:S1=8:1:1,因此,新生代中可以使用的内存空间大小占用新生代的9/10,那么有人就会问,为什么不直接分成两个区,
        一个区占9/10,另一个区占1/10,这样做的原因大概有以下几种

    1. S0与S1的区间明显较小,有效新生代空间为Eden+S0/S1,因此有效空间就大,增加了内存使用率
    
    2.  有利于对象代的计算,当一个对象在S0/S1中达到设置的XX:MaxTenuringThreshold值后,会将其分到老年代中,
        设想一下,如果没有S0/S1,直接分成两个区,该如何计算对象经过了多少次GC还没被释放,你可能会说,
        在对象里加一个计数器记录经过的GC次数,或者存在一张映射表记录对象和GC次数的关系,是的,可以,
        但是这样的话,会扫描整个新生代中的对象, 有了S0/S1我们就可以只扫描S0/S1区了~~~        
                 

发布了27 篇原创文章 · 获赞 0 · 访问量 828

猜你喜欢

转载自blog.csdn.net/u013232219/article/details/104369776
今日推荐