JVM体系结构和垃圾回收

文章目录

  • JVM体系结构和垃圾回收
    • 简介
  • 一、JVM体系结构
    • 1. 类装载器子系统
    • 2. 运行时数据区
    • 3. 执行引擎
  • 二、JVM垃圾回收
    • 1.如何判断对象"已死"
    • 2. 回收方法区
    • 3.垃圾回收算法
  • 三、内存分配与回收策略
    • 1.对象优先在Eden区分配
    • 2.大对象直接进入老年代
    • 3.长期存活的对象将进入老年代
    • 4.动态对象年龄判定

JVM体系结构和垃圾回收

简介

什么是JVM?
JVM(Java Virtual Machine)意为Java虚拟机。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
使用JVM就是为了支持与操作系统无关,实现跨平台。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。
Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

一、JVM体系结构

JVM的内部体系结构被分为三部分,分别是:

  1. 类装载器子系统(ClassLoader)
  2. 运行时数据区(PC寄存器、Java虚拟机栈,本地方法栈、堆、方法区、运行时常量池)
  3. 执行引擎(执行字节码,或者执行本地方法)

1. 类装载器子系统

顾名思义,类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。
一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Javac 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,当它在运行时(不是编译时)首次引用一个类时,它加载、链接并初始化该类文件,并转换成java.lang.Class类的一个实例。
JVM将整个类加载过程划分为了三个步骤:
1.1 加载:
JVM通过类名、类所在的包名通过ClassLoader来完成类的加载,将类.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区中的数据结构。

1.2 链接:
1)校验: 字节码校验器会校验生成的字节码是否正确,如果校验失败,我们会得到校验错误。
2)准备: 为类的静态变量分配内存,并设置其默认初始值
3)解析: 将常量池内的符号引用转换为直接引用的过程(符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。直接引用:是直接指向目标的引用,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的)

1.3 初始化:
这是类加载的最后阶段,执行类中的静态初始化代码、构造器代码以及静态属性的初始化。

2. 运行时数据区

JVM会在执行Java程序的过程中把它管理的内存划分为若干个不同的数据区域。这些数据区域各有各的用处,各有各的创建与销毁时间.。
运行时数据区主要包括以下几个运行时数据区:
线程私有区域:程序计数器、Java虚拟机栈、本地方法栈
线程共享区域:堆区、方法区、运行时常量池

2.1 程序计数器(PC寄存器)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有属于自己的PC寄存器来保存当前执行指令的地址,一旦该指令被执行,pc寄存器会被更新至下条指令的地址。
若thread执行Java方法,则PC保存下一条执行指令的地址。若thread执行native方法,这个计数器的值为空。

2.2 Java虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型 ,声明周期与线程相同。 每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。

  • 栈帧: 每个方法执行的同时会创建一个栈帧,它是虚拟机栈的基本元素。
  • 局部变量表: 一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
  • 操作数栈 : 虚拟机栈中的一个用于计算的临时数据存储区。
  • 动态链接: 在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于运行时常量池。这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态链接。
  • 方法出口: Java方法有两种返回方式,return和抛出异常,两种方式都会导致该方法对应的帧出栈和释放内存。

此区域一共会产生以下两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的深度(-Xss设置栈容量),将会抛出StackOverFlowError异常。
  • 虚拟机在动态扩展时无法申请到足够的内存,会抛出OOM(OutOfMemoryError)异常。

2.3 本地方法栈
本地方法栈与虚拟机栈的作用完全一样,他俩的区别无非是本地方法栈为虚拟机使用的Native方法服务,而虚拟机栈为JVM执行的Java方法服务。
一个native方法就是一个Java调用非Java代码的接口。也就是该方法的实现由非Java语言实现,比如用C或C++实现。

2.4 堆区
Java堆(Java Heap)是JVM所管理的最大内存区域。Java堆是所有线程共享的一块区域,在JVM启动时创建。此内存区域存放的都是类的实例对象,几乎所有的对象实例都在这里分配内存。如果在堆中没有足够的内存完成实例分配并且堆也无法再拓展时,将会抛出OOM。

2.5 方法区
主要用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。(在JDK1.7发布的HotSpot中,已经把字符串常量池移除方法区了。)

2.6 常量池
运行时常量池是方法区的一部分。存放的为字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
字面量: 字符串(JDK1.7后移动到堆中) 、final常量、基本数据类型的值。
符号引用: 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。

3. 执行引擎

分配给运行时数据区的字节码将由执行引擎执行。执行引擎读取字节码并逐段执行。

二、JVM垃圾回收

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。

1.如何判断对象"已死"

Java堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。判断对象是否已"死"有如下几种算法:
1.1 引用计数法
引用计数法:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。但引用计数法很难解决对象的循环引用问题,因此在主流的JVM中没有选用引用计数法来管理内存。
看下面的例子:

