JVM

JVM (Java虚拟机)本质上是一个计算机程序,他的职责是运行 Java字节码文件,解释为对应OS的机器指令。
JVM 包含内存管理、解释执行虚拟机指令、即时编译三大功能。
常见的JVM有HotSpot、GraalVM、OpenJ9等,另外DragonWell龙井JDK也提供了一款功能增强版的JVM。其中使用最广泛的是HotSpot。

JVM 跨平台原理

  • 本质是不同操作系统上提供了不同的JVM。(下载JDK/JRE for Linux/Windows…)
  • Java 代码“一次编写,到处执行”,其实是指由.java文件编译来的.class字节码文件(高级语言java –> javac编译 –> 字节码(提升程序运行的效率,跨平台运行))能够通过不同操作系统上的 JVM 解释为该操作系统的机器指令。
  • JVM 也不再只支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。

JVM 知识要点

1. 类加载器:将class字节码文件中的内容加载到内存中。 2. 运行时数据区域:负责管理JVM 使用到的内存,比如创建对象和销毁对象。 3. 执行引擎:将字节码文件中的指令解释成机器码,同时使用即时编译器优化性能,用gc回收内存。 4. 本地接口:调用本地已经编译好的方法(不在字节码文件中)比如虚拟机中提供的cpp方法。


