| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> Java知识库 -> JVM内存与垃圾回收篇 -> 正文阅读 |
|
[Java知识库]JVM内存与垃圾回收篇 |
一,JVM与Java体系结构1,前言作为Java工程师的你曾被伤害过吗?你是否也遇到过这些问题?
我们大部分的程序员都只用上层的一些技术,可以熟练的使用各种框架,但是对于Java的核心Java虚拟机了解甚少。因为很多人认为只有上层的技术才是我们应该研究和钻研的,对于底层的实现并不在乎,这其实是一种快节奏社会的一种病态。 如果我们把核心类库的API比做数学公式的话,那么Java虚拟机的知识就好比公式的推导过程。 计算机系统体系对我们来说越来越远,在不了解底层实现方式的前提下,通过高级语言很容易编写程序代码。但事实上计算机并不认识高级语言。 我们为什么要学习JVM?
Java vs C++ 它们都是面向对象的语言,其最大的不同在于Java是因为有虚拟机的缘故,他是动态的分配内存,有着垃圾回收机制,不需要我们手动创建和删除内存。极大的节约了我们开发的效率。但是由于垃圾回收机制不是万能的,所以我们在一些大型项目中,必须要了解JVM内部的结构,工作机制,才能设计出高扩展性应用以及出现问题是可以及时解决。 2,Java及JVM简介TIOBE语言热度排行榜:index | TIOBE - The Software Quality Company JVM:跨语言的平台 Java是目前应用最为广泛的软件开发平台之一。随着Java以及Java社区的不断壮大Java 也早已不再是简简单单的一门计算机语言了,它更是一个平台、一种文化、一个社区。
每个语言都需要转换成字节码文件,最后转换的字节码文件都能通过Java虚拟机进行运行和处理
字节码
多语言混合编程
3, Java发展的重大事件
在JDK11之前,OracleJDK中还会存在一些OpenJDK中没有的、闭源的功能。但在JDK11中,我们可以认为OpenJDK和OracleJDK代码实质上已经完全一致的程度。 不过,主流的 JDK 8 在2019年01月之后就被宣布停止更新了。另外, JDK 11 及以后的版本也不再提供免费的长期支持(LTS),而且 JDK 15 和 JDK 16 也不是一个长期支持的版本,最新的 JDK 15 只支持 6 个月时间,到 2021 年 3 月,所以千万不要把 JDK 15 等非长期支持版本用在生产。 商用版和开源版本的区别在于,商用版没有一个长期支持的版本,他只要有新的版本你就必须去更新,但是这样他会有很多新的功能,其安全性也更高,可能你在开源版本上报错,商用版本上不报错。 4,虚拟机与Java虚拟机虚拟机 所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。
无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。 Java虚拟机
作用
特点
JVM的位置 JVM是运行在操作系统之上的,它与硬件没有直接的交互(JVM是有不同系统区别的) 5,JVM的整体结构
6, Java代码执行流程
注意:
7,JVM的架构模型Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。 具体来说:这两种架构之间的区别: 基于栈式架构的特点
基于寄存器架构的特点
举例说明
对于上述代码而言,使用栈操作的话,步骤如下(找到字节码文件,用javap -c 文件名): 而用寄存器的几步就可以完成。 总结: 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。 8,JVM的生命周期虚拟机的启动 Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体现指定的。 虚拟机的执行
虚拟机的退出 有如下的几种情况:
9, JVM的发展历程Sun Classic VM
Exact VM
HotSpot VM
JRockit
IBM的J9
KVM和CDC / CLDC Hotspot
Azul VM
Liquid VM
Apache Harmony
Micorsoft JVM
Taobao JVM
Dalvik VM
Graal VM
总结具体JVM的内存结构,其实取决于其实现,不同厂商的JVM,或者同一厂商发布的不同版本,都有可能存在一定差异。主要以Oracle HotSpot VM为默认虚拟机。 二,类加载子系统1,内存结构概述
如果自己想手写一个Java虚拟机的话,主要考虑哪些结构呢?
2,类加载器与类的加载过程类加载器子系统作用
类加载器ClasLoader角色
类的加载过程
用流程图表示上述示例代码: 加载阶段
补充:加载class文件的方式
链接阶段
初始化阶段
3,类加载器分类JVM支持两种类型的类加载器。分别为引导类加载器(Bootstrap ClassLoader)和自定义加载器(User-Defined ClassLoader)。 从概念上讲,自定义类加载器一般是指程序员中有开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。 无论类加载器如何划分,在程序中我们最常见的类加载器只有三种,如图所示:
3.1,虚拟机自带的加载器启动类加载器(引导类加载器,Bootstrap ClassLoader)
扩展类加载器(Extension ClassLoader)
应用程序类加载器(系统类加载器,AppClassLoader)
3.2,用户自定义类加载器在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。 为什么要自定义类加载器?
用户自定义类加载器实现步骤:
代码演示:
4,ClassLoader的使用说明ClassLoader类是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)这也就是为什么说加载器分为引导类加载器和自定义类加载器的原因。 sun.misc.Launcher 它是一个java虚拟机的入口应用 获取ClassLoader的途径 ● 方式一:获取当前ClassLoader
● 方式二:获取当前线程上下文的ClassLoader
● 方式三:获取系统的ClassLoader
● 方式四:获取调用者的ClassLoader
5,双亲委派机制Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。 工作原理
举例 当我们加载jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar是基于SPI接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI核心类,然后在加载SPI接口类,接着在进行反向委派,通过线程上下文类加载器进行实现jdbc.jar的加载。 优势
沙箱安全机制 自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的string类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。 就是说,我们把最重要的东西放在一个盒子里,别的恶意代码无法进入,这样子就保证他的安全性。 6,其他如何判断两个class对象是否相同 在JVM中表示两个class对象是否为同一个类存在两个必要条件:
换句话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。 对类加载器的引用 JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。 类的主动使用和被动使用 Java程序对类的使用方式分为:主动使用和被动使用。 主动使用,又分为七种情况:
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化。 三,运行时数据区及程序计数器1,运行时数据区1.1,概述当我们通过前面的:类的加载-> 验证 -> 准备 -> 解析 -> 初始化 这几个阶段完成后(类加载器),就会用到执行引擎对我们的类进行使用,同时执行引擎将会使用到我们运行时数据区。 内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行,JVM内存布局规定了Java在运行过程中内存申请,分配,管理的政策,保证了JVM的高效稳定的运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局。 我们可以把大厨比作执行引擎,而后面的东西就是我们的运行时数据区,也就是说,大厨要炒菜,他必须要把食材准备好,这样子就可以在需要的时候进行即时的调整和使用。 我们通过磁盘或者网络IO得到的数据,都需要先加载到内存中,然后CPU从内存中获取数据进行读取,也就是说内存充当了CPU和磁盘之间的桥梁 Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机的启动而创建,随着虚拟机的销毁而销毁,有些则是与线程一一对应的,这些与线程对应的数据区域会随着线程的开始和结束而创建和销毁。
每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的那个框框:运行时环境。 1.2,线程线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。 1.3,JVM系统线程如果你使用console或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用 这些主要的后台系统线程在Hotspot JVM里主要是以下几个:
2,程序计数器(PC寄存器)JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据加载到寄存器中才能够运行。这里,并不是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)更为贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟(软件层次)。 作用 PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。 它是唯一一个在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域。 举例说明
字节码文件:
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢? 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。 JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。 PC寄存器为什么被设定为私有的? 我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。 PU时间片 CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。 在宏观上:俄们可以同时打开多个应用程序,每个程序并行不悖,同时运行。 但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。 四,虚拟机栈1, 虚拟机栈概述1.1,虚拟机栈出现的背景由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台的cpu架构不同,所以不能设计成基于寄存器的。 优点是跨平台,指令集小,编译器容易实现。 缺点是性能下降,实现同样的功能需要更多地指令。 1.2,初步印象有不少Java开发人员一提到Java内存结构,就会非常粗粒度地将JVM中的内存区理解为仅有Java堆(heap)和Java栈(stack)?为什么? 1.3, 内存中的栈与堆栈是运行时的单位,而不是存储的单位。
1.4,虚拟机栈的基本内容Java虚拟机栈是什么 Java虚拟机栈,早期也叫作Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用。 生命周期 生命周期和线程一致。 作用 主管Java程序的运行,他保存方法的局部变量,部分结果,并参与方法的调用和返回。 栈的特点 栈是一种快速有效的存储方式,访问速度仅次于程序计数器。 JVM直接对Java栈的操作只有两个:
对于栈来说不存在垃圾回收的问题(但是可能会出现栈溢出的情况) 面试题:开发中遇到哪些异常? 栈中可能出现的异常
2,栈的存储单位2.1,栈中存储什么?每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在的。 在这个线程上正在执行的每个方法都有一个栈帧(一一对应)。 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。 2.3,栈运行原理JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。 果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。 Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
2.4, 栈帧的内部结构每个栈帧中存储着:
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的。 3,局部变量表(Local Variables)局部变量表也被称为局部变量数组或者本地变量表
3.1,关于Slot的理解
3.2,Slot的重复利用栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
3.3,静态变量与局部变量的对比参数表分配完毕后,在根据方法体内部定义的变量的顺序和作用域分配。 我们知道类变量有两次初始化机会,一次是在准备阶段,在执行系统初始化,对类变量设置为零,另外一次则是在初始化阶段,赋予程序员在代码中定义的初始值。 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这就意味着一旦定义了局部变量,必须要人为的初始化,否则无法使用。
上面的代码就会报错,显示无法使用变量i,需要为他赋值。 3.4,补充说明在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。 4,操作数栈(Operand Stack)每一个独立的栈帧处理包含局部变量表以外,还包含了一个后进先出的操作数栈,也可以称为表达式栈(Expression Stack) 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或者提取数据,即入栈操作(push)和出栈(pop)
代码举例
字节码指令信息
操作数栈,主要用于保存计算过程的中间结果,同时作为计算出过程中变量临时的存储空间。 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。 栈中的任何一个元素都是可以任意的Java数据类型
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检测阶段的数据流分析阶段要再次验证。 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。 5,代码追踪
使用javap 命令反编译class文件:
6,栈顶缓存技术(Top Of Stack Cashing)技术前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。 7,动态链接(Dynamic Linking)动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区 每一个栈帧内部都包含了一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如:invokedynamic指令。 在java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里面。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态连接的作用就是为了将这些符号引用转换为调用方法的直接引用。
动态链接指向的是方法区运行时常量池,产生这种引用的原因是节省了内存,就像我们要在方法中使用一个对象,我们就直接通过对象名找,而不用每次将这个对象的所有属性再次声明。 为什么需要运行时常量池呢? 常量池的作用:就是为了提供一些符号和常量,便于指令的识别 8,方法的调用:解析与分配在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关 8.1,静态链接当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。 8.2,动态链接如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。 静态链接和动态链接不是名词,而是动词,这是理解的关键。 对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。 8.3,早期绑定早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。 8.4,晚期绑定如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。 随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特悄,那么自然也就具备早期绑定和晚期绑定两种绑定方式。 Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C语言中的虚函数(C中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。 8.5,虚方法和非虚方法如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法。 在类加载的解析阶段就可以进行解析,如下是非虚方法举例:
虚拟机中提供了以下几条方法调用指令:
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(fina1修饰的除外)称为虚方法。 关于invokednamic指令
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。 说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。 8.6,方法重写的本质Java 语言中方法重写的本质:
IllegalAccessError介绍 程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。 8.7,方法的调用:虚方法表在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。 虚方法表是什么时候被创建的呢? 虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。 举例1:
举例2:
总结: 虚方法表出现的原因就是因为虚方法是通过一层层找的,在刚开始的时候不清楚具体是由谁进行实现的,但是我们想可以找到一次然后存放在表中,这样子再次调用这个方法的时候就不需要一层层的查找了,所以就有了虚方法表。他的产生时间就是我们在类加载的链接阶段的初始化是进行初始化的。 9,方法返回地址(return address)存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。 当一个方法开始执行后,只有两种方式可以退出这个方法:
方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。 10,一些附加信息栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。 11,栈的相关面试题
五,本地方法接口和本地方法栈1,什么是本地方法?简单地讲,一个Native Method是一个Java调用非Java代码的接囗。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C中,你可以用extern “c” 告知c编译器去调用一个c的函数。
在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。 举例
标识符native可以与其它java标识符连用,但是abstract除外 2,为什么使用Native Method?Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。 与Java环境的交互 有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。 与操作系统的交互 JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。 Sun’s Java Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用Win32 setPriority() ApI。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVw调用。 现状 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。 3,本地方法栈Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。 本地方法栈,也是线程私有的。 允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)
本地方法是使用C语言实现的。 它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。 六, 堆1,堆(Heap)的核心概述堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,但是进程包含多个线程,他们是共享同一堆空间的。 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。 Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。( 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。 1.1,堆内存细分Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
约定:新生区(代)<=>年轻代 、 养老区<=>老年区(代)、 永久区<=>永久代 1.2,堆空间内部结构(JDK7)1.3,堆空间内部结构(JDK8)2,设置堆内存大小与OOM2.1,堆空间大小的设置Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置。
一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。 通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。 默认情况下
2.2,OutOfMemory举例
3,年轻代与老年代存储在JVM中的Java对象可以被划分为两类:
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen) 其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区) 下面这参数开发中一般不会调: 配置新生代与老年代在堆结构的占比。
在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1 当然开发人员可以通过选项“ 几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。
可以使用选项" 4,图解对象分配过程为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
流程图 总结
常用调优工具(在JVM下篇:性能监控与调优篇会详细介绍)
5,Minor GC,MajorGC、Full GCJVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。 针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)
5.1,最简单的分代式GC策略的触发条件年轻代GC(Minor GC)触发机制
老年代GC(Major GC / Full GC)触发机制
Full GC触发机制(后面细讲): 触发Full GC执行的情况有如下五种:
说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些 6,堆空间分代思想为什么要把Java堆分代?不分代就不能正常工作了吗? 经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。 7,内存分配策略如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1。对象在survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代 对象晋升老年代的年龄阀值,可以通过选项 针对不同年龄段的对象分配原则如下所示:
8,为对象分配内存:TLAB8.1,为什么有TLAB(Thread Local Allocation Buffer)?
8.2,什么是TLAB?
8.3,TLAB的再说明
9,小结:堆空间的参数设置
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行FullGC。 10,堆是分配对象的唯一选择么?在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配.。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。 此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。 10.1,逃逸分析概述如何将堆上的对象分配到栈,需要使用逃逸分析手段。 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。 逃逸分析的基本行为就是分析对象动态作用域:
举例1
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除,每个栈里面包含了很多栈帧
上述方法如果想要
举例2
参数设置 在JDK 6u23 版本之后,HotSpot中默认就已经开启了逃逸分析 如果使用的是较早的版本,开发人员则可以通过:
结论:开发中能使用局部变量的,就不要使用在方法外定义。 10.2,逃逸分析:代码优化使用逃逸分析,编译器可以对代码做如下优化: 一、栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配 二、同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。 三、分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。 栈上分配JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。 常见的栈上分配的场景 在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。 同步省略线程同步的代价是相当高的,同步的后果是降低并发性和性能。 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。 举例
代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
标量替换标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。 举例
以上代码,经过标量替换后,就会变成
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。 标量替换参数设置 参数 上述代码在主函数中进行了1亿次alloc。调用进行对象创建,由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。如果堆空间小于这个值,就必然会发生GC。使用如下参数运行上述代码:
这里设置参数如下:
10.3,逃逸分析小结:逃逸分析并不成熟关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟。 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。 目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。 本章小结年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。 老年代放置长生命周期的对象,通常都是从survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。 当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGc。 当GC发生在老年代时则被称为MajorGc或者FullGC。一般的,MinorGc的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。 七,方法区从线程共享与否的角度来看 1,栈、堆、方法区的交互关系2,方法区的理解官方文档:Chapter 2. The Structure of the Java Virtual Machine (oracle.com) 2.1,方法区在哪里?《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。 所以,方法区看作是一块独立于Java堆的内存空间。 2.2,方法区的基本理解
2.3,HotSpot中方法区的演进在jdk7及以前,习惯上把方法区称为永久代。jdk8开始,使用元空间取代了永久代。 本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9 中不存在永久代的概念。 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM(超过 而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。 永久代、元空间二者并不只是名字变了,内部结构也调整了。 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。 3,设置方法区大小与OOM3.1,设置方法区内存的大小方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。 jdk7及以前
JDK8以后
举例1:《深入理解Java虚拟机》的例子 举例2
3.2,如何解决这些OOM
4,方法区的内部结构4.1,方法区(Method Area)存储什么《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:
4.2,方法区的内部结构类型信息对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
域(Field)信息JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。 域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集) 方法(Method)信息JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
non-final的类变量
补充说明:全局常量(static final)被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。 4.3,运行时常量池 VS 常量池
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用 为什么需要常量池?一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。 比如:如下的代码:
虽然只有194字节,但是里面却使用了String、System、PrintStream及Object等结构。这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。 常量池中有什么?击中常量池内存储的数据类型包括:
例如下面这段代码:
小结常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型 4.4, 运行时常量池
5,方法区使用举例
6,方法区的演进细节
6.1,为什么永久代要被元空间替代?官网地址:JEP 122: Remove the Permanent Generation (java.net) JRockit是和HotSpot融合后的结果,因为JRockit没有永久代,所以他们不需要配置永久代 随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。 这项改动是很有必要的,原因有:
6.2,StringTable为什么要调整位置?jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。 这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。 6.3,静态变量存放在那里?
使用JHSDB工具进行分析,这里细节略掉 staticobj随着Test的类型信息存放在方法区,instanceobj随着Test的对象实例存放在Java堆,localobject则是存放在foo()方法栈帧的局部变量表中。 测试发现:三个对象的数据在内存中的地址都落在Eden区范围内,所以结论:只要是对象实例必然会在Java堆中分配。 接着,找到了一个引用该staticobj对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticobj的实例字段: 从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射class对象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点 7,方法区的垃圾回收有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的zGC收集器就不支持类卸载)。 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。 回收废弃常量与回收Java堆中的对象非常类似。 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。 8,总结常见面试题百度: 说一下JVM内存模型吧,有哪些区?分别干什么的? 蚂蚁金服: Java8的内存分代改进 JVM内存分哪几个区,每个区的作用是什么? 一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区? 二面:Eden和survior的比例分配 小米: jvm内存分区,为什么要有新生代和老年代 字节跳动: 二面:Java的内存分区 二面:讲讲Jvm运行时数据库区 什么时候对象会进入老年代? 京东: JVM的内存结构,Eden和Survivor比例。 JVM内存为什么要分成新生代,老年代,持久代。 新生代中为什么要分为Eden和survivor。 天猫: 一面:Jvm内存模型以及分区,需要详细到每个区放什么。 一面:JVM的内存模型,Java8做了什么改 拼多多: JVM内存分哪几个区,每个区的作用是什么? 美团: java内存分配 jvm的永久代中会发生垃圾回收吗? 一面:jvm内存分区,为什么要有新生代和老年代? 八,对象实例化及直接内存1,对象实例化面试题
1.1,创建对象的方式
1.2,创建对象的步骤前面所述是从字节码角度看待对象的创建过程,现在从执行步骤的角度来分析: 1. 判断对象对应的类是否加载、链接、初始化虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace(元空间)的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。 如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的 .class文件;
2. 为对象分配内存首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小 如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。
如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。
选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。 3. 处理并发问题
4. 初始化分配到的内存所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用 5. 设置对象的对象头将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。 6. 执行init方法进行初始化在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。 因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。 给对象属性赋值的操作
对象实例化的过程
2,对象内存布局2.1,对象头(Header)对象头包含了两部分,分别是运行时元数据(Mark Word)和类型指针。如果是数组,还需要记录数组的长度 运行时元数据
类型指针 指向类元数据InstanceKlass(实例类),确定该对象所属的类型。 2.2,实例数据(Instance Data)它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
2.3,对齐填充(Padding)不是必须的,也没有特别的含义,仅仅起到占位符的作用 举例
图示 小结3,对象的访问定位JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢? 3.1,句柄访问reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改 3.2,直接指针(HotSpot采用)直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据。 4,直接内存(Direct Memory)4.1,直接内存概述不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。直接内存是在Java堆外的、直接向系统申请的内存区间。来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存。通常,访问直接内存的速度会优于Java堆,即读写性能高。
4.2,非直接缓存区使用IO读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低。 4.3,直接缓存区使用NIO时,操作系统划出的直接缓存区可以被java代码直接访问,只有一份。NIO适合对大文件的读写操作。 也可能导致OutOfMemoryError异常
由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
直接内存大小可以通过 九,执行引擎1,执行引擎概述执行引擎属于JVM的下层,里面包括解释器、及时编译器、垃圾回收器 执行引擎是Java虚拟机核心的组成部分之一。 “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。 JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以(后端编译)。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。 9.1,执行引擎的工作流程
从外观上来看,所有的Java虚拟机的执行引擎输入,输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程。 2,Java代码编译和执行过程大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤 Java代码编译是由Java源码编译器(前端编译器)来完成,流程图如下所示: Java字节码的执行是由JVM执行引擎(后端编译器)来完成,流程图如下所示 2.1, 什么是解释器(Interpreter)?什么是JIT编译器?解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。 JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。 2.2,为什么Java是半编译半解释型语言?JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。 图示 3,机器码、指令、汇编语言3.1,机器码各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。 机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。 3.2,指令由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。 指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。 3.3,指令集不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。 如常见的
3.4,汇编语言由于指令的可读性还是太差,于是人们又发明了汇编语言。 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。 3.5,高级语言为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。 高级语言也不是直接翻译成机器指令,而是翻译成汇编语言码,如下面说的C和C++。 C、C++源程序执行过程编译过程又可以分成两个阶段:编译和汇编。 编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码 汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。 3.6,字节码字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码典型的应用为:Java bytecode 4,解释器JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。 为什么Java源文件不直接翻译成JMV,而是翻译成字节码文件?可能是因为直接翻译的代价是比较大的 4.1,解释器工作机制解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。 4.2,解释器分类在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。
4.3,现状由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃。 为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。 5,JIT编译器5.1,Java代码的执行分类
HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++ 程序一较高下的地步。 问题来了 有些开发人员会感觉到诧异,既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。 首先明确: 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。 所以: 尽管JRockit JVM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。 同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。 5.2,HotSpot JVM执行方式当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。 案例来了 注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。 在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。—阿里团队 5.3,概念解释Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程; 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。 还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。
5.4,热点代码及探测技术当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为OSR(On Stack Replacement)编译。 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。 目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测 采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
方法调用计数器这个计数器就用于统计方法被调用的次数,它的默认阀值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。 这个阀值可以通过虚拟机参数 当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。 热点衰减如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 另外,可以使用 回边计数器它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。 5.5,HotSpotVM 可以设置程序执行方法缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
5.6,HotSpotVM中 JIT 分类JIT的编译器还分为了两种,分别是C1和C2,在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器 和 C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示
分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。 不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server"时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。 C1 和 C2编译器不同的优化策略在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联、去虚拟化、冗余消除。
C2的优化主要是在全局层面,逃逸分析(前面讲过,并不成熟)是优化的基础。基于逃逸分析在C2上有如下几种优化
总结一般来讲,JIT编译出来的机器码性能比解释器高。C2编译器启动时长比C1慢,系统稳定执行以后,C2编译器执行速度远快于C1编译器
AOT编译器jdk9引入了AOT编译器(静态提前编译器,Ahead of Time Compiler) Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。 所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。 最大的好处:Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少Java应用给人带来“第一次运行慢” 的不良体验 缺点:
十,StringTable1,String的基本特性
1.1,String在jdk9中存储结构变更官网地址:JEP 254: Compact Strings (java.net)
动机 目前String类的实现将字符存储在一个char数组中,每个字符使用两个字节(16位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含Latin-1字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。 说明 我们建议将String类的内部表示方法从UTF-16字符数组改为字节数组加编码标志域。新的String类将根据字符串的内容,以ISO-8859-1/Latin-1(每个字符一个字节)或UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。 与字符串相关的类,如AbstractStringBuilder、StringBuilder和StringBuffer将被更新以使用相同的表示方法,HotSpot VM的内在字符串操作也是如此。 这纯粹是一个实现上的变化,对现有的公共接口没有变化。目前没有计划增加任何新的公共API或其他接口。 迄今为止所做的原型设计工作证实了内存占用的预期减少,GC活动的大幅减少,以及在某些角落情况下的轻微性能倒退。 结论:String再也不用char[] 来存储了,改成了byte [] 加上编码标记,节约了一些空间
1.2,String的基本特性String:代表不可变的字符序列。简称:不可变性。
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池是不会存储相同内容的字符串的。 String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。 使用
2,String的内存分配在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。
Java 6及以前,字符串常量池存放在永久代 Java 7中 Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内
Java8元空间,字符串常量在堆 StringTable为什么要调整? 官网地址:Java SE 7 Features and Enhancements (oracle.com)
简介:在JDK 7中,内部字符串不再分配在Java堆的永久代中,而是分配在Java堆的主要部分(称为年轻代和老年代),与应用程序创建的其他对象一起。这种变化将导致更多的数据驻留在主Java堆中,而更少的数据在永久代中,因此可能需要调整堆的大小。大多数应用程序将看到由于这一变化而导致的堆使用的相对较小的差异,但加载许多类或大量使用String.intern()方法的大型应用程序将看到更明显的差异。 3,String的基本操作
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
4,字符串拼接操作
举例一
结论就是:常量与常量的拼接结果在常量池,原理是编译期优化 举例二
举例三
举例四
举例五
5,intern()的使用官方API文档中的解释
当调用intern方法时,如果池子里已经包含了一个与这个String对象相等的字符串,正如equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个String对象被添加到池中,并返回这个String对象的引用。 由此可见,对于任何两个字符串s和t,当且仅当s.equals(t)为真时,s.intern() == t.intern()为真。 所有字面字符串和以字符串为值的常量表达式都是interned。 返回一个与此字符串内容相同的字符串,但保证是来自一个唯一的字符串池。 intern是一个native方法,调用的是底层C的方法.
如果不是用双引号声明的String对象,可以使用String提供的intern方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true。
通俗点讲,Interned string就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool) 5.1,关于intern()的面试题
在解释上面的这个问题前,我们先去解决另外一个问题,之后才能更好的解决这个问题。
通过上面的代码和字节码的分析,我们知道:
现在再去解释之前的题目
最终的代码解释如下:
扩展
这里原因也是处在intern上,在s4创建的时候呢,字符串常量池中并没有11,所以是s4在字符串常量池中创建的,之后再调用s3.intern();的时候返回的是字符串常量池中的11。这也就说明了上面出现的原因。 总结总结String的intern()的使用: JDK1.6中,将这个字符串对象尝试放入串池。
JDK1.7起,将这个字符串对象尝试放入串池。
练习1 练习2 5.2,intern的效率测试:空间角度我们通过测试一下,使用了intern和不使用的时候,其实相差还挺多的
结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间。 大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern()方法,就会很明显降低内存的大小。 6,StringTable的垃圾回收
运行结果
7,G1中的String去重操作官网地址:JEP 192: String Deduplication in G1 (java.net) Many large-scale Java applications are currently bottlenecked on memory. Measurements have shown that roughly 25% of the Java heap live data set in these types of applications is consumed by String objects. Further, roughly half of those String objects are duplicates, where duplicates means string1.equals(string2) is true. Having duplicate String objects on the heap is, essentially, just a waste of memory. This project will implement automatic and continuous String deduplication in the G1 garbage collector to avoid wasting memory and reduce the memory footprint. 目前,许多大规模的Java应用程序在内存上遇到了瓶颈。测量表明,在这些类型的应用程序中,大约25%的Java堆实时数据集被String’对象所消耗。此外,这些 "String "对象中大约有一半是重复的,其中重复意味着 "string1.equals(string2) "是真的。在堆上有重复的String’对象,从本质上讲,只是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动和持续的`String’重复数据删除,以避免浪费内存,减少内存占用。 注意这里说的重复,指的是在堆中的数据,而不是常量池中的,因为常量池中的本身就不会重复 背景:对许多Java应用(有大的也有小的)做的测试得出以下结果: ●堆存活数据集合里面string对象占了25% ●堆存活数据集合里面重复的string对象有13.5% ●string对象的平均长度是45 许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说: stringl.equals(string2)= true。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的string对象进行去重,这样就能避免浪费内存。 实现 1当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象 2如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。 3使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。 4如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。 5如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。 命令行选项
十一,垃圾回收概述及算法1,垃圾回收概述1.1,什么是垃圾?垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。 关于垃圾收集有三个经典问题:
垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。 大厂面试题 蚂蚁金服
百度
天猫
滴滴
京东
阿里
字节跳动
什么是垃圾?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。 磁盘碎片整理的日子 机械硬盘需要进行磁盘整理,同时还有坏道。 1.2,为什么需要GC
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。 1.3,早期垃圾回收在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代码:
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。 在有了垃圾回收机制后,上述代码极有可能变成这样
现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势,可以说这种自动化的内存分配和来及回收方式已经成为了线代开发语言必备的标准。 1.4,Java垃圾回收机制自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发 担忧对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。 此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见outofMemoryError时,快速地根据错误异常日志定位问题和解决问题。 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。 GC主要关注的区域GC主要关注于 方法区 和堆中的垃圾收集 垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。其中,Java堆是垃圾收集器的工作重点 从次数上讲:
2,垃圾回收相关算法对象存活判断 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。 2.1,标记阶段:引用计数算法方式一:引用计数算法引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。 缺点:
循环引用 当p的指针断开的时候,内部的引用形成一个循环,这就是循环引用。 举例 测试Java中是否采用的是引用计数算法。
上述进行了GC收集的行为,所以可以证明JVM中采用的不是引用计数器的算法。 小结引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。 Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。 Python如何解决循环引用?
2.2,标记阶段:可达性分析算法可达性分析算法(根搜索算法、追踪性垃圾收集)相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection) 所谓"GCRoots”根集合就是一组必须活跃的引用。 基本思路
在Java语言中,GC Roots包括以下几类元素:
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)。 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。 小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。 注意 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。 这点也是导致GC进行时必须“stop The World”的一个重要原因。
2.3,对象的finalization机制Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。 finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。 永远不要主动调用某个对象的finalize()方法I应该交给垃圾回收机制调用。理由包括下面三点:
从功能上来说,finalize()方法与C中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C中的析构函数。 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。 生存还是死亡?如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
以上3种状态中,是由于inalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。 具体过程判定一个对象objA是否可回收,至少要经历两次标记过程:
举例
运行结果
在第一次GC时,执行了finalize方法,但finalize()方法只会被调用一次,所以第二次该对象被GC标记并清除了。 2.4,MAT与JProfiler的GC Roots溯源MAT是什么?MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。 MAT是基于Eclipse开发的,是一款免费的性能分析工具。 大家可以在 http://www.eclipse.org/mat/ 下载并使用MAT 获取dump文件方式一:命令行使用 jmap方式二:使用JVisualVM导出捕获的heap dump文件是一个临时文件,关闭JVisualVM后自动删除,若要保留,需要将其另存为文件。 可通过以下方法捕获heap dump:
本地应用程序的Heap dumps作为应用程序标签页的一个子标签页打开。同时,heap dump在左侧的Application(应用程序)栏中对应一个含有时间戳的节点。 右击这个节点选择save as(另存为)即可将heap dump保存到本地。 方式三:使用MAT打开Dump文件JProfiler的GC Roots溯源我们在实际的开发中,一般不会查找全部的GC Roots,可能只是查找某个对象的整个链路,或者称为GC Roots溯源,这个时候,我们就可以使用JProfiler 2.5,清除阶段:标记-清除算法当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。 目前在JVM中比较常见的三种垃圾收集算法是标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact) 标记-清除算法(Mark-Sweep)标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。 执行过程当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除
缺点
何为清除?这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。 2.6,清除阶段:复制算法复制(Copying)算法为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。 核心思想将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收 优点
缺点
特别的 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行 应用场景 在新生代,对常规应用的垃圾回收,一次通常可以回收70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。 2.7,清除阶段:标记-压缩(整理)算法标记-压缩(或标记-整理、Mark-Compact)算法 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。 标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。 1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。 执行过程
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。 二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。 指针碰撞(Bump the Pointer) 如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer)。 优点
缺点
2.8,小结效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。 而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段 难道就没有一种最优算法吗? 回答:无,没有最好的算法,只有最合适的算法。 2.9,分代收集算法前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。 分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。 在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。 目前几乎所有的GC都采用分代收集算法执行垃圾回收的。 在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。 年轻代(Young Gen) 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。 老年代(Tenured Gen) 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。 这种情存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。 分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代 2.10,增量收集算法、分区算法增量收集算法上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop the World的状态。在Stop the World状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。 基本思想 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作 缺点 使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。 分区算法一般来说,在相同条件下,堆空间越大,一次Gc时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。 注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。 十二,垃圾回收相关概念1,System.gc()的理解在默认情况下,通过system.gc()或者Runtime.getRuntime().gc() 的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。 然而System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效) JVM实现者可以通过System.gc() 调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。
手动理解gc的行为
2,内存溢出与内存泄露内存溢出(OOM)内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。 由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现ooM的情况。 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存,供应用程序继续使用。 javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。 首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。原因有二:
这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
当然,也不是在任何情况下垃圾收集器都会被触发的
内存泄漏(Memory Leak)也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致00M,也可以叫做宽泛意义上的“内存泄漏”。 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。 举例
3,Stop The WorldStop-the-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。 STW事件和采用哪款GC无关,所有的GC都有这个事件。 哪怕是G1也不能完全避免Stop-the-World情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。 STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。 开发中不要用System.gc() 会导致Stop-the-World的发生。 代码演示
4,垃圾回收的并行与并发并发(Concurrent)在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。 并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。 并行(Parallel)当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。 适合科学计算,后台处理等弱交互场景。 并发 vs 并行
垃圾回收的并发与并行并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下: 并行(Parallel) 指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如ParNew、Parallel Scavenge、Parallel Old; 串行(Serial) 相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动JM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。 并发(Concurrent) 指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;如:CMS、G1 5,安全点与安全区域安全点程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint)”。 Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。 如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢? 抢先式中断:(目前没有虚拟机采用了)
主动式中断
安全区域(Safe Resion)Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?例如线程处于Sleep 状态或Blocked 状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始Gc都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。 实际执行时:
6,再谈引用:强引用我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。 【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么? 在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)这4种引用强度依次逐渐减弱。 除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。 Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用
强引用(Strong Reference)——不回收在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。 当在Java语言中使用new操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。 强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为nu11,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。 相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。
本例中的两个引用,都是强引用,强引用具备以下特点:
7,再谈引用: 软引用软引用(Soft Reference)——内存不足即回收软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。 垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。 类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。 在JDK1.2版之后提供了java.lang.ref.SoftReference类来实现软引用。
代码实现:
8,再谈引用:弱引用弱引用(Weak Reference)——发现即回收弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。 但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。 弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。 软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。 在JDK1.2版之后提供了WeakReference类来实现弱引用
弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收。 面试题:你开发中使用过WeakHashMap吗? WeakHashMap用来存储图片信息,可以在内存不足的时候,及时回收,避免了OOM 代码实现:
9,再谈引用:虚引用虚引用(Phantom Reference)——对象回收跟踪也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。 一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。 它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null 为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。 虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。 由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。 在JDK1.2版之后提供了PhantomReference类来实现虚引用。
代码实现:
10,终结器引用它用于实现对象的finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()方法,第二次GC时才回收被引用的对象。 十三,垃圾回收器1,GC分类与性能指标1.1,垃圾回收器概述垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。 从不同角度分析垃圾收集器,可以将GC分为不同的类型。 1.2,垃圾收集器分类按线程数分,可以分为串行垃圾回收器和并行垃圾回收器。 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。 按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。
按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。 1.3,评估GC的性能指标
吞吐量、暂停时间、内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。 简单来说,主要抓住两点:吞吐量、暂停时间 吞吐量 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的 吞吐量优先,意味着在单位时间内,STW的时间最短:0.2 + 0.2 = 0.4 暂停时间 “暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态。 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。 暂停时间优先,意味着尽可能让单次STW的时间最短:0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5 吞吐量vs暂停时间 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。 现在标准:在最大吞吐量优先的情况下,降低停顿时间 2,不同的垃圾回收器概述垃圾收集机制是Java的招牌能力,极大地提高了开发效率。这当然也是面试的热点。 2.1,垃圾回收器发展史有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection,对应的产品我们称为Garbage Collector。
2.2,7种经典的垃圾收集器
2.3,7款经典收集器与垃圾分代之间的关系
2.4,垃圾收集器的组合关系
2.5,不同的垃圾收集器概述为什么要有很多收集器,一个不够吗?因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。 2.6,如何查看默认垃圾收集器
使用命令行指令: 3,Serial回收器:串行回收Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。 Serial收集器作为HotSpot中client模式下的默认新生代垃圾收集器。 Serial收集器采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。
这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World) 优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在Client模式下的虚拟机是个不错的选择。 在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生,使用串行回收器是可以接受的。 在HotSpot虚拟机中,使用 总结 这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了。 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。 4,ParNew回收器:并行回收如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。Par是Parallel的缩写,New:只能处理的是新生代 ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制。 ParNew 是很多JVM运行在Server模式下新生代的默认垃圾收集器。
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?
因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作 在程序中,开发人员可以通过选项"
5,Parallel回收器:吞吐量优先HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制。 那么Parallel 收集器的出现是否多此一举?
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。 Parallel 收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。 Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-the-World"机制。 在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。在Java8中,默认是此垃圾收集器。 参数配置
6,CMS回收器:低延迟在JDK1.5时期,Hotspot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作 CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
CMS的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-World" 不幸的是,CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。 在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。 CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段
尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。 另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“ CMS收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。 有人会觉得既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact? 答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“Stop the World” 这种场景下使用。 6.1,CMS的优点
6.2,CMS的弊端
6.3,设置的参数
小结HotSpot有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC这三个Gc有什么不同呢? 请记住以下口令:
6.4,JDK后续版本中CMS的变化JDK9新特性:CMS被标记为Deprecate(不赞成)了(JEP291)
JDK14新特性:删除CMS垃圾回收器(JEP363)
7,G1回收器:区域化分代式
原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。
因为G1是一个并发回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。 G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)。 G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。 在JDK1.7版本正式启用,移除了Experimenta1的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。 与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾回收器,需要使用 7.1,G1回收器的特点(优势)与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示: 并行与并发
分代收集
空间整合
可预测的停顿时间模型(即:软实时soft real-time)这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
7.2,G1垃圾收集器的缺点相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。 7.3,G1回收器的参数设置
7.4,G1收集器的常见操作步骤G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。 7.5,G1收集器的适用场景面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜) 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。 7.6,分区Region:化整为零使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。 一个region有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,S表示属于survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。 G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。 设置H的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。 每个Region都是通过指针碰撞来分配空间。 7.7,G1垃圾回收器的回收过程G1GC的垃圾回收过程主要包括如下三个环节:
顺时针,Young gc -> Young gc + Concurrent mark->Mixed GC顺序,进行垃圾回收。 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。 7.8,Remembered Set
解决方法: 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描: 每个Region都有一个对应的Remembered Set; 每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作; 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象); 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中; 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。 7.9,G1回收过程一:年轻代GCJVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。 年轻代垃圾回收只会回收Eden区和Survivor区。 首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。 然后开始如下回收过程:
7.10,G1回收过程二:并发标记过程
7.11,G1回收过程三:混合回收当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收, 混合回收并不一定要进行8次。有一个阈值 7.12,G1回收可选的过程四:Full GCG1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。 要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。 导致G1 Full GC的原因可能有两个:
7.13,补充从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。 7.14,G1回收器优化建议年轻代大小
暂停时间目标不要太过严苛
8,垃圾回收器总结8.1,7种经典垃圾回收器总结截止JDK1.8,一共有7款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。 GC发展阶段:Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC 8.2,垃圾回收器组合不同厂商、不同版本的虚拟机实现差距比较大。HotSpot虚拟机在JDK7/8后所有收集器及组合如下图。
完全取消了这些组合的支持(JEP214),即:移除。
8.3,怎么选择垃圾回收器Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。 怎么选择垃圾收集器?
最后需要明确一个观点:
面试 对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。 这里较通用、基础性的部分如下:
另外,大家需要多关注垃圾回收器这一章的各种常用的参数 9,GC日志分析通过阅读Gc日志,我们可以了解Java虚拟机内存分配与回收策略。 内存分配与垃圾回收的参数列表
代码演示
打开GC日志
这个只会显示总的GC堆的变化,如下:
参数解析
打开GC日志
输入信息如下
参数解析
打开GC日志
输出信息如下
说明:带上了日期和实践 如果想把GC日志存到文件的话,是下面的参数:
日志补充说明
Minor GC日志Full GC日志举例
控制台
图示 可以用一些工具去分析这些GC日志 常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等 10,垃圾回收器的新发展GC仍然处于飞速发展之中,目前的默认选项G1 GC在不断的进行改进,很多我们原来认为的缺点,例如串行的Fu11GC、Card Table扫描的低效等,都已经被大幅改进,例如,JDK10以后,Fu11GC已经是并行运行,在很多场景下,其表现还略优于ParallelGC的并行Ful1GC实现。 即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在Serverless等新的应用场景下,Serial GC找到了新的舞台。 比较不幸的是CMSGC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除 10.1,JDK11新特性Epsilon:A No-Op GarbageCollector(Epsilon垃圾回收器,"No-Op(无操作)"回收器)http://openidk.iava.net/jeps/318 ZGC:A Scalable Low-Latency Garbage Collector(Experimental)(ZGC:可伸缩的低延迟垃圾回收器,处于实验性阶段)http://openidk.iava.net/jeps/333 现在G1回收器已成为默认回收器好几年了。 我们还看到了引入了两个新的收集器:ZGC(JDK11出现)和Shenandoah(Open JDK12)。主打特点:低停顿时间 Shenandoah命名源于 10.2,Open JDK12的Shenandoash GCOpen JDK12的Shenandoash GC:低停顿时间的GC(实验性) Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由oracle公司团队领导开发的Hotspot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK没有区别的Oracle公司仍拒绝在OracleJDK12中支持Shenandoah。 Shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研究项目Pauseless GC的实现,旨在针对JVM上的内存回收实现低停顿的需求.。在2014年贡献给OpenJDK。 Red Hat研发Shenandoah团队对外宣称,Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。 这是RedHat在2016年发表的论文数据,测试内容是使用Es对200GB的维基百科数据进行索引。从结果看:
总结
10.3,令人震惊、革命性的ZGC官方地址:https://docs.oracle.com/en/java/javase/12/gctuning/ ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。 《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。 ZGC的工作过程可以分为4个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。 ZGC乎在所有地方并发执行的,除了初始标记的是STw的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。 测试数据: 在ZGC的强项停顿时间测试上,它毫不留情的将Parallel、G1拉开了两个数量级的差距。无论平均停顿、95%停顿、99%停顿、99.9%停顿,还是最大停顿时间,ZGC都能毫不费劲控制在10毫秒以内。 虽然ZGC还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。 未来将在服务端、大内存、低延迟应用的首选垃圾收集器。 如果想要学习相关知识可以看: JEP 364:ZGC应用在macos上 JEP 365:ZGC应用在Windows上 JDK14之前,ZGC仅Linux才支持。 尽管许多使用zGc的用户都使用类Linux的环境,但在Windows和macos上,人们也需要ZGC进行开发部署和测试。许多桌面应用也可以从ZGC中受益。因此,ZGC特性被移植到了Windows和macos上。 现在mac或Windows上也能使用zGC了,示例如下:
10.4,其他垃圾回收器:AliGCAliGC是阿里巴巴JVM团队基于G1算法,面向大堆(LargeHeap)应用场景。指定场景下的对比: 当然,其它厂商也提供了各种别具一格的GC实现,例如比较有名的低延迟GC:Zing,有兴趣可以参考提供的链接 https://www.infoq.com/articles/azul_gc_in_detail |
|
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 | -2024/11/23 20:09:23- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |