JVM学习(一)JMM和垃圾回收算法

经常使用 Java,但对它的底层使用却不太熟悉,还有在出现 StackOverFlow 或 OOM 的时候,没能去找到原因而懊恼,于是就开始了学习 JVM。

最近看的是周志明大神写的《深入理解JVM》,觉得他写的概念比较深,所以看完一遍后,再去结合网络上的文章一起理解会更好吸收。

首先来了解一下Java虚拟机的内存模型(Java Memory Model)


Java虚拟机的内存模型(JMM)

java 运行时,数据区域划分为五个方面:
JMM模型

其中虚拟机栈,本地方法栈和程序计数器都是线程私有的,Java堆方法区各个线程共享的内存区域

下面简单介绍一下这五个区域的作用:

  • 程序计数器:
    是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。自己吗解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的指令。

  • Java虚拟机栈:
    它的生命周期与线程相同,每个方法被执行的时候都会同时创建一个栈帧(Stack Frame),用于存储局部变量表、操作栈、动态连接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈。

  • 本地方法栈:
    与虚拟机栈锁发挥的作用很相似,区别在于,虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。(Native Method就是一个java调用非java代码的接口,例如有C或C++)

  • 堆:
    是Java虚拟所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域唯一目的就是存放对象实例。同时,堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”(Garbage Collected Heap)。

    现在收集器基本都是采用的分带收集算法,所以Java堆还能细分为:新生代和老年代;再细致一点的有Eden空间,From Survivor空间、To Survivor空间等。

  • 方法区:
    与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译(JIT)之后的代码等数据。

垃圾收集器

哪些内存需要回收?

在对堆进行对象回收之前,首先要判断哪些是无效对象。需要确认这些对象哪些还“活着”,哪些已经“死去”了(即不可能再被任何途径引用到的对象)。

有两种判别方式:

  • 引用计数法
    给对象添加一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加一;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的。

  • 可达性分析法
    所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。GC Roots指的是:

    • Java虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 方法区中的类静态属性引用的对象
    • 方法区中的常量引用的对象
    • 本地方法栈中JNI(Nativce方法)的引用的对象。

两者对比:
引用计数法虽然简单,但它很难解决对象之间的相互循环引用的问题。所以常用的是可达性分析算法。

而在可达性分析算法中不可达的对象,也并不是“非死不可”,这时候它们会暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。其中会涉及到finalize()方法和F-Queue队列,想要深入了解的童鞋可以专门查找相关资料了解一下~


垃圾收集算法

  • 标记-清除算法
    最基础的收集算法是“标记-清除”(Mark-Sweep)算法:两个阶段,首先标记所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

    同样有两个缺点:第一个是效率问题,标记和清除过程的效率都不高;第二个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

    标记-清除算法

  • 复制算法
    为了解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
    当这一块的内存用完之后,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
    好处是,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效,但这种算法的代价是将内存缩小为原来的一半,感觉有点得不偿失…

Copying

书中提到,现在大部分Java虚拟机都采用这种收集算法来回收新生代。

新生代中的对象98%是朝生晚死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中空内存空间为整个新生代容量的90%(80%+10%)。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保(Handle Promotion)。

  • 标记-整理算法(Mark-Compact)
    复制收集算法,在对象存活率较高时就要执行较多的复制操作,效率将会变低。所以老年代不采用复制收集算法,而是使用标记整理算法(Mark-Compact)。

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

Mark-Compact

  • 分代收集算法
    “分代收集算法”(Generational Collectio),根据对象的生存周期的不同将内存划分为几块,一般是把Java堆分成新生代和老年代,这样就可以根据各个年代的特点采用最合适的收集算法。
    例如在新生代中,每次垃圾收集都会有大量的对象死去,只有少量的存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中,因为对象的存活率高,没有额外的空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法进行回收。

第一篇先记录一下内存模型,下一篇记录垃圾收集器。