【博学谷学习记录】超强总结,用心分享|狂野架构师JVM内存结构 垃圾回收机制CG
一、JVM内存结构
主要讲述JVM内存结构,以及线上简单的调优场景。
垃圾回收算法
如何确定对象已死?
通常,判断一个对象是否被销毁有两种方法:
1.引用计数算法:为对象添加一个引用计数器,每当对象在一个地方被引用,则该计数器加1;每当对象引用失效时,计数器减1。但计数器为0的时候,就表明该对象没有被引用。
2.可达性分析算法:通过一系列被称之为GC Roots的根节点开始,沿着引用链进行搜索,凡是在引用链上的对象都不会被回收。
GC Roots是什么?下面列举可以作为GC Roots的对象:
- Java虚拟机栈中被引用的对象,各个线程调用的参数、局部变量、临时变量等。
- 方法区中类静态属性引用的对象,比如引用类型的静态变量。
- 方法区中常量引用的对象。
- 本地方法栈中所引用的对象。
- Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象。
- 被同步锁(synchronized)持有的对象。
垃圾回收算法
标记--清除算法
标记--清除算法就是对无效的对象进行标记,然后清除。
缺点:在进行垃圾回收之后,堆空间有大量的碎片,出现了不规整的情况。在给大对象分配内存的时候,由于无法找到足够的连续的内存空间,就不得不再一次触发垃圾收集。
标记--复制算法
标记--复制算法就是把Java堆分成两块,每次垃圾回收时只使用其中一块,然后把存活的对象全部移动到另一块区域,然后清除。
缺点:每次只使用堆空间的一半,造成了Java堆空间使用率的的下降。
标记--整理算法
标记--整理算法算是一种折中的垃圾收集算法,在对象标记的过程,和前面两个执行的是一样步骤。但是,进行标记之后,存活的对象会移动到堆的一端,然后直接清理存活对象以外的区域就可以了。这样,既避免了内存碎片,也不存在堆空间浪费的说法了。
缺点:每次进行垃圾回收的时候,都要暂停所有的用户线程,特别是对老年代的对象回收,则需要更长的回收时间,这对用户体验是非常不好的。
内存模型
对内存模型
内存模型几个重要点:
1.JVM内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代和老年代,而非堆内存则为永久代。
2.新生代Young和老年代Old默认占比是1:3。
3.年轻代又会分为Eden和Survivor区,Survivor也会分为FromPlace和ToPlace,Eden、FromPlace和ToPlace的默认占比为 8:1:1。
GC类型
- Minor GC/Young GC:针对新生代的垃圾收集;
- Major GC/Old GC:针对老年代的垃圾收集。
- Full GC:针对整个Java堆以及方法区的垃圾收集。
Minor GC工作原理
通常情况下,初次被创建的对象存放在新生代的Eden区,当第一次触发Minor GC,Eden区存活的对象被转移到Survivor区的某一块区域。以后再次触发Minor GC的时候,Eden区的对象连同一块Survivor区的对象一起,被转移到了另一块Survivor区。可以看到,这两块Survivor区我们每一次只使用其中的一块,这样也仅仅是浪费了一块Survivor区。
需要注意的2点:
- 每经历过一次垃圾回收的对象,它的分代年龄就加1,当分代年龄达到15以后,就直接被存放到老年代中。
- 给大对象分配内存的时候,Eden区已经没有足够的内存空间了,大对象就会直接进入老年代。
Full GC工作原理
老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题。
需要注意的几点:
- Full GC耗时较长,发生次数远没有Minor GC频繁,太频繁意味着性能出现问题。
- 标记-清除算法会产生大量内存碎片,以后如果需要为大对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次GC回收操作。
无论是Minor GC,还是Full GC,都会产生停顿现象,即Stop-The-World。Minor GC停顿时间较短,而Full GC耗时较长将导致长时间停顿、系统无响应,极大影响系统的性能。因此,Full GC日志的监控和性能分析在性能优化中极为重要。
运行时数据区域
主要讲述Java运行时的时区,包括Java堆、虚拟机栈、本地方法栈、方法区和程序计数器相关内容。
Java程序在运行时,会为JVM单独划出一块内存区域,而这块内存区域又可以再次划分出一块运行时数据区,运行时数据区域大致可以分为五个部分:方法区、虚拟机栈、本地方法栈、堆、程序计数器
Java堆(Heap)
栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。
Java堆区具有下面几个特点:
- 存储的是我们new来的对象,不存放基本类型和对象引用。
- 由于创建了大量的对象,垃圾回收器主要工作在这块区域。
- 线程共享区域,因此是线程不安全的。
- 能够发生OutOfMemoryError。
其实,Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区。
虚拟机栈(JVM Stacks)
Java虚拟机栈也是一块被开发者重点关注的地方,同样,先把干货放上来:
- Java虚拟机栈是线程私有的,每一个线程都有独享一个虚拟机栈,它的生命周期与线程相同。
- 虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 存放基本数据类型(boolean、byte、char、short、int、float、long、double)以及对象的引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
- 这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。
本地方法栈(Native Method Stacks)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
什么是Native Method?简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用一个C的函数。
方法区(Method Area)
方法区也是一块被重点关注的区域,主要特点如下:
- 线程共享区域,因此这是线程不安全的区域。
- 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,那么他们之间的区别到底是什么?方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。不过Java 8以后就没有永久代这个说法了,元空间取代了永久代。
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。
程序计数器(Program Counter Register)
程序计数器非常简单,想必大家都不是Java的初学者了,也都应该明白一点线程与进程的概念?(灵魂拷问,你明白么?)不明白没关系,我一句话给你讲清楚。
进程是资源分配的最小单位,线程是CPU调度的最小单位,一个进程可以包含多个线程, Java线程通过抢占的方法获得CPU的执行权。现在可以思考下面这个场景。
某一次,线程A获得CPU的执行权,开始执行内部程序。但是线程A的程序还没有执行完,在某一时刻CPU的执行权被另一个线程B抢走了。后来经过线程A的不懈努力,又抢回了CPU的执行权,那么线程A的程序又要从头开始执行?
这个时候程序计数器就粉墨登场了,它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。此外,程序计数器还具有以下特点:
-
线程私有,每一个线程都有一个程序计数器,因此它是线程安全的。
-
唯一一块不存在OutOfMemoryError的区域,可能是设计者觉得没必要。
代码示例分析
象比如String,都存在在堆中。
代码示例分析
对于Java堆、方法区、线程独享区域(主要是虚拟机栈),方法的执行都是伴随着线程的,原始类型的本地变量以及引用都存放在线程栈中,而引用关联的对象比如String,都存在在堆中。
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.logging.Logger;
public class HelloWorld {
private static Logger LOGGER = Logger.getLogger(HelloWorld.class.getName());
public void sayHello(String message) {
SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.YYYY");
String today = formatter.format(new Date());
LOGGER.info(today + ":" + message);
}
}
数据进行如下存放:
- Java堆:对象HelloWorld、对象SimpleDateFormat、对象String和对象LOGGER;
- 线程独享区域(主要是虚拟机栈):message的引用、formatter的引用、today的引用;
- 方法区:类信息SimpleDateFormat、类信息Logger、类信息HelloWorld、方法sayHello(),还包括类信息的所有方法。
类的加载
简介
如果 JVM 想要执行这个 .class 文件,我们需要将其装进一个类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进JVM里面来。
重点知识:
- Java文件经过编译后变成 .class 字节码文件
- 字节码文件通过类加载器被搬运到 JVM 虚拟机中
- 虚拟机主要的5大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行。
类加载流程
类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
加载
查找并加载类的二进制数据加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口。
验证
确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
- 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object之外。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:确保解析动作能正确执行。
准备
为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
- 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
- 如果类字段的字段属性表中存在 ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
假设一个类变量的定义为:publicstaticintvalue=3,那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,value赋值为3的动作将在初始化阶段才会执行。
解析
把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化其实就是一个赋值的操作,它会执行一个类构造器的方法。由编译器自动收集类中所有变量的赋值动作,此时准备阶段时的那个static int a = 3 的例子,在这个时候就正式赋值为3
卸载
GC将无用对象从内存中卸载,Java虚拟机将结束生命周期:
- 执行了 System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
类加载器的加载顺序
加载一个Class类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的:
- BootStrap ClassLoader:rt.jar
- Extention ClassLoader: 加载扩展的jar包
- App ClassLoader:指定的classpath下面的jar包
- Custom ClassLoader:自定义的类加载器
双亲委派机制
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派机制:
- 当 AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当 ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果 BootStrapClassLoader加载失败(例如在 $JAVA_HOME/jre/lib里未查找到该class),会使用 ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用 AppClassLoader来加载,如果 AppClassLoader也加载失败,则会报出异常 ClassNotFoundException。