这里我要提一嘴。
这里讲的是java运行时数据区,也就是内存数据划分,分为几个数据区。
不是java内存模型。
这两不是一回事。java内存模型在后面并发那才会更新到。
本文主要是根据周志明老师 的《深入理解JVM》写的,其中添加了诸多自己的批注。图片来自尚硅谷的 JVM 教程。
全文比较长,小三万字。本来打算拆分开来写的,但是它们都属于运行时数据区的内容,就放在一起来。
在前面讲类加载子系统的时候,给出过虚拟机的框架图。
总体分布

其中不见 方法区 的名字,是因为方法区是一个规范,具体需要厂商自己实现,hotspot中使用了元数据区作为实现。
元数据区
运行时候内存总体上划分为以上几个区域。
但是需要注意的是元数据区,它是针对方法区具体实现,在JDK1.8之前,方法区的实现是用永久代实现的,JDK1.8以后改为使用元数据区实现。
元数据区和永久代的区别在于:元数据区使用的内存,并不在虚拟机之中,而是使用的本地内存。因此,默认情况下,元数据区的大小是仅仅受限制于你电脑的内存大小。但是可以通过虚拟机参数指定元数据区的大小。
程序计数器
其实就是PC寄存器,只不过它是java层面上用软件实现的,不是真实的寄存器。理解上,就按照汇编中的PC寄存器理解,一样的道理,里面记录这下一条要执行的字节码指令的地址。
所以具有下面的特点:
- 线程私有的,生命周期和线程一样,随着线程诞生或者消亡。
- 永远不会发生
OOM异常 。OOM=OutOfMemoryError下文再出现,以OOM代替 - 这块一丁点的内存,垃圾回收期看不上的,所以不会发生
GC。
这里不做解释,不是我不解释,假如你关注过我一些博客,你会发现,我不是那种抄书类的博主,我一般都是自己写出是什么,再解释下为什么。这里的为什么,我在几年前写过的 关于程序计数器的故事 里面解释过了。
是的,几年前,这个系列的博客,断更过几年。岁月蹉跎啊。
虚拟机栈
虚拟机栈是干嘛的。首先它是一个栈,栈是用来干嘛的,用来保存运行时数据。这一点相对于堆而言,堆是则是纯纯的数据保存区,类似于数据仓库一样。
虽然都是保存数据的,但是栈是保存运行时的数据,运行结束以后,数据就出栈,扔了。而堆上的数据则是一直存在,在程序运行期间。这也是堆需要GC的原因。
特点:
-
线程私有的,生命周期随着线程。
虽然名字叫虚拟机栈,但是不是一个虚拟机实例中只有一个虚拟机栈。相反,是一个线程独享一个,整个虚拟机中有多个。
-
是
java方法运行的内存模型。一个方法开始执行的时候,意味着一个新的栈帧入栈,方法执行完毕以后,意味着一个栈帧出栈。方法的执行过程,就是栈帧的入栈出栈。每个方法在执行的时候,都会在虚拟机栈中创建一个栈帧,在栈帧中存储着方法需要用到的数据:局部变量表、操作数栈、动态链接、方法出口等信息。
(下面会展开讲,什么是栈帧、局部变量表、操作数栈等)
-
不需要
GC,但是可能发生OOM或者StackOverFlowError。前面说过,栈只是保存在运行期间需要用到的数据,一个方法得到执行,一个栈帧入栈。方法执行完毕,栈帧出栈,所以不需要垃圾回收,但是有方法套方法,无限嵌套下去,直到栈的深度超过允许的最大深度,就会发生
StackOverFlowError。至于
OOM,是因为有的虚拟机支持栈的动态扩展,深度不够的时候,会申请新的内存,如果申请不到,则抛出OOM。
栈帧

前面提到,java方法执行,代表着一个栈帧入栈。栈帧是一种数据结构,用于支持虚拟机进行方法调用和执行。方法在执行过程中的信息就封装在栈帧中。栈帧也是虚拟机栈的基本单位,虚拟机栈的栈元素就是栈帧。里面存放着方法运行期间需要用到的各种数据:局部变量表、操作数栈、动态链接、方法出口等信息。