public class Test {
    public Object instance = null;

    public static void main(String[] args) {
        Test test1 = new Test();
        Test test2 = new Test();
        test1.instance = test2;
        test2.instance = test1;
        test1 = null;
        test2 = null;
    }
}

可以观察到,这两个对象已经不可能再被访问了,但是由于他们相互引用着对方,导致他们的引用计数器永远不可能为0,也就无法通知GC回收他们。

1.2 可达性分析法
在上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活。
此算法的核心是:通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,所走过的路径称为"引用链",当一个对象到GC Roots间没有任何引用链相连时(也就是从GC Roots到此对象不可达),证明此对象是不可用的。
如下图所示:
JVM体系结构和垃圾回收_第1张图片
虽然Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
那么此时就引出一个问题,那些对象可以作为GC Roots?
在Java语言中,可作为GC Roots的对象包含下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(Native方法)引用的对象

1.3 再谈引用
在JDK1.2以前,Java中引用的定义很传统 : 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。

  • 强引用: 强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
  • 软引用: 如引用是用来描述一些有用的但不是必须的引用。对于软引用引用着的对象,在内存空间不够的时候,垃圾回收器就会对其引用的对象进行二次回收。如果这次回收还是没有足够的内存,就会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
    软引用可以和引用队列(referenceQueue)联用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用 队列中。
  • 弱引用: 弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。弱引用与软引用的区别在于:只具有弱引用的对象在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。在JDK1.2之后提供了WeakReference类来实现弱引用。
    弱引用可以配合引用队列,如果弱引用所引用的对象被垃圾 回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
  • 虚引用: 虚引用也被称为幽灵引用或者幻影引用。它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

虚引用必须和引用队列联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。

2. 回收方法区

方法区的垃圾回收主要收集两部分内容 : 废弃常量无用的类
1.废弃常量
回收废弃常量和回收Java堆中的对象十分类似。以常量池中字面量(直接量)的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象引用常量池的"abc"常量,也没有在其他地方引用这个字面量,如果此时发生GC并且有必要的话,这个"abc"常量会被系统清理出常量池。

2.无用的类
判定一个类是否是"无用类"则相对复杂很多。类需要同时满足下面三个条件才会被算是"无用的类" :

  1. 该类所有实例都已经被回收(即在Java堆中不存在任何该类的实例)
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有在任何其他地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

3.垃圾回收算法

在确定了哪些垃圾可以被回收之后,垃圾收集器要做的就是开始进行垃圾回收,而如何高效的进行垃圾回收,就主要用到以下几种垃圾回收算法。

3.1 标记-清除算法
算法思想:"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。
JVM体系结构和垃圾回收_第2张图片
"标记-清除"算法的不足主要有两个 :

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

3.2 复制算法
算法思想:复制算法是在标记清除算法基础上演化而来,解决标记清除算法的内存碎片问题以及效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况。
JVM体系结构和垃圾回收_第3张图片
但复制算法也暴露了一个问题,那就是内存一次只能用一半,代价是不是有点大了。
现在主要使用复制算法来回收新生代
因为在新生代每次都有大量的对象死去,只有少量存活,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的Eden区和两块较小的Survivor区(一个称为From区,另一个称为To区),每次使用Eden和一块Survivor区。当进行回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot默认Eden与Survivor的大小比例是8 : 1,也就是说Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。

HotSpot实现的复制算法流程如下:

  1. 当Eden区满的时候,会触发第一次Minor gc (后边会提到),把还活着的对象拷贝到Survivor From区;当Eden区再次触发Minor gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。
  2. 当后续Eden又发生Minor gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。
  3. 部分对象会在From和To区域中复制来复制去,如此经过15次Minor gc(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代
    JVM体系结构和垃圾回收_第4张图片

3.3 标记-整理算法
标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。标记-整理算法解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。
JVM体系结构和垃圾回收_第5张图片
标记整理算法对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

3.4 分代收集算法
当前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代老年代

  • 在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法
  • 而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整 理"算法。

Minor Gc和Full GC

  • 新生代GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC(并非绝对),Major GC的速度一般会比Minor GC的慢10倍以上。

三、内存分配与回收策略

刚说过JVM如何进行内存回收的,接下来说一下如何进行内存分配。

1.对象优先在Eden区分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发生一次Minor GC。

2.大对象直接进入老年代

为了避免为大对象分配内存时,在Eden区以及两个Survivor区之间发生大量的内存复制,就让大对象直接进入老年代。所谓的大对象是指,需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
JVM提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。

3.长期存活的对象将进入老年代

既然虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代中。
为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且把对象年龄设为1.对象在Survivor空间中每"熬过"一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将晋升到老年代中。,可以通过参数-XX:MaxTenuringThreshold设置。

4.动态对象年龄判定

为了能更好的适应不同程序的内存状况,JVM并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

你可能感兴趣的:(JVM)