3. 运行时数据区 ![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/96d3659c5ee708ce19ada52d7df13973.png)
每个JVM只有一个Runtime实例,即为运行时环境。
- 每个线程:独立包括程序计数器、虚拟机栈、本地栈。
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
在HotSpot JVM里,每个线程都与操作系统的本地线程直接映射。当一个java线程准备好执行后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,他就会调用Java线程中的run()方法。
如果你使用jconsole或者任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括public static void main(String [] args)的main线程以及所有这个main线程自己创建的线程。这些主要的后台系统线程在Hotspot JVM里主要是以下几个:
- 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现,这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括“stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性的操作的调度执行。
- GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
- 编译线程:这种线程在运行时会将字节码编译到本地代码。
- 信号调度线程:这种线程接受信号并发送给JVM,在它内部通过调用适当的方法进行处理。
3.1 程序计数器(PC寄存器)
名称来源:
程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。这里并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子)。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
作用:
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
介绍:
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
- 每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程保持一致。
- 任何时间一个线程都有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址:或者,如果是在执行native方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
举例说明:
常见问题:
-
使用PC寄存器存储字节码指令地址有什么用呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
-
为什么使用PC寄存器记录当前线程的执行地址呢?
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
-
PC寄存器为什么会被设定为线程私有?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫不差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
CPU时间片:
CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
3.2 虚拟机栈
3.2.1 概述
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。
- 是线程私有的
- 生命周期和线程一致
- 主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。对于栈来说不存在垃圾回收问题。
栈是运行时的单位,而堆是存储的单位。即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。栈管运行,堆管存储。
3.2.2 栈中异常
Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
- 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。
public static void main(String[] args){
test();
}
public static void test(){
test();
}
//抛出异常:Exception in thread "main" java.lang.StackOverflowError
//程序不断的进行递归调用,而且没有推出条件,就会导致不断地进行压栈。
设置栈内存大小
可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。一般来说,栈越大,方法嵌套调用次数越多。
idea设置–>菜单栏单机run–>点击editConfiguration–>点击当前程序–>在configuration选项卡的VM option输入-Xss1m或者-Xss1024k或者-Xss1048576
默认值:windows平台不定,依赖于虚拟内存大小。linux、mac等是1024KB。
public class StackDeepTest{
private static int count = 0;
public static void recursion() {
count++;
resursion();
}
public static void main(String[] args){
try{
recursion();
}catch(Throwable e){
System.out.println("deep of calling = " + count);
e.printStackTrace();
}
}
}
3.2.3 栈的运行原理
每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。在这个线程上正在执行的每个方法都各自对应一个栈帧。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在当前方法中调用了其它方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
- 如果当前方法调用了其它方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java方法有两种返回函数的方式:一种是正常的函数返回,使用return指令;另一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
3.2.4 栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
有的地方也把动态链接和方法返回地址和一些附加信息统称为帧数据区
3.2.4.1 局部变量表
局部变量表(Local Variables)也被称为局部变量数组或本地变量表。定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括编译期可知的各种基本数据类型(8种)、对象引用(reference)、returnAddress类型。
数据安全问题:
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
局部变量表的大小:
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。
局部变量表的大小对方法嵌套深度的影响:
对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致方法嵌套调用的次数就会减少。
局部变量表的生命周期:
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
参数值的存放:
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
存储单元:
局部变量表,最基本的存储单元是Slot(变量槽)
- 32位以内的类型只占用一个slot(包括returnAddress类型)。byte、short、char在存储前被转换为int,boolean也被转换为int,0表示false、非0表示true。
- 64位的类型(long和double)占用两个slot。
局部变量表的访问:
JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。如果需要访问局部变量表中一个64位的局部变量值时,只需要使用前一个索引即可。
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
局部变量表的重复使用:
栈帧中的局部变量表中的槽位是可以重复使用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public class SlotTest{
public void localVarl(){
int a = 0;
System.out.println(a);
int b = 0;
}
public void localVar2(){
{
int a = 0;
System.out.println(a);
}
//此时的b就会复用a的槽位
int b = 0;
}
}
静态变量与局部变量的对比:
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
类变量表有两次初始化的机会,第一次是在”准备阶段“,执行系统初始化,对类变量设置零值,另一次则是在”初始化“阶段,赋予程序员在代码中定义的初始值。
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
public void test(){
int i;
System.out.println(i);
//这样的代码是错误的,没有赋值不能使用
}
对性能调优和垃圾回收的影响:
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
3.2.4.2 操作数栈
每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈(Operand Stack),也可以称之为表达式栈(Expression Stack)。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop)。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。
- 栈中的任何一个元素都可以是任意的Java数据类型。32bit的类型占用一个栈单位深度,64bit的类型占用两个栈单位深度。
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
- 我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
代码追踪
public void testAddOperation(){
byte i = 15;
int j = 8;
int k = i + j;
}
使用javap命令反编译class文件:javap -v 类名.class
public void testAddOperation();
Code:
0:bipush 15
2:istore_1
3:bipush 8
5:istore_2
6:iload_1
7:iload_2
8:iadd
9:istore_3
10:return
栈顶缓存技术
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着需要更多的指令分派(instruction dispatch)次数和内存读写次数,影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低内存的读写次数,提升执行引擎的执行效率。
3.2.4.3 动态链接
或指向运行时常量池的方法引用。
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其它方法时,就是通过常量池中指向方法的符号引用来表示的。那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
1. 方法的绑定机制
绑定是一个字段、方法、或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
早期绑定(Early Binding):
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定(Late Binding):
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。对应动态链接。
2. 链接
静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期间保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将被调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
3.虚方法
虚方法:
具备多态特性的语言具备早期绑定和晚期绑定两种绑定方式。Java中任何一个普通的方法其实都具备虚函数的特征,如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
非虚方法:
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,在类加载的解析阶段就可以进行解析。其它方法称为虚方法。非虚方法举例:
class Father {
public static void print(String str) {
System.out.println("father " + str);
}
public void show(String str) {
System.out.println("father" + str);
}
}
class Son extends Father {
}
public class VirtualMethodTest {
public static void main(String[] args) {
Son.print("coder");
//Father fa = new Father();
//fa.show("print");
}
}
4. 方法调用指令
虚拟机中提供了以下几条方法调用指令:
普通调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
动态调用指令:
5.invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现【动态类型语言】支持而做的一种改进。但是在Java7中并没有提供直接生成invokeddynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现才有了直接的生成方式。Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
5. 语言类型
动态类型语言:
对类型的检查是在编译期,判断变量自身的类型的信息。
静态类型语言:
对类型的检查是在运行期,判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态类型语言的重要特征。
6. Java语言中方法重写的本质:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常(IllegalAccessError如果发生在运行时,就说明一个类发生了不兼容的改变)。
- 否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
7.虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
那么虚方法表什么时候被创建?
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
3.2.5 方法返回地址
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置pc寄存器值等,让调用者方法继续执行下去。方法退出只有两种方式,无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。
- **正常执行完成:**调用者的pc计数器的值作为返回地址(Return Address),即调用该方法的指令的下一条指令的地址。执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口;一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
- 出现未处理的异常,非正常退出:方法执行过程中抛出异常(Exception)时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。返回地址是要通过异常表来确定,不会给他的上层调用者产生任何的返回值。异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
如果在4-16行字节码指令出现异常,那就按照19行字节码指令执行,针对于任何类型。
3.2.6 一些附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
3.3 本地方法栈
3.3.1 本地方法
简单的讲,一个本地方法(Native Method)就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其他的编程语言都有这一机制,比如在C++中,你可以用extern“C”告知C++编译器去调用一个C的函数。
“A native method is a Java method whose implementation is provided by non-java code."
在定义一个native method 时,并不提供实现体(有些像定义一个Java interface ),因为其实现体是由非java语言在外面实现的。标识符native可以与所有其它的java标识符连用,但是abstract除外。
public class IHaveNatives{
public native void methodNative1(int x);
public native static long methodNative2();
public native synchronized float methodNative3(Object o);
native void methodNative4(int[] ary) throws Exception;
}
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。
3.3.2 本地方法栈
Java虚拟机栈用于管理Java方法的调用,而本地方法栈(Native Method Stack)用于管理本地方法的调用。
本地方法也是线程私有的,也允许被实现成固定或者是可动态扩展的内存大小,在内存溢出方面是相同的,也可能抛出StackOverflowError异常和OutOfMemoryError异常。
本地方法是使用C语言实现的,它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器。
- 直接从本地内存的堆中分配任意数量的内存。
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
3.4 堆
3.4.1 堆的核心概念
一个JVM实例只存在一个堆内存,堆(Heap)也是Java内存管理的核心区域。
- JVM规范规定堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的。
- 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
- JVM规范对堆的描述,几乎所有的对象实例以及数组都应当在运行时分配在堆上。数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
- 在方法结束后,堆中的对象不会马上被移除,仅仅在GC(Garbage Collection,垃圾收集)的时候才会被移除,堆是GC的重点区域。
现代垃圾收集器大部分都基于分代收集理论设计:
- Java7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
- Java8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
新生区细分为Eden(伊甸园区)+ Survivor0 + Survivor1(幸存者0/1区又叫from/to区)
英文 | 中文 | |
---|---|---|
Young Generation Space | 新生区 | Young/New(Eden区和Survivor区) |
Tenure generation space | 养老区 | Old/Tenure |
Permanent Space | 永久区 | Perm |
Meta Space | 元空间 | Meta |
-XX:+PrintGCDetails 打印垃圾回收的细节可以看到jdk7和8的GC
上述为逻辑划分,实际上永久代或者元空间属于方法区,-Xmx和-Xms设置时只能影响到新生区和养老区。
3.4.2 堆内存大小
3.4.2.1 堆大小设置
堆是JVM管理的最大一块内存空间,在JVM启动的时候即被创建,其空间大小也就确定了。堆内存的大小可以通过选项-Xmx和-Xms来设置。
-
-Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize
-X 是jvm的运行参数 ms 是memory start
-
-Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常。
默认情况:
- 初始内存大小:物理电脑内存大小 / 64;
- 最大内存大小:物理电脑内存大小 / 4;
3.4.2.2 查看设置
- 方式一:
- **jps命令:**查看当前程序运行的进程
- **jstat命令:**查看JVM在GC的时候的统计信息,显示某进程的内存使用情况。jstat -gc 进程号
项目 | 全称 | 含义 |
---|---|---|
S0C | surviver 0 complete | 幸存者0区完整大小 |
S1C | surviver 1 complete | 幸存者1区完整大小 |
S0U | surviver 0 used | 幸存者0区已使用大小 |
S1U | surviver 1 used | 幸存者1区已使用大小 |
EC | eden complete | 伊甸园区完整大小 |
EU | eden used | 伊甸园区已使用大小 |
OC | old complete | 老年代完整的 |
OU | old used | 老年代已使用的 |
-
方式二:
-XX:+PrintGCDetails
- 方式三:
设置起始和最大内存为20M。使用工具jvisualvm.exe(在jdk的bin目录下)查看内存使用情况。然后点击某进程->点击Visual GC选项卡(如果没有可以在插件中添加)可查看堆内存使用情况,如图可验证堆内存大小为20M。
JVM反馈的大小:
- 初始内存大小:Runtime.getRuntime().totalMemory();
- 最大内存大小:Runtime.getRuntime().maxMemory();
获取到JVM反馈大小后发现比设置的小,是因为surviver区只计算一个,这涉及到垃圾回收的复制算法,两个surviver区始终有一个空着的。
3.4.3 堆结构占比
配置新生代与老年代在堆结构中的占比:
-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。2为默认值。当生命周期长的对象偏多,可以更该此比例。使用jinfo -flag NewRatio 进程号
可以查看该值。
-
配置Eden和Survivor占比:
-XX:SurvivorRatio=8,表示Eden:S0:S1=8:1:1,8为默认值。实际运行时,使用工具查看并非此比例。因为默认自适应的内存分配策略。配置-XX:-UseAdaptiveSizePolicy关闭,但不会起到效果,必须使用-XX:SurvivorRatio=8显示设置。
-
配置新生代最大内存大小:
-Xmn,一般不设置。
3.4.4 对象分配过程
-
new的对象,一般都是存放在Eden区的,当Eden区满了后,程序又需要创建对象,就会触发GC操作,一般被称为 YGC / Minor GC操作。然后将伊甸园中的剩余对象移动到幸存者0区。
-
如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的(Survivor From区),如果没有回收,就会放到幸存者1区(Survivor To区)(复制算法)。同时我们给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄加 1。如果再次经历垃圾回收,上次幸存下来的放到幸存者1区的(Survivor From区),如果没有回收,就会放到幸存者0区(Survivor To区)。如此反复。针对幸存者S0,S1区:复制之后有交换,谁空谁是to。
-
继续不断的进行对象生成和垃圾回收,当Survivor中的对象的年龄达到15的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中。年龄阀值默认是15。可以设置参数:-XX:MaxTenuringThreshold=进行更改。
-
在养老区相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。
-
若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常
java.lang.OutOfMemoryError:Java heap space
内存分配过程中的问题?
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。新生代采用复制算法的目的是为了减少碎片。
3.4.5 堆内存变化趋势
/**
* -Xms600m -Xmx600m
*/
public class HeapInstanceTest {
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<HeapInstanceTest>();
while (true) {
list.add(new HeapInstanceTest());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
3.4.6 特殊情况说明
思考:幸存区满了咋办?
- 特别注意,在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作
- 如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代
对象分配的特殊情况
-
如果来了一个新对象,如果 Eden 放不下怎么办?
如果 Eden 放不下,则触发 YGC 。
-
如果YGC后还是放不下?
则说明是超大对象,只能直接放到老年代。
-
如果老年代也放不下,怎么办?
则先触发重 GC。
-
如果触发重 GC 还是放不下?
那只能报 OOM。
3.4.7 GC垃圾回收器
3.4.7.1 GC 分类
Minor GC、Major GC、Full GC
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:
- 部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC)
- 老年代收集(Major GC/Old GC)
- 目前,只有CMS GC会有单独收集老年代的行为。
- 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
- 目前,只有G1 GC会有这种行为
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
3.4.7.2 Minor GC
年轻代(Minor GC/Young GC)触发机制:
- 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。Survivor满会晋升到old,直到Eden满才会Minor GC(Minor GC是清理整个年轻代的内存)
- 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- Minor GC会引发STW(Stop-The-World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
3.4.7.3 MajorGC
老年代 GC(MajorGC)触发机制:
- 指发生在老年代的GC
- 出现了MajorGC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
- Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
- 如果Major GC后,内存还不足,就报OOM了
3.4.7.4 Full GC
Full GC 触发机制:
触发Full GC执行的情况有如下五种:
- 调用System.gc()时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:Full GC 是开发或调优中尽量要避免的。这样STW时间会短一些
3.4.7.5 GC 日志分析
代码:
/**
* 测试MinorGC 、 MajorGC、FullGC
* -Xms9m -Xmx9m -XX:+PrintGCDetails
*/
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable t) {
t.printStackTrace();
System.out.println("遍历次数为:" + i);
}
}
}
/**
GC 日志:
com.atguigu.java1.GCTest
[GC (Allocation Failure) [PSYoungGen: 2020K->510K(2560K)] 2020K->812K(9728K), 0.0021339 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2550K->488K(2560K)] 2852K->2278K(9728K), 0.0005931 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 1949K->504K(2560K)] 3740K->3062K(9728K), 0.0005918 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 1319K->0K(2560K)] [ParOldGen: 6782K->4864K(7168K)] 8102K->4864K(9728K), [Metaspace: 3452K->3452K(1056768K)], 0.0050464 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 4864K->4864K(9728K), 0.0003452 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 4864K->4846K(7168K)] 4864K->4846K(9728K), [Metaspace: 3452K->3452K(1056768K)], 0.0061555 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
遍历次数为:16
Heap
PSYoungGen total 2560K, used 134K [0x00000ffd0, 0x0001000, 0x000000000)
eden space 2048K, 6% used [0x0000000,0x0000fd219f0,0x000000000)
from space 512K, 0% used [0x000000,0x00000fff80000,0x0000000000000)
to space 512K, 0% used [0x00000000,0x00000000,0x00000000fff80000)
ParOldGen total 7168K, used 4846K [0x000000, 0x000d00000, 0x000000)
object space 7168K, 67% used [0x0000,0x00000abba00,0x000000000)
Metaspace used 3498K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 384K, capacity 388K, committed 512K, reserved 1048576K
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at com.atguigu.java1.GCTest.main(GCTest.java:21)
Process finished with exit code 0
**/
- **[PSYoungGen: 1319K->0K(2560K)] :**年轻代总空间为 2560K ,当前占用 1319K ,经过垃圾回收后剩余 0K
- **[ParOldGen: 6782K->4864K(7168K)] :**老年代总空间为 7168K ,当前占用 6782K ,经过垃圾回收后剩余 4864K
- 8102K->4864K(9728K):堆内存总空间为 9728K ,当前占用 8102K ,经过垃圾回收后剩余 4864K
- **[Metaspace: 3452K->3452K(1056768K)] :**元空间总空间为 1056768K ,当前占用 3452K ,经过垃圾回收后剩余 3452K
- **0.0050464 secs :**垃圾回收用时 0.0050464 secs
3.4.8 内存分配策略
内存分配策略或对象提升(Promotion)规则
- 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。
- 对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代
- 对象晋升老年代的年龄阀值,可以通过选项**-XX:MaxTenuringThreshold**来设置
针对不同年龄段的对象分配原则如下所示:
- 优先分配到Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发Major GC的次数比 Minor GC要更少,因此可能回收起来就会比较慢
- 大对象直接分配到老年代:尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
- 空间分配担保: -XX:HandlePromotionFailure
/**
* 测试:大对象直接进入老年代。整个过程并没有进行垃圾回收,并且 ParOldGen 区直接占用了 20MB 的空间,说明大对象直接怼到了老年代中
* -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
*
*/
public class YoungOldAreaTest {
public static void main(String[] args) {
byte[] buffer = new byte[1024 * 1024 * 20];//20m
}
}
/**
日志:
Heap
PSYoungGen total 18432K, used 2637K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
eden space 16384K, 16% used [0x00000000fec00000,0x00000000fee935c8,0x00000000ffc00000)
from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
to space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
ParOldGen total 40960K, used 20480K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
object space 40960K, 50% used [0x00000000fc400000,0x00000000fd800010,0x00000000fec00000)
Metaspace used 3469K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
Process finished with exit code 0
**/
3.4.9 为对象分配内存:TLAB
为什么有 TLAB?
堆区是线程共享区域,并发环境下从堆区中划分内存空间是线程不安全的。为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是 TLAB?
- Thread Local Allocation Buffer,线程本地分配缓存
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 基本上所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
TLAB 的说明
- JVM将TLAB作为内存分配的首选,可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。默认开启,可通过“info -flag UseTLAB 线程号”查看开启状态。
- 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
- 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
TLAB 分配过程
问题:堆空间都是共享的么?
不一定,因为还有TLAB这个概念,在堆中划分出一块区域,为每个线程所独占
3.4.10 堆空间参数设置
官方文档
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
常用参数设置
-
-XX:+PrintFlagsInitial:查看所有的参数的默认初始值
-
-XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
查看具体某个参数的指令:jps(查看当前运行中的进程)—>jinfo -flag SurvivorRatio 进程id
-
-Xms:初始堆空间内存(默认为物理内存的1/64)
-
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-
-Xmn:设置新生代的大小(初始值及最大值)
-
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-
-XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
-
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-
-XX:+PrintGCDetails:输出详细的GC处理日志
-
-XX:+PrintGC 或 -verbose:gc :打印gc简要信息
-
-XX:HandlePromotionFalilure:是否设置空间分配担保
关于空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
-
如果大于,则此次Minor GC是安全的
-
如果小于,则虚拟机会查看**-XX:HandlePromotionFailure**设置值是否允许担保失败。
-
如果HandlePromotionFailure=true,那么会继续检查
老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
- 如果小于,则进行一次Full GC。
-
如果HandlePromotionFailure=false,则进行一次Full GC。
-
历史版本
- 在JDK6 Update 24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。
- JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。即 HandlePromotionFailure=true
3.4.11 逃逸分析
堆是分配对象存储的唯一选择吗?(章节末尾补充说明)
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
如何将堆上的对象分配到栈,需要使用逃逸分析手段:
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
- 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
逃逸分析举例
- 没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}
- 下面代码中的 StringBuffer sb 发生了逃逸
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
- 如果想要StringBuffer sb不发生逃逸,可以这样写
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
- 逃逸分析的举例
/**
* 逃逸分析
* 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果当前的obj引用声明为static的?仍然会发生逃逸。
/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance();
//getInstance().xxx()同样会发生逃逸
}
}
逃逸分析参数设置
- 在JDK 6u23版本之后,HotSpot中默认就已经开启了逃逸分析
- 如果使用的是较早的版本,开发人员则可以通过:
- 选项“-XX:+DoEscapeAnalysis"显式开启逃逸分析
- 通过选项“-XX:+PrintEscapeAnalysis"查看逃逸分析的筛选结果
逃逸分析结论
开发中能使用局部变量的,就不要使用在方法外定义。
逃逸分析之代码优化
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
3.4.11.1 栈上分配
- JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
- 常见的栈上分配的场景:在逃逸分析中,已经说明了,分别是给成员变量赋值、方法返回值、实例引用传递。
栈上分配举例
- 代码
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未发生逃逸
}
static class User {
}
}
未开启逃逸分析的情况
- JVM 参数设置
-Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
1
- 日志打印:发生了 GC ,耗时 46ms
[GC (Allocation Failure) [PSYoungGen: 65536K->808K(76288K)] 65536K->816K(251392K), 0.0009467 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 66344K->872K(76288K)] 66352K->880K(251392K), 0.0006768 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
花费的时间为: 46 ms
- 堆上面有好多好多 User 对象
开启逃逸分析的情况
- JVM 参数设置
-Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
- 日志打印:并没有发生 GC ,耗时 3ms ,栈上分配是真的快啊
花费的时间为: 3 ms
3.4.11.2 同步省略
同步省略
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步代码块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
- 如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
示例代码:
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
- 代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
字节码分析
- 注意:字节码文件中并没有进行优化,可以看到加锁和释放锁的操作依然存在,同步省略操作是在解释运行时发生的
3.4.11.3 标量替换
分离对象或标量替换
- 标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
- 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
- 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
示例代码:
public static void main(String args[]) {
alloc();
}
class Point {
private int x;
private int y;
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
- 以上代码,经过标量替换后,就会变成
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
结论:
- 可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。
- 那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
- 标量替换为栈上分配提供了很好的基础。
标量替换参数设置:
参数 -XX:+ElimilnateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
示例代码:
/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
*
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
}
未开启标量替换
- JVM 参数
-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
1
- 日志分析:伴随着 GC 的垃圾回收,用时 46ms
[GC (Allocation Failure) 25600K->816K(98304K), 0.0009418 secs]
[GC (Allocation Failure) 26416K->792K(98304K), 0.0007337 secs]
[GC (Allocation Failure) 26392K->792K(98304K), 0.0006104 secs]
[GC (Allocation Failure) 26392K->856K(98304K), 0.0009474 secs]
[GC (Allocation Failure) 26456K->824K(98304K), 0.0007392 secs]
[GC (Allocation Failure) 26424K->808K(101376K), 0.0009449 secs]
[GC (Allocation Failure) 32552K->720K(101376K), 0.0010633 secs]
[GC (Allocation Failure) 32464K->720K(100352K), 0.0004493 secs]
花费的时间为: 46 ms
开启标量替换
- JVM 参数
-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
- 日志分析:无垃圾回收,用时 4ms
花费的时间为: 4 ms
逃逸分析参数设置总结
- 上述代码在主函数中调用了1亿次alloc()方法,进行对象创建。
- 由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。
- 如果堆空间小于这个值,就必然会发生GC。
3.4.11.4 逃逸分析补充
server模式
在server模式下才可以启用逃逸分析,而上述示例中未显式设置“-server”参数,是因为在64位系统中默认是server模式。可以通过“java -version”命令查看是否是server模式。
逃逸分析并不成熟
- 关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
- 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
- 通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明。那么在本章栈上分配小节中,开启逃逸分析后为何发生性能提高?其实并不是栈上分配带来的效果,而是因为标量替换默认开启,开启逃逸分析后标量替换生效,由此带来性能提升。
再论,堆是分配对象的唯一选择么?
- 本章节中论述了经过逃逸分析带来的栈上分配和标量替换的堆外存储技术,在堆外存储技术的加持下,堆已不再是分配对象的唯一选择。
- 但是实际使用中并不一定是这样的。
- 对于大多数JVM,如我们一般默认的Hotspot JVM,栈上分配只是理论,并未实现。
- 标量替换将对象替换成基本数据类型那样的标量,虽存储到堆外,但已不是对象。
- intern字符串的缓存和静态变量曾经都被分配在永久代上,而JDK7及以后永久代已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配
- 根据以上三点又可以说堆是分配对象的唯一选择。
- 基于OpenJDK深度定制的TaoBao VM创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
3.5 方法区
3.5.1 方法区的理解
方法区的基本理解
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,关闭JVM就会释放这个区域的内存。实际的物理内存空间可以是不连续的。
多个线程同时加载同一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。
方法区的位置
《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。所以,方法区可以看作是一块独立于Java堆的内存空间。
官方文档
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4
3.5.2 方法区演进过程
Hotspot 方法区的演进过程
- 在 JDK7 及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。
- 《Java虚拟机规范》对如何实现方法区,不做统一要求。本质上,方法区和永久代并不等价。等价仅是对Hotspot而言的。而BEAJRockit / IBM J9 中不存在永久代的概念。
- 现在来看,当年使用永久代,不是好的想法。导致Java程序更容易OOm(超过-XX:MaxPermsize上限)。而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替。
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
- 永久代、元空间二者并不只是名字变了,内部结构也调整了。
3.5.3 方法区大小
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
JDK7 之前版本设置永久代大小
- 通过-XX:Permsize来设置永久代初始分配空间。默认值是20.75M
- -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
- 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。
JDK8 版本设置元空间大小
- 元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定
- 默认值依赖于平台,Windows下,-XX:MetaspaceSize 约为21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
- 与永久代不同,元数据区如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
- 对于一个64位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。
- 如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。
- 如果释放空间过多,则适当降低该值。
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
配置方法区大小示例
/**
* 测试设置方法区大小参数
*
* jdk7及以前:
* -XX:PermSize=100m -XX:MaxPermSize=100m
*
* jdk8及以后:
* -XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m
*/
public class MethodAreaDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
- CMD 命令查看设置的元空间大小
C:\Users\Heygo>jps
C:\Users\Heygo>jinfo -flag MetaspaceSize pId
C:\Users\Heygo>jinfo -flag MaxMetaspaceSize pId
- CMD 命令查看设置的永久代大小
C:\Users\Heygo>jps
C:\Users\Heygo>jinfo -flag PermSize pId
C:\Users\Heygo>jinfo -flag MaxPermSize pId
3.5.4 方法区OOM
- 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace
- 方法区OOM举例:
- 加载大量的第三方的jar包
- Tomcat部署的工程过多(30~50个)
- 大量动态的生成反射类
方法区OOM代码示例1
- 代码:OOMTest 类继承 ClassLoader 类,获得defineClass() 方法,可自己进行类的加载
/**
* jdk6/7中:
* -XX:PermSize=10m -XX:MaxPermSize=10m
*
* jdk8中:
* -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
*/
public class OOMTest extends ClassLoader {
public static void main(String[] args) {
int j = 0;
try {
OOMTest test = new OOMTest();
for (int i = 0; i < 10000; i++) {
//创建ClassWriter对象,用于生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指明版本号,修饰符,类名,包名,父类,接口
classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
//返回byte[]
byte[] code = classWriter.toByteArray();
//类的加载
test.defineClass("Class" + i, code, 0, code.length);//Class对象
j++;
}
} finally {
System.out.println(j);
}
}
}
不设置元空间的上限
- 使用默认的 JVM 参数,元空间不设置上限
10000
设置元空间的上限
- JVM 参数
-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
- 元空间出现 OOM
com.atguigu.java.OOMTest
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
at com.atguigu.java.OOMTest.main(OOMTest.java:29)
8531
方法区OOM代码示例2
- 代码:借助CGLib使得方法区出现内存溢出异常
/**
* VM Args:-XX:PermSize=10M -xx:MaxPermSize=10M
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass();
enhancer.setUseCache(false);
enhancer.setCallBack(new MethodInterceptor(){
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj,args);
}
});
enhancer.create();
}
}
static class OOMObject {
}
}
如何解决 OOM?
- 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Ec1ipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
- 内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和GC ROOT有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
- 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
3.5.5 方法区的内部结构
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型直接接口的一个有序列表
域(Field)信息
- JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
- 域的相关信息包括:
- 域名称
- 域类型
- 域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的类为 void.class
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
- 异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
代码示例
/**
* 测试方法区的内部构成
*/
public class MethodInnerStrucTest extends Object implements Comparable<String>, Serializable {
//属性
public int num = 10;
private static String str = "测试方法的内部结构";
//构造器没写
//方法
public void test1() {
int count = 20;
System.out.println("count = " + count);
}
public static int test2(int cal) {
int result = 0;
try {
int value = 30;
result = value / cal;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
@Override
public int compareTo(String o) {
return 0;
}
}
javap -v -p MethodInnerStrucTest.class > Text.txt
类型信息
- 从反编译文件可以看出,字节码文件记录了 MethodInnerStrucTest 继承了哪些类,实现了哪些方法
- 字节码文件中看不出哪个加载器加载了该类,但在运行时方法区中,类信息中记录了哪个加载器加载了该类,同时类加载器也记录了它加载了哪些类
public class com.atguigu.java.MethodInnerStrucTest
extends java.lang.Object
implements java.lang.Comparable<java.lang.String>, java.io.Serializable
域信息
- descriptor: I 表示字段类型为 Integer
- flags: ACC_PUBLIC 表示字段权限修饰符为 public
public int num;
descriptor: I
flags: ACC_PUBLIC
private static java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC
方法信息
- descriptor: ()V 表示方法返回值类型为 void
- flags: ACC_PUBLIC 表示方法权限修饰符为 public
- stack=3 表示操作数栈深度为 3
- locals=2 表示局部变量个数为 2 个(实例方法包含 this)
- test1() 方法虽然没有参数,但是其 args_size=1 ,这时因为将 this 作为了参数
public void test1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=2, args_size=1
0: bipush 20
2: istore_1
3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: ldc #6 // String count =
15: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
18: iload_1
19: invokevirtual #8 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
22: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: return
LineNumberTable:
line 17: 0
line 18: 3
line 19: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this Lcom/atguigu/java/MethodInnerStrucTest;
3 26 1 count I
3.5.6 域信息特殊情况
public class MethodAreaTest {
public static void main(String[] args) {
Order order = null;
order.hello();
System.out.println(order.count);
}
}
class Order {
public static int count = 1;
public static final int number = 2;
public static void hello() {
System.out.println("hello!");
}
}
// 程序运行结果
hello!
1
non-final 类型的类变量
- 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
- 类变量被类的所有实例共享,即使没有类实例时你也可以访问它,如代码所示,即使我们把order设置为null,也不会出现空指针异常
全局常量:static final
全局常量就是使用 static final 进行修饰。全局常量在编译的时候就会被分配了。
- 反编译查看字节码指令,可以发现 number 的值已经写死在字节码文件中了
public static int count;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
public static final int number;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 2
3.5.7 常量池
3.5.7.1 ClassFile常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。(数量值、字符串值、类引用、字段引用、方法引用)。虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
为什么需要常量池?
- 一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池
- 这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。
public class MethodAreaDemo {
public static void main(String[] args) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a + b);
}
}
虽然上述代码很少,但是里面却使用了String、System、PrintStream及Object等结构。如果代码多的话,引用的结构将会更多,重复的使用必然会导致字节码文件臃肿不堪。
直观常量池
在idea中,找见.class文件,右键Open in Terminal打开在该文件目录下的终端窗口。执行javap命令得到反编译的字节码文件,也可保存至txt文档方便查看。
javap -v -p SimpleClass.java > text.txt
如下为MethodAreaDemo.java的字节码文件,其中 “Constant pool:” 便是常量池,带 # 的字节码指令,就是使用了常量池的引用。如”15: getstatic #2“
Classfile /D:/wangyan/learn/learn/learn-common/target/classes/util/MethodAreaDemo.class
Last modified 2021-1-2; size 614 bytes
MD5 checksum a61d403404a7cfb5052efa448d59b689
Compiled from "MethodAreaDemo.java"
public class util.MethodAreaDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#24 // java/lang/Object."<init>":()V
#2 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #27.#28 // java/io/PrintStream.println:(I)V
#4 = Class #29 // util/MethodAreaDemo
#5 = Class #30 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Lutil/MethodAreaDemo;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 x
#18 = Utf8 I
#19 = Utf8 y
#20 = Utf8 a
#21 = Utf8 b
#22 = Utf8 SourceFile
#23 = Utf8 MethodAreaDemo.java
#24 = NameAndType #6:#7 // "<init>":()V
#25 = Class #31 // java/lang/System
#26 = NameAndType #32:#33 // out:Ljava/io/PrintStream;
#27 = Class #34 // java/io/PrintStream
#28 = NameAndType #35:#36 // println:(I)V
#29 = Utf8 util/MethodAreaDemo
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/System
#32 = Utf8 out
#33 = Utf8 Ljava/io/PrintStream;
#34 = Utf8 java/io/PrintStream
#35 = Utf8 println
#36 = Utf8 (I)V
{
public util.MethodAreaDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lutil/MethodAreaDemo;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=5, args_size=1
0: sipush 500
3: istore_1
4: bipush 100
6: istore_2
7: iload_1
8: iload_2
9: idiv
10: istore_3
11: bipush 50
13: istore 4
15: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
18: iload_3
19: iload 4
21: iadd
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: return
LineNumberTable:
line 9: 0
line 10: 4
line 11: 7
line 12: 11
line 13: 15
line 14: 25
LocalVariableTable:
Start Length Slot Name Signature
0 26 0 args [Ljava/lang/String;
4 22 1 x I
7 19 2 y I
11 15 3 a I
15 11 4 b I
}
SourceFile: "MethodAreaDemo.java"
3.5.7.2 图解字节码指令执行流程
- 初始状态
- 将操作数500压入操作数栈,然后弹出,存储到局部变量表中索引为 1 的位置。// int x = 500;
- 将操作数100压入操作数栈,然后弹出,存储到局部变量表中索引为2的位置。// int y = 100;
- 读取本地变量 1 ,压入操作数栈;读取本地变量 2 ,压入操作数栈。两数相除,计算结果放在操作数栈顶,之后执行 istore_3 指令,将计算结果从操作数栈中弹出,存入局部变量 3 中。存储到局部变量表中索引为2的位置。// int a = x / y;
省略istore_3,bipush50,istore4图例
-
获取 System类中的out字段的值,并推入操作数栈。 // **System.out.**println(a + b);
截取相关常量池:
Constant pool: #2 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream; #25 = Class #31 // java/lang/System #26 = NameAndType #32:#33 // out:Ljava/io/PrintStream; #31 = Utf8 java/lang/System #32 = Utf8 out #33 = Utf8 Ljava/io/PrintStream;
- 将本地变量 3 和4的值取出,压入操作数栈中,进行加法运算,将计算结果放在操作数栈顶 // System.out.println(a + b);
-
调用println()方法 ,输出加法结果。// System.out.println(a + b)
截取相关常量池:
Constant pool: #3 = Methodref #27.#28 // java/io/PrintStream.println:(I)V #27 = Class #34 // java/io/PrintStream #28 = NameAndType #35:#36 // println:(I)V #34 = Utf8 java/io/PrintStream #35 = Utf8 println #36 = Utf8 (I)V
- main() 方法执行结束
3.5.7.3 运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- 常量池表(Constant Pool Table)在类和接口加载到虚拟机后,存放到方法区的运行时常量池中。
- JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 上面代码调用 System.out.println() 方法时,首先需要看看 System 类有没有加载,再看看 PrintStream 类有没有加载。如果没有加载,则执行加载,执行时,将常量池中的符号引用(字面量)转换为直接引用(真正的地址值)
- 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutofMemoryError异常。
运行时常量池 VS 常量池
- 方法区,内部包含了运行时常量池
- 字节码文件,内部包含了常量池
- 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。
- 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
3.5.8 方法区的演进细节
关于永久代的说明
首先明确:只有Hotspot才有永久代。BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一
Hotspot中方法区的变化:
JDK 版本 | 演变细节 |
---|---|
JDK1.6及以前 | 有永久代(permanent generation),静态变量存放在永久代上 |
JDK1.7 | 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中 |
JDK1.8及之后 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆。 |
永久代为什么要被元空间替代?
官方文档:
http://openjdk.java.net/jeps/122
官方的牵强解释:JRockit是和HotSpot融合后的结果,因为JRockit没有永久代,所以他们不需要配置永久代。
原因分析:
-
为永久代设置空间大小是很难确定的。
- 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工
程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。Exception in thread 'dubbo client x.x connector' java.lang.OutOfMemoryError:PermGen space
- 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
因此,默认情况下,元空间的大小仅受本地内存限制。
- 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工
-
对永久代进行调优是很困难的。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型。一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。
字符串常量池 StringTable 为什么要调整位置?
JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
静态变量存放在哪里?
代码示例 1
/**
* jdk6/7:
* -Xms200m -Xmx200m -XX:PermSize=300m -XX:MaxPermSize=300m -XX:+PrintGCDetails
* jdk 8:
* -Xms200m -Xmx200m -XX:MetaspaceSize=300m -XX:MaxMetaspaceSize=300m -XX:+PrintGCDetails
*/
public class StaticFieldTest {
private static byte[] arr = new byte[1024 * 1024 * 100];//100MB
public static void main(String[] args) {
System.out.println(StaticFieldTest.arr);
}
}
[B@4554617c
Heap
PSYoungGen total 59904K, used 5171K [0x00000000fbd80000, 0x0000000100000000, 0x0000000100000000)
eden space 51712K, 10% used [0x00000000fbd80000,0x00000000fc28ceb0,0x00000000ff000000)
from space 8192K, 0% used [0x00000000ff800000,0x00000000ff800000,0x0000000100000000)
to space 8192K, 0% used [0x00000000ff000000,0x00000000ff000000,0x00000000ff800000)
ParOldGen total 136704K, used 102400K [0x00000000f3800000, 0x00000000fbd80000, 0x00000000fbd80000)
object space 136704K, 74% used [0x00000000f3800000,0x00000000f9c00010,0x00000000fbd80000)
Metaspace used 3473K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
arr数组对象大小为100MB,通过GC日志可以看出:无论在JDK6/7/8 的环境下,老年代始终为100MB,也就是说arr始终存放在老年代即堆中。得出结论:静态变量指向的对象实体始终都在堆空间。
代码示例 2
/**
* 《深入理解Java虚拟机》中的案例:
* staticObj、instanceObj、localObj存放在哪里?
*/
public class StaticObjTest {
static class Test {
static ObjectHolder staticObj = new ObjectHolder();
ObjectHolder instanceObj = new ObjectHolder();
void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");
}
}
private static class ObjectHolder {
}
public static void main(String[] args) {
Test test = new StaticObjTest.Test();
test.foo();
}
}
- 使用 JHSDB.exe工具进行分析(在JDK9的时候才引入的)
- 分析: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堆之中,从我们的实验中也明确验证了这一点
3.5.9 方法区的垃圾回收
- 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
- 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
- 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
方法区常量的回收
- 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用
- 字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等
- 而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
- 回收废弃常量与回收Java堆中的对象非常类似。
方法区类的回收
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及 -XX:+TraceClass-Loading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.6 运行时数据区总结
栈、堆、方法区的交互关系
- Person 类的 .class 信息存放在方法区中
- person 变量存放在 Java 栈的局部变量表中
- 真正的 person 对象存放在 Java 堆中
- 在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的
从线程共享与否的角度来看
ThreadLocal:如何保证多个线程在并发环境下的安全性?典型应用就是数据库连接管理,以及独立会话管理
a堆中分配**。
- 接着,找到了一个引用该staticObj对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticObj的实例字段:
- 从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点
3.5.9 方法区的垃圾回收
- 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
- 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
- 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
方法区常量的回收
- 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用
- 字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等
- 而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
- 回收废弃常量与回收Java堆中的对象非常类似。
方法区类的回收
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及 -XX:+TraceClass-Loading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.6 运行时数据区总结
栈、堆、方法区的交互关系
- Person 类的 .class 信息存放在方法区中
- person 变量存放在 Java 栈的局部变量表中
- 真正的 person 对象存放在 Java 堆中
- 在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的
从线程共享与否的角度来看
ThreadLocal:如何保证多个线程在并发环境下的安全性?典型应用就是数据库连接管理,以及独立会话管理