Java虚拟机(JVM)、垃圾回收学习

JVM体系结构概述

JVM位置

在这里插入图片描述
JVM是运行在操作系统之上的,它与硬件没有直接的交互。

JVM体系结构

在这里插入图片描述

  • 白色的模块说明线程私有,几乎不存在垃圾回收。方法区和堆存在垃圾回收。
  • 栈管运行,堆管存储。栈是线程私有,不存在垃圾回收。栈保存基本类型变量+对象的引用+实例方法。java方法 = 栈帧。
  • 栈记录了方法之间调用和执行情况,类似于排班表。用来存储指向下一条指令的地址。它是当前线程所执行的字节码的行号执行器。
  • Native方法不归JAVA管,所以计数器是空的 。
  • 方法区是存放类结构信息的地方,是一种规范。

JVM执行流程如下:
首先类加载器会把 Java代码转换成字节码,运行时数据区再把字节码加载到内存中。而字节码文件只是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎,将字节码翻译成底层系统指令再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。

类装载器

负责加载class文件,class文件在文件开头有特定的文件标示,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。类加载器结构如下:
在这里插入图片描述
1、系统自带加载器:

  • 启动类加载器
    加载lib下路径下的类
  • 扩展类加载器
    加载lib/ext路径下的类
  • 应用程序类加载器
    ClassLoader负责,加载用户路径上所指定的类库。

2、用户自定义加载器

除顶层启动类加载器之外,其他都有自己的父类加载器。

工作过程:如果一个类加载器收到一个类加载的请求,它首先不会自己加载,而是把这个请求委派给父类加载器,只有父类无法完成时子类才会尝试加载,这种机制叫双亲委派
示例代码:

1
2
3
4
5
6
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("hello world");
}
}

输出:
Error: Main method not found in class java.lang.String, please define the main method as:
public static void main(String[] args)
or a JavaFX application class must extend javafx.application.Application

原因是在执行这个方法程序时JVM首先加载的是Bootstrap加载器,由于JVM中有java.lang.String这个类,所以会首先加载这个类,而在这个类中没有main方法,所以报错。

本地库接口

本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C程序,Java 诞生的时候是 C/C横行的时候,要想立足,必须有调用 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。正常开发中这辈子估计用不到了。

本地方法栈

它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

虚拟机栈

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。栈主要保存下面三类数据:

  • 本地变量
    输入参数和输出参数以及方法内的变量
  • 栈操作
    记录出栈、入栈的操作
  • 栈帧数据
    包括类文件、方法等

栈内存溢出代码示例:

1
2
3
4
5
6
7
8
public class Test {
public static void sayHello(){
sayHello();
}
public static void main(String[] args) {
sayHello();
}
}

方法区

供各线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容。上面讲的是规范,在不同虚拟机里头实现是不一样的,最典型的就是永久代(PermGen space)和元空间(Metaspace)。但是实例变量存在堆内存中,和方法区无关

程序计数器

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。
如果执行的是一个Native方法,那这个计数器是空的。
用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory=OOM)错误。

栈+堆+方法区的交互关系

1、通过句柄访问:
在这里插入图片描述

Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。详细参考上图。

2、通过直接指针访问
在这里插入图片描述
reference 中直接存储对象地址;HotSpot是使用直接指针的方式来访问对象。

两种方式的区别:使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference 自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好。

堆体系结构概述

简介

类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
在这里插入图片描述
在这里插入图片描述
如上面两张图所示,堆逻辑上由"新生+养老+永久代"组成。按GC又可分为"新生+养老代"两部分。JAVA8用元空间代替了永久代。区别是永久代用的是堆内存,元空间使用本机物理内存

新生代和老年代

除去永久代(元空间),新生代占1/3,老年代占2/3。在新生代中,又分为伊甸园Eden、幸存者Survivor From和幸存者Survivor To三个区域,且比例为8:1:1。新生代是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。参考后面的垃圾回收详细过程

永久代

永久代是一个常驻内存区域,用于存放JDK自身所携带的Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存

堆参数调优入门

在这里插入图片描述

  • 堆的参数主要有两个
    -Xms 堆的初始内存大小
    -Xmx 堆的最大内存大小
  • 新生代有一个参数 -Xmn用于调新生区和养老区的比例。这个一般不调。
  • 永久代有两个参数
    -XX:PermSize 永久代的初始内存大小
    -XX:MaxPermSize 永久代的最大内存大小
    Java8后没有这两个参数了,因为元空间是使用物理内存

程序打印出的JVM相关参数说明:
在这里插入图片描述

垃圾回收

