JVM

  • 对JVM的理解?Java8虚拟机和之前的变化更新?
  • 什么是OOM(Out of Memory),栈溢出(StackOverFlowError)?怎么分析?
  • JVM常用调优参数?
  • 内存快照如何抓取?怎么分析Dump文件?
  • 谈谈JVM中,类加载器的认识。rt.jar ext app

Java栈、本地方法栈、程序计数器没有垃圾。

JVM调优基本在调方法区和堆,大部分在调堆。

1. JVM的位置

2. JVM体系结构

image-20210823162738904
Image
  1. 方法区和堆区是所有线程共享的内存区域;而java栈、本地方法栈和程序计数器是运行时线程私有的内存区域。

  2. Java栈又叫做jvm虚拟机栈

  3. 方法区(永久代)在 jdk8 中又叫做元空间 Metaspace

  4. 方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT编译器,英文写作Just-In-Time Compiler)编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

  5. 在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
  6. 在JDK1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
  7. 在JDK1.8之后JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。同时在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域
  8. 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。默认大概是用了20M的大小

4.java代码执行流程:

  java程序--(编译javac)-->字节码文件.class-->类装载子系统化身为反射类Class--->运行时数据区--->(解释执行)-->操作系统(Win,Linux,Mac JVM)

  • 所有的栈和程序计数器并不会产生垃圾,所以不会有垃圾回收
  • 我们所说的JVM调优是在方法区和堆里进行调优,99%是堆调优
  • 注意:我们平时说的栈是指的Java栈,本地方法栈(native method stack) 里面装的都是native方法。

3. JVM生命周期

1.启动

通过引导类根加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的.

2.执行

  • 一个运行中的java虚拟机有着一个清晰的任务:执行Java程序;
  • 程序开始执行的时候他才运行,程序结束时他就停止;
  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程

3.退出

  • 程序正常执行结束
  • 程序异常或错误而异常终止
  • 操作系统错误导致终止
  • 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且java安全管理器也允许这次exit或halt操作
  • 除此之外,JNI 规范描述了用 JNI Invocation API 来加载或卸载Java虚拟机时,Java虚拟机的退出情况

4. 类加载子系统

  1. 类加载子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识,JVM并不是通过检查文件后缀是不是.class来判断是否需要加载的,而是通过文件开头的特定文件标志即16进制CA TE BA BE;
文件开头的特殊标识
Image

2.加载后的Class类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

5. 类加载器

作用:加载Class文件。

类实例化后,变量名在Java栈中,指向堆中的具体实例。

image-20210823164046702
Image
image-20210823164423427
Image
  1. 虚拟机自带加载器
  2. 启动类(根)加载器
  3. 扩展类加载器
  4. 应用程序加载器