当前正在执行的方法,称为当前方法;当前方法对应的栈帧,必然是此刻虚拟机栈的栈顶元素,也称为当前栈帧;定义了正在执行的方法的类,称为当前类。
如果当前方法执行过程中,调用了另外一个方法,则新的被调用的方法的栈帧入栈,成为当前栈帧。被调用的方法,成为当前方法。当前方法执行完毕以后,当前栈帧出栈,会将执行结果返回给上一个栈帧,上一个栈帧成为新的当前栈帧。
栈帧出栈,一般只有两种可能:
-
当前方法正常执行结束,遇到
return指令。当前栈帧出栈 -
当前方法执行过程种,发生没有被捕获的异常,也会导致当前栈帧出栈。
前面说过,当前栈帧出栈,会将执行结果返回给上一个栈帧,发生异常了,也是如此,将异常也返回到上一层栈帧了,也就是返回调用方法的地方。
局部变量表
在代码编译期间,就确定了栈帧需要多大的局部变量表。也就是说一个栈帧分配多大内存,在编译期间就确定了,存放在Code属性的max_locals中。运行期间不会改变大小。
怎么个确定法,是取最大需要分配的空间,也就是默认所以的条件语句都是真,都会执行到,所以,都会为其分配空间。但是会复用空间,不然太浪费。具体怎么分配以及怎么复用,我待会会解释。我先说下,什么是局部变量表。
什么是局部变量表
局部变量表,其实是一个数据数组,也就是一个数组类型。数组的元素类型是Slot类型。Slot类型的大小,虚拟机没有明确规定规定,只是说,32位的数据应该能使用1个Slot保存,64位的数据使用2个Slot保存。Slot的具体大小是没有规定的。Slot的大小,完全可以随着处理器、操作系统的不同而不同。
注意,书里面提到了一句话,上面说到的
32位的数据类型,是JVM中的数据类型和java语言中的数据类型不一样,这里面的32位数据类型,在保存时候,可以使用32位或者更小的物理内部保存。这也是不能确定Slot大小的原因。只是说用
Slot能存下,但是Slot可以是32位的,也可以更小。
关于JVM中的数据类型
32位的数据类型有:boolean,byte,char,short,int,float,returnAddress ,还有一个引用类型reference ,虚拟机规范也没有指明它的长度,这里算作32,其实可能也是64位,看虚拟机的位数。64位的数据类型有:double,long 。
上面说到:JVM中的数据类型和java语言中的数据类型不一样,原因是:在虚拟机中``boolean,byte,char,short在存储之前都被转成int,其中boolean类型,使用0表示false,非0表示true。也就是都当成32位数据类型,这也是上面为啥JVM中数据类型只有两种:32,64`的原因。
Slot 的分配和复用
32位的数据应该能使用1个Slot保存,64位的数据类型使用两个连续的Slot 保存。获取64位数据的时候,只能使用第一个Slot 的下标获取数据。不能使用第二个Slot 的下标。
如:使用3,4两个Slot,那么只能通过下标3访问。如果使用4,访问第二个Slot,虚拟机会抛出异常的。
这里提一个东西,有个印象就行,没印象也无所谓。
这里发现
64位数据,会分割存储在两个Slot里面,那么数据的读写,也被分割为两次,一次只能读写一个Slot。在并发中其实会发生问题的,需要保证是原子操作。但是这里,不需要保证原子操作,因为局部变量表在虚拟机栈中,虚拟机栈是线程私有的,不存在并发。
前面说过,局部变量法的大小在编译期间就确定了。按照最大可能内存分配的。
下面我给出一段代码:
名字无所谓,这名字是我测试生成无向图的算法的单元测试,我在里面继续写的,懒得新的测试类。
void createUDG() {
int num = 90 ;
if(num > 100){
int a = 99 ;
int d = 101 ;
System.out.println(a);
}
double result = 100 ;
int test = 0 ;
System.out.println(result);
}
下面是我自己的理解。
首先假设条件语句都会执行,这样分配空间才正确,免得到时候会执行,但是没分配空间。
算上if语句会执行。一共有num,a,d,test 4个32位的数据。1个64位的result数据。那么应该分配几个Slot。4+2 = 6 个。
还有一件事,不说你可能压根没想起来,以前学java的时候,应该听说过一句话吧:实例方法中的第一个参数其实是this。这句话是对的,原因就在我们现在学的这个局部变量表中。实例方法的局部变量表的第一个Slot是固定的,是this.
这样算下来是6+1=7 。但是仔细看代码,a,d的作用域是有限的,不是整个方法。按照执行逻辑,从上往下执行。在if范围域结束以后,a,d就没用了。
如果它两占用的2个Slot是可以覆盖的。那么后面的64位的result,正好是可以复用这2个Slot。实际情况确实如此,这也就是Slot的复用。
那么一共使用了this,num,a,d,test 共5个Slot。其中result复用了2,3下标的Slot。
给你们上一张图,验证我的说法:
可以看到局部变量最大槽数:5,下面是表中内容:

第四列序号 代表的是Slot的下标。0是this,有2个2,只有最上面的2是第一个使用下标2的Slot,后面出现的下标2都是复用第一个2的空间,也就是result是复用了前面的a,d。验证了我的理解。
Slot 复用的细节
关于Slot的复用,还有一个细节,它会影响GC
在局部变量表直接或者间接索引的对象,是不会被GC的。
看一段代码:
public void test(){
{
byte[] placeHolder = new byte[64 * 1024 * 1024];
}
System.gc();
}
在执行以后,placeHolder引用的64M空间并没有被回收。原因是因为局部变量表中的引用还在。
改为下面的代码:
public void test(){
{
byte[] placeHolder = new byte[64 * 1024 * 1024];
}
int a = 0 ;
System.gc();
}
执行以后,确实回收了内存。因为我们在placeHolder引用的后面,又定义了一个a ,它会复用placeHolder引用的Slot。导致局部变量表中不再引用内存中的对象,所以内存被回收了。
这也是为啥推广,不使用一个对象以后,设置其引用为null,就是改变局部变量表中Slot,让它变为对null的引用,从而让GC执行。但是书上并不推荐我们依赖设置null来影响GC,因为我们的代码,在编译以后。就变得面目全非了,还存在编译优化,可能复制操作,被优化掉了。我们应该老老实实使用变量作用域,让GC自己来发现。
Slot 没有默认值
最后说下,使用Slot的下标顺序,是按照参数和局部变量的定义先后顺序分配的,并且分配时间发生在方法调用的时候。如果一个方法参数或者局部变量没有设置值,那么复制的时候,就会报错。不会有默认值这么一说,像类加载的准备阶段一样。
public void test(){
int a ;
System.out.println(a); // 报错,
}
安慕希的设计,多多少少有点反人类。
厚的牛奶,不配吸管,还是塑料瓶装。
一瓶就没有喝光过,永远残留一堆在瓶子里。
2021年11月17日23:50:19
操作数栈
栈是受限的线性表,可以用链表或者数组实现。操作数栈,就是使用数组实现的。和数组渊源挺深,局部变量表也是数组。
什么是操作数栈
我们知道JVM的大部分指令都是零地址的,是没有操作数的。只有操作指令,所以执行过程,完全依赖操作数栈,指令要操作的元素就在栈中。这个栈就是操作数栈。这也是为啥JVM是基于栈的指令集架构
操作数栈的作用
根据上面的介绍,明白操作数栈,是配合指令使用的,有的指令往操作数栈中放数据,有的指令从操作数栈中获取数据。所以操作数栈的作用:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈的使用
操作数栈和局部变量表一样,其深度,也在编译期间就确定了,保存在code属性的max_stacks中。
在方法刚执行的时候,操作数栈就会被创建,按照max_stacks的大小。但是是一个空栈。执行到具体指令的时候,才会往里面压入数据或者取出数据。注意,只能操作栈顶,虽然是数组实现的,但是按照栈的特性操作的。
其中,32位数据占用1个栈单位深度,64位数据占用2个栈单位深度。
调用方法的操作数栈
在方法返回的时候,如果有返回值,返回值会被写入当前栈顶。
在方法调用的时候,会先将方法需要的参数,从左往右依次压入栈中,然后传给被调用的方法。动态参数也是如此,和C从右往左的入栈顺序不一样。动态参数,也是如此,是新建一个数组,传数组引用的,不是传整个数组。
上面是我看字节码指令,自己理解的。
看具体的代码和字节码:
void createUDG() {
int num = 90;
int cc = test(num, 98);
}
int test(int num, int... dd) {
int a = 90;
int c = 99;
int b = num;
return num;
}
字节码指令的理解
如果你学过汇编,那应该很好理解。没学过的话,我已经逐行解释了,希望你也能看懂。
这里提醒下:
存储和取值针对的是局部变量表的
Slot位置。压栈、出栈是针对操作数栈。数据在操作数栈和局部变量表中移动。
// createUDG 方法的字节码
// 90 压入操作数栈
0 bipush 90
// 弹出栈顶元素 90 存进 1 号 Slot(num)中
2 istore_1
// 下面几步 在为调用方法准备
// 将 0 号 slot(this) 压入栈顶 ,方法的第一个参数
3 aload_0
// 将 1 号 Slot(num) 压入栈顶 方法的第二个参数
4 iload_1
// 将 int类型常数 1 压入栈顶, 准备 方法的第三个参数:数组,这里是指定的长度
5 iconst_1
// 准备 方法的第三个参数数组的长度,创建数组
// 创建一个基本类型数组,并将数组引用 压入栈顶
// 数组长度是当前栈顶的值,10 可不是长度,Arr_type:10代表int(字节码指令文档中可查)
6 newarray 10 (int)
// 复制栈顶元素,并将复制值 压入栈顶
// 现在栈顶元素 和 第二个元素,都是数组的引用
8 dup
// 将 int类型常数 0 压入栈顶
9 iconst_0
// 98 压入栈顶
10 bipush 98
// iastore用到操作数栈的格式:arrayref, index, value ,三个参数,往前数三个,分别是数组引用、常数0,常数98。用完记得出栈,因为最后是平栈的。
// iastore 的语义:将栈顶 int 类型数据传入指定数组的指定下标中
// 这里就是将 栈顶的98 存入到之前 dup 复制的数组的 0 下标中。
12 iastore
// 调用 test 方法,参数是 this ,num,数组引用,用完也出栈
13 invokevirtual #2 <com/example/demo/GraphTest.test>
// 返回的返回值存入 2 号 Slot(CC) 中
16 istore_2
17 return
可以看到,在调用test 方法之前,将需要用到的参数,按照定义的顺序,从左往右,依次入栈。并且有的字节码指令是用到操作数栈中的元素,用完就出栈了,最后是平栈的。
在此中,我们还能看到一些细节,比如数组的创建。JVM自己创建了数组,用来传递可变参数。
栈帧之间的数据共享
栈帧,在模型上,不同的栈帧是隔离的,但是实际实现的时候,hotspot 在这块是有数据共享的。Hotspot将调用者的操作数栈和被调用者的局部变量表做了数据共享,有重叠部分。这是合乎情理的,在调用者处,把要用到的参数都入栈了,在被调用者那里,为哈还要浪费空间,重新给这些参数准备空间。何不直接共享。
比如,上面在调用test方法之前,createUDG 方法的栈帧中的操作数栈中,已经把要用到的参数都入栈了。在test中,需要为方法的参数和局部变量赋值,也就是往局部变量表中塞值。我们知道这部分值已经在test方法的操作数栈中了。test方法直接将createUDG 方法的栈帧中的操作数栈的数据当成自己局部变量表的一部分。省了部分局部变量表空间。
栈顶缓存技术
有一篇文章讲的很好:栈顶缓存技术。不但讲了栈顶缓存技术是什么,还讲了怎么实现。通过实现原理,我们才能清楚,为啥栈顶缓存技术可以高效。
下面我说下,上文中可能没讲到的细节,是我在另外一本书《揭秘java虚拟机》中看到的:
hotspot的栈顶缓存,是遇到要压栈的操作的时候,是直接往寄存器中送数据。
如果寄存器中没有数据,也就是栈顶缓存为空,则数据就放在寄存器中,不会往操作数栈中送了。这样CPU要使用数据,就会很快,因为在寄存器中。
如果寄存器中已经有了数据。也就是栈顶缓存不为空,则把栈顶缓存的数据,从寄存器中搬回来,搬到它本来应该去的地方:操作数栈中。然后把当前要送的数据,送到寄存器中。
这样,通过把一个值放进寄存器中。其他字节码指令在用到操作数的时候,如果用到一个操作数,直接去寄存器中,获取。如果用到两个操作数,那么一个在寄存器中,一个在操作数栈中。因此,减少了访问内存的次数。
动态链接
首先说下,什么是动态链接,名字很酷炫,其实就是翻译,符号引用翻译为直接引用。
这里需要特别注意下:JAVA中在编译的时候,是全部使用符号引用来替代直接引用的。也就是说,编译出来的class文件的常量池中一开始全部是符号引用和字面量。符号引用翻译为直接引用的时机在后面的加载解析阶段,有的符号引用甚至推迟到运行时,才会被翻译为直接引用,这种翻译就是动态连接。
栈帧中持有指向方法所属的类型的运行时常量池的引用,注意是 指向运行时常量池的引用。这和周志明老师的书上有出入。
为什么持有该引用
书上的话:栈帧中还保存了一个指向运行时常量池中该栈帧所属方法的引用。持有这个引用是为了动态链接。
我看了
oracle原文:Each frame contains a reference to the run-time constant pool for the type of the current method to support *dynamic linking* of the method code说的不是指向方法,说的是方法所属的类型的运行时常量池。和书上直接两个意思。恐怖。我就是始终无法理解指向方法是干嘛的,跑去看原文…
但是我查看资料,也没理清楚,这个引用是怎么支持动态链接的,换句话说,动态链接是怎么实现的。关于
JVM的资料太少了,而且都是抄书。搜到的文章基本都是周志明老师的书上的原话,也没有解读。后来我换到
Stack Overflow上找,栈帧中持有这个引用的原因是,有个回答,我比较认可的是:静态方法没有this,找不到类本身自己在哪里,所以持有一个指向运行时常量池的引用。
持有引用以后,可以找到运行时常量池,便于翻译,将符号引用解析成直接引用。因为字节码运行的时候,是需要运行时常量池的,因为运行时常量池相当于一个资源仓库,需要用到东西都在里面。
实例方法中有this能找到堆上的类对象在哪里(通过this找到实例对象,再通过实例对象getClass()找到类对象),也就很容易找到运行时常量池。静态方法没有this,无法定位运行时常量池在哪里,所以就在栈帧中持有一个运行时常量池的引用吧。

这个图,按照Oracle的原文说,是不对的,不是指向方法,是指向方法所属类型的运行时常量池。不过也奇怪,图中引用名称还是当前类常量池引用,然后指向的时候,却指向了常量池中的方法引用。
JVM的学习,就这样吧,瞎学,资料太少了。沙里淘金一样。资料少,重复率还高,互相抄。不说人话,就动态连接这,我学习的时候,困扰我两天,我始终想不明白,持有方法引用,是如何方便了动态连接。
中文一搜索,全是书上的内容。英文搜索
dynamic linking还能找到点有用的信息,真是时势造英雄,环境太重要了,我们的学习环境,技术氛围…
方法返回地址
方法返回有两种情况:
- 正常执行完毕,返回。称之为正常完成出口
NMIC = normal method invocation completion - 产生异常,并且该异常没有在异常表中声明。即发生了没有处理的异常。这种返回,称为异常完成出口
AMIC。
如果是正常完成出口,会将调用者的pc寄存器的值作为返回地址,所以当前栈帧中保存的方法返回地址,就是调用者的PC寄存器值,也就是被调用方法的下一条指令地址。
如果是异常完成出口,那么就是由异常处理器表来处理,直接抛出异常了,保存不保存返回地址,也就无所谓了。
但是应该是都保存吧。毕竟在创建栈帧的时候,方法是否正常退出还是未知的,应该都把下一条指令地址保存在栈帧中。
方法返回会做的一些操作
方法返回,在虚拟机栈中的体现,就是当前栈帧出栈。出栈以后可能会进行下面的操作
- 恢复上层栈帧的局部变量表和操作数栈
- 如果有返回值,则把返回值压入上层栈帧的操作数栈中
- 调整
PC寄存器的值。
附加信息
栈帧中还可以有一些其他附加信息,这点不重要。
动态连接、方法返回地址、附加信息,有时候统称为栈帧信息。
方法调用
这里注意下,方法调用和平时说的方法调用不是一回事,平时我们说的方法调用,调用XXX方法,更多的含义是执行XXX方法。
这里的方法调用,是在
JVM层次上,指的是确定调用哪一个方法,而不是执行,更多的是确认。
提到动态连接,需要说下方法调用。
前面在动态连接的开始处,已经说过了,java的特别之处,编译期间,只有符号引用,不含有连接这一步. 所以常量池中全是符号引用。
解析
对类加载子系统熟悉的人都知道,加载阶段过后,是连接阶段,连接分三步,其中就有解析阶段。这个解析阶段也就是符号引用翻译为直接引用。
但是当时并没有说明或者说清楚哪些符号引用是在解析阶段翻译为直接引用的。
其实换位思考下,我们也能猜出,解析阶段能够翻译为直接引用的。必然是一开始就能够确定执行的是哪一个方法,因为符号引用翻译为直接引用,就是确定具体调用哪一个方法。方法调用就是干这件事的。
那么什么方法可以一开始就确定,后面不会变?
极端点,多态是不可能一开始就确定下来的。接口方法也应该不能,因为接口可以有许多实现。
静态方法可以,因为静态方法不能被重写和继承。说是谁就是谁。同样不能被继承的还有私有方法、构造器方法。
这是站在语言分析的角度,站在虚拟机的角度,其实更简单。只有5个指令。
-
invokestatic调用静态方法 -
invokespecial调用特别的方法:私有方法、构造器方法、父类方法父类方法也是特别的方法,原因在于,你要显示的调用父类方法,就需要使用
super关键字,你一点使用了super,那么就确定下来,是调用父类的方法。 -
invokevirtual调用虚方法虚方法的对立面,是那些解析阶段就可以确定下来的方法,它们不虚,它们很实。其他解析阶段不能确定的方法,那就是虚的,后面动态连接确定。
因此除了静态方法、特别的方法,其他方法统称为虚方法。
但是有个特殊的
final修饰的方法,虽然也是使用invokevirtual指令,但是final方法不算虚方法。 -
invokeinterface调用接口方法 -
invokedynamic调用动态方法这个有别于前面四个指令,前四个指令是虚拟机决定调用谁,
invokedynamic是用户程序员决定调用谁。
这5个指令中,前2个指令调用的方法就是非虚方法,在解析阶段就可以确定的方法。
分派
在解析阶段可以确定的方法调用,称为解析。解析是静态的,能够被解析的符号引用,在编译期间就确定了,然后在解析的时候,翻译为直接引用。
分派则可能是静态的也可能是动态的。还分为单分派和多分派。两两组合,分为四种。
单分派和多分派是根据宗量数分的。
宗量:方法的接收者、方法的参数都称为宗量。
解释为 分派的时候,如果只根据方法接收者来确定,就是单分派,如果既需要方法接收者也需要方法参数,那么就称为多分派。
静态分派
需要再引入两个概念:变量的静态类型、实际类型。
Human是Man的父类
Human man = new Man();
对于这样一句代码,Human是变量的静态类型,man是变量的实际类型。
静态类型和实际类型都会发生变化,但是二者的变换不尽相同。静态类型仅仅在使用的时候才能发生变化,也就是强转,但是静态类型本身不会变化;
sr.sayHello((Man)man);
这里在使用的时候,强转了,对于方法参数而言,传的时候Man类型,但是静态类型本身,也就是man变量的本身类型还是Human。
实际类型的变换,则不是这样,是变了就变了。
Human是Women的父类
man = new Women();
变量man指向的实际类型已经从Man变为Women了。但是这种变换只有在运行时才能知道,编译期间是不能获知的。也就是说,编译器在编译期间无法获取变量的实际类型。
Human man = new Man();
Human woman = new Women();
sr.sayHello(man);
sr.sayHello(man);
上面这段代码,将说明白java的静态分派:
在方法接收者(调用者)明确是sr的前提下,已经有一个宗量是确定的了,然后根据方法参数类型,确定到底分派到谁。
并且静态类型是编译期可知的。所以无论我们传谁进去,都被当成静态类型。所以即使sr类中有好多个sayHello方法,最后也是确定调用sayHello(Human)这个版本。
这种依赖静态类型来定位具体调用哪一个方法,就是静态分派。典型应用就是重载。
有时候也不是百分百能定位到符合的方法,如果找不到,则寻找最符合的。按照接口寻找、父类寻找等。
比如我在《Thinking in java》里面看到的基本数据类型重载,就是按照char>int>long>float>double的顺序来依次寻找。如果还寻找不到,则往接口上寻找,往Object类上寻找。
动态分派
上面说静态分派体现的是重载,动态分派体现的其实就是重写。
Human man = new Man();
Human woman = new Women();
man.sayHello();
woman.sayHello();
上面这段代码,虽然左边都是Human,也就是静态类型相同,但是实际类型不同。编译的时候,都是按照静态类型,在Human里面找有没有sayHello这个方法。运行的时候,则是根据实际类型的方法运行。
这也是顺口溜,编译看左边,运行看右边的原因。
具体如何寻找实际调用的方法?
这是字节码指令invokevirtual的原因。invokevirtual指令的逻辑如下:
-
找到操作数栈顶的第一个元素,获取它指向对象的实际类型,记住
C。就这一步就绕过了静态类型,实打实的用实际类型。 -
然后递归的在
C及其父类、接口中寻找对应方法。匹配上以后,再判断权限。如果都通过,则返回,否则抛出异常。这和 类加载子系统 的解析过程差不多。
单分派和多分派
java中静态分派是多分派。
静态分派在编译期间确定,根据方法的接收者和方法参数两个宗量,来确定具体的方法调用。
java中动态分派是单分派。
在动态分派的时候,已经确定了方法签名,比如
sayHello(Human)。此时参数类型Human已经不重要了,无论传递过来的是Man还是Woman,一点都不重要。参数的静态类型、实际类型是啥,不会影响最后的结果。影响最后的结果是方法的接收者是谁,方法接收者的实际类型是啥。也就是调用谁的
sayHello(Human)。这时候仅仅根据一个宗量来判断。所以是单分派。
动态分派的实现
动态分派根据invokevirtual指令的逻辑寻找具体的方法调用。如果每次都按照逻辑寻找的话,很浪费时间。
不同的虚拟机有不同的优化手段,一种常见的优化手段:虚方法表。

在方法区中为类建立一个虚方法表。如果是接口方法,还有一张接口方法表。表中存放着类对用的方法的实际地址。
比如,一个方法是从父类继承来的,并且自己没有重写,那么它的实际地址,肯定不在子类中,所以子类的虚方法表中,和父类的虚方法表中的方法入口地址一致。如果自己重写了,则指向自己重写的。
这样,一次寻找完成以后,就会有缓存,下次再寻找,就直接拿表中数据,不需要再次寻找。
虚方法表由虚拟机在为类的变量赋予初始值之后,进行初始化。
还有一些优化手段:内联缓存、守护内联。以后会写。
堆
堆的知识点这一章,我主要是写了解读。
知识原文不是我写的,因为我没找到对应的知识点在书上哪里…所以拿了一份网上流传的老师的课件,做了删改,添加了批注。
堆的核心概述
堆与进程
-
堆针对一个
JVM进程来说是唯一的。也就是一个进程只有一个JVM实例,一个JVM实例中就有一个运行时数据区,一个运行时数据区只有一个堆和一个方法区。这里需要说明下的是,什么是
JVM进程?每启动一个
java应用,就是启动了一个JVM进程。同一时刻,同一台机器上,可以同时启动多个java应用,对应着,同时有多个JVM进程。不是一台机器上安装了一个
JDK,只有一个JVM进程,相当于多开JVM。 -
但是进程包含多个线程,他们是共享同一堆空间的。
堆的通识概念
-
一个
JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。 -
Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,堆是JVM管理的最大一块内存空间,并且堆内存的大小是可以调节的。这里必须批注下:
正常的语序应该是:
Java堆区在JVM启动的时候即被创建,堆内存的大小是可以调节的,即在启动之前进行堆内存大小设置,启用以后其空间大小就确定了。不然会有歧义:确定了大小以后,为啥还可以调节。
-
《
Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。 -
所有的线程共享
Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。 -
《
Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)- 从实际使用角度看:“几乎”所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)
-
数组和对象可能永远不会存储在栈上(不一定),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
-
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 也就是触发了GC的时候,才会进行回收
- 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有
stop the world = STW
-
堆,是
GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
随着
JVM的迭代升级,原来一些绝对的事情,在后续版本中也开始有了特例,变的不再那么绝对。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2itDZsO5-1640160557426)(https://blog-1307734415.cos.ap-nanjing.myqcloud.com/blog/images/0002.png)]
这幅图形象的画出了,堆、栈、方法区的关系。
博主在类加载子系统中曾经提到过这里的方法区:字节码文件在加载以后,在方法区中创建了一个数据结构,然后在堆上创建一个类对象,作为方法区中数据结构的入口。
堆内存细分

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
Java7 及之前堆内存逻辑上分为三部分:新生区+养老区+永久区Young Generation Space新生区Young/New- 又被划分为
Eden区和Survivor区
- 又被划分为
Old generation space养老区Old/TenurePermanent Space永久区Perm
Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间Young Generation Space新生区,又被划分为Eden区和Survivor区Old generation space养老区Meta Space元空间Meta
总结起来:
1.8以后元空间替代了永久代。注意新生代被划分为三部分。
有个细节需要注意下:无论是
1.7的永久代还是1.8的元空间,都不算堆空间大小的。堆空间大小:新生代+老年代。方法区和堆的关系,逻辑上是在一起的,方法区是堆的一部分。实际中,有的虚拟机实现,分开了,比如
Hotspot,就把方法区叫非堆,表明和堆不在一起。
JVisualVM可视化查看堆内存
双击jdk目录下的jvisualvm,再安装Visual GC插件。
不想翻看
JDK目录,就在CMD里面输入jvisualvm,也是可以启动jvisualvm的。

其他方式查看堆内存
方式一: jps / jstat -gc 进程id
jps:查看java进程
jstat:查看某进程内存使用情况

xxC :表示XX的总共容量。xxU :表示XX的已经使用的容量。
方式二:-XX:+PrintGCDetails

PrintGCDetails参数,会打印GC细节,如果有GC行为也会打印出来,这里没有GC,所以只打印了最后堆内存的情况。
设置堆内存大小与 OOM
JVM常用内存参数的用法
-
Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项-Xms和-Xmx来进行设置。-
-Xms用于表示堆区的起始内存,等价于**-XX:InitialHeapSize**
ms是memory start还记得前面说过,堆内存大小:新生代+老年代,
所以这里设置的是 年轻代+老年代 的初始内存大小
-
-Xmx则用于表示堆区的最大内存,等价于**-XX:MaxHeapSize**
同样的,这里设置的也是(年轻代+老年代)的最大内存大小
-
-
一旦堆区中的内存大小超过
-Xmx所指定的最大内存时,将会抛出OutofMemoryError异常。 -
通常会将
-Xms和-Xmx两个参数配置相同的值。假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。
频繁的扩容和释放造成不必要的压力,避免在
GC之后调整堆内存给服务器带来压力。避免容量震荡。
-
内存默认情况:
- 初始内存大小:物理电脑内存大小
1/64 - 最大内存大小:物理电脑内存大小
1/4
- 初始内存大小:物理电脑内存大小
关于堆内存的一个细节
首先设置堆内存的大小为600M。

然后写代码测试下:
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
输出结果:发现堆内存大小只有575M,
-Xms : 575M
-Xmx : 575M
为什么会少25M?
因为S0区和S1区两个只有一个能使用,另一个用不了。在GC的时候,对象在它们之间复制传递。只能使用其中的一个。也就是实际可用堆内存,是要减去一个S区的。
这里少算一个S区,这里的一个S区,刚好是25M。
年轻代与老年代
-
存储在
JVM中的Java对象可以被划分为两类:- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与
JVM的生命周期保持一致
-
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)
-
其中年轻代又可以划分为Eden(伊甸园)空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)

- 配置新生代与老年代在堆结构的占比
- 默认**-XX:NewRatio**=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
- 可以修改**-XX:NewRatio**=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
- 在
HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8 : 1 : 1, - 当然开发人员可以通过选项**-XX:SurvivorRatio**调整这个空间比例。比如
-XX:SurvivorRatio=8 - 几乎所有的
Java对象都是在Eden区被new出来的。 - 绝大部分的
Java对象的销毁都在新生代进行了(有些大的对象在Eden区无法存储时候,将直接进入老年代),IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。 - 可以使用选项
-Xmn设置新生代最大内存大小,但这个参数一般使用默认值就可以了。

对象分配过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
文字描述
-
new的对象先放伊甸园区。此区有大小限制。 -
当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(
MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。 -
然后将伊甸园中的剩余对象移动到
幸存者0区。 -
如果再次触发垃圾回收,此时上次幸存下来的放到
幸存者0区的,如果没有回收,就会放到幸存者1区。这里就是为啥两个幸存者区,在同一个时刻,只能使用一个的原因,因为在存活下来的对象,在幸存者区里面,相互倒腾,刷对象年龄,每幸存一次,对象年龄就+1,达到参数设置的阈值的时候,就转去老年代。
-
如果再次经历垃圾回收,此时会重新放回
幸存者0区,接着再去幸存者1区。 -
啥时候能去养老区呢?可以设置次数。默认是15次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:-XX:MaxTenuringThreshold=N 进行设置
最大设置15。
因为对象年龄,保存在对象头中,用四位比特存储,最大值就是15。
并且这个阈值,不要更改比较好,改小了,大部分对象进入老年代,
YGC就失去了意义。 -
在养老区,相对悠闲。当养老区内存不足时,再次触发
GC:Major GC,进行养老区的内存清理 -
若养老区执行了
Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。
画图解释
- 我们创建的对象,一般都是存放在
Eden区的,当我们Eden区满了后,就会触发GC操作,一般被称为YGC / Minor GC操作
这里有个细节需要注意下:
是
Eden区满了,就触发YGC,YGC的垃圾回收范围,包括幸存者区。相反幸存者区满了,是不会触发
YGC的,只会将其中的对象晋升到老年代。后面的特殊情况处有说明。

-
当我们进行一次垃圾收集后,红色的对象将会被回收,而绿色的独享还被占用着,存放在
S0(Survivor From)区。同时我们给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄加1。 -
同时
Eden区继续存放对象,当Eden区再次存满的时候,又会触发一个MinorGC操作,此时GC将会把Eden和Survivor From中的对象进行一次垃圾收集,把存活的对象放到Survivor To(S1)区,同时让存活的对象年龄+ 1
下一次再进行GC的时候,
1、这一次的s0区为空,所以成为下一次GC的S1区
2、这一次的s1区则成为下一次GC的S0区
3、也就是说s0区和s1区在互相转换。

- 我们继续不断的进行对象生成和垃圾回收,当
Survivor中的对象的年龄达到15的时候,将会触发一次Promotion晋升的操作,也就是将年轻代中的对象晋升到老年代中

关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
特殊情况说明
对象分配的特殊情况
- 如果来了一个新对象,先看看
Eden是否放的下?- 如果
Eden放得下,则直接放到Eden区 - 如果
Eden放不下,则触发YGC,执行垃圾回收,看看还能不能放下?
- 如果
- 将对象放到老年区又有两种情况:
- 如果
Eden执行了YGC还是无法放不下该对象,那没得办法,只能说明是超大对象,只能直接放到老年代 - 那万一老年代都放不下,则先触发
FullGC,再看看能不能放下,放得下最好,但如果还是放不下,那只能报OOM
- 如果
- 如果
Eden区满了,将对象往幸存区拷贝时,发现幸存区放不下啦,那只能便宜了某些新对象,让他们直接晋升至老年区

GC分类
-
我们都知道,
JVM的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现STW(Stop the World)的问题,而Major GC和Full GC出现STW的时间,是Minor GC的10倍以上 -
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)-
部分收集:不是完整收集整个
Java堆的垃圾收集。其中又分为:- 新生代收集(
Minor GC/Young GC):只是新生代(Eden,s0,s1)的垃圾收集 - 老年代收集(
Major GC/Old GC):只是老年代的圾收集。 - 目前,只有
CMS GC会有单独收集老年代的行为。 - 注意,很多时候
Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。 - 混合收集(
Mixed GC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为
- 新生代收集(
-
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
-
由于历史原因,外界各种解读,
majorGC和Full GC有些混淆。
Young GC
年轻代 GC(Minor GC)触发机制
- 当年轻代空间不足时,就会触发
Minor GC,这里的年轻代满指的是Eden代满。Survivor满不会主动引发GC,在Eden区满的时候,会顺带触发s0区的GC,也就是被动触发GC(每次Minor GC会清理年轻代的内存) - 因为
Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。 Minor GC会引发STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

Major/Full GC
Full GC有争议,两者触发条件很混淆。
MajorGC触发机制
-
指发生在老年代的
GC,对象从老年代消失时,我们说Major Gc或Full GC发生了 -
出现了
MajorGc,经常会伴随至少一次的Minor GC。(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)原因其实很好理解。
因为在分配新的对象内存的时候,首先在新生代分配,如果新生代空间不足,会触发一个
YGC。YGC以后,空间还是不足,就会直接放进老年代中。这时候,如果老年代空间也不足,则会触发
OGC。所以说,在发生
OGC的时候,一般都触发过了YGC。 -
Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。 -
如果
Major GC后,内存还不足,就报OOM了
Full GC 触发机制
触发Full GC执行的情况有如下五种:
- 调用
System.gc()时,建议系统执行FullGC,但是不必然执行,因为只是建议。 - 老年代空间不足
- 方法区空间不足
- 通过
Minor GC后进入老年代的平均大小大于老年代的可用内存 - 由
Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
其实说到这,也发现,为啥FullGC和MajorGC 容易混淆了,都是在老年代空间不足的时候,触发。
说明:Full GC 是开发或调优中尽量要避免的。这样STW时间会短一些
GC日志分析
使用参数-XX:+PrintGCDetails 打印GC细节。
拿其中的一条来讲解,怎么看:
[GC (Allocation Failure) [PSYoungGen: 2037K->504K(2560K)] 2037K->728K(9728K), 0.0455865 secs] [Times: user=0.00 sys=0.00, real=0.06 secs]
- 首先是开头
[GC (Allocation Failure):Allocation Failure是GC发生的原因。 [PSYoungGen:表示GC行为发生在哪里,PSYoungGen表示发生在新生代。- 紧接着的
2037K->504K(2560K):(2660K)表示年轻代的总空间大小;2037K->504K,前面的2037表示GC前的年轻代使用空间大小;后面的504k表示GC以后,新生代的使用空间大小。 - 后面的
2037K->728K(9728K):(9728K)表示堆内存总空间为9728K,当前堆空间一共占用2037K,经过垃圾回收后堆空间还占用728K 0.0455865 secs是本次GC的执行时间。
堆空间分代思想
为什么要把Java堆分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
- 新生代:有
Eden、两块大小相同的survivor(又称为from/to或s0/s1)构成,to总为空。 - 老年代:存放新生代中经历多次
GC仍然存活的对象。


其实不分代完全可以,分代的唯一理由就是优化GC性能。
对象内存分配策略
- 如果对象在
Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。 - 对象在
Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代 - 对象晋升老年代的年龄阀值,可以通过选项**-XX:MaxTenuringThreshold**来设置
针对不同年龄段的对象分配原则如下所示:
-
优先分配到Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发
Major GC的次数比Minor GC要更少,因此可能回收起来就会比较慢 -
大对象直接分配到老年代:尽量避免程序中出现过多的大对象
-
长期存活的对象分配到老年代
-
动态对象年龄判断:如果
Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。这一条其实很简单,也是必须的。就是幸存者区满,提前晋升的另外一种说法。
你想啊,年龄相同并且占幸存者区空间的一半。下一次
GC以后,如果活了下来,继续保留在幸存者区的话,比它们年龄大的也还继续待在幸存者区。因为年龄小的对象的体量太大了,已经占用一半幸存者区空间了。这样下去,幸存者区,早晚满。不如现在,就把比它活的更久的对象,直接晋升。
-
空间分配担保:
-XX:HandlePromotionFailure。
后面会讲什么是 空间分配担保。
这里只需要知道 空间分配担保 ,会影响是否进行
Full GC.
TLAB 对象分配内存
为什么有 TLAB
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在
JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的,为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。 - 所以希望堆上有一些空间,不是共享的,是线程独享的。这就是
TLAB。- 每次线程分配空间的时候,先在自己独享的
TLAB中分配,这样就不需要加锁了 - 除非
TLAB满了,或者不够用了,才会尝试在堆上分配空间。此时再加锁。
- 每次线程分配空间的时候,先在自己独享的
什么是 TLAB
TLAB(Thread Local Allocation Buffer)
- 从内存模型而不是垃圾收集的角度,对
Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。 - 多线程同时分配内存时,使用
TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

-
每个线程都有一个
TLAB空间 -
当一个线程的
TLAB存满时,可以申请新的TLAB或者使用公共区域(蓝色)的,使用蓝色空间,需要加锁。
TLAB 细节问题
-
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
因为有的对象太大,
TLAB一开始就放不下这样的对象,此时就只能加锁在堆空间上分配了。 -
在程序中,开发人员可以通过选项
-XX:UseTLAB设置是否开启TLAB空间。 -
默认情况下,
TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。 -
一旦对象在
TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
1、哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定 ----这是《深入理解JVM》–第三版里说的
而且也不是一定加锁在堆上分配,也可能继续申请新的
TLAB。
TLAB 分配过程

堆空间参数设置
常用参数设置
官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
/**
* 测试堆空间常用的jvm参数:
* -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处理日志
* 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
* -XX:HandlePromotionFailure:是否设置空间分配担保
*/
空间分配担保
1、在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
-
如果大于,则此次
Minor GC是安全的 -
如果小于,则虚拟机会查看
-XX:HandlePromotionFailure设置值是否允担保失败。-
如果
HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小.
- 如果大于,则尝试进行一次
Minor GC,但这次Minor GC依然是有风险的; - 如果小于,则进行一次
Full GC。
- 如果大于,则尝试进行一次
-
如果
HandlePromotionFailure=false,则进行一次Full GC。
-
就是是否可以接受空间分配失败的危险,如果可以接受,则少一次FULL GC;如果不可接受,则直接FULL GC。
在1.7 以后,废弃了。规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。
堆是分配对象的唯一选择么?
理论上不是,通过逃逸分析,对象是可以分配到栈上的。但是JVM的具体实现不同,导致的结果不同。Hotspot 就没有使用栈上分配,所以对象还都是创建在堆上。
- 随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。- 此外,前面提到的基于
OpenJDK深度定制的TaoBao VM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
逃逸分析
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少
Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。 - 通过逃逸分析,
Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。 - 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
逃逸分析举例
总结起来,对象要是没有逃逸,那么就是在方法内诞生,在方法内消亡。
- 没有发生逃逸的对象,则可以分配到栈(无线程安全问题)上,随着方法执行的结束,栈空间就被移除(也就无需GC)
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 1.7版本之后,HotSpot中默认就已经开启了逃逸分析 - 如果使用的是较早的版本,开发人员则可以通过:
- 选项
-XX:+DoEscapeAnalysis显式开启逃逸分析 - 通过选项
-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果
- 选项
代码优化
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。
分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
同步省略(同步消除)
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,
JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。 - 如果没有,那么
JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
注意:字节码文件中并没有进行优化,加锁和释放锁的操作依然存在,同步省略操作是在解释运行时发生的。
标量替换
分离对象或标量替换
- 标量(
scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。 - 相对的,那些还可以分解的数据叫做 聚合量(
Aggregate),Java中的对象就是 聚合量,因为他可以分解成其他 聚合量 和 标量 。 - 在
JIT阶段,如果经过 逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
标量替换举例代码:
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}
以上代码,经过标量替换后,就会变成
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
-
可以看到,
Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。 -
那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
注意:
标量替换是不创建对象,重点是替换。
栈上分配是还需要创建对象,但是在栈中创建对象。
-
标量替换为栈上分配提供了很好的基础。
标量替换参数设置
参数-XX:+ElimilnateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
逃逸分析的不足
- 关于逃逸分析的论文在
1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。 - 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
- 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
最后,还记得前文说的到对象都是创建在堆上的问题吗?当时说的实现不同,导致的结果不同。
注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。Oracle Hotspot JVM中并未这么做,HotSpot仅仅实现了标量替换,这一点在逃逸分析相关的文档里已经说明,所以可以明确在HotSpot虚拟机上,所有的对象实例都是创建在堆上。
堆的总结
- 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
- 老年代放置长生命周期的对象,通常都是从
Survivor区域筛选拷贝过来的Java对象。 - 当然,也有特殊情况,我们知道普通的对象可能会被分配在
TLAB上; - 如果对象较大,无法分配在
TLAB上,则JVM会试图直接分配在Eden其他位置上; - 如果对象太大,完全无法在新生代找到足够长的连续空闲空间,
JVM就会直接分配到老年代。 - 当
GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC。 - 当
GC发生在老年代时则被称为Major GC或者Full GC。 - 一般的,
Minor GC的发生频率要比Major GC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
方法区
栈、堆、方法区的交互关系

-
Person类的.class信息存放在方法区中 -
person变量存放在Java栈的局部变量表中 -
真正的
person对象存放在Java堆中 -
在
person对象中,有个指针指向方法区中的person类型数据,表明这个person对象是用方法区中的Person类new出来的这一点比较重要,堆上的对象内部有个指针,指向方法区中
class对象的数据结构。
方法区的位置
前面在堆的部分,说过,方法区是堆的一部分。这也是JVM规范提到的。
JVM规范中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。
也就是厂商具体实现的时候,方法区和堆可以不在一起。其中HotSpot JVM,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

Hotspot JVM中,布局如上所示,堆和方法区分开了。
方法区的基本理解
方法区主要存放的是 Class,而堆中主要存放的是实例化的对象
-
方法区
(Method Area)与Java堆一样,是各个线程共享的内存区域。多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。 -
方法区在
JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。 -
方法区的大小,跟堆空间一样,可以选择
固定大小或者可扩展。 -
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:
java.lang.OutofMemoryError:PermGen space或者
java.lang.OutOfMemoryError:Metaspace常见的方法区溢出原因:
- 加载大量的第三方的
jar包 Tomcat部署的工程过多(30~50个)- 大量动态的生成反射类
- 加载大量的第三方的
-
关闭
JVM就会释放这个区域的内存。
HotSpot方法区演进
- 在
JDK7及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。我们可以将方法区类比为Java中的接口,将永久代或元空间类比为Java中具体的实现类 - 本质上,方法区和永久代并不等价。仅是对
Hotspot而言的可以看作等价。《Java虚拟机规范》对如何实现方法区,不做统一要求。 - 例如:
BEAJRockit / IBM J9中不存在永久代的概念。- 现在来看,当年使用永久代,不是好的
idea。导致Java程序更容易OOm(超过-XX:MaxPermsize上限)
- 现在来看,当年使用永久代,不是好的
- 而到了
JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替 - 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
- 永久代、元空间二者并不只是名字变了,内部结构也调整了。它两的变化在于将自己从虚拟机内部搬到虚拟机外部。
- 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出
OOM异常

Hotspot中方法区的变化
JDK1.6及以前 | 有永久代(permanent generation),静态变量存储在永久代上 |
|---|---|
JDK1.7 | 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中 |
JDK1.8 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。 |
字符串常量池
这里的字符串常量池和常量池不是一个东西~,它是 StringTable 。
移动字符串常量池的原因
JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发。- 这就导致
StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
总结起来就是:
字符串对象在开发中很常用,创建的很多,但是其中有相当一部分,用完以后,就被废弃了。此时应该对其进行回收。
但是字符串常量池放在永久代中,触发GC的概率很低,得不到及时的回收。占用永久代的空间。
方法区变化图示



设置方法区大小与 OOM
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 -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位 的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。- 如果初始化的高水位线设置
过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
如何解决方法区 OOM
-
要解决
OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Ec1ipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow) -
内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和GC ROOT有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题内存泄漏:是指可用内存越来越少,即有不再使用的对像一直没有被回收。一般就是代码问题了。
内存溢出:即所以的对象都需要活着,但是内存确实不够用了。
-
如果是内存泄漏,可进一步通过工具查看泄漏对象到
GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。 -
如果不存在
内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
方法区的内部结构

方法区存储内容

可以分为三大部分:运行时常量池、类元信息、虚方法表
类元信息
类型信息
具体的类信息,记录类级别的信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(类限定名)
- 这个类型直接父类的完整有效名(对于
interface或是java.lang.Object,都没有父类) - 这个类型的修饰符(
public,abstract,final的某个子集) - 这个类型直接接口的一个有序列表
域(Field)信息
就是具体的字段信息。记录字段级别的信息。
注意,这里是字段的引用,放在方法区中。引用对用的对象,都在堆上。
不要搞混,以为是字段引用的对象,放在方法区中。
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。- 域的相关信息包括:域名称,域类型,域修饰符(
public,private,protected,static,final,volatile,transient的某个子集)
静态变量
静态变量也属于域信息
-
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
-
类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
这条有点意外,申明一个类型的引用,赋值为
null,也可以通过这个空引用,调用类的静态方法。
静态常量
被static final修饰的域变量,则是常量了,在编译期间,就被放进了常量池中。不会放到方法区中。
方法(Method)信息
记录方法级别的信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
-
方法名称
-
方法的返回类型(包括
void返回类型),void在Java中对应的为void.class -
方法参数的数量和类型(按顺序)
-
方法的修饰符(
public,private,protected,static,final,synchronized,native,abstract的一个子集) -
方法的字节码(
bytecodes)、操作数栈、局部变量表的大小(abstract和native方法除外)这里保存的是操作数栈和局部变量表的大小。不是具体的内容,具体的内容在栈帧中。
-
异常表(
abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
虚方法表
还有一张前面讲的 虚方法表,也在方法区中。
运行时常量池
运行时常量的位置。如图所示:

- 方法区,内部包含了
运行时常量池 - 字节码文件,内部包含了
常量池。(之前的字节码文件中已经看到了很多Constant pool的东西,这个就是常量池) - 运行时常量池,基本就是类文件中的常量池。因为类文件中的常量池在被加载以后,就放进了方法区中,变为运行时常量池。
- 所以先看看常量池。
常量池

常量池的理解:
常量池中最开始,即编译的时候,放置的都是类中用到的符号引用,此时,并没有把真实的直接引用放进去,取代的,放的符号引用,比如放置类的全限定名。
这样做以后,字节码文件会比较少,因为,真实的内容,并没有放进去,放进去的最多算占位符。并且一开始,也不知道用到的那些引用指向的对象在哪里。
等到加载的时候,开始解析这些符号引用,才能知道一部分符号引用的对象在哪里,此时将符号引用解析为直接引用,即将常量池中符号引用解析为直接引用。剩下的解析时候,还不能确定的,就拖到运行时,动态连接。
符号引用被解析为直接引用,相当于,常量池中存放的字符串占位符,被替换为对象的真实地址。
当初学汇编没白学,寄存器的知识,还有指令的知识,JVM 中都能用上。
常量池中存放的内容
各种符号引用和字面值:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
运行时常量池和常量池的区别
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
动态性的意义在于:
常量池中的内容是固定的,在编译以后就确认了,不能再改变了。
而运行时常量池中的数据,是可以动态维护的。可以创建新的数据塞进去。String.intern 方法就会往运行时常量池中放字符串。
总结
有了对常量池的认识,再看运行时常量池就简单多了:
- 运行时常量池(
Runtime Constant Pool)是方法区的一部分。 - 常量池表(
Constant Pool Table)是Class字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。(运行时常量池就是常量池在程序运行时的称呼) - 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池类似于传统编程语言中的符号表(
symbol table),但是它所包含的数据却比符号表要更加丰富一些。 - 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则
JVM会抛OutofMemoryError异常。
永久代为什么要被元空间替代?
发展上的原因
官方文档中的说法是:为了和JRocket一致,因为JRocket中没永久代。用户不需要配置永久代的相关信息。
而Oracle想要融合JRocket,Hotspot,融合的简化,就是Hotspot也不要永久代了,这样新的JVM的用户不需要配置永久代。
不然JRocket 的老用户,还需要配置下永久代,人家会不习惯。而Hotspot老用户,告诉他不需要再配置永久代,他会很习惯。毕竟减法永源比加法让人更容易接受。
技术上的原因
不好确定方法区的大小。因为不能确定需要加载多少个类。尤其是动态加载的类,更不好提前估计需要多少个类,所以不好提前设置永久代的大小。
对永久代进行优化也是一个问题。虽然虚拟机规范中没有明确要求对方法区进行垃圾回收,但是Hotspot进行了GC。方法区中的GC,集中在对废弃的常量和不再使用的类型。但是不再被使用的类型的判断条件很苛刻。不好对其进行回收。
方法区的垃圾回收
前面说过,虚拟机规范对这个区域没有明确要求必须进行垃圾回收。
Hotspot自己多事进行了GC。其实也不是多事。在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
常量的回收
HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
类卸载
判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是
Java堆中不存在该类及其任何派生子类的实例。 - 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如
OSGi、JSP的重加载等,否则通常是很难达成的。 - 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息
运行时数据区总结


97
343

被折叠的 条评论
为什么被折叠?