垃圾回收详细过程

新生代 GC (Minor GC):频繁,速度快。
老年代 GC (Major GC / Full GC):出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对)。Major GC 的速度一般会比 Minor GC 慢十倍以上。
垃圾回收详细过程如下:

  • 当Eden区的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象所引用的对象进行销毁,然后将Eden区中的剩余对象移动到From区。
  • 当Eden区再次触发GC的时候会扫描Eden区和From区,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域(如果有对象的年龄已经达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1。
  • 然后,清空Eden和From中的对象。
  • 最后,To区和From区互换,原To区成为下一次GC时的From区,保证名为To区是空的。。部分对象会在From区和To区中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代。
  • 若老年代内存也满了,那么这个时候将产生MajorGC(FullGC),进行老年代的内存清理。若老年代执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM错误“OutOfMemoryError”。

如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:

  • Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
  • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

判断对象是否存活的算法

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。这种算法的优缺点:

  • 优点
    简单且高效(object-c用的这种算法)
  • 缺点
    需要额外的空间来存储计数器、很难处理循环引用

可达性分析算法

可达性分析算法又叫根搜索算法,是通过一些"GC Roots"对象作为起点,从这些节点开始往下搜索,搜索通过的路径成为引用链(Reference Chain),当一个对象没有被GC Roots的引用链连接的时候,说明这个对象是不可用的。
在这里插入图片描述
上图中对象1-4是GC Roots可达的,不会被回收;对象5-7之间虽然可达但是GC Roots不可达所以会被回收。
在Java中固定可作为GC Roots的对象有:

  • 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量等
  • 在方法区中类静态属性引用的对象,如Java类的引用类型静态变量
  • 在方法区中常量引用的对象,如字符串常量池里的引用
  • 在本地方法栈中JNI引用的对象(Native方法)
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常NPE,OOM等,系统类加载器
  • 所有被同步锁(Synchronized)持有的对象
  • 反应Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

可达性算法的优缺点如下:

  • 优点
    解决了循环引用的问题
  • 缺点
    在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。
    误报并没有什么伤害,Java虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。 一旦从原引用访问已经被回收了的对象,则很有可能会直接导致Java虚拟机崩溃。

垃圾回收算法

复制算法

复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。 HotSpot JVM年轻代的垃圾回收就是使用着复制算法。动图如下:
在这里插入图片描述
上图动画中,Area空闲表示To区域,Area激活表示From区域,绿色表示不被回收对象,红色表示被回收对象。
复制算法适用于年轻代,因为年轻代对象90%以上都是"朝生夕死"的,其优缺点如下:

  • 优点
    不产生内存碎片
  • 缺点
    浪费一半内存;对象存活率很高时浪费复制时间

标记清除算法

标记清除算法就是在程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。标记是指从根节点开始标记引用的对象,清除是指清理未被标记引用的对象(垃圾对象)。动图如下:
在这里插入图片描述
绿色表示不被回收对象,红色表示被回收对象。

标记清除算法适用于老年代,其优缺点如下:

  • 优点
    解决了引用计数器算法中的循环引用的问题
  • 缺点
    • 效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要暂停应用程序,对于交互性要求比较高的应用,体验非常差。
    • 通过标记清除算法清理出的内存,碎片化比较严重,被回收的对象存在于内存的各个角落,所以清理出来的内存是不连贯的。

标记整理算法

标记整理又叫标记压缩算法,是一种老年代的回收算法,它在标记清除算法的基础上做了一些优化。它的基本思想是首先从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。
动图如下:
在这里插入图片描述
绿色表示不被回收对象,红色表示被回收对象。

标记整理算法适用于老年代,其优缺点如下:

  • 优点
    解决了复制算法中的占内存问题,解决了标记清除算法中产生内存碎片问题
  • 缺点
    不仅要标记所有存活对象,还要整理所有存活对象的引用地址,导致效率不高

分代收集算法

分代收集算法就是目前虚拟机使用的回收算法,它解决了标记整理不适用于老年代的问题,将内存分为各个年代。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。
在不同年代使用最合适的算法,新生代存活率低,可以使用复制算法。而老年代对象存活率高,没有额外空间对它进行分配担保,所以只能使用标记清除或者标记整理算法。

参考链接如下:
尚硅谷周阳JVM视频
Java虚拟机(JVM)你只要看这一篇就够了
JVM的4种垃圾回收算法、垃圾回收机制与总结

自愿打赏成功后发送截图到邮箱1271826574@qq.com可获取本文的MarkDown源文件,邮件标题为:需要一份jvm_gc。