注意:

  • Class loader有多种,可以说三个,也可以说是四个(第四个为自己定义的加载器,继承 ClassLoader),系统自带的三个分别为:

  • 启动类加载器(Bootstrap) ,C++所写

    • 这个类加载使用C/C++语言实现的,嵌套在JVM内部
    • 它用来加载java的核心库(JAVA_HOME/jre/lib/rt.jar/resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
    • 并不继承自java.lang.ClassLoader,没有父加载器
    • 加载拓展类和应用程序类加载器,并指定为他们的父加载器,即ClassLoader
    • 出于安全考虑,BootStrap启动类加载器只加载包名为java、javax、sun等开头的类
  • 扩展类加载器(Extension) ,Java所写

    • java语言编写 ,由sun.misc.Launcher$ExtClassLoader实现。
    • 派生于ClassLoader类,不是继承
    • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会由拓展类加载器自动加载
  • 应用程序类加载器 (AppClassLoader)

    • java语言编写, 由sun.misc.Launcher$AppClassLoader实现。
    • 派生于ClassLoader类,不是继承
    • 它负责加载环境变量classpath或系统属性 java.class.path指定路径下的类库
    • 该类加载器是程序中默认的类加载器,一般来说,java应用的类都是由它来完成加载
    • 通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器

    用户自定义类加载器:

    在日常Java应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,必要时可以自定义类加载器来定制类的加载方式。

    为什么要自定义类加载器呢?

    • 隔离加载类
    • 修改类加载方式
    • 扩展加载源
    • 防止源码泄露

我们自己new的时候创建的是应用程序类加载器(AppClassLoader)。

ClassLoader classLoader = Test.class.getClassLoader();
System.out.println(classLoader);
// [email protected]     

System.out.println(classLoader.getParent());
// [email protected] jre/lib/ext/*

System.out.println(classLoader.getParent().getParent());
// null Java程序获取不到 jre/lib/rt.jar

6. 功能细分

img
Image

加载模块

  1. 通过一个类的全限定名获取定义此类的二进制字节流;

  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据;

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

链接模块分为三块,即验证、准备、解析

验证

  1. 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。

  2. 主要包括四种验证,文件格式验证,源数据验证,字节码验证,符号引用验证。

准备

  1. 为类变量分配内存并且设置该类变量的默认初始值,即零值;

  2. 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;

  3. 不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。

解析

  1. 将常量池内的符号引用转换为直接引用的过程。

  2. 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

  3. 符号引用就是一组符号来描述所引用的目标。符号应用的字面量形式明确定义在《Java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

  4. 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info/CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。

注意:

  • 如果是JDK自带的类(Object、String、ArrayList等),其使用的加载器是Bootstrap加载器;如果自己写的类,使用的是AppClassLoader加载器;Extension加载器是负责将把java更新的程序包的类加载进行
  • 输出中,sun.misc.Launcher是JVM相关调用的入口程序
  • Java加载器个数为3+1。前三个是系统自带的,用户可以定制类的加载方式,通过继承Java. lang. ClassLoader

JVM中表示两个class对象是否为同一个类

  1. 在jvm中表示两个class对象是否为同一个类存在的两个必要条件

  2. 类的完整类名必须一致,包括包名

  3. 即使类的完整类名一致,同时要求加载这个类的ClassLoader(指ClassLoader实例对象)必须相同;是引导类加载器、还是定义类加载器

  4. 换句话说,在jvm中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的.

  5. 对类加载器的引用,JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证两个类型的加载器是相同的。

7. 双亲委派机制

安全

App->Ext->Bootstrap(最终执行)

Bootstrap

当一个类收到了类的加载请求,首先不会尝试自己去加载这个类,而是把这个类请求委派给父类去完成,每一个层次的类加载器都是如此,因此所有的加载请求都应该传送到启动类加载中。只有当父类加载器反馈自己无法完成这个请求的时候,(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

注意:

  • 双亲委派机制:“我爸是李刚,有事找我爹”。 例如:需要用一个A.java这个类,首先去顶部Bootstrap根加载器去找,找得到你就用,找不到再下降一层,去Extension加载器去找,找得到就用,找不到再将一层,去AppClassLoader加载器去找,找得到就用,找不到就会报"CLASS NOT FOUND EXCEPTION"。
// 测试加载器的加载顺序
package java.lang;

public class String {

    public static void main(String[] args) {

        System.out.println("hello world!");

    }
}

/*
* output:
* 错误: 在类 java.lang.String 中找不到 main 方法
* */

上面代码是为了测试加载器的顺序:首先加载的是Bootstrap加载器,由于JVM中有java.lang.String这个类,所以会首先加载这个类,而不是自己写的类,而这个类中并无main方法,所以会报“在类 java.lang.String 中找不到 main 方法”。

这个问题就涉及到,如果有两个相同的类,那么java到底会用哪一个?如果使用用户自己定义的java.lang.String,那么别使用这个类的程序会去全部出错,所以,为了保证用户写的源代码不污染java出厂自带的源代码,而提供了一种“双亲委派”机制,保证“沙箱安全”。即先找到先使用。

8. 沙箱安全机制

Java安全模型的核心就是Java沙箱(Sandbox) ,

什么是沙箱? 沙箱是一个限制程序运行的环境。沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。 沙箱主要限制系统资源访问,那系统资源包括什么? CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。 所有的Java程序运行都可以指定沙箱,可以定制安全策略。

在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信任的。对于授信的本地代码,可以访问一切本地资源,而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱(Sandbox)机制。

下图为JDK1.0安全模型:

image-20210824105340287
Image

但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件的时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问,如下图所示JDK1.1的安全模型。

image-20210824155608645
Image

在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论是本地代码还是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示JDK1.2安全模型。

image-20210824160042907
Image

当前最新的额安全机制实现,引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责域关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件,就具有了当前域的全部权限,如下图所示最新的安全模型JDK1.6:

image-20210824160456911
Image

8.1. 组成沙箱的基本组件

  • 字节码校验器(bytecode verifier) :确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。

  • 类裝载器(class loader) :其中类装载器在3个方面对Java沙箱起作用

    • 它防止恶意代码去干涉善意的代码;
    • 它守护了被信任的类库边界;
    • 它将代码归入保护域,确定了代码可以进行哪些操作。

虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成, 每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。

  1. 从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用;
  2. 由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。

  3. 存取控制器(access controller) :存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。

  4. 安全管理器(security manager) : 是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。
  5. 安全软件包(security package) : java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括:
    • 安全提供者
    • 消息摘要
    • 数字签名keytools
    • 加密
    • 鉴别

9. Native 本地

凡是带了native关键字的,说明java的作用范围达不到了,会去调用底层c语言的库,会进入本地方法栈,会调用本地接口(JNI)。

JNI的作用:扩展Java的使用,融合不同的语言为Java所用。最初想融合C、C++。

它在内存区域中专门开辟了一块标记区域:Native Method Stack,登记native方法,在最终执行的时候,加载本地方法库中的方法通过JNI。

例如:Java程序驱动打印机,管理系统,掌握即可,在企业级应用较为少见。

调用其他接口:Socket. Restful. WebService~.http~

Thread类中竟然有一个只有声明没有实现的方法,并使用native关键字。用native表示,也此方法是系统级(底层操作系统或第三方C语言)的,而不是语言级的,java并不能对其进行操作。native方法装载在native method stack中。

10. 本地方法栈

1.Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法(一般非Java实现的方法)的调用。

2.本地方法栈,也是线程私有的。

3.允许被实现成固定或者是可动态拓展的内存大小。(和Java虚拟机栈在内存溢出方面情况是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常。
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。

4.本地方法是使用C或C++语言实现

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

6.当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限

  • 本地方法可以通过本地方法接口来 访问虚拟机内部的运行时数据区
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存

7.并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

11. PC寄存器

  • 注意:native方法不归java管,所以计数器是空的。

PC寄存器是用来存储指向下一条指令的地址,也就是即将将要执行的指令代码。由执行引擎读取下一条指令。

1.它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。

2.在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

3.任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果实在执行native方法,则是未指定值(undefined),因为程序计数器不负责本地方法栈。

4.它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

5.字节码解释器工作时就是通过改变这个计数器的值来选取下一跳需要执行的字节码指令。

6.它是唯一一个在java虚拟机规范中没有规定任何OOM(Out Of Memery)情况的区域,而且没有垃圾回收。

利用javap -v xxx.class反编译字节码文件,查看指令等信息

img
Image

Java栈、本地方法栈、程序计数器线程私有,不存在垃圾回收。

方法区、堆线程共享、存在垃圾回收。

11.1. PC寄存器面试常问

  1. 使用PC寄存器存储字节码指令地址有什么用呢(为什么使用PC寄存器记录当前线程的执行地址呢)

(1)多线程宏观上是并行(多个事件在同一时刻同时发生)的,但实际上是并发交替执行的

(2)因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行

(3)JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令

所以,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

  1. PC寄存器为什么会设定为线程私有?

(1)我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢?

(2)为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

12. 方法区

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

但是实例变量在堆内存中,和方法区无关。

注意:

  • 方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间; 静态变量、常量、类信息(构造方法、接口定义、普通方法)、运行时的常量池存在方法区中,但是实例变量(普通变量)存在堆内存中,和方法区无关
  • 方法区:绝对不是放方法的地方,他是存储的每一个类的结构信息(比如static)
  • 永久代和元空间的解释: 方法区是一种规范,类似于接口定义的规范:List list = new ArrayList(); 把这种比喻用到方法区则有:

    1. java 7中:方法区 f = new 永久代();
    2. java 8中:方法去 f = new 元空间();

13. 栈

数据结构

程序 = 数据结构 + 算法

栈:栈内存,主管程序的运行,生命周期和线程同步。

线程结束,栈内存也就释放,对于栈来说,不存在垃圾回收问题。

栈主要存储三类数据:

本地变量:输入参数、输出参数和方法内变量。

栈操作:记录出栈、入栈操作。

栈帧数据:包括类文件、方法等。

注意:

  • 栈是运行时的单位,堆是存储的单位。

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

  • 栈是线程私有,不存在垃圾回收

  • 虚拟机栈的生命周期同线程一致

  • 栈帧的概念:java中的方法被扔进虚拟机的栈空间之后就成为“栈帧”,比如main方法,是程序的入口,被压栈之后就成为栈帧。

  • 栈的作用:

    主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

13.1. 栈运行原理

  1. 不同线程所包含的栈帧是私有的,不允许存在相互引用,即不可能在一个栈帧之中引用另一个线程的栈帧。
  2. 如果当前方法调用了其他方法,被调用方法返回之际,当前栈帧会传回此被调用方法的执行结果给前一个栈帧(调用者方法),接着,虚拟机会丢弃当前栈帧(被调用方法的栈帧),使得前一个栈帧重新成为当前栈帧。
  3. Java方法有两种返回函数的方式 。一种是正常的函数返回,使用return指令,另一种是如果出现未经捕捉的异常,则已抛出异常的形式返回。不管使用哪种方式,都会导致栈帧被弹出。

14. 三种JVM

  • SUN公司 HotSpot
  • BEA JRockit
  • IBM J9VM

15. 堆

Heap,一个JVM只有一个堆内存。

堆内存的大小是可以调节的。

类加载器读取了类文件后,一般会把类的实例,实例方法,变量等放到堆中。

保存我们所有引用类型的实例对象。

所有的对象实例以及数组都应当在运行时分配在堆上(但并不是全部)

堆,是GC(垃圾收集器)执行垃圾回收的重点区域

堆内存分为三个区域:

  • 新生区(伊甸园区) Young/New

    所有的对象都在伊甸园区new出来的。

  • 养老区 Old

  • 永久区 Perm

    这个区域会常驻内存,用来存在JDK自身携带的Class对象和Interface元数据。存储的是Java运行时的一些环境或类信息,这个区域不存在垃圾回收,关闭JVM虚拟机就会释放这个区域的内存。

    一个启动类,加载了大量第三方jar包,如Tomcat部署了太多应用,或者大量动态生成的反射类如果不断的被加载直到内存满的话会崩。

    • JDK1.6之前:永久代,常量池在方法区中。
    • JDK1.7:永久代,但是慢慢的退化了,去永久代。常量池在堆中。
    • JDK1.8之后,无永久代,常量池在元空间。
  1. Java7之前,方法区位于永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动JVM时可以设置一个固定值,不可变;

  2. Java7中,static变量从永久代移到堆中;

  3. Java8中,取消永久代,方法存放于元空间(Metaspace),元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中。

image-20210825114119354
Image

GC垃圾回收,主要在伊甸园区和养老区。

假设新生区和养老区都满了,会抛出OOM,堆内存不够。

image-20210825121018210
Image

注意:

  • GC发生在伊甸园区,当对象快占满新生代时,就会发生YGC(Young GC,轻量级GC)操作,伊甸园区基本全部清空
  • 幸存者0区(S0),别名“from区”。伊甸园区没有被YGC清空的对象将移至幸存者0区,幸存者1区别名“to 区”
  • 每次进行YGC操作,幸存的对象就会从伊甸园区移到幸存者0区,如果幸存者0区满了,就会继续往下移,如果经历数次YGC操作对象还没有消亡,最终会来到养老区
  • 如果到最后,养老区也满了,那么就对养老区进行FGC(Full GC,重GC),对养老区进行清洗
  • 如果进行了多次FGC之后,还是无法腾出养老区的空间,就会报OOM(out of Memory)异常
  • from区和to区位置和名分不是固定的,每次GC过后都会交换,GC交换后,谁空谁是to区
  • 大对象直接进入养老区,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率

GC清理过程图

image-20210825123846309
Image

注意:

  • 整个堆分为新生区和养老区,新生区占整个堆的1/3,养老区占2/3。新生区又分为3份:伊甸园区:幸存者0区(from区):幸存者1区(to区) = 8:1:1

  • 每次从伊甸园区经过GC幸存的对象,年龄(代数)会+1

  • -XX:MaxTenuringThreshold=15调整多少代进入老年区

  • 关于默认的晋升年龄是15。

    默认晋升年龄并不都是15,这个是要区分垃圾收集器的,CMS就是6.

默认情况下:分配的总内存是电脑内存的1/4,而初始化的内存为64/1.

  • -Xms设置初始化内存分配大小。
  • -Xmx设置最大分配内存。
  • -XX:+PrintGCDetails打印GC垃圾回收信息。
  • -XX:+HeapDumpOnOutOfMemoryErrorOOM Dump.
  • -XX:+MaxTenuringThreshold=5通过这个参数可以设定进入老年代的时间。默认值为15.

-Xms1024m -Xmx1024m -XX:+PrintGCDetails

public class Test {
    public static void main(String[] args) throws Exception {
        // 返回虚拟机试图使用的最大内存
        long max = Runtime.getRuntime().maxMemory();
        // 返回jvm的初始化总内存
        long total = Runtime.getRuntime().totalMemory();

        System.out.println((double) max / 1024 / 1024);
        System.out.println((double) total / 1024 / 1024);

        /*
1820.5
123.0
         */

        /*
-Xms1024m -Xmx1024m -XX:+PrintGCDetails

981.5
981.5
Heap
 PSYoungGen      total 305664K, used 15729K [0x00000007aab00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 262144K, 6% used [0x00000007aab00000,0x00000007aba5c420,0x00000007bab00000)
  from space 43520K, 0% used [0x00000007bd580000,0x00000007bd580000,0x00000007c0000000)
  to   space 43520K, 0% used [0x00000007bab00000,0x00000007bab00000,0x00000007bd580000)
 ParOldGen       total 699392K, used 0K [0x0000000780000000, 0x00000007aab00000, 0x00000007aab00000)
  object space 699392K, 0% used [0x0000000780000000,0x0000000780000000,0x00000007aab00000)
 Metaspace       used 3373K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 372K, capacity 388K, committed 512K, reserved 1048576K
         */
    }
}

(305664+699392) / 1024 = 981.5

经过计算,新生代和老年代总内存大小等于JVM内存。可以看出元空间并不属于堆(no heap)。

即元空间逻辑上存在,物理上不存在堆中。

15.1. OOM报错解决

  1. 先尝试扩大堆内存。
  2. 如果扩大后还报错,分析内存,分析哪个代码出现问题。
    • 能够看到代码第几行出错:内存快照分析工具,MAT,Jprofiler。
    • Debug,一行行分析代码。

MAT,Jprofiler作用:

  • 分析Dump内存文件,快速定位内存泄漏问题。
  • 获得堆中的数据。
  • 获得大的对象。大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
  • ...

Dump文件

-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError

image-20210825133343049
Image
image-20210825132849763
Image
image-20210825132918639
Image
image-20210825132927079
Image
image-20210825133141109
Image

16. 对象的创建过程

  1. 判断对象对应的类是否加载、链接、初始化。虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。
  2. 为对象分配内存。首先计算对象占用空间大小,接着在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。如果内存是规整的,那么虚拟机将采用指针碰撞法来为对象分配内存。假设java堆中内存是绝对规整的,所有用过的内存放一边,未使用过的放一边,中间有一个指针作为临界点,如果新创建了一个对象则是把指针往未分配的内存挪动与对象内存大小相同距离,这个称为指针碰撞。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有整理过程的收集器时使用指针碰撞;如果内存不规整,则使用空闲列表法(Free List)。事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。
  3. 处理并发安全问题。在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:一是采用CAS, CAS 是乐观锁的一种实现方式。所谓乐观锁就是每次不加锁,而是假设没有冲突而去完成某项操作,如果因为冲突失败,就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;二是为每个线程预先分配一块TLAB——通过-XX:+/-UseTLAB参数来设定(JDK8及之后默认开启),为每一个线程预先分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。
  4. 初始化分配到的空间。所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。(这里要区别一下类加载过程的准备阶段)
  5. 设置对象的对象头。将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM的实现。
  6. 执行init方法进行初始化。在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

17. GC垃圾回收

注意:在实际工作中,禁用system.gc()

作用区域:方法区、堆。

JVM在进行GC时,并不是对这三个区域统一回收。大部分时候回收都是新生代。

  • 新生代
  • 幸存区(from to)
  • 老年区

GC两种类型:轻GC(普通GC),重GC(全局GC)

image-20210825134427891
Image

题目:

  • JVM的内存模型和分区。详细到每个分区放什么。
  • 堆里面的分区有哪些?特点?
  • GC的算法。
      1. 引用计数法
      1. 复制算法(Copying)
      1. 标记清除(Mark-Sweep)
      1. 标记压缩(Mark-Compact)
  • 轻GC(Minor GC)和重GC(Fill GC)分别在什么时候发生。
    • 普通GC(minor GC):只针对新生代区域的GC,指发生在新生代的垃圾收集动作,因为大多数Java对象存活率都不高,所以Minor GC非常频繁,一般回收速度也比较快。
      • 全局GC(major GC or Full GC):指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴随至少一次的Minor GC(但并不是绝对的)。Major GC的速度一般要比Minor GC慢上10倍以上 (因为养老区比较大,占堆的2/3)

18. GC算法

18.1. 引用计数法(现在一般不采用)

image-20210825135616051
Image

缺点:

  1. 计数器本身有消耗。
  2. 较难处理循环引用

JVM的实现一般不采用这种方式。

18.2. 复制算法(Copying)

年轻代中使用的是Minor GC(YGC),这种GC算法采用的是复制算法(Copying)。

Minor GC会把Eden中的所有活的对象都移到Survivor区域中,如果Survivor区中放不下,那么剩下的活的对象就被移到Old generation中,即一旦收集后,Eden是就变成空的了。

当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,通过 -XX:MaxTenuringThreshold 来设定参数),这些对象就会成为老年代。

-XX:MaxTenuringThreshold — 设置对象在新生代中存活的次数

image-20210825141449506
Image

年轻代中的GC,主要是复制算法(Copying)。 HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1,一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块(from),当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法的优点是不会产生内存碎片,缺点是耗费空间

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。

image-20210825142524926
Image
image-20210825142546509
Image
image-20210825142621471
Image

上面动画中,Area空闲代表to,Area激活代表from,绿色代表不被回收的,红色代表被回收的。

优点:

  • 没有内存碎片

复制算法它的缺点也是相当明显的:

  • 浪费了一半的内存,这太要命了。
  • 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

复制算法最佳使用场景:最佳存活度较低的时候。

18.3. 标记清除算法(Mark-Sweep)

复制算法的缺点就是费空间,其是用在年轻代的,老年代一般是由标记清除或者是标记清除与标记整理的混合实现。

image-20210825143345570
Image

用通俗的话解释一下标记清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。

主要进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:从引用根节点开始标记遍历所有的GC Roots, 先标记出要回收的对象。
    • 清除:遍历整个堆,把标记的对象清除
  • 优点
    • 不需要额外的空间。
  • 缺点
    • 两次扫描,严重浪费时间。
    • 会产生内存碎片。

18.4. 标记压缩算法(Mark-Compact)

是标记清除的再优化。

标记压缩(Mark-Compact)又叫标记清除压缩(Mark-Sweep-Compact),或者标记清除整理算法。老年代一般是由标记清除或者是标记清除与标记整理的混合实现

image-20210825144322972
Image

面试题:四种算法那个好 Answer:没有那个算法是能一次性解决所有问题的,因为JVM垃圾回收使用的是分代收集算法,没有最好的算法,只有根据每一代他的垃圾回收的特性用对应的算法。新生代使用复制算法,老年代使用标记清除和标记整理算法。没有最好的垃圾回收机制,只有最合适的。

面试题:请说出各个垃圾回收算法的优缺点

  • 内存效率:复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
  • 内存整齐度:复制算法=标记整理算法>标记清除算法。
  • 内存利用率:标记整理算法=标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程。

难道就没有一种最优算法吗?Java 9 之后出现了G1垃圾回收器,能够解决以上问题,有兴趣参考这篇文章

  1. 新生代存活率低,适合用复制算法。
  2. 老年代区域大,存活率相对较高,适合用标记清除(内存碎片不是很多)+标记压缩混合实现。

19. 可参考的书籍

  • 《深入理解JVM》
Copyright © rootwhois.cn 2021-2022 all right reserved,powered by GitbookFile Modify: 2022-11-26 20:03:31

results matching ""

    No results matching ""