聊聊JVM

前言

在讲以下内容之前,我们先通过一个思维导图了解一下本文的大致内容

该图片引用自深入理解Java虚拟机总结-思维导图

————————————————

内存区域划分

这一部分我们主要来详细了解一下运行时数据区的内容

Java虚拟机栈

特点
线程私有区域,生命周期与线程相同

作用
Java虚拟机栈是描述的Java中方法执行的内存模型: 每个方法在执行的同时会创建一个栈帧(stack frame),用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法调用至执行完成的时候,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表存放了各种编译器可知的基本数据类型、引用数据类型和returnAddress指令(指向了下一条字节码指令的地址),我们平时常说的栈指的就是Java虚拟机栈里面的局部变量表的内容

程序计数器

特点
线程私有区域
概述
程序计数器是一块较小的内存空间,可以看作是当前线程执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成

本地方法栈

本地方法栈跟Java虚拟机栈的作用是类似的,他们的区别是Java虚拟机栈执行的是Java方法,而本地方法栈执行的是虚拟机用到的Native方法

Java堆

特点
所有线程共享区域,开发人员关注的核心区域,垃圾收集器管理的主要区域,Java虚拟机中内存最大的一块
概述
该内存区块是Java虚拟机中最大的一块内存区域,在虚拟机启动时创建,几乎所有的对象实例都会在这里进行分配,根据内存回收的角度来看,现在的大多数收集器大多采用分代收集算法,所以Java堆还可以进行进一步的细分:新生代和老年代,新生代再细致一点可分为Eden空间,from survivor空间,to survivor空间。

方法区

特点
所有线程共享区域
概述
方法区与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码数据等,平时说的常量池就位于该区域。虽然虚拟机规范把方法区作为堆的一个逻辑部分,但他还有另外的一个别名叫非堆,目的就是和Java堆进行区分

运行时常量池

概述
运行时常量池是方法区中的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区中的运行时常量池中存放

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,也可能导致OOM异常,在JDK1.4中新加入了NIO,引入了一种基于Channel与Buffer的I/O方式,它可以通过native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,在一些场景中能显著提升性能,因为避免了再Java堆和native堆上来回复制数据

如何判断对象已死?

在Java堆里面几乎存放着Java世界中所有的对象实例,垃圾收集器在进行垃圾回收前,第一件事情就是需要判断哪些对象还“存活”着,哪些对象已经“死去”

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器就+1,引用失效时,计数器就-1,任何时刻计数器为0的对象即不再使用的对象,即可以理解为这个对象已经“死去”了,在下次进行垃圾回收的时候,就可以将这些对象收集。引用计数实现简单,判定效率也很高,但是他很难解决对象之间的相互循环引用的问题

根搜索算法(可达性分析算法)

通过一系列的被称为GC Roots的对象作为起始点,从这些节点开始向下进行搜索,搜索所走过的路径为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明是对象不可用的。

哪些对象可以用来当做GC ROOTS呢?

  • 虚拟机栈中局部变量表中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象

垃圾收集算法

复制收集算法

缺点

  • 可用内存大小变为原来的一半
  • 如果对象存活率较高,会出现大量复制,效率会降低

将可用内存按容量分为大小相等的两块,每次可使用其中的一块,当一块中的内存耗尽时,对这块内存区域进行垃圾收集,将这块内存中所有还存活的对象复制到另一块,再把这块的内存区域一次性清理掉,这样每次只对整个半区进行内存回收,分配的时候就不用了考虑内存碎片的问题了

标记清除算法

缺点

  • 先标记,后清除,两阶段效率都不高
  • 产生大量内存碎片

先标记出所有需要回收的对象,标记完成后统一进行回收,后续的垃圾收集算法都是基于这种算法进行改进而得到的,缺点是标记和清除效率都不高,并且标记清除后会有大量的不连续的内存碎片

标记-整理算法

与标记清除算法类似,但是不是直接清除,而是将所有可存活的对象向一端移动,然后直接清理掉边界外的内存。

新生代对象特点

大部分情况都是朝生夕死

老年代对象特点

对象存活率较高,没有额外空间进行分配担保

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体表现。

Serial垃圾收集器

特点

  • 单线程收集
  • 新生代收集器
  • 收集时停止所有工作线程的工作(STOP THE WORLD)
  • 跟其他垃圾收集器相比简单而高效(单个CPU环境下,不存在线程上下文切换的开销,可以获得最高的单线程收集效率)
  • client模式下新生代默认垃圾收集器

Serial收集器是最基本发展最悠久的垃圾收集器,曾经(JDK1.3.1之前)是虚拟机新生代的收集的唯一选择,看名字就可以看出来,这个收集器是一个单线程的收集器,但他的单线程不仅仅体现在它只会使用一个垃圾回收线程去完成垃圾回收工作,更重要的是,在进行垃圾回收的时候必须暂停其他所有的工作线程,直至收集结束。这项工作实际上是虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。


