zoukankan      html  css  js  c++  java
  • JVM内存模型与垃圾回收GC

    Java开发有个很基础的问题,虽然我们平时接触的不多,但是了解它却成为Java开发的必备基础——这就是JVM。在C++中我们需要手动申请内存然后释放内存,否则就会出现对象已经不再使用内存却仍被占用的情况。在Java中JVM内置了垃圾回收的机制,帮助开发者承担对象的创建和释放的工作,极大的减轻了开发的负担。那是不是我们就不需要了解JVM了,显然在做一些优化或者深入研究应用性能的时候,JVM还是起了很关键的作用的。因此本篇就总结性的描述下JVM的内存模型与垃圾回收相关的知识。

    本文的主要内容如下:

    • 内存模型
    • 垃圾回收
    • 参考文章

    内存模型

    各部分的功能

    这几个存储区最主要的就是栈区和堆区,那么什么是栈什么是堆呢?说的简单点,栈里面存放的是基本的数据类型和引用,而堆里面则是存放各种对象实例的。

    堆与栈分开设计是为什么呢?

    • 栈存储了处理逻辑、堆存储了具体的数据,这样隔离设计更为清晰
    • 堆与栈分离,使得堆可以被多个栈共享。
    • 栈保存了上下文的信息,因此只能向上增长;而堆是动态分配

    栈的大小可以通过-XSs设置,如果不足的话,会引起java.lang.StackOverflowError的异常

    栈区

    线程私有,生命周期与线程相同。每个方法执行的时候都会创建一个栈帧(stack frame)用于存放 局部变量表、操作栈、动态链接、方法出口。

    存放对象实例,所有的对象的内存都在这里分配。垃圾回收主要就是作用于这里的。

    • 堆得内存由-Xms指定,默认是物理内存的1/64;最大的内存由-Xmx指定,默认是物理内存的1/4。
    • 默认空余的堆内存小于40%时,就会增大,直到-Xmx设置的内存。具体的比例可以由-XX:MinHeapFreeRatio指定
    • 空余的内存大于70%时,就会减少内存,直到-Xms设置的大小。具体由-XX:MaxHeapFreeRatio指定。

    因此一般都建议把这两个参数设置成一样大,可以避免JVM在不断调整大小。

    程序计数器

    这里记录了线程执行的字节码的行号,在分支、循环、跳转、异常、线程恢复等都依赖这个计数器。

    方法区

    类型信息、字段信息、方法信息、其他信息

    总结

    名称特征作用配置异常
    栈区 线程私有,使用一段连续的内存空间 存放局部变量表、操作栈、动态链接、方法出口 -XSs StackOverflowError OutOfMemoryError
    线程共享,生命周期与虚拟机相同 保存对象实例 -Xms -Xmx -Xmn OutOfMemoryError
    程序计数器 线程私有、占用内存小 字节码行号
    方法区 线程共享 存储类加载信息、常量、静态变量等 -XX:PermSize -XX:MaxPermSize OutOfMemoryError

    垃圾回收

    如何定义垃圾

    有两种方式,一种是引用计数(但是无法解决循环引用的问题);另一种就是可达性分析。

    判断对象可以回收的情况:

    • 显示的把某个引用置位NULL或者指向别的对象
    • 局部引用指向的对象
    • 弱引用关联的对象

    垃圾回收的方法

    Mark-Sweep标记-清除算法

    这种方法优点就是减少停顿时间,但是缺点是会造成内存碎片。

    Copying复制算法

    这种方法不涉及到对象的删除,只是把可用的对象从一个地方拷贝到另一个地方,因此适合大量对象回收的场景,比如新生代的回收。

    Mark-Compact标记-整理算法

    这种方法可以解决内存碎片问题,但是会增加停顿时间。

    Generational Collection 分代收集

    最后的这种方法是前面几种的合体,即目前JVM主要采取的一种方法,思想就是把JVM分成不同的区域。每种区域使用不同的垃圾回收方法。

    上面可以看到堆分成三个区域:

    • 新生代(Young Generation):用于存放新创建的对象,采用复制回收方法,如果在s0和s1之间复制一定次数后,转移到年老代中。这里的垃圾回收叫做minor GC;
    • 年老代(Old Generation):这些对象垃圾回收的频率较低,采用的标记整理方法,这里的垃圾回收叫做 major GC。
    • 永久代(Permanent Generation):存放Java本身的一些数据,当类不再使用时,也会被回收。

    这里可以详细的说一下新生代复制回收的算法流程:

    在新生代中,分为三个区:Eden, from survivor, to survior。

    • 当触发minor GC时,会先把Eden中存活的对象复制到to Survivor中;
    • 然后再看from survivor,如果次数达到年老代的标准,就复制到年老代中;如果没有达到则复制到to survivor中,如果to survivor满了,则复制到年老代中。
    • 然后调换from survivor 和 to survivor的名字,保证每次to survivor都是空的等待对象复制到那里的。

    垃圾回收器

    串行收集器 Serial

    这种收集器就是以单线程的方式收集,垃圾回收的时候其他线程也不能工作。

    并行收集器 Parallel

    以多线程的方式进行收集

    并发标记清除收集器 Concurrent Mark Sweep Collector, CMS

    大致的流程为:初始标记--并发标记--重新标记--并发清除

    G1收集器 Garbage First Collector

    大致的流程为:初始标记--并发标记--最终标记--筛选回收

    参考

      • JVM内存模型:http://developer.51cto.com/art/200911/165015.htm
      • 垃圾回收:http://www.importnew.com/19085.html
      • JVM垃圾回收器:http://www.cnblogs.com/chengxuyuanzhilu/p/7088316.html
      • 内存模型: http://blog.csdn.net/u012152619/article/details/46968883
      • JVM初探 -JVM内存模型

        标签 : JVM


        JVM是每个Java开发每天都会接触到的东西, 其相关知识也应该是每个人都要深入了解的. 但接触了很多人发现: 或了解片面或知识体系陈旧. 因此最近抽时间研读了几本评价较高的JVM入门书籍, 算是总结于此. 本系列博客的主体来自 深入理解Java虚拟机(第二版) 和 实战Java虚拟机 两部书, 部分内容参考 HotSpot实战 和 深入理解计算机系统 以及网上大量的文章. 若文内有引文未注明出处的, 还请联系作者修改.


         
        JVM 虚拟机架构(图片来源: 浅析Java虚拟机结构与机制)


        JVM 内存区域

        JVM会将Java进程所管理的内存划分为若干不同的数据区域. 这些区域有各自的用途、创建/销毁时间:


        (图片来源: JAVA的内存模型及结构)


        一. 线程私有区域

        线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束而创建/销毁(在Hotspot VM内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死).

        1. Program Counter Register(程序计数器):

        一块较小的内存空间, 作用是当前线程所执行字节码的行号指示器(类似于传统CPU模型中的PC), PC在每次指令执行后自增, 维护下一个将要执行指令的地址. 在JVM模型中, 字节码解释器就是通过改变PC值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖PC完成(仅限于Java方法, Native方法该计数器值为undefined). 
        不同于OS以进程为单位调度, JVM中的并发是通过线程切换并分配时间片执行来实现的. 在任何一个时刻, 一个处理器内核只会执行一条线程中的指令. 因此, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 这类内存被称为“线程私有”内存.


        2. Java Stack(虚拟机栈)

        虚拟机栈描述的是Java方法执行的内存模型: 每个方法被执行时会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息. 每个方法被调用至返回的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程(VM提供了-Xss来指定线程的最大栈空间, 该参数也直接决定了函数调用的最大深度).

        • 局部变量表(对应我们常说的‘堆栈’中的‘栈’)存放了编译期可知的各种基本数据类型(如boolean、int、double等) 、对象引用(reference : 不等同于对象本身, 可能是一个指向对象起始地址的指针, 也可能指向一个代表对象的句柄或其他与此对象相关的位置, 见下: HotSpot对象定位方式) 和 returnAddress类型(指向一条字节码指令的地址). 其中longdouble占用2个局部变量空间(Slot), 其余只占用1个. 如下Java方法代码可以使用javap命令或javassist等字节码工具读到:
        public String test(int a, long b, float c, double d, Date date, List<String> list) {
            StringBuilder sb = new StringBuilder().append(a).append(b).append(c).append(d).append(date);
        
            for (String str : list) {
                sb.append(str);
            }
        
            return sb.toString();
        }
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9

        注: javap/javassist读到的其实是静态数据, 而局部变量表内存储的却是运行时动态加载的动态数据, 但因为局部变量表所需的内存空间在编译期间即可完成分配, 当进入一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间大小不会改变, 因此可以在概念上认定这两部分内容存储的数据格式相同.


        3. Native Method Stack(本地方法栈)

        与Java Stack作用类似, 区别是Java Stack为执行Java方法服务, 而本地方法栈则为Native方法服务, 如果一个VM实现使用C-linkage模型来支持Native调用, 那么该栈将会是一个C栈(详见: JVM学习笔记-本地方法栈(Native Method Stacks)), 但HotSpot VM直接就把本地方法栈和虚拟机栈合二为一.


        二. 线程共享区域

        随虚拟机的启动/关闭而创建/销毁.


        1. Heap(Java堆)

        几乎所有对象实例和数组都要在堆上分配(栈上分配、标量替换除外), 因此是VM管理的最大一块内存, 也是垃圾收集器的主要活动区域. 由于现代VM采用分代收集算法, 因此Java堆从GC的角度还可以细分为: 新生代(Eden区From Survivor区To Survivor区)和老年代; 而从内存分配的角度来看, 线程共享的Java堆还还可以划分出多个线程私有的分配缓冲区(TLAB). 而进一步划分的目的是为了更好地回收内存和更快地分配内存.


        2. Method Area(方法区)

        即我们常说的永久代(Permanent Generation), 用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)

        不过在1.7的HotSpot已经将原本放在永久代的字符串常量池移出: 

        而在1.8中, 永久区已经被彻底移除, 取而代之的是元数据区Metaspace(这一点在查看GC日志和使用jstat -gcutil查看GC情况时可以观察到),与永久代不同, 如果不指定Metaspace大小, 如果方法区持续增长, VM会默认耗尽所有系统内存.

        • 运行时常量池 
          方法区的一部分. Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用, 这部分内容会存放到方法区的运行时常量池中(如前面从test方法中读到的signature信息). 但Java语言并不要求常量一定只能在编译期产生, 即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 如Stringintern()方法.

        三. 直接内存

        直接内存并不是JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可以使用Native函数库直接分配堆外内存, 然后使用DirectByteBuffer对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能. 
        显然, 本机直接内存的分配不会受到Java堆大小的限制(即不会遵守-Xms、-Xmx等设置), 但既然是内存, 则肯定还是会受到本机总内存大小及处理器寻址空间的限制, 因此动态扩展时也会出现OutOfMemoryError异常.


        HotSpot对象

        对象新建

        • new一个Java Object(包括数组和Class对象), 在JVM会发生如下步骤:

          1. VM遇到new指令: 首先去检查该指令的参数是否能在常量池中定位到一个类的符号引用, 并检查这个符号引用代表的类是否已被加载、解析和初始化过. 如果没有, 必须先执行相应的类加载过程.
          2. 类加载检查通过后: VM将为新生对象分配内存(对象所需内存的大小在类加载完成后便可完全确定), VM采用指针碰撞(内存规整: Serial、ParNew等有内存压缩整理功能的收集器)或空闲链表(内存不规整: CMS这种基于Mark-Sweep算法的收集器)方式将一块确定大小的内存从Java堆中划分出来.
          3. 除了考虑如何划分可用空间外, 由于在VM上创建对象的行为非常频繁, 因此需要考虑内存分配的并发问题. 解决方案有两个: 
            • 对分配内存空间的动作进行同步 -采用 CAS配上失败重试 方式保证更新操作的原子性;
            • 把内存分配的动作按照线程划分在不同的空间之中进行 -每个线程在Java堆中预先分配一小块内存, 称为本地线程分配缓冲TLAB, 各线程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB时才需要同步锁定(使用-XX:+/-UseTLAB参数设定).
          4. 接下来将分配到的内存空间初始化为零值(不包括对象头, 且如果使用TLAB这一个工作也可以提前至TLAB分配时进行). 这一步保证了对象的实例字段可以不赋初始值就直接使用(访问到这些字段的数据类型所对应的零值).
          5. 然后要对对象进行必要的设置: 如该对象所属的类实例如何能访问到类的元数据信息对象的哈希码对象的GC分代年龄等, 这部分息放在对象头中(详见下).
          6. 上面工作都完成之后, 在虚拟机角度一个新对象已经产生, 但在Java视角对象的创建才刚刚开始(<init>方法尚未执行, 所有字段还都为零). 所以new指令之后一般会(由字节码中是否跟随有invokespecial指令所决定-Interface一般不会有, 而Class一般会有)接着执行<init>方法, 把对象按照程序员的意愿进行初始化, 这样一个真正可用的对象才算完全产生出来.

        对象存储布局

        HotSpot VM内, 对象在内存中的存储布局可以分为三块区域:对象头、实例数据和对齐填充:

        • 对象头包括两部分: 
          • 一部分是类型指针, 即是对象指向它的类元数据的指针: VM通过该指针确定该对象属于哪个类实例. 另外, 如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度. 

            注意: 并非所有VM实现都必须在对象数据上保留类型指针, 也就是说查找对象的元数据并非一定要经过对象本身(详见下面句柄定位对象方式).

          • 一部分用于存储对象自身的运行时数据: HashCodeGC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳等, 这部分数据的长度在32位和64位的VM(暂不考虑开启压缩指针)中分别为32bit和64bit, 官方称之为“Mark Word”; 其存储格式如下:
        状态标志位存储内容
        未锁定 01 对象哈希码、对象分代年龄
        轻量级锁定 00 指向锁记录的指针
        膨胀(重量级锁定) 10 执行重量级锁定的指针
        GC标记 11 空(不需要记录信息)
        可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄
        • 实例数据部分是对象真正存储的有效信息, 也就是我们在代码里所定义的各种类型的字段内容(无论是从父类继承下来的, 还是在子类中定义的都需要记录下来). 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响. HotSpot默认的分配策略为longs/doublesintsshorts/charsbytes/booleansoops(Ordinary Object Pointers), 相同宽度的字段总是被分配到一起, 在满足这个前提条件下, 在父类中定义的变量会出现在子类之前. 如果CompactFields参数值为true(默认), 那子类中较窄的变量也可能会插入到父类变量的空隙中.
        • 对齐填充部分并不是必然存在的, 仅起到占位符的作用, 原因是HotSpot自动内存管理系统要求对象起始地址必须是8字节的整数倍, 即对象的大小必须是8字节的整数倍.

        对象定位

        建立对象是为了使用对象, Java程序需要通过栈上的reference来操作堆上的具体对象. 主流的有句柄和直接指针两种方式去定位和访问堆上的对象:

        • 句柄: Java堆中将会划分出一块内存来作为句柄池, reference中存储对象的句柄地址, 而句柄中包含了对象实例数据与类型数据的具体各自的地址信息: 

        • 直接指针(HotSpot使用): 该方式Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址: 

        这两种对象访问方式各有优势: 使用句柄来访问的最大好处是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不变. 而使用直接指针最大的好处就是速度更快, 它节省了一次指针定位的时间开销,由于对象访问非常频繁, 因此这类开销积小成多也是一项非常可观的执行成本.


        参考 & 拓展
        深入理解Java虚拟机
        实战Java虚拟机
        HotSpot实战
        深入理解计算机系统
        JVM内幕:Java虚拟机详解 (荐)
        Java内存管理:深入Java内存区域
        JAVA的内存模型及结构
        Memory Management in the Java HotSpot Virtual Machine
        Java HotSpot VM Options
        JVM实用参数(一)JVM类型以及编译器模式
        HotSpot虚拟机对象探秘
  • 相关阅读:
    算法笔记_225:数字密码发生器(Java)
    LVS专题-(1)LVS基本介绍
    Mysql加锁过程详解(7)-初步理解MySQL的gap锁
    java实现二叉树的构建以及3种遍历方法
    java设计模式-菜鸟网络
    数据结构与算法(周鹏-未出版)-第六章 树-习题
    数据结构与算法(周鹏-未出版)-第六章 树-6.5 Huffman 树
    数据结构与算法(周鹏-未出版)-第六章 树-6.4 树、森林
    数据结构与算法(周鹏-未出版)-第六章 树-6.3 二叉树基本操作的实现
    数据结构与算法(周鹏-未出版)-第六章 树-6.2 二叉树
  • 原文地址:https://www.cnblogs.com/erma0-007/p/8655029.html
Copyright © 2011-2022 走看看