JVM
2023-05-28 19:46:12 1 举报
AI智能生成
基于尚硅谷宋红康老师及图灵JVM视频总结
作者其他创作
大纲/内容
JVM体系结构
字节码
- java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码
- 不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的JVM上运行
Java虚拟机
虚拟机
所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机
- 大名鼎鼎的Visual Box,VMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
- 程序虚拟机的典型代表就是`Java虚拟机`,它专门为执行单个计算机程序而设计,`在Java虚拟机中执行的指令我们称为Java字节码指令
java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成
JVM平台的各种语言可以共享Java虚拟机带来的`跨平台性`、优秀的`垃圾回收器`,以及可靠的`即时编译器
JVM平台的各种语言可以共享Java虚拟机带来的`跨平台性`、优秀的`垃圾回收器`,以及可靠的`即时编译器
特点:
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
JVM的位置
JVM是运行在操作系统之上的,它与硬件没有直接的交互
JVM的体系结构
JVM的整体结构
`解释器与即时编译器`并存的架构
Java代码的执行流程
JAVA 编译器,也可以称为前端编译器
JIT(just in time 及时的)编译器 —— 即时编译器
市面上比较主流的虚拟机基本上都是 解释执行(翻译为字节码) 和 即时编译(JIT 编译期)并存的方式,解释器主要是来保证响应时间的,一开始就逐行的针对字节码指令进行解释执行,JIT编译器可以针对字节码指令,其中有一些代码是反复执行的,我们称之为`热点代码`,热点代码可以使用JIT编译器在编译成机器指令,这出现了二次编译`(第一次编译把源文件编译成字节码文件,第二次编译是把字节码文件中的字节码指令编译成机器指令),同时因为热点指令是反复执行的,还会将其放`在方法区中缓存起来` ,下次可以直接调用,`JIT编译期主要负责程序执行的性能
JVM的架构模型
Java编译器输入的指令流基本上是一种`基于栈的指令集架构`
总结
JVM的生命周期
虚拟机的启动
子主题
虚拟机的执行
一个运行中的Java虚拟机有着一个清晰的任务:`执行Java程序`。
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)(不是Object)来完成的就停止
执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程
虚拟机的退出
程序正常执行结束
程序在执行过程中遇到了异常或错误而异常终止
由于操作系统用现错误而导致Java虚拟机进程终止
某线程调用Runtime类或system类的exit方法,或`Runtime类的halt`方法,并且Java安全管理器也允许这次exit或halt操作。
除此之外,JNI(Java Native Interface) native 方法规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。
JVM的发展历程
类加载子系统
类加载子系统的作用
类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。(cafe babe)
ClassLoader只负责class文件的加载,至于它是否可以运行,则由 Execution Engine(执行引擎)决定。
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
`class file`存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
class file`加载到JVM中,被称为`DNA元数据模板`,放在`方法区`。
-在`.class文件->JVM->最终成为元数据模板`,此过程就要一个运输工具(类装载器`Class Loader`),`扮演一个快递员的角色。`
加载
加载class文件的方式
- 从`本地系统`中直接加载
- 通过`网络`获取,典型场景:Web Applet
- 从`zip压缩包`中读取,成为日后`jar、war格式的基础`
- 运行时计算生成`,使用最多的是:`动态代理技术`
- 由其他文件生成,典型场景:`JSP应用从专有数据库中提取.class文件`,比较少见
- 从加密文件中获取,典型的防Class文件被反编译的保护措施
类的加载是懒加载
JVM采用的是懒加载机制,即只有类在被使用时才加载
链接
验证(Verify)
确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
准备(Prepare)
为类变量分配内存并且设置该类变量的默认初始值,即零值。
不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化
这里不会为实例变量分配初始化,类变量会分配在方法区`中,而实例变量是会随着对象一起分配到Java堆中
解析(Resolve)
将常量池内的符号引用转换为直接引用的过程
符号引用就是一组符号来描述所引用的目标`。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。`直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等
初始化
类加载器的分类
JVM支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
Java的核心类库都是使用根加载器(引导类加载器)进行加载的。
JVM自带的加载器
启动类加载器(引导类加载器,Bootstrap ClassLoader)
扩展类加载器( Extension ClassLoader )
应用程序类加载器(系统类加载器,AppClassLoader)
用户自定义类加载器
为什么要自定义类加载器?
- 隔离加载类(避免我同包同名类的冲突)
- 修改类加载的方式
- 扩展加载源
- 防止源码泄漏
用户自定义类加载器实现步骤
整个类加载的流程
获取ClassLoader的途径
- - 获取当前ClassLoader:clazz.getClassLoader()
- - 获取当前线程上下文的ClassLoader:Thread.currentThread().getContextClassLoader()
- - 获取系统的ClassLoader:ClassLoader.getSystemClassLoader()
- - 获取调用者的ClassLoader:DriverManager.getCallerClassLoader()
双亲委派机制
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载
双亲委派机制的优势
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改 【保护核心API】
打破双亲委派机制
全盘负责委托机制
当一个ClassLoder装载一个类时,除非显示的使用另外一个ClassLoder,该类所依赖及引用的类也由这个ClassLoder载入
打破双亲委派机制
Tomcat打破双亲委派机制
Tomcat是个web容器, 那么它要解决什么问题?
一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离
部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。
Tomcat 如果使用默认的双亲委派类加载机制行不行?
第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性
第三个问题和第一个问题一样。
我们再看第四个问题,我们想我们要怎么实现jsp文件的热加载,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
Tomcat自定义加载器详解
- CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。
- WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离
- JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。
- 引导类加载器 和 扩展类加载器 的作⽤不变
- 系统类加载器:正常情况下加载的是 CLASSPATH 下的类,但是 Tomcat 的启动脚本并未使⽤该变量,⽽是加载tomcat启动的类,⽐如bootstrap.jar,通常在catalina.bat或者catalina.sh中指定。 位于CATALINA_HOME/bin下
- commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
- catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
- sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
- WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本;他⽤来加载本应⽤程序 /WEB-INF/classes 和 /WEB-INF/lib 下的类。
Tomcat加载流程
- ⾸先从 Bootstrap Classloader加载指定的类
- 如果未加载到,则从 /WEB-INF/classes加载
- 如果未加载到,则从 /WEB-INF/lib/*.jar 加载
- 如果未加载到,则依次从 System、Common、Shared 加载(在这最后⼀步,遵从双亲委派机制)
通过Java命令执行代码的大体流程
如何判断两个Class对象相同?
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
运行时数据区概述及线程
运行时数据区
线程
- 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
- java执行的线程都是调用的操作系统的本地线程 - 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。
JVM系统线程
程序计数器
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行 `native` 方法,则是未指定值`(undefined)
- (作用)它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取`下一条需要执行的字节码指令。
唯一一个在Java虚拟机规范中没有规定任何`out of MemoryError`情况的区域
CPU时间片
Java的多线程的线程调度是 抢占式调度模型
虚拟机栈
概述
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的`栈帧(Stack Frame)`【栈里面存储的基本单位】,对应着一次次的Java方法调用。【一个栈帧对应一个java方法】
栈是运行时的单位,而堆是存储的单位(宏观上)
- 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
- 堆解决的是数据存储的问题,即数据怎么放,放哪里
作用
主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回
对象的创建会在堆中创建,但是引用地址会存储在栈中
特点
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
栈不存在垃圾回收问题(栈存在溢出的情况)。程序计数器既不存在GC,也不存在 OOM
设置栈的大小
默认虚拟机栈的大小是1M(1024k),因为虚拟机栈是伴随着线程的生命周期,所以我们常说创建1个线程是我需要消耗1M的内存是因为要创建虚拟机栈。
-Xss
栈帧(栈的存储单位)
栈中的数据都是以栈帧(Stack Frame)的格式存在
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
栈运行原理
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。(因为`栈是线程私有的`)
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常(未捕获)。不管使用哪种方式,都会导致栈帧被弹出
栈帧内部结构
局部变量表(Local Variables)
是什么?
字节码文件解析
方法描述
字节码指令
异常
关于Slot的理解
是什么?
Slot的重复利用
静态变量与局部变量的对比
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈(operand Stack)
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last - In - First -Out)的 操作数栈,也可以称之为 表达式栈(Expression Stack)
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值 (32bit的类型占用一个栈单位深度,64bit类型占用两个栈单位深度)
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
操作数栈的执行流程实例
栈顶缓存技术(Top of Stack Cashing)
- 基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数
- 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
动态链接(DynamicLinking)【指向运行时常量池的方法引用】
方法调用:解析与分配
链接
- 静态链接: 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期克制,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
绑定机制
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。(多态)
虚方法和非虚方法
普通调用指令
- invokestatic(非虚方法):调用静态方法,解析阶段确定唯一方法版本
- invokespecial(非虚方法):调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
动态调用指令
invokedynamic:动态解析出需要调用的方法,然后执行
动态类型语法与静态类型语法区别
方法的调用:虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕
如果类中重写了方法,那么调用的时候,就会直接在虚方法表中查找,否则将会直接连接到Object的方法中
方法返回地址(Return Address)【方法正常退出或者异常退出的定义】
一个方法的结束,有两种方式
- - 正常执行完成
- - 出现未处理的异常,非正常退出
方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 正常执行
- 异常
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
正常完成
- 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
- 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn(引用类型)。另外还有一个return指令声明为void的方法,实例初始化方法,类和接口的初始化方法使用。
异常
抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
附加信息
本地方法接口
什么是本地方法?
为什么用本地方法?
与外部非JAVA环境外交互
有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节
与操作系统交互
JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。`通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的`。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法
本地方法栈
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈,也是线程私有的
允许被实现成固定或者是可动态扩展的内存大小。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
- 可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存
堆
概述
一个进程对应一个JVM的实例。 一个JVM实例中就会有一个运行时数据区(Runtime),也就意味着只有一个堆空间,等于说是一个进程中的所有线程共享堆空间、方法区
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)
内存细分
JDK7: 新生代 + 老年代 + 永久代
JDK8: 新生代 + 老年代 + 元空间
设置堆内存大小 & OOM
-Xms
-Xmx
新生代 & 老年代
配置新生代与老年代在堆结构的占比
-XX:NewRatio=2(默认)
Servivor区占比
-XX:SurvivorRatio=8 (默认)
实际默认是: 6:1:1
-XX:-UseAdaptiveSizePolicy
设置新生代最大内存大小
-Xmn
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
为了避免为大对象分配内存时的复制操作而降低效率
为什么要分新生代和老年代?
对象分配过程
详细流程
CMS收集器默认 -XX:MaxTenuringThresold = 6
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to区
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
对象动态年龄判断
当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。
GC类型
概述
Minor GC(Young GC)【年轻代】
- 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满, Survivor满不会引发GC。(每次Minor GC会清理年轻代的内存。)
- 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
Major GC(Old GC)
- 指发生在老年代的GC,对象从老年代消失时,我们说"Major GC”或“Full GC”发生了。
- 出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在`ParallelScavenge收集器`的收集策略里就有直接进行Major GC的策略选择过程)也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
- Major GC的速度一般会比Minor GC慢10倍以上, STW的时间更长。
- 如果Major GC后,内存还不足,就报OOM了。
Full GC
触发Full GC 执行的情况有如下五种:
调用`System.gc()`时,系统建议执行Full GC,但是不必然执行
老年代空间不足
方法区空间不足
通过Minor GC后进入老年代的平均大小大于老年代的可用内存
由Eden区、survivon space (From Space)区向survivor space1 (ToSpace)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
MinorGC可能会引发Full GC
老年代空间分配担保机制
为对象分配内存: TLAB
为什么要有TLAB(Thread Local Allcation Buffer)?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB?
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分, JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
- 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
- 在程序中,开发人员可以通过选项-XX:UseTLAB”设置是否开启TLAB空间。
- 默认情况下, TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
- 一旦对象在TLAB空间分配内存失败时, JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接直在Eden空间中分配内间中分配内配内
更多关于TAPB的探讨
堆空间的参数设置
-XX:+PrintFlagsInitial :查看所有的参数的默认初始值
-XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms:初始堆空间内存 (默认为物理内存的1/64)
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-Xmn:设置新生代的大小。(初始值及最大值)
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代中Eden和SO/S1空间的比例
-XX:MaxTenuringThreshold: 设置新生代垃圾的最大年龄(survivor区中的阈值)
-XX:printGCDetails:输出详细的GC处理日志 打印gc简要信息: ①-XX:printGC ② -verbose:gc
-XX: HandlePromotionFailure:否设置空间分配担保
堆是分配对象的唯一选择吗?【逃逸分析】
如果经过`逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术
此外,基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap) 技术【GC 看不见的堆】实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
此外,基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap) 技术【GC 看不见的堆】实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。
参数设置
-XX: +DOEscapeAnalysis: 显式开启逃逸分析通过选项
-XX: +PrintEscapeAnalysis: 查看逃逸分析的筛I选结果。
在使用逃逸分析,编译器可以对代码做如下优化
栈上分配
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。JIT编译器在编译期间根据逃逸分析的结果`,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
同步省略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
在动态编译同步块的时候, JIT编译器可以借助逃逸分析来`判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程`。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
在动态编译同步块的时候, JIT编译器可以借助逃逸分析来`判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程`。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器(栈中,Java是基于栈的编译器)中
-XX:+ElimilnateAllocations: 开启了标量替换(默认打开),允许将对象打散【对象的成员变量】分配在栈上。
逃逸分析并不成熟
无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程,一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段
日均百万级订单交易系统如何设置JVM参数
尽可能让对象都在新生代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收。
方法区
堆、栈、方法区的交互关系
理解
- 方法区看作是一块独立于Java堆的内存空间
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误`: java.lang.OutofMemoryError:PermGen space(JDK7之前)` `java.lang.OutofMemoryError: Metaspace(JDK8之后)`,加载大量的第三方的jar包; Tomcat部署的工程过多(30-50个)大量动态的生成反射类。
- 方法区看作是一块独立于Java堆的内存空间
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 物理内存空间中和Java堆区一样都可以是不连续的。
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
设置大小 & OOM
设置大小
永久代(JDK7)
-XX:PermSize:来设置永久代初始分配空间。默认值是20.75M
-XX:MaxPermSize:来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
当JVM加载的类信息容量超过了这个值,会报异常outofMemoryError:PermGenspace
元空间(JDK8)
-XX:MetaspaceSize:
-XX:MaxMetaspaceSiz
默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize 的值是-1,即没有限制。与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutofMemoryError: Metaspace。
合理设置
-XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线, Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活) ,然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。元空间大小的上调会引发Full GC,为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值
如何解决OOM
内部结构
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- ① 这个类型的完整有效名称(全名=包名.类名)
- ② 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
- ③ 这个类型的修饰符(public, abstract, final的某个子集)
- ④ 这个类型直接接口的一个有序列表
域(Feild)信息
方法(Method)信息
non-final的类变量
- 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
- 类变量被类的所有实例共享,即使没有类实例时你也可以访问它。
运行时常量池 & 常量池
方法区,内部包含了运行时常量池。字节码文件,内部包含了常量池
ClassFile中的常量池
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
几种在常量池内存储的数据类型包括:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性
运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。
方法区的演进
字符串常量池 & 静态变量 【针对是符号引用】
String Table 为什么要调整?
jdk7中将stringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
静态变量存放在哪?
静态引用对应的对象实体始终存储在堆空间,我们所谈论的静态变量和常量池在 jdk6/7/8上的变化是针对于符号引用来说
方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
废弃的常量【主要 字面量 & 符号引用】
字面量
符号引用
HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了`-Xnoclassgc`参数进行控制,还可以使用`-verbose:class`以及`-XX: +TraceClass - Loading`、 `-XX:+TraceClassUnLoading`查看类加载和卸载信息
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
如何判断一个类是无用的类[方法区回收]?
该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
方法区的回收条件比较苛刻
对象实例化
创建对象的方式
new()
Class newInstance()
Contructor 的 newInstance(xx)
clone()
序列化 & 反序列化
第三方库 Objenesis
创建对象的步骤
1. 判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查询对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到则进行类加载,并生成Class类对象。
2. 为对象分配内存
内存归整——指针碰撞法
内存不归整
虚拟机需要维护一个列表
空闲列表分配
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的的垃圾回收器是否带着有压缩整理功能所决定
3. 处理并发问题
采用CAS配上失败重试保证更新的原子性
每个线程预先分配一块TLAB
4. 初始化分配到空间
所有属性设置默认值(零值初始化),保证对象实例字段在不赋值可以直接使用。
5. 设置对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
6. 执行 init 方法进行初始化
在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有`invokespecial`指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
对象内存布局
对象头(Header)
运行时元数据【Mark World】
Mark Word的结构
hash: 保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
age: 保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
biased_lock: 偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
lock: 锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
JavaThread*: 保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
epoch: 保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
32位JVM下的对象结构描述
64位JVM下的对象结构描述
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针
Mark Word中锁标记枚举
类型指针【Klass Pointer】
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针[c++底层对象指针],虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节
为什么要指针压缩 ?
在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
为了减少64位平台下内存的消耗,启用指针压缩功能
在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
数组长度(只有数组对象有 4个字节)
例子
普通对象
数组对象
Mark World
实际数据(Instance Data)
规则
相同宽度的字段分配在一起
父类分配的字段会在子类之前
如果 compectFeilds 设置为 true (默认 true),则子类的窄变量可以在放在父类变量的间隙
对其填充(Padding)
由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
例子
对象访问定位
对象访问方式
句柄访问
- 优点:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。
- 缺点:因为存在句柄池的存在,增加了内存的使用,并且在访问的效率上降低。
直接访问(Hotspot默认)
直接内存
- 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 直接内存是在Java堆外的、直接向系统申请的内存区间
- 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
- 通常,访问直接内存的速度会优于Java堆。即读写性能高。因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
非直接缓冲区
直接缓冲区
由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,和直接内存的总和依然受限于操作系统能给出的最大内存
缺点
分配回收成本较高
不受JVM内存回收管理
直接内存大小可以通过`MaxDirectMemorySize`设置如果不指定,默认与堆的最大值-Xmx参数值一致。
执行引擎
概述
将字节码指令解释/编译为对应平台上的本地机器指令 【翻译】
Java代码执行编译流程
Java是半编译半解释型语言
- 解释器:当Java虚拟机启动时会根据预定义的规范`对字节码采用逐行解释`的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- JIT (Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
机器码、指令、汇编语言
机器码
各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。用它编写的程序一经输入计算机, CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。
指令
由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
指令集
不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。如常见的 x86指令集,对应的是x86架构的平台;ARM指令集,对应的是ARM架构的平台
汇编语言
由于指令的可读性还是太差,于是人们又发明了汇编语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行
解释器
“实时翻译者”【借助程序计数器】
解释器的工作机制
解释器真正意义上所承担的角色就是一个运行时“实时翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作,
分类
字节码解释器:在执行时通过`纯软件代码`模拟字节码的执行,效率非常低下。
模板解释器将:`每一条字节码和一个模板函数相关联,`模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成
- Interpreter模块:实现了解释器的核心功能
- Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
JIT编译器
AVA代码的执行分类
- 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行。
- 第二种是编译执行(直接编译成机器码)。现代虚机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行。
解释器和编译器为何并存?
HotSpot JVM的执行方式
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率
热点代码
什么是?
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为热点代码,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On StackReplacement)编译
怎么选择?
方法调用计数器
阈值: -xx:CompileThreshold Server模式默认 10000次 Client 模式 1500次
流程
热度衰减
回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译
HotSpot 设置程序启动方式
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
- -Xint:完全采用解释器模式执行程序
- -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
- -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。
HotSpot VM的JIT分类
在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和ServerCompiler,但大多数情况下我们简称为c1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器
- -client:指定Java虚拟机运行在Client模式下,并使用c1编译器;C1编译器会对字节码进行`简单和可靠的优化,耗时短`。以达到`更快的编译速度`。
- -server:指定Java虚拟机运行在Server模式下,并使用C2编译器。C2进行`耗时较长的优化,以及激进优化`。但`优化的代码执行效率更高`。
分层编译(Tiered Compilation)策略
程序解释执行(不开启性能监控)可以触发c1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。
C1编译器上主要有方法内联,去虚拟化、冗余消除
方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化:对唯一的实现类进行内联
冗余消除:在运行期间把一些不会执行的代码折叠掉
C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化
标量替换:用标量值代替聚合对象的属性值
栈上分配:对于未逃逸的对象分配对象在栈而不是堆
同步消除:清除同步操作,通常指synchronized
机器在热机状态可以承受的负载要大于冷机状态
案例:注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部启机,此故障说明了JIT的存在。——阿里团队
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部启机,此故障说明了JIT的存在。——阿里团队
String Table 字符串常量池
String 基本特性
String 存储结构变更
JDK 8:char [] value jdk9:byte []
为什么要变更?
String Tabe 字符串常量池
String 的内存分配
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊。它的主要使用方法有两种
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊。它的主要使用方法有两种
- - 直接使用双引号声明出来的String对象会直接存储在常量池中。比如: String info = "atguigu.com";
- - 如果不是用双引号声明的String对象,可以使用String提供的intern ()方法。这个后面重点谈
StringTable 为什么要调整位置?
- 方法区垃圾回收频率低
字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译期优化
常量池中不会存在相同内容的常量
只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
如果拼接的结果调用intern ()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
示例
字符串拼接操作,不一定使用的是StringBuilder,如果拼接符号左右两边都是字符串常量或者常量引用,则仍然使用编译器优化,即非StringBuilder方式
针对于 final 修饰类、方法、基本数据类型、引用数据类型的变量的结构能使用上 final 的时候建议加上
通过StringBuilder的 append() 的方式添加字符串的效率要远高于使用string字符串拼接方式
- ① StringBuilder的 append() 的方式自始至终只创建过一个StringBuiilder的对象,使用String的字符串拼接的方式:创建过多个StringBuilder 和 String的对象。
- ② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String对象,内存占用更大,如果进行了GC,就需要花费额外的时间
改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值 highLevel的情况下,建议使用构造器 StringBuilder sb = new StringBuilder(highLevel) 避免底层进行扩容
intern() 使用
intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中
jdk6
- - 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- - 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
jkd7及以后
- - 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- - 如果没有,则会把对象的引用地址复制一份,放入串池,`并返回串池中的引用地址`
练习
G1中的String去重操作
实现
当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。使用一个hashtable来记录所有的被string对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。如果存在, String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。如果查找失败, char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了
命令行选项
UsestringDeduplication (bool) :开启String去重,默认是不开启的,需要手动开启。
PrintStringDeduplicationstatistics (bool):打印详细的去重统计信息
StringDeduplicationAgeThreshold (uintx) :达到这个年龄的String对象被认为是去重的候选对象
题目: new String("ab") 会创建几个对象? new String("a") + new String("b") 呢 ?
垃圾回收器
概述
什么是垃圾?
为什么需要GC?
Java的垃圾回收器
优点: 自动管理
缺点: 黑匣子
垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。(Java堆是垃圾收集器的工作重点)
从次数上讲
- 频繁收集Young区(新生代)
- 较少收集0ld区(老年区)
- 基本不动Perm区(方法区)
垃圾回收算法
标记阶段
引用计数法
循环引用
可达性分析
基础思路
GC Roots
虚拟机栈中引用的对象
本地方法栈内JNI (通常说的本地方法)引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象 (比如 字符串常量池StringTable)
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用。
在新生代进行可达性分析的时候,可能老年代就做为GC Roots
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是导致GC进行时必须"stop The World(STW)的一个重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的
清除阶段
标记-清除算法(Makr-Sweep)
- 标记: Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
- 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
何时清除垃圾对象?
复制算法(copying)
如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,这样效率会比较高,如果垃圾对象比较少,因为要去复制对象的内存空间并且进行维护对象引用的关系,效率会比较低。
适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivoro和Survivor1区。不适用于老年代
适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivoro和Survivor1区。不适用于老年代
标记-压缩算法(Mark-Compact)
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact) 算法。二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的
指针碰撞
对象的finalization机制
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。finalize ()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
永远不要主动调用某个对象的finalize ()方法,应该交给垃圾回收机制调用
在finalize ()时可能会导致对象复活。
finalize ()方法的执行时间是没有保障的,它完全由GC线程决定(gc线程的优先级比较低),极端情况下,若不发生GC,则finalize ()方法将没有执行机会。
一个糟糕的finalize ()会严重影响GC的性能。
对象状态
虚拟机中的对象一般处于三种可能的状态。如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize ()中复活。
- 不可触及的:对象的finalize ()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
判断一个对象是否被回收
示例
分代收集算法
年轻代
年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解
老年代
老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
- Mark阶段的开销与存活对象的数量成正比。
- Sweep阶段的开销与所管理区域的大小成正相关。
- Compact阶段的开销与存活对象的数据成正比。
增量回收算法
基本思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降
分区算法
垃圾回收相关概念
System.gc()理解
内存溢出(OOM)
没有空闲内存,并且垃圾收集器也无法提供更多内存。
- Java虚拟机的堆内存设置不够
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
内存泄漏(Memory Leak)
严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏
- 1、单例模式单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。(加长了对象的生命周期)
- 2、一些提供close的资源未关闭导致内存泄漏数据库连接(dataSourse.getConnection()) ,网络连接(socket)和io连接必须手动close,否则是不能被回收的。
STW
垃圾回收的并行 & 并发 【Gc线程 & 用户线程】
- 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如ParNew、 Parallel Scavenge, Parallel 0ld
- 串行(Serial):相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
并发Concurrent) :指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行
- 用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
- 如: CMS、G1
安全点 & 安全区域
安全点(safe point)
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint),Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题
大部分指令的执行时间都非常短暂,通常会根据是否具有让程序长时间执行的特征为标准。
比如:选择些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
大部分指令的执行时间都非常短暂,通常会根据是否具有让程序长时间执行的特征为标准。
比如:选择些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
- 方法返回之前
- 调用某个方法之后
- 抛出异常的位置
- 循环的末尾
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
抢先式中断:(目前没有虚拟机采用了)首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
安全区域(Safe Region)
安全区域指的是,在某段代码中,引用关系不会发生变化,线程执行到这个区域是可以安全停下进行 GC 的。因此,我们也可以把 安全区域看做是扩展的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否处于 STW 状态,如果是,则需要等待直到恢复
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否处于 STW 状态,如果是,则需要等待直到恢复
引用
强
不回收
软
内存溢出前回收
弱
gc发现即回收
虚
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
终结器引用
- 它用以实现对象的finalize()方法,也可以称为终结器引用。
- 无需手动编码,其内部配合引用队列使用。
- 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象
垃圾回收器
GC分类
按照线程数来分
串行垃圾回收器 & 并行回收器
按照工作模式分
并发式垃圾回收器 & 独占式垃圾回收器
- 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
- 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
按碎片处理方式分
压缩式 & 非压缩式
- 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。(指针碰撞)
- 非压缩式的垃圾回收器不进行这步操作。(空闲列表)
按工作的内存区间分
年轻代 & 老年代垃圾回收器
性能指标
吞吐量
gc 频率降低,但是GC时间变长
暂停时间
gc频率增加 Gc时间减少,但是总体时间变长
内存占用
垃圾收集开销
收集频率
总结
垃圾回收的组合关系
如何查看默认的垃圾回收器
- -XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
- - 使用命令行指令: `jino -flag 相关垃圾回收器参数 进程ID`
GC日志分析
参数
Minor GC 日志
Full GC 日志
示例
日志补充说明
"[GC"和" [Full GC"说明了这次垃圾收集的停顿类型,如果有"Full"则说明GC发生了"StopThe World"
使用Serial收集器在新生代的名字是`Default New Generation`,因此显示的是"`[DefNew`"
使用ParNew收集器在新生代的名字会变成"`[ParNew`",意思是"`Parallel New Generation`
使用Parallel Scavenge收集器在新生代的名字是"[PSYoungGen"老年代的收集和新生代道理一样,名字也是收集器决定的
使用G1收集器的话,会显示为"`garbage-first heap`"
Allocation Failure 表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
[PSYoungGen: 5986K->696K (8704K) ] 5986K->704K(9216K)`
括号内: GC回收前年轻代大小,回收后大小, (年轻代总大小)
括号外: GC回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
括号内: GC回收前年轻代大小,回收后大小, (年轻代总大小)
括号外: GC回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
user代表用户态回收耗时,sys内核态回收耗时,real实际耗时。由于多核的原因,时间总和可能会超过real时间
GC 日志工具
可以用一些工具去分析这些gc日志。常用的日志分析工具有: GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter, garbagecat等
垃圾回收器的新发展
Open JDK12 的 Shenandoah GC
Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由oracle公司团队领导开发的HotSpot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK没有区别的oracle公司仍拒绝在OracleJDK12中支持Shenandoah。Shenandoah垃圾回收器最初由**RedHat**进行的一项垃圾收集器研究项目PauselessGC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDKRed Hat研发Shenandoah团队对外宣称, Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200 MB还是200GB, 99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载
- Shenandoah GC 的强项: 低延迟时间
- Shenandoah GC 的弱项: 高运行负担下的吞吐量下降
ZGC
ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的, (暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法,以低延迟为首要目标的一款垃圾收集器
ZGC的工作过程可以分为4个阶段:并发标记-并发预备重分配-并发重分配-并发重映射等。ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的
JVM架构图
OOM & GC
常见面试题
垃圾回收组合关系
垃圾回收器分类
Serial回收器:串行回收
新生代(Serial)【复制算法】 老年代 (Serial Old)【标记-压缩算法】
Serial 0ld是运行在Client模式下默认的老年代的垃圾回收器Serial old在Server模式下主要有两个用途
- ① 与新生代的ParallelScavenge配合使用
- ② 作为老年代CMS收集器的后备垃圾收集方案
-XX:+UseSerialGC
优势
简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说, Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在Client模式下的虚拟机是个不错的选择
ParNew回收器:并行回收
ParNew收集器则是Serial收集器的多线程版本
新生代(ParNew) 老年代(Serial Old)
复制算法
-XX:+UseParNewGC
-XX:ParallelGCThreads
限制线程数量,默认开启和CPU数据相同的线程数。
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。但是在单个CPU的环境下, ParNew收集器不比serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销
Parallel回收器:吞吐量优先
和ParNew收集器不同, Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。自适应调节策略也是Parallel Scavenge与ParNew一个重要区别
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序
Java8,默认是此垃圾收集器。
Parallel Scavenge 新生代
复制算法
Parallel O1d 老年代
标记压缩算法
参数设置
XX:+UseParallelGC
指定年轻代使用Parallel并行收集器执行内存回收任务
-XX: +UseParallelOldGC
指定老年代都是使用并行回收收集器
-XX: ParallelGCThreads
-XX: MaxGCPauseMillis
设置垃圾收集器最大停顿时间(即STW的时间)
-XX:GCTimeRatio
垃圾收集时间占总时间的比例(= 1 / (N + 1))。默认值99(垃圾回收器时间不超过 1%)
-XX:+UseAdaptiveSizePolicy
设置Parallel Scavenge收集器具有自适应调节策略在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点 (在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills) ,让虚拟机自己完成调优工作)
CMS回收器:低延迟
并发收集器(垃圾收集线程与用户线程同时工作)
标记清除算法(为了并发)
新生代只能选择ParNew或者Serial收集器中的一个
并发标记过程占用整个过程的一大半(80%)
尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行"Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要"Stop-theWorld” ,只是尽可能地缩短暂停时间。由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的
由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阔值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。
Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢 ?
因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合"Stop the World”这种场景下使用
优点
并发收集、低延迟
缺点
产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC
CMS收集器对CPU资源非常敏感,在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
CMS收集器无法处理浮动垃圾。可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生【会临时启用 SerialOld 收集器来进行垃圾收集】。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间
参数设置
-XX: +UseConcMarkSweepGC
开启该参数后会自动将`-XX:+UseParNewGC`打开。即: ParNew (Young区用) +CMS (Old区用) +Serial Old的组合。
-XX: CMSlnitiatingOccupanyFraction
XX:+UseCMSInitiatingOccupancyOnly
只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
-XX: CMSFullGCsBeforeCompaction
多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
-XX: +UseCMSCompactAtFullCollection
指定在执行完FullGC后对内存空间进行压缩整理
-XX: ParallelCMSThreads
CMS的线程数量
-ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟。
-XX:ConcGCThreads
并发的GC线程数
-XX:+CMSScavengeBeforeRemark
在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段
-XX:+CMSParallellnitialMarkEnabled
表示在初始标记的时候多线程执行,缩短STW
-XX:+CMSParallelRemarkEnabled
在重新标记的时候多线程执行,缩短STW;
JDK9新特性: CMS被标记为`Deprecate`了
DK14新特性:`删除`CMS垃圾回收器(JEP363)移除了CMS垃圾收集器
DK14新特性:`删除`CMS垃圾回收器(JEP363)移除了CMS垃圾收集器
垃圾收集底层算法实现
三色标记(可达性分析底层算法)
在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。漏标的问题主要引入了三色标记算法来解决。
三色标记算法是把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成三种颜色:
三色标记算法是把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成三种颜色:
- 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
- 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
- 白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标-读写屏障
漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB)
漏标案例
增量更新
当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了
原始快照(STAB)
当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的
写屏障(类似AOP)
写屏障实现SATB
写屏障实现增量更新
读屏障
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下
CMS:写屏障 + 增量更新
G1,Shenandoah:写屏障 + SATB
ZGC:读屏障
为什么G1用SATB?CMS用增量更新?
SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描
ZGC 有一个标志性的设计是它采用的染色指针技术,染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果讲这些信息直接维护在指针中,显然可以省去一些专门的记录操作。而 ZGC 没有使用写屏障,只使用了读屏障,显然对性能大有裨益的
记忆集与卡表
在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。
为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针
为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针
hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。
卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。
hotSpot使用的卡页是2^9大小,即512字节
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里
卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。
hotSpot使用的卡页是2^9大小,即512字节
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里
卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。
Hotspot使用写屏障维护卡表状态
Hotspot使用写屏障维护卡表状态
G1回收器:垃圾优先
概述
特点
并发 & 并行
并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
并发性: G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
分代收集
从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代
空间整合
Region之间是复制算法,整体上是标记-压缩算法
两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显
两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显
可预测的停顿时间模型
缺点
相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存,应用上则发挥其优势。平衡点在 `6-৪G`
操作步骤
G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
- 第一步:开启G1垃圾收集器
- 第二步:设置堆的最大内存
- 第三步:设置最大的停顿时间
G1中提供了三种垃圾回收模式: YoungGC、Mixed GC和Full GC,在不同的条件下被触发。
使用场景
面向服务端应用,针对具有大内存、多处理器的机器
最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案
如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒; (G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
在下面的情况时,使用G1可能比CMS好
- ① 超过50%的Java堆被活动数据占用
- ② 对象分配频率或年代提升频率变化很大;
- ③ GC停顿时间过长(长于0.5至1秒)
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程
分区 Region 化整为零
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幕,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1
一个region有可能属于Eden, Survivor 或者old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域, S表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块,主要用于存储大对象,如果超过1.5个region,就放到H(在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%)
设置H的原因:对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题, G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待
回收过程
Remembered Set
存在的问题?
G1 回收过程一: 年轻代 GC
首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set) ,回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
如果Survivor区空间耗尽是不会引发年轻代GC的,Survivor区的垃圾回收属于被动回收,当Eden区进行回收顺带会回收Survivor
G1 回收过程二: 并发标记过程
主要就是计算 Region 的垃圾占有率
G1 回收过程三: 混合回收(Fixed GC)
当越来越多的对象晋升到老年代oldregion时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个OldGC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些O1dRegion进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
G1 回收可选的过程四: Full GC (兜底)
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),单线程的内存回收算法进行垃圾回收
导致G1Full GC的原因可能有两个
Evacuation的时候没有足够的to-space来存放晋升的对象
并发处理过程完成之前空间耗尽
参数设置
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
-XX:G1MaxNewSizePercent:新生代内存最大空间(默认60%)
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
-XX:G1MixedGCLiveThresholdPercent:(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
优化建议
年轻代大小
避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
固定年轻代的大小会覆盖暂停时间目标
暂停时间目标不要太过严苛
G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量
ZGC收集器
0 条评论
下一页