JVM学习笔记运行时数据区

Posted 九死九歌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM学习笔记运行时数据区相关的知识,希望对你有一定的参考价值。

一、运行时数据区与线程概述

1 运行时数据区的结构


  其中方法区和堆是随着虚拟机的创建而创建摧毁而摧毁,为各个线程所共用。而程序计数器(PC)、本地方法栈(NMS)、虚拟机栈(VMS)则是随着某个线程的创建而创建摧毁而摧毁。

  垃圾回收大部分是发生在堆区,但也有一些发生在 方法区/元空间/永久代 中。另外对于JIT编译缓冲,有的书中说是在方法区中,有的书上说是独立于方法区存在,众说纷纭。

2 一些比较重要的守护线程

  要知道执行Java程序的时候,并不是只有main方法这一个线程。还有很多很多虚拟机自动开启的守护线程。比较重要的有以下几个:

  ① 虚拟机线程:负责线程栈收集,线程栈挂起,以及偏向锁的撤销。

  ② 周期任务线程:是时间周期事件的体现(如中断),用于周期性操作的调度执行。

  ③ GC线程:顾名思义,负责垃圾回收。

  ④ 编译线程:负责JIT编译,将常用字节码编译为机器指令并缓存。

  ⑤ 信号调度线程:负责接受信号并发送给JVM,内部通过调用适当方法进行处理。

二、虚拟机栈

1 栈的运行原理

  一个时间点上,只会有一个正在活动得到栈帧。该栈帧叫做当前栈帧,与之对应的方法叫做当前方法,再与之对应的类,叫做当前类。就有点像栈顶,懂吧。

  栈帧的内部主要是局部变量表、操作数栈、其次还有动态链接、方法返回地址、和一些附加信息(有时会忽略附加信息)。有时还会将动态链接、方法返回地址、附加信息称为帧数据区。

  不同的线程之间的方法是无法共享的,也就是他们的栈帧是无法共享的。

  当前栈帧无论是使用return或是抛出异常,都会弹栈。

2 栈帧的内部结构 — 局部变量表

  局部变量表是一个数字数组。局部变量表中最基本的存储单元是slot(变量槽)。32位以内的数据,每个数据指针一个变量槽,如byte short char boolean。而long和double则要转换成两个变量槽。

  局部变量表的大小在编译期间就定下来了。方法运行期间不会修改。我们随便写一个方法:

public static void main(String[] args) 
	Test test = new Test();
	int n = 16;


  这里面起始pc就是字节码指令的相对地址。长度则是该变量可以起作用的范围,声明完可不就能起作用了。所以同一个方法内,起始PC和长度之和大部分情况下是一个常量。

  另外,成员方法的局部变量表中必然是有一个this的,就在局部变量表的第一个位置上。这也算是解释了static方法为什么不能使用this关键字。声明一个没有任何内容的成员方法func()并查看局部变量表:

  顺带插一嘴其他的。

  方法名、描述符与访问标志。

  异常表:

  杂项:操作数最大深度、局部变量最大槽数、字节码长度等。

  行号表,即class中字节码指令相对地址和java源代码中的行号的对应关系。

3 共用变量槽

  这是我认为一个很有趣的地方,先看如下代码:

public void func() 
	int a = 0;
	
		int b = 0;
		b = a + 1;
	
	int c = a + 1;

  查看他的方法属性和局部变量表:


  我们发现虽然有四个局部变量,但局部变量表长度却是3,这是为什么的,我们发现变量b从 pc = 4 时进入局部变量表,在 pc = 8 时退出。而原本b的位置后面被c重复利用了(他俩index都是2),所以才会出现这种状况。

  为了防止出现占内存溢出,我们在开发中也可以通过妥善利用大括号来缩小栈帧大小。

4 java中的变量

Java变量分类有两种分法:

分法一:按照数据类型分

  • 基本数据类型
  • 引用数据类型

分法二:按照在类中的位置分:

  • 成员变量:在使用前,都经过默认初始化复制

    • 类变量(静态变量,被static修饰的成员变量):linking的prepare阶段给变量默认赋值—>initial阶段:给变量显示赋值即静态代码块赋值
    • 实例变量(没有被static修饰的成员变量):随着对象的创建,会在堆空间分配实例变量空间,进行默认赋值
  • 局部变量:使用前必须显示赋值,否则编译不通过。