类的生命周期

  • 类的生命周期:加载,连接,初始化,使用,卸载
  • 其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。
    在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
  1. 类的加载:查找并加载类的二进制数据
    • 类加载器通过一个类的全限定名来获取其定义的二进制字节流。可以使用Java代码拓展的不同的渠道。
    • JVM 将字节码中的信息保存到内存的方法区(虚拟概念)
    • 生成一个InstanceKlass对象(c++编写),保存类的所有信息,里边还包含实现特定功能比如多态的信息。
    • 在Java堆中生成一个代表这个类的java.lang.Class对象(包含字段比InstanceKlass中的少,控制访问范围和保证安全性),作用是在Java代码中去获取类的信息(反射),以及存储静态字段的数据(JDK8后) 加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。 类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
    • 加载.class文件的方式:从本地系统中直接加载、通过网络下载.class文件、从zip,jar等归档文件中加载.class文件、从专有数据库中提取.class文件、将Java源文件动态编译为.class文件
  2. 连接
    • 2.1 验证: 确保被加载的类的正确性。验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
      • 文件格式验证: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内(不能高于环境jdk版本)、常量池中的常量是否有不被支持的类型。
      • 元数据验证: 对字节码描述的信息进行语义分析(注意: 对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object之外。
      • 字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
      • 符号引用验证: 确保解析动作能正确执行。
    • 2.2 准备: 为类的静态变量分配内存,并将其初始化为默认值。这些内存都将在方法区中分配。
      • 这时进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量在对象实例化时随着对象一块分配在Java堆
      • 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。 假设一个类变量的定义为: public static int value = 3;那么变量value在准备阶段过后的初始值为0,而非3,因为这时候尚未开始执行任何Java方法,把value赋值为3的动作将在初始化阶段才会执行。
      • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,系统不会为其赋予默认零值。
      • 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值.而局部变量使用前必须显式地为其赋值,否则编译时不通过。
      • 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
    • 2.3 解析: 把类中的符号引用(在字节码文件中使用编号来访问常量池中内容)转换为直接引用(符号->内存地址)
      解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
  3. 初始化:为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。
    • 在Java中对类变量进行初始值设定有两种方式: 声明类变量是指定初始值、使用静态代码块为类变量指定初始值
    • JVM初始化步骤
      • 假如这个类还没有被加载和连接,则程序先加载并连接该类
      • 假如该类的直接父类还没有被初始化,则先初始化其直接父类
      • 假如类中有初始化语句,则系统依次执行这些初始化语句
    • 类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下:
      • 创建类的实例,也就是new的方式
      • 访问某个类或接口的静态变量(注意final修饰且等号右边是常量的变量不会触发初始化)或者对该静态变量赋值
      • 调用类的静态方法、执行Main方法的当前类
      • 反射(如Class.forName(“com.pdai.jvm.Test”))
      • 初始化某个类的子类,则其父类也会被初始化
      • Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
  4. 使用
    (即程序员工作阶段)类访问方法区内的数据结构的接口, 对象是Heap区的数据。
  5. 卸载
    • Java虚拟机将结束生命周期的几种情况:
      • 执行了System.exit()方法
      • 程序正常执行结束
      • 程序在执行过程中遇到了异常或错误而异常终止
      • 由于操作系统出现错误而导致Java虚拟机进程终止

JVM 类加载

类的生命周期的加载,连接,初始化阶段

类字节码详解

源代码通过编译器编译为字节码,再通过类加载子系统进行加载到JVM中运行;
字节码文件中保存了源代码编译之后的内容,以二进制的方式存储,无法直接用记事本打开阅读;
常用工具:对于本地/服务器上的字节码文件:javap;jclasslib,有idea插件版本,在代码编译(构建项目)后实时看到字节码文件内容。
对于运行中的程序:Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率。(jar包,使用java -jar启动 Arthas包)服务器上文件使用javap命令直接查看,也可以通过arthas的dump命令导出字节码文件再查看本地文件。还可以使用jad命令反编译出源代码。

  1. 基础信息:magic魔数(Java文件头 CAFEBABE,软件通过文件头来校验文件的类型,而非文件扩展名),主副版本号(指编译字节码文件的JDK版本号(如主版本号52对应JDK8,减44) 用于判断当前字节码的版本与运行时的JDK是否兼容(低版本JDK不能运行高版本字节码)冲突时建议降低第三方依赖的版本号),访问标识(public,final等等),父类和接口
  2. 常量池:避免相同内容重复定义,节省空间。保存了字符串常量、类或接口名、字段名主要在字节码指令中使用。
    常量池中的数据都有一个编号,编号从1开始。在字段或者字节码指令中通过编号可以快速的找到对应的数据。
    字节码指令中通过编号引用到常量池的过程称之为符号引用。
  3. 字段:当前类或接口声明的字段信息
  4. 方法:当前类或接口声明的方法信息,为字节码指令(在操作数栈和局部变量表上执行 iconst,istore,iload,iinc等指令,i++和++i区别就在指令iinc和iload的顺序颠倒)
    main方法:Java类的main方法是程序的入口点。在字节码文件中,这通常表示为public static void main(String[] args)方法。Java虚拟机(JVM)将通过public static void main(String[] args)方法来执行程序。字节码指令会调用这个方法,并它将启动程序的执行。
    init方法:类的构造函数,用于创建类的实例。在字节码文件中,方法是类的构造函数,用于初始化新对象的状态。这个方法通常是由invokespecial指令调用的,用于初始化新对象。
    clinit方法:类的类构造函数,也称为静态初始化方法。这个方法用于执行类的静态初始化代码块。在字节码文件中,方法通常由clinit指令表示。这个方法用于执行类级别的静态初始化操作
    Code属性:每个方法都有一个Code属性,其中包含了该方法的字节码指令,也就是方法体的实际执行代码
  5. 属性:类的属性,比如源码的文件名,内部类的列表等

类加载器

类加载器(ClassLoader)负责在类加载过程中的字节码获取并加载到内存这一部分。通过加载字节码数据放入内存转换成byte[](只负责这一部分),接下来调用虚拟机底层方法将byte[]转换成方法区和堆中的数据。
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:

  • 启动类加载器: Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的,无法直接通过代码查看,程序员难以直接调试。
    它使用C++实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分;所有其他的类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
  • 扩展类加载器: Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
  • 应用程序类加载器: Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器
因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点: 1. 在执行非置信代码之前,自动验证数字签名。2. 动态地创建符合用户特定需要的定制化构建类。 3.从特定的场所取得java class,例如数据库中和网络中。
URLClassLoader: 这是一个常见的用户自定义类加载器,可以从指定的URL加载类。在很多情况下,当需要从外部加载一些类或JAR文件时,使用URLClassLoader是比较方便的。URLClassLoader是Application ClassLoader的子类加载器。

JVM类加载机制

  • 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
  • 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  • 缓存机制 ,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
  • 双亲委派机制,如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去
    完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
  • 在Java中如何使用代码的方式去主动加载一个类呢?
    方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类
    方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载

双亲委派机制

由于Java虚拟机中有多个类加载器,双亲委派机制的核心是解决一个类到底由谁加载的问题。
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否(被这个类加载器)加载过,再由顶向下(如果位于这个类加载器的加载目录中)进行加载。
双亲委派机制的好处有两点: 第一是避免恶意代码替换JDK中的核心类库,确保的完整性和安全性;二是避免一个类重复地被加载

打破双亲委派机制:

  1. 自定义类加载器
    通常情况下,我们都是直接使用系统类加载器。但有时,我们也需要自定义类加载器。比如 应用通过网络传输 Java 类的字节码,为保证安全性经过了加密处理,这时系统类加载器就无法对其进行加载;Tomcat通过自定义类加载器实现应用之间类隔离;
    自定义类加载器一般都是继承自 ClassLoader 类,重写 findClass方法。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    public class MyClassLoader extends ClassLoader {
    ...
    protected Class<?> findClass(String name) throws ClassNotFoundException {
    byte[] classData = loadClassData(name);
    if (classData == null) { throw new ClassNotFoundException();
    } else { return defineClass(name, classData, 0, classData.length); // 连接阶段
    }
    }
    private byte[] loadClassData(String className) {
    String fileName = root + File.separatorChar
    + className.replace('.', File.separatorChar) + ".class";
    try {
    InputStream ins = new FileInputStream(fileName);
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    int bufferSize = 1024;
    byte[] buffer = new byte[bufferSize];
    int length = 0;
    while ((length = ins.read(buffer)) != -1) {
    baos.write(buffer, 0, length);
    }
    return baos.toByteArray();
    } catch (IOException e) {
    e.printStackTrace();
    }
    return null;
    }
    ...
    public static void main(String[] args) {
    MyClassLoader classLoader = new MyClassLoader();
    classLoader.setRoot("D:\\temp");
    Class<?> testClass = null;
    try {
    testClass = classLoader.loadClass("com.pdai.jvm.classloader.Test2");
    Object object = testClass.newInstance();
    System.out.println(object.getClass().getClassLoader());
    } catch () {... }
    }
    }
  2. 线程上下文类加载器 ???实际上没打破。。?**?????????**
    大量应用在 java底层,比如JDBC和JNDI等
  3. Osgi框架的类加载器
    历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载;还使用类加载器实现热部署功能。

JDK8 后的类加载器

由于JDK9引入了module的概念,类加载器在设计上发生了很多变化。

  1. 启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件.启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
  2. 扩展类加载器被替换成了平台类加载器(Platform Class Loader)
    平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑

JVM 内存结构

Java 虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。!!注意不要和Java内存模型混淆了,,

Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。【 类的生命周期的使用,卸载阶段
线程私有:程序计数器、虚拟机栈、本地方法区
线程共享:堆、方法区,堆外内存(Java7的永久代或JDK8的元空间、代码缓存)

程序计数器

程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的内存地址。
它是一块很小的固定大小的内存空间,不会发生内存溢出 "OutOfMemoryError"

  1. 在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
  2. 在多线程执行情况下,CPU会不停的做任务切换,这导致经常中断或恢复。JVM 需要通过程序计数器记录 CPU 切换前解释执行到那一句指令并继续解释运行。每个线程都有它自己的程序计数器,独立计算,不会互相影响。

Java 虚拟机栈

  • 早期也叫 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
    作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
  • JVM栈 采用栈的数据结构来管理方法调用中的基本数据,先进后出,每一个方法的调用使用一个栈帧(Stack Frame)来保存。
    IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况
  • 栈帧的组成
    • 局部变量表:在运行过程中存放所以的局部变量,包括实例方法的this对象,方法的参数,方法体中声明的局部变量
      • 编译成字节码文件时就可以确定局部变量表的内容,包含局部变量的编号、生效范围等;
        局部变量表数据在 字节码文件 - 方法 - Code - LocalVariableTable 查看。
      • 实际在栈帧中,局部变量表是一个数组,数组中每一个位置为(slot),long和double类型占两个槽,其他类型占一个。
      • 实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。
      • 方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致局部变量表
    • 操作数栈:JVM在执行指令的过程中用来存放临时数据的一块区域。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
      • 它是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
      • 在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小
    • 帧数据
      • 动态链接:当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
      • 方法出口:方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
      • 异常表的引用:异常表存放代码中异常的处理信息,包含了try代码块和catch代码块执行后跳转到的字节码指令位置
  • 栈内存溢出:一个线程的Java 虚拟机栈中,如果栈帧过多,占用内存超过栈内存可分配的最大大小就会出现内存溢出,出现StackOverflowError错误
    • 如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构,windows(64位)下的JDK8测试最小值为180k,最大值为1024m
    • 要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss。一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为 -Xss256k 节省内存。

本地方法栈

  • Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是(c++编写的)native本地方法(如文件IO操作)的栈帧.
  • 在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数,同时方便出现异常时也把本地方法的栈信息打印出来

**栈是运行时的单位,而堆是存储的单位**

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。

一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上,并且在栈上保存一个对象的引用(在堆上的地址)。
达到上限之后,就会抛出 OutofMemory错误。
栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

  • 堆内存中有三个值,used 指的是当前已使用的堆内存,total 是JVM已分配的可用堆内存,max 是JVM可以分配的最大堆内存。total 不足时,JVM会继续分配内存给堆,total 值增大但最多只能与 max 相等。
  • max 默认是系统内存的 1/4,total 默认是系统内存的 1/64。实际使用中一般都需要设置这两个值。
    Java服务端程序开发时,建议将 -Xmx(max最大值)和 -Xms(初始的total)设置为相同的值,避免再次申请内存。

方法区

  • 方法区是存放基础信息的位置,线程共享,主要包括三部分;
    • 类的元信息:类的基本信息 (元信息),一般称之为InstanceKlass对象。在类的加载阶段完成.
    • 运行时常量池:放的是字节码中的常量池内容。字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池
    • 字符串常量池:字符串常量池存储在代码中定义的常量字符串内容。比如String s2 = “abc” 的 “abc”即 s2这个字符串常量就会被放入字符串常量池(而String s1 = new String(“abc”) 中的 s1这个对象会被放入堆内存)
  • 方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下
    • JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
    • JDK8及之后的版本将方法区存放在元空间中,元空间位于OS维护的直接内存中,默认在不超过OS承受的上限可以一直分配
    • !! JDK6中字符串常量池在方法区(永久代)中,永久代在堆中;JDK7中从字符串常量池永久代中移出,仍在堆中;而JDK8后方法区(永久代->元空间)从堆中移出,字符串常量池仍在堆中

直接内存

直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域.

  • 在JDK1.4中引入了NIO机制,使用了直接内存,主要为了解决以下两个问题:
    1、Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
    2、I0操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路.
  • 要创建直接内存上的数据,可以使用ByteBuffer。语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
    注意事项: arthas的memory命令可以查看直接内存大小,属性名direct。

Java 内存模型

??


JVM GC 垃圾回收

在C/C++这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏。内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出
Java中为了简化对象的释放,引入了自动的垃圾回收 (Garbage Collection 简称 GC) 机制。【 类的生命周期的卸载阶段 ??
线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以只用考虑线程不共享部分。JVM GC主要负责对堆上的内存进行回收。

方法区的回收

方法区中能回收的内容主要就是不再使用的类.判定一个类可以被卸载。需要同时满足下面三个条件
1、此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
2、加载该类的类加载器已经被回收
3、该类对应的java.lang.Class 对象没有在任何地方被引用
开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。

堆的回收

如何判断堆上的对象可以回收? Java中的对象是否能被回收,是根据对象是否被引用来决定的。引用说明该对象还在使用,不允许被回收。

常见的有两种判断方法:引用计数法和可达性分析法。

  • 引用计数法:会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1.
    • 优点是实现简单,C++中的智能指针就采用了引用计数法
    • 缺点主要有两点: 1.每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响 2.存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题
  • 可达性分析算法:Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象GRoot)和普通对象,对象与对象之间存在引用关系。如果其(反向?引用)到某个GC Root对象是可达的,对象就不可被回收。
    • 四大类 GCRoot 对象
      • 线程Thread对象(放在堆中,指向对应的栈)
      • 系统类加载器加载的java.lang.Class对象,引用类中的静态变量 ???、,,??
      • 监视器对象,用来保存同步锁 synchronized关键字持有的对象
      • 本地方法调用时使用的全局对象

