JVM 内存模型与垃圾回收机制

java内存模型

在C++中我们需要手动申请内存然后释放内存,否则就会出现对象已经不再使用内存却仍被占用的情况。在Java中JVM内置了垃圾回收的机制,帮助开发者承担对象的创建和释放的工作,极大的减轻了开发的负担。那是不是我们就不需要了解JVM了,显然在做一些优化或者深入研究应用性能的时候,JVM还是起了很关键的作用的。

Java内存模型结构分为线程共享区和线程私有区

  • 线程共享区: 堆、方法区
  • 线程私有区: 虚拟机栈、本地方法栈、程序 计数器

堆: 用于存放对象实例和数组 ,由于堆是用来存放对象实例,因此堆也是垃圾收集器管理的主要区域,故也称为GC堆。由于现在的垃圾收集器基本都采用分代收集算法,所以堆的内部结构只包含新生代和老年代。

方法区:

  • 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 方法区通常和永久区(Perm)关联在一起,但永久代与方法区不是一个概念,只是有的虚拟机用永久代来实现方法区,这样就可以用永久代GC来管理方法区,省去专门内存管理的工作
  • 根据Java虚拟机规范的规定,当方法区无法满足内存分配的需求时,将抛出 OutOfMemoryError 异常

虚拟机栈:

  • 每个方法在执行的时候都会创建一个 栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息
  • 每个方法从调用直至完成的过程,对应一个栈帧在虚拟机栈中入栈到出栈的过程
  • 局部变量表主要存放一些基本类型的变量和对象句柄,它们可以是方法参数,也可以是方法的局部变量

程序计数器:
为什么需要程序计数器?

  • 在多线程情况下,当线程数超过CPU数量或CPU内核数量时,线程之间就要根据时间片轮询抢夺CPU时间资源。也就是说,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的程序计数器去记录其正在执行的字节码指令地址
  • 程序计数器是线程私有的一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器
  • 如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的字节码指令的地址
  • 如果正在执行的是 Native 方法,则计数器的值为空
  • 程序计数器是唯一一个没有规定任何 OutOfMemoryError 的区域*

    ———————————JVM 内存模型———————————

img

JAVA中的垃圾回收机制

程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中 程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了 。而Java堆区和方法区则与之不同,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾定位分析:

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

判断对象可以回收的情况:
  • 显示的把某个引用置位NULL或者指向别的对象
  • 局部引用指向的对象
  • 弱引用关联的对象

如何确定某个对象是“垃圾”?

引用计数算法

首先来谈谈什么是引用:JAVA中当一个对象被创建的时候会给该对象分配一个变量,这个变量便称为对象的引用。当任何其它变量被赋值为这个对象的引用时,计数加1。但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。 此种处理方式是最快速的。但是有bug,相互引用的变量永远无法为0

public class ReferenceFindTest {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();

        object1.object = object2;
        object2.object = object1;

        object1 = null;
        object2 = null;
    }
}

这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

可达性分析算法

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。例如如中EFG 对象在图中不可达 ,但是相互引用,他们便是GC处理的对象。

img

在Java语言中,可作为GC Roots的对象包括下面几种:   a) 虚拟机栈中引用的对象(栈帧中的本地变量表);   b) 方法区中类静态属性引用的对象;   c) 方法区中常量引用的对象;   d) 本地方法栈中JNI(Native方法)引用的对象。

分代收集算法

img

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的 核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域 。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

年轻代(Young Generation)的回收算法

a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。

c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

年老代(Old Generation)的回收算法

a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

持久代(Permanent Generation)的回收算法

用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,具体的回收可参见上文2.5节。

常见的垃圾收集器

下面一张图是HotSpot虚拟机包含的所有收集器,图是借用过来滴:

img

Serial收集器(复制算法)

新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

Serial Old收集器(标记-整理算法)

老年代单线程收集器,Serial收集器的老年代版本。

ParNew收集器(停止-复制算法)

新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。

Parallel Scavenge收集器(停止-复制算法)

并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。

Parallel Old收集器(停止-复制算法)

Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。

CMS(Concurrent Mark Sweep)收集器(标记-清理算法)

高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。

GC是什么时候触发的(面试最常见的问题之一)

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。

Scavenge GC

一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC ,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以 Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

Full GC

对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此 应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节 。有如下原因可能导致Full GC:

a) 年老代(Tenured)被写满;

b) 持久代(Perm)被写满;

c) System.gc()被显示调用;

d) 上一次GC之后Heap的各域分配策略动态变化;