5 栈帧的内部结构 — 操作数栈

  就和数据结构课设的表达式求值里面的操作数栈是一个审儿的。

  比如下面这个例子:

public void func(String[] args) 
	int a = 20;
	int b = 30;
	int c = a + b;

  对应字节码:

 0 bipush 20	// 将20压入操作数栈
 2 istore_1		// 将操作数栈栈顶int型元素弹栈弹入局部变量一(局部变量表中索引为一)
 3 bipush 30	// 将30压入操作数栈
 5 istore_2		// 将操作数栈栈顶int型元素弹栈弹入局部变量二
 6 iload_1		// 将int型的本地变量一压入操作数栈栈顶
 7 iload_2		// 将int型的本地变量二压入操作数栈栈顶
 8 iadd			// 将栈顶两个int型数弹出并相加,然后将结果压入栈顶
 9 istore_3		// 将操作数栈栈顶int型元素存入局部变量三
10 return		// 返回

  上面没有出现0号局部变量,因为0号是this。

  我们发现操作数栈分别在pc = 0、pc = 3、 pc = 8时入栈。又分别在pc = 2、pc = 5弹栈,在pc = 8时弹两次栈。用伪代码表示即:

0:  operandStack.push(20);
2:  localVariableTable[1] = operandStack.pop();
3:  operandStack.push(30);
5:  localVariableTable[2] = operandStack.pop();
6:  operandStack.push(localVariableTable[1]);
7:  operandStack.push(localVariableTable[2]);
8:  operandStack.push(operandStack.pop() + operandStack.pop());
9:  localVariableTable[3] = operandStack.pop();
10: return;

  那么我们可以推断出来操作数栈的最大深度就是2了。

  如果方法有返回值。如下:

public int func() 
	int a = 20;
	int b = 30;
	int c = a + b;
	return c;

  那就得把返回值存入栈顶,如下:

 0 bipush 20
 2 istore_1
 3 bipush 30
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 iload_3
11 ireturn

  用伪代码表示为:

0:  operandStack.push(20);
2:  localVariableTable[1] = operandStack.pop();
3:  operandStack.push(30);
5:  localVariableTable[2] = operandStack.pop();
6:  operandStack.push(localVariableTable[1]);
7:  operandStack.push(localVariableTable[2]);
8:  operandStack.push(operandStack.pop() + operandStack.pop());
9:  localVariableTable[3] = operandStack.pop();
10: operandStack.push(localVariableTable[3]);
11: return operandStack.pop();

  常说java虚拟机的执行引擎是基于栈的执行引擎,其中的栈就是操作数栈。

  和局部变量表一样,操作数栈的深度在编译时就确定好了。

  另外频繁使用栈,访问内存,这无疑是很低效率的。因而诞生了栈顶缓存技术,把栈顶元素缓存到通用寄存器中,大幅度加快速度。该技术并未大量应用。

