一、Java运行时数据区
简图:
简述:
- 堆、方法区是线程共享的,虚拟机栈、程序计数器、本地方法栈是线程私有的,一个线程一份。
- 虚拟机栈的基本单位是栈帧,一个方法的开始执行意味着一个栈帧进栈,一个方法的执行结束意味着一个栈帧进栈
- 程序计数器(PC)是记录字节码指令地址的地方,字节码解释器通过改变PC的值来执行一条一条字节码指令。
- 本地方法栈的结构跟虚拟机栈类似,主要是用来执行本地方法。
- 堆是存放对象的地方,JDK1.7后类变量和字符串常量池也放在了堆。
- 方法区主要存放被类加载器加载后的各种类型信息、方法信息、字段信息、常量、静态变量、即时编译器代码缓存等。
1.虚拟机栈
栈帧是虚拟机栈的基本单位,主要由局部变量表、操作数栈、动态连接、方法返回地址和一些附加信息组成。
局部变量表
局部变量表主要存放该方法的参数和方法内声明的变量。局部变量表是一个索引从0开始的,类似数组的结构。局部变量表的基本单位是变量槽(slot)。Java虚拟机规范并没有指出一个slot需要用多大内存来实现,一般都是4个字节(32位)。slot可以存放的内容包括boolean、byte、char、short、int、float、reference、returnAddress、double、long。其中除了double、long是64位的需要占两个slot,其它数据类型占1个slot。示例图如下:
如果执行的是实例方法(非static)方法,局部变量表的第一个slot(索引为0)存放的是该方法所属实例的引用(即”this“指针)。
局部变量表的大小是在编译后就可以确定的。对于变量只在该方法的某个区域有效,局部变量表提供了一种slot复用的机制,示例代码:
public void test2(){
int a = 1;
{
int b = 5;
a += b;
}
int c = 6;
}
在该方法中,局部变量b在第6行之后就失效了,所以变量b和变量c可以公用一个slot,即该方法的局部变量表只需要三个slot(一个this,一个a,一个b、c复用)。
对于局部变量,如果没有赋初值,就不能使用,示例:
public void hello(){
int a;
System.out.println(a);
}
操作数栈
同局部变量表一样,操作数栈的深度也是在编译时确定的。操作数栈的每一个元素可以是long、double在内的所有数据类型,其中long、double占2个栈容量,其它的占一个。
操作数栈的作用:方法运行时,各种字节码指令执行的时候会往操作数栈写入和提取内容(各种数据类型,包括引用),对应入栈和出栈操作。示例:
public class OpStatckTest {
static void show(){
System.out.println("hello!");
}
public static void main(String[] args) {
int i = 0;
i++;
int j = 0;
j += i;
show();
}
}
使用javap -v反编译字节码指令后的main方法对应的指令码:
Code:
stack=2, locals=3, args_size=1
0: iconst_0 //将常量0加载到操作数栈
1: istore_1 //将操作数栈的栈顶元素加载到局部变量表索引为0的位置
2: iinc 1, 1 //局部变量自增指令,i++
5: iconst_0 //将常量0加载到操作数栈
6: istore_2 //将常量0加载到操作数栈
7: iload_2 //将局部变量表索引为2的元素加载到操作数组,即j=0,0入栈
8: iload_1 //将局部变量表索引为1的元素加载到操作数组,即i=1,1入栈
9: iadd //将栈顶两个元素出栈,相加后入栈,0+1=1
10: istore_2 //将栈顶元素出栈,存入局部变量表索引为2的位置,即j=1
11: invokestatic #5 // Method show:()V
14: return //方法返回
可以看到,方法执行过程中操作数栈和局部变量表需要频繁交互,于是有了栈顶缓存技术:
有了栈顶缓存技术,进行方法调用时就可以直接共用一些数据了,无须再进行额外的参数复制传递了。
动态连接
每个栈帧都包含一个指向常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
运行时常量池中有各种方法的符号引用,本方法的栈帧的动态连接就是为了调用其它方法时将其它方法的符号引用转化为直接引用。
方法返回地址
方法返回地址存放调用该方法时PC寄存器的值,以便该方法return之后恢复到原方法的执行。以上面为例:
方法的退出有两种方法:
1.正常return;
2.出现异常且没有被处理;
只有正常return才会恢复PC的值为方法返回地址的值,出现异常且没有处理时,方法返回地址由异常处理器表来确定。
附加信息
这个是Java虚拟机没有规定具体怎么实现的,各个不同的虚拟机可以自行设计,添加一些额外的东西。
2.程序计数器
程序计数器(PC)的作用是保存当前字节码指令的地址,字节码解释器根据这个地址去找到相应的字节码指令解释执行。执行完成后,PC更新为下一条指令的地址,字节码解释器再找到指令解释执行,以此循环下去。
程序计数器是线程私有的。操作系统通过分配时间片的方式分配给线程去执行,当线程时间片用完了之后,就需要由操作系统调度去切换线程,此时PC的值不能被修改,不然恢复线程时就不知道执行到哪里了。所以需要一个线程一份程序计数器(或许是将PC的值保存起来?这点很疑惑,至少操作系统是这样做的)
当执行的是一个Java方法时,程序计数器的值为正在执行的虚拟机字节码指令的地址;当执行的是一个本地(Native)方法时,程序计数器的值为空(Undefined)。
3.本地方法栈
本地方法栈的用于管理本地方法的执行,也是线程私有的。它的具体做法是Native Method Stack中登记Native方法,在Execution Engine执行时加载本地方法库。
4.堆
堆是所有线程共享的区域,一个JVM实例只存在一个Java堆,堆也是内存管理的核心区域。
堆区是存放对象的地方,根据分代收集理论又可以划分成新生代和老年代,新生代又细分为Eden、from、to三个区域。
在逻辑上,方法区也属于堆的一部分。逻辑上应该是这样子的:
图示:
关于对象在堆的内存布局以及怎样分配的,见后面。
5.方法区
方法区和堆一样,是各个线程共享的区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存、域信息、方法信息等。
Hotshot虚拟机方法区在JDK1.7,1.8的演变
1.JDK1.6及1.6以前,方法区采用永久代实现,类变量(静态变量)、字符串常量池还在方法区中,方法区实际存在于虚拟机内存中;
2.JDK1.7,方法区采用永久代实现,类变量(静态变量)、字符串常量池被移出方法区放入堆中,方法区实际存在于虚拟机内存中;
3.JDK1.8,方法区采用元空间实现,类变量(静态变量)、字符串常量池被移出方法区放入堆中,方法区实际存在于直接内存中。
永久代为什么要被元空间替换?
1.为永久代设置空间大小是很难确定的。在某些场景下,动态加载的类过多,容易产生永久代的OOM。而采用元空间实现,元空间的大小实际是受本地内存限制的,不容易产生OOM;
2.对永久代进行调优是很困难的。判断类是否允许回收需要同时满足以下三个条件:
- 该类的所有实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例;
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载等,否则是很难达成的;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问到该类的方法。
而满足了这三个条件也是仅仅允许回收,也不一定会进行回收。
StringTable为什么要调整?
如果放在永久代或者元空间,字符串常量池的回收效率自然也不高。而一般我们很多字符串是可以回收的,如果创建了大量字符串而不及时回收,浪费空间,在永久代实现还容易导致OOM。所以放入堆中,是为了方便垃圾回收。
二、对象分配与GC
1.创建一个新对象时,会在新生区的Eden进行分配;如果新生区的空间不足以分配,会进行一次Minor GC(Young GC)后检查是否够分配;如果还是不够,就放入老年区;如果老年区还是放不下,就进行一次Full GC;进行完Full GC后old区还是放不下,抛出OOM。
2.新生代收集(Minor GC\Young GC)的过程:进行Minor GC时,对Eden区可以回收的对象进行回收,不能回收的放入survivor的to区;同时对Survivor的两个区From和To,检查From区可以回收的对象,如果对象不能回收就分代年龄加一,如果分代年龄到达阈值了(一般是15),就晋升老年代。
3.老年代收集(Major GC/Old GC):对老年代进行收集。目前只有CMS垃圾回收器会对Old区单独回收。
4.整堆收集(Full GC):收集整个Java堆和方法区。
5.每次GC都会导致Stop The World现象,即停止其它线程的运行。
分配过程示意图:
参考资料:
1、周志明《深入理解Java虚拟机》第三版;
2、尚硅谷宋红康详解Java虚拟机(康师傅yyds)