Serial/Serial Old垃圾收集器运行示意图

ParNew收集器

特点

  • 新生代收集器
  • Serial收集器的多线程版本
  • 许多运行在server模式下的虚拟机中首选的新生代收集器

ParNew收集器实际上就是Serial收集器的多线程版本,除了使用多条线程进行垃圾手机外,其余的控制参数、垃圾收集算法,Stop The World、对象分配规则、回收策略都跟Serial垃圾收集器完全一样。许多运行在server模式下的虚拟机中首选的新生代收集器,除了Serial外,只有Parnew能配合CMS垃圾收集器工作,单CPU场景下,不会比Serial收集器有更好的效果(存在线程切换的开销),


ParNew 收集器运行示意图

Parallel Scavenge收集器

特点

  • 新生代收集器
  • 使用复制算法,多线程收集
  • 关注点与其他收集器不同,关注的是达到一个可控制的吞吐量

Parallel Scavenge收集器的特点是他关注的是可控制的吞吐量,即CPU运行用户代码的时间/(CPU运行用户代码时间+ 垃圾收集时间),举例来说,虚拟机运行100分钟,垃圾收集时间为1分钟,那吞吐量就是99%,该收集器提供了两个参数用于精准控制吞吐量,分别是控制最大垃圾收集时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数

MaxGCPauseMillis 是一个大于0的毫秒数,当然并不是设置的越小越好,GC停顿时间的缩短是以牺牲吞吐量和新生代空间来换取的: 比如把新生代调小,收集300M的新生代肯定比500M要快,这会导致垃圾收集会变得比之前更频繁, 举个例子 原来10秒收集一次,每次停顿100毫秒,现在每5秒收集一次,每次停顿70毫秒,停顿时间是缩小了,但是吞吐量也下来了

吞吐量 = CPU运行用户代码的时间/(CPU运行用户代码时间+ 垃圾收集时间)
GCTimeRatio 相当于 1 / 吞吐量

Serial-Old 收集器

特点

  • 老年代收集器
  • 单线程,回收时暂停所有用户线程
  • 使用标记-整理算法

Parallel Old收集器

特点

  • 老年代收集器
  • Parallel Scanvenge的老年代版本,使用多线程
  • 使用标记-整理算法

CMS垃圾收集器

特点

  • 老年代收集器
  • 采用标记-清除算法
  • 并发收集,低停顿
  • CMS MinorGC时会暂停所有的用户线程,并以多线程的方式进行垃圾回收。FullGC时不再暂停应用线程,而是使用若干个后台进程定期的对老年代空间进行扫描,及时回收不再使用的对象

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的垃圾收集器,很大一部分Java应用集中在互联网网站和B/S系统的服务端上,这类应用重视服务的响应速度,希望系统停顿时间较短,CMS就比较符合这类应用的需求

垃圾收集过程

  • 初始标记 标记GCRoots 能直接关联的对象,速度很快 暂停所有用户线程
  • 并发标记 进行GCRoots Tracing的过程
  • 重新标记 修正并发标记期间 因用户程序继续工作导致标记产生变动的那一部分对象的标记记录,停顿时间比初始标记时间稍长一些,远比并发标记时间短 暂停所有用户线程
  • 并发清除

缺点

  • 因为使用标记-清除算法,易产生大量内存碎片,内存碎片较多时,将会给分配大对象带来很大麻烦 可以通过开启FullGC时候进行内存整理,但是会延长停顿时间
  • CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure而导致另一次的FullGC产生
  • 对CPU资源敏感

浮动垃圾: 并发清除阶段,垃圾收集线程和用户线程并行执行,伴随着程序的运行,就会不断有垃圾产生,这些新产生的垃圾在标记过程之后产生,CMS无法在当次垃圾收集进行


CMS收集器运行示意图

G1垃圾收集器

G1的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,G1被设计用来长期取代CMS垃圾收集器,和CMS的相同点在于都属于并发收集器,大部分收集阶段都不需要挂起应用程序。区别在于G1没有CMS的碎片化问题,同时提供更加可控的停顿时间

G1将整个堆划分为一个个大小相等的小块,每一块的内存是连续的。和分代算法一样,G1中每个块也会充当Eden,Survivor,Old角色,但是他们不是固定的,这使得内存使用更加灵活

特点

  • 引入分区概念
  • 合理利用垃圾收集各个周期的资源,解决了CMS及其他收集器的缺陷
  • 使用标记-整理算法

对比CMS

  • 不再基于标记-清除算法,不会产生内存碎片
  • 停顿时间可控 G1可以通过设置预期停顿时间来控制垃圾收集时间来避免应用雪崩现象
  • 并行与并发 G1 能够充分的利用CPU,多核环境下的硬件优势来缩短STW的停顿时间

垃圾大致收集过程

  • 新生代垃圾收集
  • 并发收集,和用户线程同时执行
  • 混合垃圾收集
  • 必要的时候FullGC