6 栈帧的内部结构 — 动态链接

  许多字节码指令执行时都需要访问到常量池。因而每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。而这些引用就叫动态链接。

  在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用( symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

  举个例子:

package com.spd.jvm;

public class Test 

	private int num;

	public void func1() 
		func2();
	

	private void func2() 
		num++;
	



  对应的字节码指令为:

func1():
	 0 aload_0			// 将引用类型的本地变量0(即this)压入操作数栈栈顶
	 1 invokespecial #2	// 调用超类构造方法、实例初始化方法、私有方法。此处为私有方法,引用类型#2指向常量池中的func2()
	 4 return			// 返回空

func2():
	 0 aload_0			// 将引用类型的本地变量0(即this)压入操作数栈栈顶
	 1 dup				// 复制操作数栈栈顶数值并将复制值压入栈顶
	 2 getfield #3		// 获得指定的静态域,并压入栈顶。此处#3为运行时常量池中的num
	 5 iconst_1
	 6 iadd
	 7 putfield #3		// 将栈顶元素弹出到指定静态域。
	10 return

  这段字节码中出现了例如#2 #3 这样的东西,他们就是指向常量池的引用。或者叫动态链接。

7 方法的调用

  如果某方法调用方法时,如果编译阶段就能获知调用什么,那就叫做静态链接。如果只有运行时才能知道调用什么,那就叫动态链接。

  原方法与被调用方法之间存在绑定关系。如果是静态链接,那对应的就是早期绑定,如果是动态绑定,那就是使用晚期绑定了。

  另外java中也有虚方法和非虚方法的概念。早期绑定就是虚方法。万起绑定就是非虚方法。虚属性不能自己定义,是编译器决定的。

  非虚方法有:静态方法、私有方法、final方法、实例构造器方法、父类方法。其他都是虚方法。也就是能体现多态的都是虚方法。

  虚拟机中提供的方法调用指令由以下五种:

  • 普通调用方法:
    • ① invokestatic:调用静态方法。
    • ② invokespecial:调用<init>()方法、私有及父类方法、解析阶段唯一确定方法版本。
    • ③ invokevirtual:调用所有虚方法
    • ④ invokeinterface:调用接口方法
  • 动态调用方法:
    • ⑤ invokedynamic:动态解析出需要调用的方法、然后执行

  其中① ②都是非虚方法,其他都是虚方法。但也有个例外:③也可能调用final方法,这种情况是非虚方法。

8 JAVA语言的动态特性

  invokedynamic是java为了实现“动态类型语言”支持而做出的改进。

  但他只是为JAVA语言提供了一些动态语言的特性。java在本质上还是属于静态语言。

  我们看下面这个例子:

package com.spd.jvm;

public class Test 

	public static void lambda(Func func) 
		System.out.println(func.func());
	

	public static void main(String[] args) 
		Func func = () -> true;
		lambda(func);
		lambda(() -> 64);
	



@FunctionalInterface
interface Func 
	Object func();


  对应字节码:

lambda(Func func):
	 0 getstatic #2			// 获得指定静态域,#2即是
	 3 aload_0				// 将引用类型从局部变量表中存入操作数栈
	 4 invokeinterface #3	// 调用接口方法,#3即是func()
	 9 invokevirtual #4		// 调用实例方法,#4即是println
	12 return

main(String args[]):
	 0 invokedynamic #5		// 调用动态方法,#5即是lambda$main$0():
	 5 astore_1				// 将栈顶引用变量弹入局部变量表
	 6 aload_1				// 将引用类型从局部变量表中存入操作数栈
	 7 invokestatic #6		// 执行静态方法,#6就是lambda
	10 invokedynamic #7		// 调用动态方法,#5即是lambda$main$1():
	15 invokestatic #6		// 执行静态方法,#6就是lambda
	18 return

lambda$main$0():
	 0 iconst_1
	 1 invokestatic #9 <java/lang/Boolean.valueOf : (Z)Ljava/lang/Boolean;>
	 4 areturn

lambda$main$1():
	 0 bipush 64
	 2 invokestatic #8 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
	 5 areturn

  这些特性的出现,有利于Java平台的动态语言解释器。

9 方法重写的本质

  1.找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。

  2.如果在过程结束;如果不通类型c中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过,则返回java.lang.IllegalAccessError异常。即是无访问或修改权限。

  3.否则,按照继承关系从下往上依次对(的各个父类进行第⒉步的搜索和验证过程。

  4.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

10 虚方法表

  为了提高性能,jvm会在类的方法区中创建一个虚方法表来实现。使用索引表来代替查找。表中存着各个方法的实际入口。

  虚方法表会在类加载的链接阶段被创建并初始化。

  举个例子:

class Father 

	@Override
	public String toString() 
		return "I am son/father.";
	

	public void method() 
		System.out.println("Father.method");
	



class Son extends Father 
	@Override
	public void method() 
		System.out.println("Son.method");
	

  父类继承Object并重写toString,而子类继承父类,重写了method,未重写toString。那么在虚方法表中,Son的hashcode、clone等方法,由于父类未重写,所以直接指向Object的hashcode、clone等。而toString父类重写了,直接继承自Father,故这个方法指向Father

11 栈帧的内部结构 — 方法返回地址

  方法返回地址就是用来存储pc寄存器的值。

  方法执行完而退出时。就得回到调用他的那个方法那里继续往下执行。而调用它的方法的pc地址就存储在方法返回地址中。

12 本地方法接口、本地方法库与本地方法栈

  native修饰的java方法即是用其他语言编写的方法,主要是c。

  实现本地方法和本地方法库调用的部件就是本地方法接口。

  而本地方法的栈帧就是存放在本地方法栈中。

  另外,在hotspot中,本地方法栈和虚拟机栈合二为一了。

三、堆区

1 堆空间的概述

  堆空间是jvm中最大的一片内存空间。它是所有线程共享的。

  几乎所有的对象实例都会在堆空间中分配内存。

  堆空间在物理上是不连续的,但在逻辑上是连续的。

  虽然说各个线程共享堆。但不是完全共享,完全共享必然存在线程安全问题。故每个线程在对空间中有各自独有的缓冲区(TLAB)

  方法运行结束后,堆中的内容并不会马上被移除,而是等待cg时被移除。只要java栈弹栈就移除堆中内容的话,移除发生太频繁,影响效率。

2 堆空间的分代


  堆空间分为新生代、养老区、原空间。但其中原空间在逻辑上属于堆空间,在物理上属于方法区。

3 堆空间的大小设置和查看

  -Xms: 用来设置堆空间(新生代 + 老年代)的初始内存大小。(ms即memory start)

  -Xmx: 用来设置对空间(新生代 + 老年代)的最大内存大小。(mx即memory max)

  默认堆空间初始大小是 物 理 电 脑 内 存 大 小 64 \\frac物理电脑内存大小64 64,默认堆空间最大大小是 物 理 电 脑 内 存 大 小 4 \\frac物理电脑内存大小4 4

  如下是查看方式:

package com.spd.jvm;

public class Test 
	
	public static void main(String[] args) 

		long initMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
		long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

		System.out.println("-Xms: " + initMemory + "MB");
		System.out.println("-Xmx: " + maxMemory + "MB");

	
	


  在开发中,通常将初始大小和最大大小设置成同一个值。因为堆空间不断地扩容和释放内存,会给机器带来不必要的压力。(想想ArrayList的扩容,这个不仅要扩容,由于gc存在,利用率太低了还得缩容)

  我们把初始大小和最大大小都设置成600MB,却会发现运行结果是575MB。这是由于幸存者一区和幸存者二区同一时间内只能使用一个,这涉及到垃圾回收的知识。就是说设置的时候给的内存,人家是按两个幸存者都有的情况分配这么多。但是统计的时候却是按只有一个算。

4 新生代与老年代

  存储在JVM中的Java对象可以被划分为两类:一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。

  一个对象刚刚创建就会进入伊甸园(圣经中人类刚刚创建也是生活在伊甸园),若一段时间内没有被gc而幸存下来,那他就会进入幸存者一区或二区。若幸存者经过一段时间还未被gc,那就会进入老年代。其中绝大部分的对象都是在新生代中消失的。

  修改老年代与新生代的方法:-XX: NewRatio-2,表示新生代占一份,老年代占两份。新生代占三分之一。

  一般来说,这个比例很少会去进行修改,除非我们明确地知道代码中会有大量生命周期比较长或比较短的对象。

  另外,默认情况下,伊甸园和幸存者0区和幸存者1区的比例是8 : 1 : 1。开发人员可以通过一XX:SurvivorRatio=?来调整这个比例。

5 对象的分配

  对象刚创建时会进入伊甸园,伊甸园装满后,开始启用gc算法,进行Young GC / Minor GC。把垃圾清除掉,把非垃圾存入幸存者零区(from区)。并给他们的年龄计数器赋值为“1”。这时伊甸园就空了。

  待伊甸园再次被填满,进行一轮Minor GC,找到垃圾后释放掉。然后将非垃圾移入幸存者一区(to区)。判断幸存者零区内容是否需要被GC,若需要GC则释放掉,否则将幸存者零区(from区)的内容的年龄计数器自增,并移入幸存者一区(to区)

  此时s1中有数据,而s0中没有数据,那么s1就成了from区,s0成了to区。

  在两个幸存者区不停传递,就会增加年龄计数器的值,若某些数据的年龄计数器数值达到15了,那就把它的年龄计数器自增并移动到老年代。移入老年代的过程叫做晋升promotion,而15被称为阈值(不想画图了)

  gc的频繁发生地是新生代,很少在老年区gc,几乎不在幸存者区进行gc。

6 对象分配的特殊情况


  超大对象直接进入老年代,幸存者区装不下则直接晋升老年代。

7 Minor GC、Major GC及Full GC

  Minor GC即Young GC,针对于新生代。

  Major GC即Old GC针对于老年代(会报OOM),只有CMS GC会有单独收集老年代的行为。

  Full GC针对于整个堆空间。

  GC分为部分GC(Partial GC)和整堆GC(Full GC)

8 JVM的内存分配策略

9 对空间为每个线程分配的TLAB

10 堆空间常用参数设置

11 空间分配担保

12 逃逸分析



四、方法区

1 栈、堆、方法区的交互关系

2 方法区的基本理解

  《Java虚拟机规范》中明确说明:"尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap (非堆),目的就是要和堆分开。

  方法区的大小决定了系统可以定义多少类,如果系统定义了太多类(例如加载过多第三方jar包、或tomcat部署的工程太多),就会导致方法区溢出报OOM。

  启动JVM创建方法区,关闭JVM释放方法区。

  所谓的永久代、元空间都是方法区的实现方式。从jdk8开始,就用元空间代替了永久代。

  永久代与元空间最大的区别在于元空间使用的是本地内存,而永久代使用的是虚拟机设置的内存中。

3 设置方法区大小的相关参数

3 如何解决OOM

4 方法区的内部结构

  《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。



  另外,如图:类加载器会记录它都加载了哪些类。而方法区中的类信息也会记录这些类是哪些类加载器加载的。

  补充说明:每个全局常量(static final)在编译的时候就会被分配。而非准备或初始化阶段。

5 常量池

  方法区中包含的叫做运行时常量池,而字节码文件中包含的叫常量池。将字节码文件中的常量池加载到方法区以后,就叫做运行时常量池了。

  也就是说字节码的常量池中的类都是符号引用,在运行的时候才会转换成直接引用。

  我们用jclasslib来查看字节码:

  其中的#2 #3 #4 便是常量池中的符号引用。

6 运行时常量池


  比起字节码文件中的常量池,方法区中的运行时常量池具有动态性。有可能运行时常量池的信息会比常量池的多。

7 方法区的演进细节


  要注意一点,很多帖子和书上都说静态变量是存储在方法区中,实际上这是错的,如上图,静态变量应该存储在堆里。

  jdk6:

  jdk7:

  jdk8:

  ① 在jdk8中为什么要把永久代替换成元空间?

  其中对于第二点,永久代调优困难,因为永久代是虚拟机内存,需要进行GC判断某个类还需不需要使用(永久代需要Full GC来回收垃圾)。所以永久代的GC效率较低,这个过程很浪费时间。

  ② 在jdk7中为什么要把字符串常量池(StringTable)和静态变量调整位置?

  正如上面所说,永久代GC效率低,在Full GC中才会触发。而Full GC是老年代空间不足、永久代空间不足时才会触发。这就导致StringTale回收率不高,进一步导致永久代内存不足。而将StringTable放进堆里便能及时回收内存了。

8 方法区的垃圾回收

  方法区垃圾回收主要回收的是常量池中废弃的常量和不再使用的类型。

  有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对万法区的约界定非吊见松的,北业刊知

以上是关于JVM学习笔记运行时数据区的主要内容,如果未能解决你的问题,请参考以下文章

JVM--内存区域划分

jvm

JVM线程内存区域划分

JVM中的堆的新生代老年代永久代

[转]JVM内存区域划分Eden SpaceSurvivor SpaceTenured Gen,Perm Gen解释

SSM-mybatis-1