五种引用

  • 强引用:可达性算法中描述的一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要关系存在普通对象就不会被回收。
  • 软引用:相对于强引用是一种比较弱的引用关系
    • 如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。
    • 软引用常用于缓存中(提高效率,不影响可用性)
    • 在JDK 1.2版之后提供了sftReference类来实现软引用。软引用的执行过程如下:
      1.将对象使用软引用包装起来,new SoftReference<对象类型>(对象)
      2.内存不足时,虚拟机尝试进行垃圾回收。
      3.如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象
      4.如果依然内存不足,抛出OutofMemory异常
      1
      2
      3
      4
      5
      6
      7
      byte[] bytes = new byte[1024 * 1024 * 100];    // 建立数组(强引用对象??                
      SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes); // 建立软引用
      bytes = null; // 释放强引用
      System.out.println(softReference.get()); // :[B@2503dbd3
      byte[] bytes2 = new byte[1024 * 1024 * 100]; // 内存不足,释放软引用对象
      System.out.printIn(softReference.get()); // :null
      byte[] bytes2 = new byte[1024 * 1024 * 100]; // :直接抛OutofMemory异常
    • ???软引用队列:软引用中的对象如果在内存不足时回收,SoftReference对象本身也需要被回收。如何知道哪些SoftReference对象需要回收呢? SoftReference提供了一套队列机制:1、软引用创建时,通过构造器传入引用队列2、在软引用中包含的对象被回收时,该软引用对象会被放入引用队列3通过代码遍历引用队列,将SoftReference的强引用删除
  • 弱引用:弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时不管内存够不够都会直接被回收
    在JDK1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。
    弱引用对象本身也可以使用引用队列进行回收。
  • 虚引用:虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
  • 终结器引用:指的是在对象需要被回收时,对象将会被放置在Finalizer类中的引用队列中,并在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做,如果耗时过长会影响其他对象的回收。

