了解JVM(HotSpot)

Java

Posted by NKQ on February 8, 2021

研究JVM首先要知道它是做什么的,它是一个虚拟机,我们写的java程序需要经过编译后生成class文件,在JVM内完成执行,JVM是如何做到这些的?它包含哪些结构?里面的一些机制是怎么实现的?最近学习了一点皮毛,打算记录下

JVM的基本结构

img

结构来看,执行我们的代码需要从编译我们写的Java代码开始,一直到类加载器再到JVM内部完成程序的执行,中间包含了许多的细节

  • 类加载器,链接,初始化

  • 双亲委派机制
  • 沙箱机制

  • GC 垃圾回收
  • JNI
  • 堆栈内存
  • 程序计数器

沙箱机制(类的装载阶段)

我们的写的程序被编译后,需要通过类加载器完成类的创建,由反射机制可知,对象可以有多个但是他们的类始终都是那一个,也就是可以理解为在内存中存在一个类的“对象”,通过这个类的”对象“来创建一个一个的对象,这个对象是如何创建的?答案就是通过类加载器

在java中存在三个类加载器,分别是

  • 应用程序类加载器(Application
  • 扩展类加载器(Extension
  • 启动类加载器(Bootstrap

加载类的步骤大致是这样的

img

可以看到实例化的对象在栈中存在一个引用,并且可以通过反射的方法来获取对象的信息

沙箱机制描述

沙箱机制是Java安全模型的核心机制,它限制程序对系统资源的访问,包括CPU,内存,文件系统,网络等等。通过将代码限制在特定范围内执行,保证了整个系统的安全性

它包括字节码校验器,以及类加载器,以此实现Java体系的安全

字节码校验器

在编译一个Java文件时,字节码校验器会对其进行检查,如果不符合Java规范,会直接编译失败,但是核心类不会经过字节码的校验

类加载器和双亲委派机制

在类加载器加载类时,会逐级上探,如果高一级的类加载器可以加载的类,就让高级的加载器去加载,最后加载不了的才用低级别的加载器,这样可以保证Java体系的安全性

  1. Java的根加载器(BootStap)是使用C++编写,并随着JVM的启动而启动,通过它加载的类,我们通过反射的方法得到的加载器类型为null,因为java无法识别这种类型,获取它的引用时,就只能返回一个null了

  2. 当Java虚拟机要加载一个类时,到底派出哪个类加载器去加载呢?

    • 首先当前线程的类加载器去加载线程中的第一个类(假设为类A)。 注:当前线程的类加载器可以通过Thread类的getContextClassLoader()获得,也可以通过setContextClassLoader()自己设置类加载器。

    • 如果类A中引用了类B,Java虚拟机将使用加载类A的类加载器去加载类B。

    • 还可以直接调用ClassLoader.loadClass()方法来指定某个类加载器去加载某个类。

类加载时通过这样的机制,可以保证Java的重要部分不会被恶意改动,保护代码,比如String类、Object类,通过任何的设置,最终都一定是由根加载器来加载

通过这样的机制,保证同一个类仅被加载一次,当一个类第二次被加载时,之前加载过它的类加载器就会检查到这一情况,直接返回第一次加载的”对象“,以此保证一个类仅会被加载一次

自定义的类加载器

同一个class文件,通过指定被两个不同的加载器加载后,他们也不是同一个类了

这是因为虚拟机对每一个类加载器加载的类维护一个命名空间,命名空间由一系列的唯一名称组成,它们互相之间是不可见的,虽然是同一个类,但由不同类加载器加载后,它们被赋予了不同的“名字”

Tomcat定义了多个不同的类加载器,做到了以下几点

  • 保证了同一个服务器的两个Web应用程序的Java类库隔离;
  • 保证了同一个服务器的两个Web应用程序的Java类库又可以相互共享;比如多个Spring组织的应用程序不能共享,会造成资源浪费;
  • 保证了服务器尽可能保证自身的安全不受不受部署Web应用程序影响;
  • 支持JSP应用的服务器,大多需要支持热替换(HotSwap)功能。

存取控制器

存取控制器用以控制类对核心API对操作系统的存取权限,这个控制策略设定,可以由用户设置

安全管理器

安全管理器是核心API和操作系统的主要接口,作用是实现对类的权限控制,他的权限比存取控制器更高

安全软件包

可以让我们对对应用提供额外的安全特性,包括

  • 安全提供者
  • 数字签名
  • 加密
  • 鉴别
  • 消息摘要

Native关键字和方法区

Native关键字

当出现这个关键字时说明Java无法完成这一操作,需要调用底层的C库,这些方法将会进入本地方法栈(Native Method Stack),

在执行引擎执行时,调用本地的库

比如以下应用

  • 线程
  • 操作硬件(打印机、鼠标、键盘等)

Java通过调用JNI(Java Native Interface)来扩展Java类的使用,可以融合其他编程语言为Java所用

方法区

方法区是一个概念,存储类信息、常量池、静态变量、JIT编译后的代码等数据,它被所有的线程共享,方法区属于共享区间

静态变量(Static)、常量(final)、类信息(构造方法和接口定义)、运行时的常量池(字符串)都存在方法区中。

要注意,实例变量存储在堆内存中,和方法区无关

PC寄存器

每个线程都有一个程序计数器,实际上是很小的一部分,它是线程私有的,是一个指针,指向了方法区的方法字节码(存储指向下一条指令的地址),会在执行引擎中读取下一条指令

栈内存

栈内存是一种先进后出,后进先出的结构,主管程序的运行,程序的生命周期,线程同步,当线程结束后,栈内存也被清空,因此也不存在垃圾回收的问题

栈中存储基本类型,对实例对象的引用和实例的方法

栈帧

程序每执行一个方法(执行一步)都会产生一个栈帧,正在执行的方法会在栈的顶部,每一个栈帧包含对上一个栈帧的关联信息、方法内容、需要的变量以及引用等等,当栈帧的叠加超过了栈的高度,就会抛出栈溢出错误(StackOverFlowError),导致程序的崩溃

堆内存

img

一个JVM仅有一个堆内存,其大小是可以调节的

在类加载器加载完毕后,会把类对象、方法、变量、常量等等一些需要引用的对象放到堆内存中,保存引用类型的真实对象;

堆内存中分为三个区域

新生代(伊甸园区)

包括Eden区、ServivorFrom、ServivorTo三个区

  • Eden区,大约占堆内存的三分之一,新生的对象会被创建到此位置,但如果对象很大,会被直接创建到老年代,当Eden区接近满载时,会触发MinorGC垃圾回收
  • ServivorTo:保留了一次MinorGC过程中的幸存者
  • ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者

MinorGC

  • MinorGC采用复制算法

  • 把Eden和ServivorFrom区域中存活的对象复制到ServicorTo区域(如果有对象的年龄以及达到了老年的标准(可调节),则赋值到老年代区),同时把这些对象的年龄+1(如果ServicorTo不够位置了就放到老年区)

  • 清空Eden和ServicorFrom中的对象

  • ServicorTo和ServicorFrom互换,原ServicorTo成为下一次GC时的ServicorFrom区

老年代

存放生命周期较长的对象,当老年代完全满了之后会触发OOM(OutOfMemoryError)错误

OOM错误并不会经常发生,一般情况下在新生代MinorGC触发后,进入老年代的对象过大时会触发MajorGC来清理空间

MajorGC

首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象

优点:不需要额外空间

缺点:

  • MajorGC的耗时比较长,因为要扫描再回收

  • MajorGC会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配

当触发MajorGC也没用时,内存被撑爆了,就会抛出OOM(OutOfMemoryError)错误

永久代(元空间)

内存的永久保存区域,存储元数据和Class,GC不会对此区域进行清理,因此此区域会随着Class的加载变得越来越多,最终可能抛出OOM(OutOfMemoryError)错误

在JDK1.8以后,永久代被移除,变成了元空间,区别在于元空间并不在虚拟机中了,使用本地内存,因此它只受限于本地内存的大小,类的元数据放入本地内存, 字符串池和类的静态变量放入Java堆中. 这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制

采用元空间而不用永久代的几点原因:

  1. 为了解决永久代的OOM问题,元数据和class对象存在永久代中,容易出现性能问题和内存溢出
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出(因为堆空间有限,此消彼长)
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
  4. Oracle 可能会将HotSpot 与 JRockit 合二为一

永久代和方法区的关系

  • Java 6中,方法区中包含的数据,除了JIT编译生成的代码存放在native memory的CodeCache区域,其他都存放在永久代

  • Java 7中,Symbol的存储从PermGen移动到了native memory,并且把静态变量从instanceKlass末尾(位于PermGen内)移动到了java.lang.Class对象的末尾(位于普通Java heap内)

  • Java 8中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间(Metaspace),‑XX:MaxPermSize 参数失去了意义,取而代之的是-XX:MaxMetaspaceSize

出现OOM错误后该怎么调试

  1. 可以先通过调节堆内存并在控制台打印出内存的信息,参数为-XX:+printGCDetails,快速检查是不是因为内存不足
  2. Dump内存文件进行分析,参数为-XX:+DumpOnOutOfMemoryError,生成hprof文件
  3. 使用内存快照分析工具分析Dump内存文件,快速定位内存泄漏,比如MAT,Jprofiler,这些工具可以方便的完成这些事情
    • 查看占用较大的对象
    • 查看引用的对象
    • 查看线程信息
    • 查看CPU信息
    • 获得堆中的数据
    • 等等……
  4. 经过分析后,再对代码进行针对性的修改,达到调试目的

GC算法

引用计数法(JVM未采用)

每一次调用后都对对象进行计数+1,调用失效后就-1,计数为0的对象会被清理

优点包括:

  • 实现简单
  • 判断高效

缺点是存在判断逻辑的错误,无法解决对象间互相循环引用的问题

MinorGC(复制算法)

  • 首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象

  • MajorGC的耗时比较长,因为要扫描再回收

  • MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配

当触发MajorGC也没用时,内存被撑爆了,就会抛出OOM(OutOfMemoryError)错误

这种算法优点是没有内存碎片产生,但是造成了内存空间的浪费,有一部分内存空间是空置的

MajorGC(标记清除算法)

首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象

优点:不需要额外空间

缺点:

  • MajorGC的耗时比较长,因为需要扫描两遍

  • MajorGC会产生内存碎片

MajorGC的优化,标记压缩算法

可以再次进行扫描对象,达到清理内存碎片的目的,但是会产生多余的移动操作

GC算法总结

内存效率:复制算法 > 标记清除算法 > 标记压缩算法 内存整齐度:复制算法 = 标记压缩算法 > 标记清除算法 内存利用率:标记压缩算法 = 标记清除算法 > 复制算法

由于各种算法存在着不同的缺陷,GC使用分代收集算法

  • 在新生代使用复制算法
  • 在老年代混合使用标记清除和标记压缩算法

参考:

JVM系列三:JVM参数设置、分析

JVM的新生代、老年代、MinorGC、MajorGC

Java8内存模型—永久代(PermGen)和元空间(Metaspace)

JVM(十)——类的加载与加载器

关于Java类加载双亲委派机制的思考(附一道面试题)

方法区的Class信息,又称为永久代,是否属于Java堆?