G1的堆结构

传统的GC收集器将内存空间划分为新生代老年代和永久代(JDK1.8去除了永久代,引入了元空间metaspace),这种划分的特点是各代的存储地址是连续的,而在G1收集器中则引入了分区的概念,弱化了分代的概念。

  1. 整个堆默认分为2048份均分,每块大小是一致的(1M-32M)
  2. 逻辑上,也会分为Eden,Survivor,Old区,但是各个区的大小是不固定的
  3. 未分配区域可以为任何一个代使用

应用场景

  • 服务器多核CPU,JVM所占内存较大的情况(至少大于4G)
  • 应用在运行过程中会产生大量内存碎片,需要经常压缩空间
  • 想要更可控,可预期的GC停顿周期,防止高并发下应用雪崩现象

内存分配与垃圾回收策略

TODO

虚拟机类加载机制

在了解类加载过程之前,我们其实需要了解一下什么是类加载,Java虚拟机把类的描述数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。Class文件由类加载器加载后,在JVM中形成一份描述Class结构的元信息对象,通过该元信息对象可以知晓Class文件的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息对象间接调用Class对象的功能,这里就是我们经常能见到的Class类

类加载的过程

从图中我们可以看到类加载的过程分为加载、连接和初始化这么几个过程

  • 加载 查找并导入class文件
  • 连接 把类的二进制数据合并至jre中
    • 验证 检查载入class文件数据的正确性
    • 准备 给类的静态变量分配存储空间
    • 解析 将符号引用转成直接引用
  • 初始化 对类的静态变量,静态代码块执行初始化操作

类的加载

加载这一步是指将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区中的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区中的数据结构。类加载的最终产物是位于堆区的Class对象,向开发者提供了访问方法区中数据结构的接口

加载.class文件的方式有以下几种

  • 本地系统直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将java文件编译为.class文件

JVM需要通过以下步骤完成对类的加载

  • 通过类的全名获取该类的二进制字节流
  • 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
  • 在JVM堆区域生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口

验证

验证主要是为了确保class中的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。大致会经历以下四个阶段的验证

  • 文件格式验证 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机所处理,该验证的主要目的就是保证输入的字节流能正确的解析并存储与方法区内,只有经过该阶段的验证,字节流才会进入内存中的方法区进行存储
  • 元数据验证 对类的元数据信息进行语义校验,保证不存在不符合java语言规范的元数据信息
  • 字节码验证 该阶段主要工作是进行数据流和控制流分析,对类的方法进行校验分析,以保证被验证的类的方法在运行时不会做危害虚拟机安全的行为
  • 符号引用验证 最后一阶段的验证 发生在虚拟机将符号引用转变为直接引用的时候,主要是对类以外的信息进行匹配性校验(常量池中的各种符号引用)

准备

解析

什么是双亲委派机制?

双亲委派机制就是说,当类加载器收到类加载请求的时候,该类加载器并不会去加载该类,而是将请求委派给父加载器,如果父加载器上层还有父加载器的话,还会将请求委派给更上层的父加载器,最终会将传送到最顶层的类加载器,只有当父加载器范围内找不到所需的类的时候,会将结果返回给子类加载器,然后由子类加载器尝试自己加载

为什么要使用双亲委派机制?

如果不用双亲委派机制的话,有可能出现同一个类被不同的加载器加载多次的情况,如果发生这种情况,那么同一份字节码可能会在内存中出现多份,并且不同由不同类加载器加载的字节码对象又各不相同,从而导致一些意想不到的结果(相同的类通过不同的classLoader加载后生成的实例,通过instanceof发现不是同一个类),所以为了安全性考虑,防止核心API被随意篡改,这里采用了双亲委派机制,如果一个类已经被父类加载器加载过了,那么就直接返回,子类加载器不做任何事情。

那么为什么又要破坏双亲委派机制呢?

因为父类的类加载器范围是有限的,有些情况需要委托子类加载器去加载class文件,比如说java.sql.Driver接口是jdk提供的接口,而实现是需要各个厂商来自行实现的,这个时候根本就没有必要用父类加载器去加载了,因为父类加载器是肯定没有该类的(父类加载器只能加载JAVA_HOME/lib下的jar中的class文件),所以这个时候就需要委托子类加载器去自行加载厂商的driver实现了,从而破坏了默认的双亲委派机制

JVM如何判断两个类是否相同?

前面在双亲委派模型中曾经提到过,当由一个类加载器进行类的加载的时候,首先不由该类加载器进行加载,而是把这个工作交给父加载器,那么这个过程其实就说明了一个问题,当我们需要判断两个类是否是同一个类的时候,实际上需要判断两个内容是否相同

  • 类全名
  • 类加载器
    类全名相同的时候,如果类加载器不同的话,实际上这两个类也会被判定为两个完全不同的类

参考文章

《深入理解Java虚拟机》 第2版

Java类加载机制
【JVM】浅谈双亲委派和破坏双亲委派