垃圾回收算法

  • 垃圾回收要做的有两件事:1、找到内存中存活的对象; 2、释放不再存活对象的内存,使得程序能再次利用这部分空间
  • 三种评价标准:堆使用效率、吞吐量,以及最大停时间不可兼得。
    一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。不同的垃圾回收算法,适用不同的场景。
  • 标记清除算法
    • 核心思想:1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。 2.清除阶段,从内存中删除没有被标记也就是非存活对象
    • 优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
    • 缺点:1.碎片化问题由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配 2.分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
  • 复制算法
    • 核心思想: 1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间 (From空间) 2.在垃圾回收GC阶段,将From中存活对象复制到To空间。 3.将两块空间的From和To名字互换
    • 优点:吞吐量高、不会发生碎片化
    • 缺点:内存使用效率低,每次只能让一半的内存空间来为创建对象使用
  • 标记整理算法:也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案.
    • 核心思想:1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象 2.整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间
    • 优点:内存使用效率高,不会发生碎片化
    • 缺点:整理阶段的效率不高

分代垃圾回收算法

  • 分代垃圾回收算法(GenerationalGC):将上述描述的垃圾回收算法组合进行使用,
    • 它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法
    • 一般将堆分为新生代和老年代。新生代使用: 复制算法;老年代使用: 标记清除 或者 标记整理 算法
    • 将新生代划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
    • HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间不够用,此时需要依赖于老年代进行分配担保,即借用老年代的空间存储放不下的对象。
    • 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
      在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
    • 分代GC算法将堆分成年轻代和老年代主要原因有:
      1、可以通过调整年轻代和老年代的比例来适应不同型的应用程序,提高内存的利用率和性能。 2、新生代和老年代使用不同的垃圾回收算法,新生一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。 3、分代的设计中允许只回收新生代 (minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(fullgc),STW时间就会减少
  • 内存分配策略
    1. 分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC
      大对象直接进入老年代:大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
      -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免 Eden 和 Survivor 区之间的大量内存复制。
    2. Minor GC 会把需要 eden中和 From需要回收的对象回收,把没有回收的对象放入To区。接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC此时会回收eden区和S1(from)中的对象,并把eden和from区中剩余的对象放入SO。 !!如果to区满了,会将没有回收的对象放入老年代。
      注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
    3. 长期存活的对象进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。-XX:MaxTenuringThreshold 用来定义年龄阈值。
      动态对象年龄判定:虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,,,
    4. 当老年代中空间不足,无法放入新的对象时,先尝试minor gc(因为存在直接从 eden区放如老年代的情况)。如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出OutofMemory异常。
    5. 空间分配担保:在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
      如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
  • Full GC 的触发条件
    对于 Minor GC,其触发条件非常简单:当 Eden 空间满时就将触发一次。而 Full GC 则相对复杂,有以下条件:
    1.调用 System.gc() 2. 老年代空间不足 3. 空间分配担保失败 4. JDK 1.7及以前的永久代空间不足在 5. Concurrent Mode Failure
  • 内存回收策略
    Minor GC、Major GC、Full GC
    JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代,方法区)区域一起回收的,大部分时候回收的都是指新生代。针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类: 部分收集 (Partial GC),整收集(Full GC)。
    • 部分收集: 不是完整收集整个 Java 堆的垃圾收集。其中又分为:
      1. 新生代收集 (Minor GC/Young GC) : 只是新生代的垃圾收集。
      2. 老年代收集 (Major GC/Old GC) : 只是老年代的垃圾收集
        目前,只有 CMS GC 会有单独收集老年代的行为。很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
      3. 混合收集 (Mixed GC) : 收集整个新生代以及部分老年代的垃圾收集;目前只有 G1 GC 会有这种行为
    • 整堆收集 (Full GC) : 收集整个 Java 堆和方法区的垃圾

