JVM概述
JVM:Java Virtual Machine,Java程序的运行环境,JVM 通过软件来模拟 Java 字节码的指令集
虚拟机:通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的计算机系统
JVM功能:虚拟机是 Java 平台无关的保障
- 通过 Classloader 寻找和装载 class 文件
- 解释字节码成为指令并执行,提供 class 文件的运行环境
- 进行运行期间的内存分配和垃圾回收
- 提供与硬件交互的平台
JVM规范
JVM规范为不同的硬件平台提供了一种编译 Java 技术代码的规范
JVM规范作用
- JVM 规范为不同的硬件平台提供了一种编译Java技术代码的规范
- 该规范使 Java 软件独立于平台,因为编译是针对作为虚拟机的“一般机器”而做,这个“一般机器”可用软件模拟并运行于各种现存的计算机系统,也可用硬件来实现
规范主要内容
- 字节码指令集(相当于中央处理器CPU)
- Class 文件格式
- 数据类型和值
- 运行时数据区
- 栈帧
- 特殊方法
- 类库
- 异常
- 虚拟机的启动、加载、链接和初始化
官方文档:https://docs.oracle.com/javase/specs/index.html
字节码指令集
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码
加载和存储指令
加载和存储指令用于将数据从栈帧的本地变量表和操作数栈之间来回传递
将一个本地变量加载到操作数栈的指令:
ioad、iload_<n>
、lload、lload_<n>
、 fload、fload_<n>
、 dload、dload_<n>
、 aload、aload_<n>
将一个数值从操作数栈存储到局部变量表的指令:
istore、 istore_<n>
、 lstore、lstore_<n>
、 fstore、fstore_<n>
、 dstore、dstore_<n>
、 astore、astore_<n>
将一个常量加载到操作数栈的指令:
bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_ml、iconst_<i>
、lconst_<l>
、fconst_<f>
、 dconst_<d>
算术指令
加法指令:iadd、ladd、fadd、dadd
减法指令:isub、lsub、fsub、dsub
乘法指令:imul、lmul、fmul、dmul
除法指令:idiv、lliv、fdiv、ddiv
求余指令:irem、lrem、frem、drem
求负值指令:ineg、lneg、fneg、dneg
移位指令:ishl、ishr、iushr、lshl、lshr、lusr
按位或指令:ior、lor
按位与指令:iand、land
按位异或指令:ixor、lxor
局部变量自增指令;iinc
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
类型转换
宽化类型转换:i2l、i2f、i2d、l2f、l2d、f2d
int => long、float、double
long => float、double
float => double
窄化类型转换:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f
int => btye、short、char
long => int
float => int、long
double => int、long、float
对象的创建与操作
创建类实例的指令:new
创建数组的指令:newarray、 anewarray、 multianewarray
访问类字段和类实例字段指令:getfield、putfield、getstatic、putstatic
把一个数组元素加载到操作数栈的指令:baload、caload、saload、laload、faload、daload、aaload
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、lastore、fastore、dastor、aastore
取数组长度的指令:arraylength
检查类实例或数组类型的指令:instanceof、checkcast
方法调用和返回指令
- invokevirtual指令:调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)。这也是Java语言中最常见的方法分派方式
- invokeinterface指令:调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用
- invokespecial指令:调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
invokestatic指令:调用命名类中的类方法(static方法) - invokedynamic指令:调用以绑定了 invokedynamic指令的调用点对象(call site object)作为目标的方法。调用点对象是一个特殊语法结构,当一条invokedynamic指令首次被Java虚拟机执行前,Java虚拟机将会执行一个引导方法(bootstrap method)并以这个方法
运行结果作为调用点对象。因此,每条 invokedynamic指令都有独一无二的链接状态,这是它与其他方法调用指令的一个差异
Class文件格式
Class文件是JVM的输入,Java虚拟机规范中定义了 Class文件的结构。 Class文件是JVM实现平台无关、技术无关的基础
-
Class文件是一组以8字节为单位的字节流,各个数据项目按顺序紧凑排列
-
对于占用空间大于8字节的数据项,按照高位在前的方式分割成多个8字节进行存储
-
Class文件格式里面只有两种类型:无符号数、表
无符号数:基本数据类型,以u1、u2、u4、u8来代表几个字节的无符号数
表:由多个无符号数和其它表构成的复合数据类型,通常以 _info 结尾 -
javap工具生成非正式的“虛拟机汇编语言”,格式如下
<index><opcode>[<operand1> [<operand2>...]][]>
<index>:指令操作码在数组中的下标,该数组以字节形式来存储当前方法的JVM代码;也可以是相对于方法起始处的字节偏移量
<opcode>:指令的助记码、<operand>是操作数、<comment>是行尾的注释<opcode>
[<operand1>
[<operand2>
...]][]>
<index>:指令操作码在数组中的下标,该数组以字节形式来存储当前方法的JVM代码;也可以是相对于方法起始处的字节偏移量
<opcode>:指令的助记码、<operand>
是操作数、<comment>
是行尾的注释<opcode>
[<operand1>
[<operand2>
...]][]>
<index>:指令操作码在数组中的下标,该数组以字节形式来存储当前方法的JVM代码;也可以是相对于方法起始处的字节偏移量
<opcode>:指令的助记码、<operand>
是操作数、<comment>
是行尾的注释
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
- magic:魔数,唯一作用是确定这个文件是否为一个能被虚拟机所接受的c1ass文件。魔数值固定为0XCAFEBABE,不会改变
- minor_version(副版本号)、major_version(主版本号):分别表示c1ass文件的副、主版本。共同构成了c1ass文件的格式版本号
- constant_pool_count:常量池计数器,值等于常量池表中的成员数+1
常量池表的索引值只有在>0且小<constant_pool_count才会认为是有效的,对于1ong和double类型有例外情况 - constant_poo1:常量池,一种表结构,包含c1ass文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量
常量池中的每一项都具备相同的特征作为类型标记,用于确定该项的格式,这个字节称为tag_byte(标记字节、标签字节) - access_flags:访问标志,一种由标志所构成的掩码,用于表示某个类或者接口的访问权限及属性。每个标志的取值及其含义
- this_class:类索引,值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个c1ass文件所定义的类或接口
- super_class:父类索引,对于类来说,super_class的值要么是0,要么是对常量池表中某项的一个有效索引值。如果它的值不为0,那么常量池在这个索引处的成员必须为CONSTANT_Class_info类型常量,它表示这个class文件所定义的类的直接超类。在当前类的直接超类,以及它所有间接超类的ClassFi1e结构体中,access_flags里面均不能带有ACC_FINAL标志
- interfaces_count:接口计数器,值表示当前类或接口的直接超接口数量
- interfaces:接口表,每个成员的值必须是对常量池表中某项的有效索引值,它的长度interfaces_count。每个成员interfaces[i] 必须为CONSTANT_C1ass_info结构,其中0≤i<interfaces_count。在interfaces[]中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces[0]对应的是源代码中最左边的接口
- fields_count:字段计数器,值表示当前class文件fields表的成员个数。fields表中每个成员都是一个field_info结构,用于表示该类或接口所声明的类字段或者实例字段
- fields:字段表,fields表中的每个成员都必须是一个 fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述,fields表描述当前类或接口声明的所有字段,但不包括从父类或父接口继承的那些字段
- methods_count:方法计数器,值表示当前class文件 methods表的成员个数。methods表中每个成员都是一个method_info结构_
- methods:方法表,methods表中的每个成员都必须是一个 method_info结构,用于表示当前类或接口中某个方法的完整描述,如果某个method_info结构的access_fags项既没有设置 ACC NATIVE标志也没有设置 ACC ABSTRACT标志,那么该结构中也应包含实现这个方法所用的Java虚拟机指令
- attributes count:属性计数器,值表示当前class文件属性表的成员个数。属性表中每项都是一个attribute_info结构
- attributes:属性,每个项的值必须是 attribute_info结构
JVM组成
- 类加载器(Class Loader):加载 class 文件到内存
- 执行引擎(Execution Engine):解释命令,交由操作系统执行
- 本地库接口(Native Interface):融合不同的语言为 Java 所用
- 运行时数据区(Runtime Data Area):堆、栈、方法区、本地方法栈、程序计数器
类加载、链接、初始化
加载类的流程
- 加载:查找并加载类文件的二进制数据
- 连接:就是将已经读入内存的类的二进制数据合并到MM运行时环境中去
- 验证:确保被加载类的正确性
- 准备:为类的静态变量分配內存,并初始化它们
- 解析:把常量池中的符号引用转换成直接引用
- 初始化:为类的静态变量赋初始值
类加载
类加载概念
类加载功能:
- 通过类的全类名来获取该类的 .class 文件中的二进制字节流
- 把二进制字节流读入到内存中,转化为方法区的运行时数据结构
- 在堆上创建一个 Class 对象,封装类在方法区内的数据结构,并向外提供了访问方法区内数据结构的接口
类加载方式
- 常见:本地文件系统中加载、从 jar 等归档文件中加载
- 动态:将 java 源文件动态编译成 class
- 其它:网络下载、从专有数据库中加载等等
能不能自己实现一个 java.lang.String 并加载
可以,但在应用的时候,需要用自己的类加载器去加载,否则,系统的类加载器永远只是去加载 jre.jar 包中的那个。由于在 Tomcat 的 Web 应用程序中,都是由webapp自己的类加载器先加载 WEB-INF/classess 目录中的类,然后才委托上级的类加载器加载,如果在 Tomcat 的 Web 应用程序中写一个 java.lang.String,这时候Servlet 程序加载的就是自己写的 java.lang.String
类的加载器
类的加载器:加载类的工具,加载的类在堆(内存)的 Class 对象中只会存在一份,不可重复加载
- JVM自带的加载器:
- 启动类加载器:
BootstrapClassLoader
,负责加载虚拟机的核心类库,比如 java.lang.* 等 - 扩展类加载器:
ExtensionClassLoader
(JDK 1.8),从 java.ext.dirs 中加载类库,或从 JDK 安装目录 jre\lib\ext 子目录下加载类库 - 平台类加载器:PlatformClassLoader(JDK 1.9之后),用于加载一些平台相关的模块,比如:java. scripting、java. compiler*、 java.corba等
- 应用程序类加载器:
AppClassLoader
,用于加载应用级别的模块,从环境变量 classpath 或系统属性java.class.path 所指定的目录中加载类,是用户自定义的类加载器的默认父加载器
- 启动类加载器:
- 用户自定义的加载器:
java.lang.ClassLoader
子类,用户定制类的加载方式
其加载顺序是在所有系统类加载器的最后
JDK 8:
- 启动类加载器:负责将< JAVA_HOME>/lib,或者 -Xbootclasspath 参数指定的路径中的,且是虚拟机识别的类库加载到内存中(按照名字识别,比如 rt.jar,对于不能识别的文件不予装载)
- 扩展类加载器:负责加载<JRE_HOME>/lib/ext,或者 java.ext.dirs 系统变量所指定路径中的所有类库
- 应用程序类加载器:负责加载 classpath 路径中的所有类库
双亲委派模型
JVM 中 ClassLoader 通常采用双亲委派模型,要求除了启动类加载器BootstrapClassLoader
外,其余类加载器都应有自己父级加载器
注意:这里的父子关系是组合而不是继承
优点:如果没有双亲委派而是由各个类加载器自行加载,内存中可能会出现多个相同的类。双亲委派能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类
工作流程:ClassLoader.loadClass()
一个类加载器(ClassLoader)收到了类加载的请求,首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,层层委派,最终所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载
- 一个类加载器接收到类加载请求后,检查类是否加载过,没有则让父类加载器加载
- 当父类加载器加载失败(ClassNotFoundException)才尝试自己加载
注意:
- 实现双亲委派代码在 Classloader 的 loadClass() ,如果自定义类加载器,推荐覆盖实现 findClass()
- 如果有一个类加载器能加载某个类,称为定义类加载器,所有能成功返回该类的class的类加载器都被称为初始类加载器
- 如果没有指定父加载器,默认就是启动加载器
- 每个类加载器都有自己的命名空间,命名空间由该加载器及其所有父加载器所加载的类构成,不同的命名空间,可以出现类的全路径名相同的情况
- 运行时包由同一个类加载器的类构成,决定两个类是否属于同一个运行时包,不仅要看全路径名是否一样,还要看定义类加载器是否相同。只有属于同一个运行时包的类才能实现相互包内可见
破坏双亲委派
双亲模型有个问题:父加载器无法向下识别子加载器加载的资源
为了解决这个问题,引入了线程上下文类加载器,可以通过 Thread 的 setContextClassLoader() 进行设置
另外—种典型情况就是实现热替换,比如 OSGI 的模块化热部署,它的类加载器就不再是严格按照双亲委派模型,很多可能就在平级的类加载器中执行
类连接
类连接验证内容
- 类文件结构检査:按照 JVM 规范规定的类文件结构进行验证
- 元数据验证:对字节码描述的信息进行语义分析,保证其符合 Java 语言规范要求
- 字节码验证:对数据流和控制流进行分析,确保程序语义是合法和符合逻辑的。主要对方法体进行校验
- 符号引用验证:对类自身以外的信息,也就是常量池中的各种符号引用,进行匹配校验
类连接中的解析
- 准备:为类的静态变量分配内存,并将其初始化为默认值。类变量的定义为
public static int val = 3;
那么变量 val 在准备阶段过后的初始值不是 3 而是 0 - 解析:把常量池中的符号引用转换成直接引用的过程。符号引用:以一组无歧义的符号来描述所引用的目标,与虚拟机的实现无关;直接引用:直接指向目标的指针、相对偏移量、或是能间接定位到目标的句柄,是和虚拟机实现相关的
主要针对:类、接口、字段、类方法、接口方法、方法类型方法句柄、调用点限定符
如果类中存在初始化语句,就依次执行这些初始化语句
如果是接口的话:
初始化一个类的时候,并不会先初始化它实现的接口
初始化一个接口时,并不会初始化它的父接口
只有当程序首次使用接口里面的变量或者是调用接口方法的时候,才会导致接口初始化调用 Classloader 类的 loadClass 方法来装载一个类,并不会初始化这个类,不是对类的主动使用
类的初始化时机
Java 程序对类的使用方式分成:主动使用和被动使用
JVM 必须在每个类或接口“首次主动使用”时才初始化它们;被动使用类不会导致类的初始化
主动使用:
- 创建类实例
- 访问某个类或接口的静态变量
- 调用类的静态方法
- 反射某个类
- 初始化某个类的子类,而父类还没有初始化
- JVM 启动的时候运行的主类
- 定义了 default 方法的接口,当接口实现类初始化时
类的初始化
类的初始化:为类的静态变量赋予正确的初始值,或者说是执行类构造器 <clinit>
方法的过程
JVM负责对类进行初始化,主要对类变量进行初始化
如果类存在父类,且父类没有初始化,就先初始化父类
类的卸载
当代表一个类的 Class 对象不再被引用,那 Class 对象的生命周期就结束了,对应在方法区的数据也会被卸载
JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的
Java内存分配
JVM的简化架构
运行时数据区
运行时数据区:JVM运行时将数据分区域存储,强调对内存空间的划分
程序计数器
PC寄存器:Program Counter Register,一块较小的内存空间,可看作当前线程所执行的字节码的行号指示器
作用:由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
- 每个线程拥有一个PC寄存器,线程私有,用来存储指向下一条指令的地址
- 在创建线程的时候,创建相应的PC寄存器
- 执行本地方法时,PC寄存器的值为 undefined
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域
Java栈
Java 虚拟机栈:Java Virtual Machine Stacks,由一系列帧(Frame)组成(Java栈也叫做帧栈),线程私有
作用:描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈(Java没有寄存器,所有参数传递使用操作数栈)、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程
- 帧用来保存一个方法的局部变量、操作数栈、常量池指针、动态链接、方法返回值等
- 每一次方法调用创建一个帧,并压栈,退出方法的时候,修改栈顶指针就可以把栈帧中的内容销毁
- 局部变量表存放了编译期可知的各种基本数据类型和引用类型,每个slot存放32位的数据,long、double占两个槽位
-
局部变量表
局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 JVM 都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常
-
操作栈
操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往
栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操
作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中i++ 和 ++i 的区别:
- i++:从局部变量表取出 i 并压入操作栈(load memory),然后对局部变量表中的 i 自增 1(add&store memory),将操作栈栈顶值取出使用,如此线程从操作栈读到的是自增之前的值。
- ++i:先对局部变量表的 i 自增 1(load memory&add&store memory),然后取出并压入操作栈(load memory),再将操作栈栈顶值取出使用,线程从操作栈读到的是自增之后的值。
之前之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 值比预期的小
-
动态链接
每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接 -
方法返回地址
- 方法执行时有两种退出情况:
正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;
异常退出 - 无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
返回值压入上层调用栈帧。
异常信息抛给能够处理的栈帧。
PC计数器指向方法调用后的下一条指令
- 方法执行时有两种退出情况:
优点:存取速度比堆快,仅次于寄存器
缺点:存在栈中的数据大小、生存期是在编译期决定的,缺乏灵活性
栈区内存会有垃圾回收吗?
Java堆
Java堆概念
Java堆:Java Heap,被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可
- 用来存放应用系统创建的对象和数组(new),所有线程共享Java堆
- Java垃圾回收(GC)主要就管理堆空间,对分代GC来说,堆也是分代的
- Java堆是在运行期动态分配内存大小,自动进行垃圾回收
Java堆内存一定是线程共享的吗?
优点:运行期动态分配内存大小,自动进行垃圾回收
缺点:效率相对较慢
堆和栈区别
- 物理地址:堆的物理地址不连续;栈的物理地址连续
- 存储:堆存放对象、数组;栈存放操作数栈、返回结果
- 线程共享:堆线程共享;栈线程私有
Java堆结构
在堆上为对象分配内存就是把一块大小确定的内存从堆内存中划分出来,将对象放进去
整个堆大小=新生代[Eden+Survivor] + 老年代
- 创建的对象会优先在 Eden 分配,如果是大对象(很长的字符串数组)则可以直接进入老年代。虚拟机提供-
XX:PretenureSizeThreshold
参数,令大于这个参数值的对象直接在老年代中分配,避免在 Eden 区和两个 Survivor 区发生大量的内存拷贝。另外,长期存活的对象将进入老年代,每一次 MinorGC(年轻代GC),对象年龄就大一岁,默认 15 岁晋升到老年代,通过-XX:MaxTenuringThreshold
设置晋升年龄 - 新生代用来放新分配的对象;新生代中经过垃圾回收,没有回收掉的对象,被复制到老年代
- 如果Survivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,那么年龄大于等于该对象年龄的对象即可晋升到老年代,不必要等到-XX:MaxTenuringThreshold
从前的持久代,用来存放 Class、 Method等元信息的区域,从JDK8开始去掉了,取而代之的是元空间( MetaSpace),元空间并不在虚拟机里面,而是直接使用本地內存
JVM堆内存结构是怎样的?哪些情况会触发GC?会触发哪些GC?
堆分配内存
指针碰撞
适用于堆内存完整的情况,已分配的内存和空闲内存分表在不同的一侧,通过一个指针指向分界点,当需要分配内存时,把指针往空闲的一端移动与对象大小相等的距离即可,用于Serial和ParNew等不会产生内存碎片的垃圾收集器
空闲列表
适用于堆内存不完整的情况,已分配的内存和空闲内存相互交错,JVM通过维护一张内存列表记录可用的内存块信息,当分配内存时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录,最常见的使用此方案的垃圾收集器就是CMS
内存分配并发问题的解决:CAS、TLAB
对象内存布局
对象在内存中存储的布局(这里以 HotSpot虛拟机为例来说明)分为:对象头、实例数据和对齐填充
- 对象头
- Mark Word:存储对象自身的运行数据,如:Hash Code、GC分代年龄、锁状态标志等
- 类型指针:对象指向它的类元数据的指针
- 实例数据:真正存放对象实例数据的地方
- 对齐填充:这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为 HotSpot要求对象起始地址都是8字节的整数倍,如果不是,就对齐
内存的分配如何保证线程安全呢?
对分配内存空间的动作进行同步处理,通过“CAS + 失败重试”的方式保证更新指针操作的原子性
把分配内存的动作按照线程划分在不同的空间之中,即给每一个线程都预先分配一小段的内存,称为本地线程分配缓存(TLAB),只有TLAB用完并分配新的TLAB时,才需要进行同步锁定。 虚拟机是否使用TLAB,可以通过-XX: +/-UserTLAB参数来设定
对象访向定位
在JVM规范中只规定了reference类型是一个指向对象的引用,但没有规定这个引用具体如何去定位、访问堆中对象的具体位置
因此对象的访问方式取决于JVM的实现,目前主流的有:使用句柄或使用指针两种方式
-
使用句柄:Java堆中会划分出一块内存来做为句柄池reference中存储句柄的地址,句柄中存储对象的实例数据和类元数据的地址
-
使用指针:Java堆中会存放访问类元数据的地址, reference存储的就直接是对象的地址
new一个对象的过程:new对象
先查看对象所属的类有没有被加载到内存,如果没有的话,就会先通过类的全限定名来加载。加载并初始化类完成后,再进行对象的创建工作
-
加载、链接、初始化
-
加载:
由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的 java.lang.Class 对象实例 -
链接:将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中
- 验证:
格式验证:验证是否符合class文件规范
语义验证:检查一个被标记为 final 的类型是否包含子类;检查一个类中的 final 方法是否被子类进行重写;确保父类和子类之间没有不兼容的一些方法声明(如方法签名同,方法返回值不同)
操作验证:在操作数栈中的数据必须进行正确的操作,对常量池中的各种符号引用执行验证(通常在解析阶段执行,检查是否可以通过符号引用中描述的全限定名定位到指定类型上,以及类成员信息的访问修饰符是否允许访问等) - 准备:
为类中的所有静态变量分配内存空间,并为其设置一个初始值
被final修饰的static变量(常量),会直接赋值 - 解析:
将常量池中的符号引用转为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法),这个可在初始化后再执行
解析需要静态绑定的内容 // 所有不会被重写方法和域都会被静态绑定
- 验证:
-
初始化:
- 为静态变量赋值
- 执行static代码块,static代码块只有jvm能够调用。如果是多线程需要同时初始化一个类,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程
- 先父后子:子类存在对父类的依赖
类的加载顺序是先加载父类后加载子类,初始化也一样,父类初始化时,子类静态变量的值也有有的,是默认值 - 方法区会存储当前类类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句 和 静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用
-
-
创建对象
- 在堆区分配对象需要的内存
分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量 - 对所有实例变量赋默认值
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值 - 执行实例初始化代码
初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法 - 如果有类似于
Child c = new Child()
形式的 c 引用的话,在栈区定义 Child 类型引用变量 c,然后将堆区对象的地址赋值给它
每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问
- 在堆区分配对象需要的内存
new String("Hefery")创建对象数量
字符串常量池没有“Hefery”字符串对象,创建两个;如果字符串常量池有“Hefery”字符串对象,创建一个
“Hefery”是字符串字面值,编译阶段会在字符串常量池创建一个字符串对象指向“Hefery”字符串字面值
使用 new 的方式会在堆中创建一个字符串对象
方法区
方法区:Method Area,各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常
- 方法区是线程共享的,通常用来保存装载的类的结构信息
- 通常和元空间关联在一起,但具体的跟JVM实现和版本有关
- JVM规范把方法区描述为堆的一个逻辑部分,但有个别名称为Non-heap(非堆),为了与Java堆区分开
栈、堆、方法区之间的交互关系
运行时常量池
运行时常量池:Runtime Constant Pool,方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常
- Class文件中每个类或接口的常量池表,在运行期间的表示形式,通常包括:类的版本、字段、方法、接口等信息
- 在方法区中分配
- 通常在加载类和接口到JVM后,就创建相应的运行时常量池
本地方法栈
本地方法栈:Native Method Stack,与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是Java虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务
Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常
线程开始调用本地方法时,会进入 个不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native
Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。JNI 类本地方法最著名的应该是 System.currentTimeMillis()
,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性
Java内存模型
Java内存模型:共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信,控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见(在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象)
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量
注意:
- Java内存模型主要关注JVM中把变量值存储到內存和从内存中取出变量值这样的底层细节
- 所有变量(共享的)都存储在主內存中,每个线程都有自己的工作内存;工作内存中保存该线程使用到的变量的主内存副本拷贝
- 线程对变量的所有操作(读、写)都应该在工作内存中完成
- 不同线程不能相互访问工作内存,交互数据要通过主内存
JDK8 之后的 JVM 内存布局
JDK8 之前的内存区域图
内存间的交互操作
Java内存模型规定了一些操作来实现内存间交互,JVM会保证它们是原子的
lock:锁定,把变量标识为线程独占,作用于主内存变量
unlock:解锁,把锁定的变量释放,别的线程才能使用,作用于主内存变量
read:读取,把变量值从主内存读取到工作内存
load:载入,把read读取到的值放入工作内存的变量副本中
use:使用,把工作内存中一个变量的值传递给执行引擎
assign:赋值,把从执行引擎接收到的值赋给工作内存里面的变量
store:存储,把工作内存中一个变量的值传递到主内存中
write:写入,把 store进来的数据存放如主内存的变量中
内存间交互的规则
- 不允许read和load、 store和write操作单独出现,以上两个操作必须按顺序执行,不保证连续执行,也就是说read与load之间、 store与 write之间是可插入其他指令
- 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中
- 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作
- 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的 unlock操作,变量才会被解锁
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值
- 如果一个变量没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不能 unlock 个被其他线程锁定的变量
- 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存(执行 store 和 write 操作)
多线程的可见性
可见性:一个线程修改了变量,其他线程可以知道
保证可见性的常见方法:volatile、 synchronized、final(一旦初始化完成,其他线程就可见)
类加载器的类之间的可见性 (委托机制、单一性、可见性)
volatile
volatile:JVM提供的最轻量级的同步机制,用volatile修饰的变量,对所有的线程可见,即对 volatile 变量所做的写操作能立即反映到其它线程中。用 volatile 修饰的变量,在多线程环境下仍然是不安全的
volatile修饰的变量,是禁止指令重排优化
适合使用 valatile的场景:
运算结果不依赖变量的当前值
或者能确保只有一个线程修改变量的值
指令重排
指令重排:JVM为了优化,在条件允许的情况下,对指令进行一定的重新排列,直接运行当前能够立即执行的后续指令,避开获取下一条指令所需的等待时间
线程内串行语义,不考虑多线程间的语义
不是所有的指令都能重拍,比如:
写后读 a=1;b=a;写一个变量之后,再读这个位置
写后写 a=1;a=2;写一个变量之后,再写这个变量
读后写 a=b;b=1;读一个变量之后,再写这个变量
a=1;b=2;是可以重排的
指令重拍的基本规则
- 程序顺序原则:一个线程内保证语义的串行性
- volatile 规则:volatile变量的写,先发生于读
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A先于B,B先于C那么A必然先于C
- 线程的start方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断 interrupt() 先于被中断线程的代码
- 对象的构造函数执行结束先于finalize()
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
Java线程安全处理方法
-
不可变是线程安全的
-
互斥同步(阻塞同步):synchronized、java.util.concurrent.ReentrantLock。目前这两个方法性能已经差不多了,建议优先选用synchronized
ReentrantLock增加了如下特性:
- 等待可中断:当持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待
- 公平锁:多个线程等待同一个锁时,须严格按照申请锁的时间顺序来获得锁
- 锁绑定多个条件:一个ReentrantLock对象可以绑定多个condition对象,而synchronized是针对一个条件的,如果要多个,就得有多个锁
-
非阻塞同步:一种基于冲突检查的乐观锁定策略,通常是先操作,如果没有冲突,操作就成功了,有冲突再采取其它方式进行补偿处理
-
无同步方案:其实就是在多线程中,方法并不涉及共享数据,自然也就无需同步了
锁优化之自旋锁与自适应自旋
自旋:如果线程可以很快获得锁,那么可以不在OS层挂起线程,而是让线程做几个忙循环,这就是自旋
自适应自旋:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定
如果锁被占用时间很短,自旋成功,那么能节省线程挂起、以及切换时间,从而提升系统性能
如果锁被占用时间很长,自旋失败,会白白耗费处理器资源,降低系统性能
锁消除
在编译代码的时候,检测到根本不存在共享数据竞争,自然也就无需同步加锁了;通过-XX:+EliminateLocks来开启。同时要使用-XX:+DoEscapeAnalysis开启逃逸分析,所谓逃逸分析:1.如果一个方法中定义的一个对象,可能被外部方法引用,称为方法逃逸;2.如果对象可能被其它外部线程访问,称为线程逃逸,比如赋值给类变量或者可以在其它线程中访问的实例变量
锁粗化
通常我们都要求同步块要小,但一系列连续的操作导致对一个对象反复的加锁和解锁,这会导致不必要的性能损耗。这种情况建议把锁同步的范围加大到整个操作序列
轻量级锁
轻量级是相对于传统锁机制而言,本意是没有多线程竞争的情况下,减少传统锁机制使用OS实现互斥所产生的性能损耗。实现原理很简单,就是类似乐观锁的方式
如果轻量级锁失败,表示存在竞争,升级为重量级锁,导致性能下降
偏向锁
偏向锁是在无竞争情况下,直接把整个同步消除了,连乐观锁都不用,从而提高性能;所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程
只要没有竞争,获得偏向锁的线程,在将来进入同步块,也不需要做同步
当有其它线程请求相同的锁时,偏向模式结束
如果程序中大多数锁总是被多个线程访问的时候,也就是竞争比较激烈,偏向锁反而会降低性能
使用-XX:-UseBiasedLocking来禁用偏向锁,默认开启
JVM中获取锁的步骤
会先尝试偏向锁;然后尝试轻量级锁
再然后尝试自旋锁
最后尝试普通锁,使用OS互斥量在操作系统层挂起
同步代码的基本规则
尽量减少锁持有的时间
尽量减小锁的粒度
JVM内存分配参数
Trace跟踪参数
打印GC的信息:-Xlog:gc*
指定 GClog的位置,以文件输出:-X1og:gc:garbage-collection.log
每一次GC后,都打印堆信息:-Xlog:gc+heap=debug
GC日志格式
- GC发生的时间,也就是JVM从启动以来经过的秒数
- 日志级别信息、日志类型标记
- GC识别号
- GC类型和说明GC的原因
- 容量:GC前容量 -> GC后容量该区域总容量)
- GC持续时间,单位秒。有的收集器会有更详细的描述,比如:user表示应用程序消耗的时间,sys表示系统内核消耗的时间、real表示操作从开始到结束的时间
Java堆的参数
-Xms
:初始堆大小,默认物理内存的1/64
-Xmx
:最大堆大小,默认物理内存的1/4
-Xmn
:新生代大小,默认整个堆的3/8
-XX:+HeapDumpOnOutOfMemoryError
:OOM时导出堆到文件
-XX:HeapDumpPath=path
:导出OOM的路径
-XX:OnOutOfMemoryError
:OOM时,执行一个脚本
-XX:NewRatio
:老年代与新生代的比值,如果Xms=xmx,且设置了Xmn的情况下,该参数不用设置
-XX:SurvivorRatio
:Eden区和Survivor区的大小比值,设置为8,则两个Survivo区与一个Eden区的比值为2:8,一个Survivor占整个新生的1/10
-XX:+UseConcMarkSweepGC
-XX:+UseG1GC
-XX:MinHeapSize=8m
-XX:InitialHeapSize=9m
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space:堆内存不够
Java栈的参数
-Xss
:通常只有几百K,决定了函数调用的深度
java.lang.StackOverflowError:方法递归调用
元空间的参数
-XX:MetaspaceSize
:初始空间大小
-XX:MaxMetaspaceSize
:最大空间,默认是没有限制的
-XX:MinMetaspaceFreeRatio
:在GC之后,最小的Metaspace剩余空间容量的百分比
-XX:MaxMetaspaceFreeRatio
:在GC之后,最大的Metaspace剩余空间容量的百分比
字节码执行引擎
字节码执行引擎概述
JVM的字节码执行引擎
- 基本功能:输入字节码文件,然后对字节码进行解析并处理,最后输出执行的结果
- 实现方式:
通过解释器直接解释执行字节码
通过即时编译器产生本地代码,也就是编译执行
两者皆有
栈帧概述
- 栈帧用于支持 JVM 进行方法调用和方法执行的数据结构
- 栈帧随着方法调用而创建,随着方法结束而销毁
- 栈帧里面存储了方法的局部变量、操作数栈、动态连接、方法返回地址等信息
栈帧结构
- 局部变量表:用来存放方法参数和方法内部定义的局部变量的存储空间
- 以变量槽 slot 为单位,目前一个 slot 存放 32 位以内的数据类型
- 对于 64 位的数据占 2 个 s|ot
- 对于实例方法,第 0 位 slot 存放的是 this,然后从1 到 n,依次分配给参数列表
- 然后根据方法体内部定义的变量顺序和作用域来分配 slot
- slot 是复用的,以节省栈帧的空间,这种设计可能会影响到系统的垃圾收集行为
- 操作数栈:用来存放方法运行期间,各个指令操作的数据
- 操作数栈中元素的数据类型必须和字节码指令的顺序严格匹配
- 虛拟机在实现栈帧的时候可能会做一些优化,让两个栈帧出现部分重叠区域,以存放公用的数据
- 动态连接:每个栈帧持有一个指向运行时常量池中该栈帧所属方法的引用,以支持方法调用过程动态连接
- 静态解析:类加载的时候,符号引用就转化成直接引用
- 动态连接:运行期间转换为直接引用
- 方法返回地址∶方法执行后返回的地址
方法调用、静态分派和动态分派
方法调用:方法调用就是确定具体调用那-个方法,并不涉及方法内部的执行过程
- 部分方法是直接在类加载的解析阶段,就确定了直接引用关系
- 对于实例方法,也称虚方法,因为重载和多态,需要运行期动态委派
分派:单分派和多分派:就是按照分派思考的纬度,多余一个的就算多分派,只有一个的称为单分派
- 静态分派:所有依赖静态类型来定位方法执行版本的分派方式,比如:重载方法
- 动态分派:根据运行期的实际类型来定位方法执行版本的分派方式,比如:覆盖方法
垃圾回收
垃圾回收基础
垃圾的定义
垃圾:内存中已经不再被使用到的内存空间
如何判定垃圾
引用计数法
- 原理:给对象添加引用计数器,有访问加1,引用失效减1,计数器有值说明还在被引用,就不是垃圾
- 优点:实现简单、效率高
- 缺点:不能解决对象之间循环引用的问题
可达性分析
- 原理:从 GC Roots 节点向下搜索对象节点,搜索走过的路经称为引用链,当一个对象到根之间没有连通的话,则该对象不可用
- 可作为 GC Roots 对象
- 虛拟机栈内存(栈帧局部变量)引用的对象
- 方法区中静态引用和常量引用指向的对象
- 被启动类(bootstrap加载器)加载的类和创建的对象
- 本地方法栈中 Native 方法引用的对象
- 被同步锁 synchronized 修饰的对象
- OopMap
Hotspot使用了一组叫做 OopMap 的数据结构达到准确式 GC 的目的,不用每次从根节点查找。在 OopMap 的协助下,JVM可以很快的做完 GC Roots 枚举。但是 JVM 并没有为每一条指令生成一个 OopMap。记录 OopMap 的这些“特定位置”被称为安全点,即当前线程执行到安全点后才允许暂停进行 GC。如果一段代码中,对象引用关系不会发生变化,这个区域中任何地方开始 GC 都安全,那么这个区域称为安全区域
判断是否垃圾步骤
- 根搜索算法判断不可用
- 看是否有必要执行 finalize 方法
- 两个步骤走完后对象仍然没有人使用,那就属于垃圾
在什么情况下,Java对象不需要垃圾回收也能回收掉?
对象的引用级别
- 强引用(Strong Reference):类似于 Object a=new A() ,不会被回收
- 软引用(Soft Reference):还有用但并不必须的对象。用 SoftReference 类来实现软引用
- 弱引用(Weak Reference):非必须对象,垃圾回收时会回收掉。用 WeakReference 类来实现弱引用
- 虚引用(Phantom Reference):垃圾回收时会回收掉。用 PhantomReference 类来实现虚引用
如何回收垃圾
GC类型
- MinorGC/YoungGC:发生在新生代的收集动作
- MajorGC/OldGC:发生在老年代的GC,目前只有CMS收集器会有单独收集老年代的行为
- MixedGC:收集整个新生代以及部分老年代,目前只有G1收集器会有这种行为
- FulIGC:收集整个 Java 堆和方法区的GC,发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小。如果大于,则进行一次Full GC(老年代GC) ,如果小于,则查看HandlePromotionFailure设置是否允许担保失败 ,如果允许,那只会进行一次Minor GC ,如果不允许,则改为进行一次Full GC
发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小。如果大于,则进行一次Full GC(老年代GC) ,如果小于,则查看HandlePromotionFailure设置是否允许担保失败 ,如果允许,那只会进行一次Minor GC ,如果不允许,则改为进行一次Full GC
Stop-The-World
- STW 是 Java 中一种全局暂停的现象,多半由于 GC 引起。所谓全局停顿,就是所有 Java 代码停止运行, native 代码可以执行,但不能和 JVM 交互
- 危害是长时间服务停止,没有响应;对于 HA 系统,可能引起主备切换,严重危害生产环境
什么时候执行STOP THE WORLD?
垃圾收集类型
- 串行收集:GC单线程内存回收、会暂停所有的用户线程,如:Serial
- 并行收集:多个GC线程并发工作,此时用户线程是暂停,如:Parallel
- 并发收集:用户线程和GC线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程,如:CMS
判断类无用的条件
- JVM 中该类的所有实例都已经被回收
- 加载该类的 Classloader 已经被回收
- 没有任何地方引用该类的 class 对象
- 无法在任何地方通过反射访问这个类
垃圾回收算法-方法
标记-清除法(Mark-Sweep)
-
原理:标记和清除两个阶段,先标记出要回收的对象,然后统一回收这些对象
-
优点:简单
-
缺点:
效率不高,标记和清除的效率都不高
标记清除后会产生大量不连续的内存碎片,从而导致在分配大对象时触发GC
复制算法(Copying)
- 原理:把内存分成两块完全相同的区域每次使用其中一块,当一块使用完了,就把这块上还存活的对象拷贝到另外一块,然后把这块清除掉
- 优点:实现简单,运行高效,不用考虑内存碎片问题
- 缺点:内存有些浪费
- JVM 实际实现中,是将内存分为一块较大的 Eden 区和两块较小的 Survivor 空间,每次使用 Eden 和块 Survivor,回收时,把存活的对象复制到另一块 Survivor
- Hotspot默认的 Eden 和 Survivor 比是8:1,也就是每次能用90%的新生代空间
- 如果 Survivor 空间不够,就要依赖老年代进行分配担保,把放不下的对象直接进入老年代
- 分配担保:当新生代进行垃圾回收后,新生代的存活区放置不下,那么需要把这些对象放置到老年代去的策略,也就是老年代为新生代的GC做空间分配担保
- 在发生 MinorgGC 前,JVM会检查老年代的最大可用的连续空间,是否大于新生代所有对象的总空间,如果大于,可以确保 MinorGC 安全
-
如果小于,那么 JVM 会检查是否设置了允许担保失败,如果允许,则继续检查老年代最大可用的连续空间,是否大于历次晋升到老年代对象的平均大小
-
如果大于,则尝试进行一次 MinorGC;如果不大于,则改做一次 FullGC
-
标记-整理法(Mark-Compact)
- 原理:标记过程跟标记清除一样,但后续不是直接清除可回收对象而是让所有存活对象都向一端移动,然后直接清除边界以外的内存
- 场景:由于复制算法在存活对象比较多的时候,效率较低,且有空间浪费,因此老年代般不会选用复制算法,老年代多选用标记整理算法
垃圾收集器-实现
HotSpot中的收集器
- 新生代Young generation:Serial、ParNew、Parallel Scavenge、G1
- 老年代Tenured generation:CMS、Serial old、Parallel old、G1
串行收集器-Serial
Serial(串行)收集器/ Serial old收集器,单线程,在垃圾收集时,会Stop-the-World
Serial/ Serial Old收集器运行示意图:
优点:简单,单CPU没有多线程的交互开销,可能更高效,默认的 Client 模式下的新生代收集器
使用:-XX:+UseSerialGC来开启,会使用:Serial+ Serial Old的收集器组合(新生代使用复制算法,老年代使用标记-整理算法)
并行收集器-ParDew
ParDew(并行)收集器:使用多线程进行垃圾回收,在垃圾收集时,会Stop-the-World
ParDew收集器运行示意图:
优点:在并发能力好的CPU环境里,它停顿的时间要比串行收集器短;但对于单CPU或并发能力较弱的CPU,由于多线程交互开销,可能比串行回收器更差。Server模式首选的新生代收集器,且能和CMS收集器配合使用
使用:
- 不再使用
-XX:+UseParNewGo
来单独开启,使用 CMS 即可 -XX:ParallelGCThreads
:指定线程数,最好与 CPU 数量一致
新生代收集器-Parallel Scavenge
新生代Parallel Scavenge收集器 / Parallel old收集器:应用于新生代的、使用复制算法、并行的收集器
跟 ParNew 很类似,但更关注吞吐量,能最高效率的利用CPU,适台运行后台应用
新生代Parallel Scavenge/Parallel old收集器运行示意图:
使用:-XX:+UseParallelGC
来开启;使用-XX:+UseParallelOldGC
来开启老年代使用 Parallel Old 收集器,使用 Parallel Scavenge+ Parallel old的收集器组合
-XX:MaxGCPauseMillis
:设置GC的最大停顿时间-XX:GCTimeRatio
:设置吞吐量大小-XX:+UseAdaptiveSizePolicy
:打开GC自适应调节策略
CMS收集器
CMS(Concurrent Mark and Sweep并发标记清除)收集器分为:以获取最短回收停顿时间为目标
- 初始标记:只标记 GC Roots 能直接关联到的对象
- 并发标记:进行 GC Roots Tracing 的过程
- 重新标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
- 并发清除:并发回收垃圾对象
CMS收集器运行示意图:
注意:
- 在初识标记和重置标记阶段还是可能发生Stop-the-World
- 使用标记-清除算法,多线程并发收集的垃圾收集器
- 重置线程是清空跟收集相关数据并重置,为下一次收集做准备
优点:低停顿,并发执行
缺点:
- 并发执行,对CPU资源压力大
- 无法处理在处理过程中产生的垃圾,可能导致Full GC
- 采用标记-清除算法会产生大量碎片,从而在分配大对象时可能会触发Full GC
使用:
-XX:+UseConcMarkSweepGC
:使用 ParNew + CMS + Serial old的收集器组合,Serial Old将作为CMS出错的后备收集器-XX:CMSInitiatingOccupancyFraction
:设置CMS收集器在老年代空间被使用多少后触发回收,默认80%
G1收集器
G1(Garbage-Frst)收集器:一款面向服务端应用的收集器
特点
-
G1把内存划分成多个独立的区域(Region)
-
G1仍采用分代思想,保留了新生代和老年代,但它们不再是物理隔离的,而是一部分 Region 的集合,且不需要 Region 是连续的
-
G1能充分利用多CPU、多核环境硬件优势,尽量缩短STW
-
G1整体上采用标记-整理算法,局部是通过复制算法,不会产生内存碎片
-
G1的停顿可预测,能明确指定在一个时间段内,消耗在垃圾收集上的时间不能超过多长时间
-
G1跟踪各个 Region 里面垃圾堆的价值大小(回收能清理除更多空间),在后台维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限时间内的高效收集
G1收集器新生代回收过程:
G1收集器老年代回收过程:
原理
- 初始标记:只标记GC Roots能直接关联到的对象
- 并发标记:进行GC Roots Tracing的过程
- 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
- 筛选回收:根据时间来进行价值最大化的回收
开启G1:-XX:+UseG1GC
-XX:MaxGCPauseMillis=time
:最大 GC 停顿时间,软目标,M将尽可能(但不保证)停顿小于这个时间-XX:InitiatingHeapOccupancyPercent
:堆占用了多少的时候就触发 GC,默认为 45-XX:NewRatio=ratio
:默认为2-XX:SurvivorRatio=ratio
:默认为8-XX:MaxTenuringThreshold=threshold
:新生代到老年代的岁数,默认是 15-XX:ParallelGCThreads=threads
:并行 GC 的线程数,默认值会根据平台不同而不同-XX:ConcGCThreads=threads
:并发 GC 使用的线程数-XX:G1ReservePercent=percent
:设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值是10%-XX:G1HeapRegionSize=size
:设置的 G1 区域的大小。值是 2 的幂,范围是 1MB 到 32MB。目标是根据最小的 Java 堆大小划分出约 2048 个区域
ZGC收集器
ZGC收集器:JDK11加入的具有实验性质的低延迟收集器
设计目标:支持TB级内存容量,暂停时间低(<10ms),对整个程序吞吐量的影响小于15%
新技术:着色指针和读屏障
新生代垃圾回收器和老生代垃圾回收器都有哪些?有什么区别?
GC性能指标和JVM内存配置原则
GC性能指标
- 吞吐量 = 应用代码执行的时间 / 运行的总时间
- GC负荷:与吞吐量相反,是GC时间 / 运行的总时间
- 暂停时间:发生Stop-the-World的总时间
- GC频率:GC在一个时间段发生的次数
- 反应速度:从对象成为垃圾到被回收的时间(交互式应用通常希望暂停时间越少越好)
JVM内存配置原则
- 新生代:尽可能设置大点,如果太小会导致
- 新生代垃圾回收(YGC)次数更加频繁
- 可能导致 YGC 后的对象进入老年代,如果此时老年代满了,会触发FGC
- 老年代
- 针对响应时间优先的应用:由于老年代通常采用并发收集器,因此其大小要综合考虑并发量和并发持续时间等参数.如果设置小了,可能会造成内存碎片,高回收频率会导致应用暂停;如果设置大了,会需要较长的回收时间
- 针对吞吐量优先的应用:通常设置较大的新生代和较小的老年代,这样可以尽可能回收大部分短期对象,减少中期对象,而老年代尽量存放长期存活的对象
- 依据对象的存活周期进行分类,对象优先在新生代分配,长时间存活的对象进入老年代
- 根据不同代的特点,选取合适的收集算法:少量对象存活,适合复制算法;大量对象存活,适合标记清除或者标记整理
JVM性能监控
JVM监控作用
- 对 JVM 运行期间的内部情况进行监控,比如:对 JVM 参数、CPU、内存、堆等信息的查看
- 辅助进行性能调优
- 辅助解决应用运行时的一些问题,比如:OutOfMemoryError、内存泄露、线程死锁、锁争用、Java进程消耗 CPU 过高等
命令行工具
jps
JVM Process Status Tool,主要用来输出JVM中运行的进程状态信息,语法格式如下:jps [options] [hostid]
hostid字符串的语法与URI的语法基本一致:[protocol:][/]hostname][:port][/servername],如果不指定hostid,默认为当前主机或服务器
-class 显示ClassLoad的相关信息
-compiler 显示JIT编译的相关信息
-gc 显示和gc相关的堆信息
-gccapacity 显示各个代的容量以及使用情况
-gcmetacapacity 显示metaspace的大小
-gcnew 显示新生代信息
-gcnewcapacity 显示新生代大小和使用情况
-gcold 显示老年代和永久代的信息
-gcoldcapacity 显示老年代的大小
-gcutil 显示垃圾收集信息
-gccause 显示垃圾回收的相关信息(通-gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因
-printcompilation 输出JIT编译的方法信息
jinfo
打印给定进程或核心文件或远程调试服务器的配置信息。语法格式:jinfo [option] pid # 指定进程号(pid)的进程
jstack
主要用来查看某个Java进程内的线程堆栈信息。语法格式如下:jstack [option] pid
jmap
用来查看堆内存使用状况,语法格式如下;jmap [option] pid
jmap [option] executable core
jmap [option][server-id@]remote-hostname-or-ip
jstat
JVM统计监测工具,查看各个区内存和GC的情况
jstatd
jstat [generalOption | outputOptions vmid [interval[s|ms][count]] ]
虚拟机的jstat守护进程,主要用于监控JVM的创建与终止,并提供一个接口,以允许远程监视工具附加到在本地系统上运行的JVM
jcmd
JVM诊断命令工具,将诊断命令请求发送到正在运行的Java虚拟机,比如可以用来导出堆,查看java进程,导出线程信息,执行GC等
图形化工具
jconsole
一个用于监视Java虚拟机的符合JMX的图形工具。它可以监视本地和远程JVM,还可以监视和管理应用程序
jmc
JDK Mission Control,Java任务控制(JMC)客户端包括用于监视和管理Java应用程序的工具,而不会引入通常与这些类型的工具相关联的性能开销
visualvm
一个图形工具,它提供有关在Java虚拟机中运行的基于Java技术的应用程序的详细信息
Java VisualVM提供内存和CPU分析,堆转储分析,内存泄漏检测,访问MBean和垃圾回收。
两种连接方式:JMX、jstatd
- JMX连接:可以查看:系统信息、CPU使用情况、线程多少、手动执行垃圾回收等比较偏于系统级层面的信息
- jstatd连接方式可以提供:JVM内存分布详细信息、垃圾回收分布图、线程详细信息,甚至可以看到某个对象使用内存的大小
你有没有遇到过OutOfMemory问题?你是怎么来处理这个问题的?
对内存溢出的理解、什么情况下会内存溢出,你在编写程序的时候有出现过内存溢出么
线上应用频繁Full GC如何处理?
如果应用周期性地出现卡顿,你会怎么来排查这个问题?
StackOverFlow异常有没有遇到过?这个异常会在什么情况下被触发?如何指定线程堆栈的大小?
如何把Java内存的数据全部 dump 出来
JVM性能调优
调什么
- 内存方面
- JVM需要的内存总大小
- 各块内存分配,新生代、老年代、存活区
- 选择合适的垃圾回收算法、控制GC停顿次数和时间
- 解决内存泄露的问题,辅助代码优化
- 内存热点:检查哪些对象在系统中数量最大,辅助代码优化
- 线程方面
- 死锁检查,辅助代码优化
- Dump线程详细信息:查看线程内部运行情况,查找竞争线程,辅助代码优化
- CPU热点:检查系统哪些方法占用了大量CPU时间,辅助代码优化
如何调
- 监控JVM的状态,主要是内存、线程、代码、I/O几部分
- 分析结果,判断是否需要优化
- 调整:垃圾回收算法和内存分配;修改并优化代码
- 不断的重复监控、分析和调整,直至找到优化的平衡点
排查一个线上的服务异常?
首先查看当前进程的 JVM 启动参数,查看内存设置是否存在明显问题。
查看 GC 日志,看 GC 频率和时间是否明显异常。
查看当前进程的状态信息 top -Hp pid,包括线程个数等信息。
jstack pid 查看当前的线程状态,是否存在死锁等关键信息。
jstat -gcutil pid 查看当前进程的GC情况。
jmap -heap pid 查看当前进程的堆信息,包括使用的垃圾收集器等信息。
用 jvisiual 打开 dump 二进制文件,分析是什么对象导致了内存泄漏,定位到代码处,进行code review
调的目标
- GC的时间足够的小
- GC的次数足够的少
- 将转移到老年代的对象数量降低到最小
- 减少Full GC的执行时间
- 发生Full GC的间隔足够的长
JVM调优策略
- 减少创建对象的数量
- 减少使用全局变量和大对象
- 调整新生代、老年代的大小到最合适
- 选择合适的GC收集器,并设置合理的参数
调优冷思考
- 多数的Java应用不需要在服务器上进行GC优化
- 多数导致GC问题的Java应用,都不是因为参数设置错误,而是代码问题
- 在应用上线之前,先考虑将机器的JVM参数设置到最优(最适合)
- JVM优化是到最后不得已才采用的手段
- 在实际使用中,分析JVM情况优化代码比优化JVM本身要多得多
- 如下情况通常不用优化:
- Minor GC执行时间不到50ms
- Minor GC执行不频繁,约10秒一次
- Full GC执行时间不到1s
- Full GC执行频率不算频繁,不低于10分钟1次
调优经验
- 要注意client模式和Server模式的选择
- 要想GC时间小必须要一个更小的堆;而要保证GC次数足够少,又必须保证一个更大的堆,这两个是有冲突的,只能取其平衡
- 针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值
- 新生代和老年代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率NewRadio来调整,也可以通过-XX:newSize-XX:MaxNewSize来设置其绝对大小,同样,为了防止新生的堆收缩,通常会把 -XX:newSize -XX:MaxNewSize设置为同样大小
- 合理规划新生代和老年代的大小
- 如果应用存在大量的临时对象,应该选择更大的新生代;如果存在相对较多的持久对象,老年代应该适当增大。在抉择时应该本着Full GC尽量少的原则,让老年代尽量缓存常用对象,JVM的默认比例 1:2 也是这个道理
- 通过观察应用一段时间,看其在峰值时老年代会占多少内存,在不影响Full GC的前提下,根据实际情况加大新生代,但应该给老年代至少预留1/3的增长空间
- 线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太大了,一般256K就足用。在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程
分析和处理内存溢出
内存泄露导致系统崩溃前的一些现象,比如:
- 每次垃圾回收的时间越来越长,Full GC时间也延长到好几秒
- Full GC的次数越来越多,最频繁时隔不到1分钟就进行一次Full GC
- 老年代的内存越来越大,并且每次Full GC后年老代没有内存被释放
- 老年代堆空间被占满的情况:根据垃圾回收前后情况对比,同时根据对象引用情况分析,辅助去查找泄漏点
- 堆栈溢出的情况:通常抛出 java.lang.StackOverflowError 例外,一般就是递归调用没退出,或者循环调用造成
重点是调优的过程、方法和思路
内存调整、数据库连接调整、内存泄漏查找等
OOM排查
- 查看服务器日志,捕捉内存溢出异常
- jstat 查看监控 JVM 内存和 GC 情况,判断问题出处
- 使用 mat 工具载入 dump 文件,分析大对象的占用情况