垃圾回收器

垃圾回收器是垃圾回收算法的具体实现
由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用。

  • Serial + Serial Old 收集器
    Serial Serial Old
    单线程串行回收年轻代的垃圾回收器 Serial垃圾回收器老年代版本,采用单线程串行回收
    年轻代,复制算法 老年代,标记-整理算法
    优点 单CPU处理器下吞吐量非常出色
    缺点 多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
    适用场景 Java编写的客户端程序或者硬件配置有限的场景 与Serial垃圾回收器搭配使用或者在CMS特殊情况下使用
  • ParNew + CMS 收集器
    ParNew CMS
    本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收 关注的是系统的暂停时间。允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
    参数 -XX:+UseParNewGC 新生代使用ParNew,回收器老年代使用串行回收器 XX:+UseConcMarkSweepGC
    年轻代,复制算法 老年代,标记-清除算法
    优点 多CPU处理器下停顿时间较短 系统由于垃圾回收出现的停顿时间较短,用户体验好
    缺点 吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用 1、内存碎片问题 2、退化问题 3、浮动垃圾问题
    适用场景 Java编写的客户端程序或者硬件配置有限的场景 大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等
  • ParNew + CMS 收集器
    Parallel Scavenge Parallel Old
    JDK8默认的年轻代垃圾回收器多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点 是为Parallel Scavenge收集器设计的老年代版本,利用多线程并发收集。
    参数 -XX:+UseParallelGC 或-XX:+UseParallelOldGC,可以使用Parallel Scavenge + Parallel Old这种组合
    年轻代,复制算法 老年代,标记-整理算法
    优点 吞吐量高,而目手动可控为了提高吞吐量,虚拟机会动态调整堆的参数 并发收集,在多核CPU下效率较高
    缺点 不能保证单次的停顿时间 暂停时间会比较长
    适用场景 后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出 与Parallel Scavenge配套使用
  • 垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:
    JDK8及之前:ParNew + CMS (关注暂停时间)、Parallel Scavenge + Parallel Old (关注吞吐量)、不建议 G1(较大堆并且关注暂停时间)
    JDK9之后:G1 (默认)

G1收集器

  • 它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。使命是未来可以替换掉 CMS 收集器。
    G1 将 Parallel Scavenge与 CMS的优点融合:1.支持巨大的堆空间回收,并有较高的吞吐量。2.支持多CPU并行垃圾回收 3.允许用户设置最大暂停时间。
  • G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,可以直接对新生代和老年代一起回收。
  • G1垃圾回收有两种方式:1、年轻代回收 (Young GC) 2、混合回收(Mixed GC)
    • 年轻代回收:回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地XX:MaxGCPauseMillis=n (默认200) 保证暂停时间。
    • 混合回收分为:初始标记 (initial mark) 、并发标记 (concurrent mark)、最终标记(remark或者FinalizeMarking)、并发清理 (cleanup) ;G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbagefirst)名称的由来。
    • 注意:如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
  • G1 执行流程
    1、新创建的对象会存放在Eden区。当G1判新年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC
    2、标记出Eden和Survivor区域中的存活对象
    3、根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域
    4、后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区
    5、当某个存活对象的年龄到达值 (默认15),将被放入老年代
    6、部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humonous区,如果对象过大会横跨多个Region。
    7、多次回收之后,会出现很多Old老年代区,此时总堆占有率达到闻值时(-XX:InitiatingHeap0ccupancyPercent默认45%)
    会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成???不会产生内存碎片

JVM 排错调优

??


JVM 知识体系