JVM - 运行时数据区

1. 概述

在这里插入图片描述
内存是非常重要的系统资源,是硬件和CPU的中间仓库和桥梁,承载着操作系统和应用程序的实时运行;
JVM内存布局规定了Java在运行过程中内存申请,分配,管理的策略,保证了JVM的高效稳定运行,不同的JVM对于内存的划分方式和管理机制存在部分差异(比如方法区的不同)

在这里插入图片描述
一个JVM的实例对应着一个Runtime实例,一个Runtime实例就相当于对应着一个运行时数据区

2. JVM中线程说明

在Hotspot JVM中,每个线程都与操作系统的本地线程直接映射,当一个线程准备好执行以后,此时一个操作系统的本地线程也同时创建,Java线程执行终止后,本地线程也会回收;

操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用java线程中的run()方法

3. 运行时数据区内部结构

3.1 程序计数器(程序钩子)

它的作用可以看做是当前线程所执行的字节码的行号指示器

在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

我们称这类内存区域为“线程私有”的内存

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储

对于Natvie方法

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined

.If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native, the value of the Java Virtual Machine’s pc register is undefined.

pc寄存器”是在抽象的JVM层面上的概念——当执行Java方法时,这个抽象的“pc寄存器”存的是Java字节码的地址。实现上可能有两种形式,一种是相对该方法字节码开始处的偏移量,叫做bytecode index,简称bci;另一种是该Java字节码指令在内存里的地址,叫做bytecode pointer,简称bcp

native方法而言,它的方法体并不是由Java字节码构成的,自然无法应用上述的“Java字节码地址”的概念。所以JVM规范规定,如果当前执行的方法是native的,那么pc寄存器的值未定义——是什么值都可以。

上面是JVM规范所定义的抽象概念,那么实际实现呢?

Java线程总是需要以某种形式映射到OS线程上。映射模型可以是1:1(原生线程模型)、n:1(绿色线程 / 用户态线程模型)、m:n(混合模型)

以HotSpot VM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。就像一个用C或C++写的多线程程序,它在线程切换的时候是怎样的,Java的native方法也就是怎样的

也就是说native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的

OutOfMemoryError

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

为什么需要PC寄存器

Java字节码解释器需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令;
CPU在不断切换线程,这个时候切换回来以后,就得知道接着从哪里开始继续执行

在这里插入图片描述

3.2 虚拟机栈

虚拟机栈描述的是Java方法执行的内存模型

3.2.1 基本概念

栈是运行时的单位,堆是存储的单位

栈解决的是程序运行时问题,即程序如何执行,或者说如何处理数据
堆解决的是数据存储的问题,即数据怎么放,放在哪里(栈也可以放,放基本类型数据,对象引用,returnAddress类型)

线程私有

该区域也是线程私有的,其生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,主管Java程序的运行,它保存方法的局部变量(8中基本数据类型,对象的引用地址),部分结果并参与方法的调用和返回

OutOfMemoryError
存在OOM不存在GC

栈中可能出现的异常
Java虚拟机规范允许Java栈的大小是动态的或者固定不变

StackOverFlowError:如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机容量可以在线程创建的时候独立选择;如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,就会抛出StackOverFlowError(比如我疯狂递归没有出口,那么就会疯狂创建栈帧不出战,自然会栈内存异常)

OutOfMemoryError:如果Java虚拟机栈是动态扩展的,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新线程的时候没有足够的内存去创建对应的虚拟机栈,那么会抛出OutOfMemoryError

3.2.2 栈运行原理

每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在的,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构;
一个方法对应一个栈帧,栈帧是内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
在这里插入图片描述

3.2.3 栈帧的存储结构

在这里插入图片描述
每一个方法从调用开始至执行完成的过程都对应一个栈帧在虚拟机栈中从入栈到出栈的过程

栈帧中存放了局部变量表,操作数栈(表达式栈),动态链接(指向运行时常量池的引用),方法的返回地址(方法正常退出或异常退出的定义)和一些附加信息;

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并写入到方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现

① 局部变量表

局部变量表也被称为局部变量数组或者本地变量表,是一组变量值存储空间,定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量**,这些数据类型包括基本数据类型对象引用(reference),以及returnAddress类型

对于为什么是数字数组,首先对于byteshortchar在存储前会被转换为intboolean也会被转换为int,0标识false,1标识true

由于局部变量表是建立在线程的栈上的是线程的私有数据,所以不存在线程安全问题

局部变量表所需容量大小是在编译期间就确定下来的,保存在方法的Code属性的maximun local variables数据项中,在方法的运行期间是不会改变局部变量表大小的

public class LocalVariablesTest {
    public static void main(String[] args) {
        LocalVariablesTest test = new LocalVariablesTest();
        int a = 10;
        int b = 12;
        int c = 13;
    }
}
//javap -v LocalVariablesTest.class

反编译后局部变量表的结果如下
在这里插入图片描述
在这里插入图片描述
关于slot的理解

  • 局部变量表的容量以变量槽slot为最小单位,虚拟机规范中并没有说一个slot的所占内存大小,虚拟机规范只是导向型的说明一个slot能够存放一个32位的数据

  • 在局部变量表中,32位以内的类型只占一个Slot(包括returnAddress),64位的占2个Slot(longdouble)

  • JVM会为局部变量表的每一个Slot都分配一个索引,通过索引访问局部变量值

  • 如果当前栈帧是由构造方法或者实例方法创建的,那么该对象的引用this会放在index为0的slot
    在这里插入图片描述
    这也就变相解释了为什么static方法中不能使用this

slot的重复利用
栈帧中的局部变量表中的槽位时可以重复利用的,如果当前的pc计数器超过了某个变量的作用域,那么在其作用域知之后声明的新的局部变量会复用过期局部变量的槽位,从而达到节省资源的目的

public void SlotTets(){
	{
		int a = 0;
		System.out.println(a);
	}
    int b = 0;//b会和a公用槽位
}

在这里插入图片描述
Slot的复用可能会直接影响垃圾回收行为
局部变量表中的变量是重要的垃圾回收根节点,只要被直接变量表中直接或者间接引用的对象都不会被回收

局部变量表中的变量要赋初值才能使用
和类变量不同,局部变量不存在两次赋值过程,不存在先为局部变量赋予默认值的操作,所以不能不赋值而直接使用局部变量

② 操作数栈(表达式栈)

每一个栈帧中除了局部变量表以外还包含操作数栈,操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

操作数栈就是JVM执行引擎的一个工作区,我们所说的Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

操作数栈在方法执行的过程中,根据字节码指令,往栈中写入数据(入栈)或提取数据(出栈)

某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用后再把值压入操作数栈(比如复制,交换,相加等操作)
在这里插入图片描述
针对下面的代码看一下

public void testAddOperation(){
	byte i = 15;
	int j = 8;
	int k = i+j;
}
 0 bipush 15 //把i 15压入操作数栈,byte,short,char存放的时候都以int来保存
 2 istore_1  //把操作数栈顶的数出栈放入局部变量表slot1(slot0放了this)
 3 bipush 8  //把j 8压入操作数栈
 5 istore_2  //把操作数栈顶的数出栈放入局部变量表slot2
 6 iload_1	 //从局部变量表取索引为1的入栈
 7 iload_2	 //从局部变量表取索引为2的入栈
 8 iadd		 //+ 需要执行引擎翻译成机器指令让Cpu运算
 9 istore_3	 //把操作数栈顶的数出栈放入局部变量表slot3
10 return

**从字节码角度看i++++i **

public void testAboutPlusPlus(){
	int i = 10;
	int j = i++;

	int n = 10;
	int m = ++n;
}
//-------------i++ 先把i压入操作数栈,再在局部变量表进行++,再把操作数栈的值出栈
 0 bipush 10
 2 istore_1
 3 iload_1	  //先load
 4 iinc 1 by 1//iinc是在局部变量表的slot上进行自增运算
 7 istore_2	  //存的是最初load的值
//-------------++i 先在局部变量表进行++,再把i压入操作数栈,再把操作数栈的值出栈
 8 bipush 10
10 istore_3
11 iinc 3 by 1//先iinc
14 iload_3	  //再load
15 istore 4	  //存的是iinc以后的值
17 return

从字节码分析x = x++
永远等于0
① 先从局部变量表取x放到操作数栈
② 局部变量表的x自增
③ 把操作数栈的x放到局部变量表x的槽位

③ 动态链接

每个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码可以实现动态链接,比如invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中;比如:描述一个方法调用了另外的方法,就是通过常量池中指向方法的符号引用来标识的,那么动态链接的作用就是为了将这些符号引用准换为调用方法的直接引用(具体可以看文章的的第五点)

看下面的代码

public class DynamicLinkingTest {
    int num = 0;
    public void methodA(){
        methodB();
        num++;
    }
    public void methodB(){

    }
}

methodA中调用了methodB
在这里插入图片描述
在这里插入图片描述
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接

④ 方法返回地址

存储调用该方法的PC寄存器的值
一个方法的结束有两种方式:① 正常执行结束 ② 出现未处理的异常非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令地址;
而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中不保存这部分信息

本质上,方法的退出就是当前栈帧出栈的过程,此时,需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器的值等,让调用者的方法继续执行下去

⑤ 附加信息

可选

3.3 本地方法栈

Java虚拟机栈用于管理Java方法的调用,本地方法栈式用于管理本地方法的调用,是线程私有的

什么是本地方法
简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用一个C的函数,比如clone()方法就是本地方法,直接去调用cc++实现

在定义一个native method时,并不提供实现体(有些像定义一个java interface),因为其实现体是由非java语言在外面实现的

public class IHaveNatives{
	native public void Native1( int x ) ;
    native static public long Native2() ;
    native synchronized private float Native3( Object o ) ;
    native void Native4( int[] ary ) throws Exception ;
}

为什么要使用native方法
java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
① 与java环境外交互:
有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。

② 与操作系统交互:
JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎 样,它毕竟不是一个完整的系统,它经常依赖于一些底层(underneath在下面的)系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

③ Sun’s Java
Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.ThreadsetPriority()方法是用java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用Win32 SetPriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用

3.4 Java

Java堆是Java虚拟机所管理的内存中的最大的一块,Java堆是线程共享的一块内存区域,虽说堆是线程共享的,但是堆中也可以分划分线程私有的缓冲区(TLAB,Thread Local Allocation Buffer

堆唯一的目的就是存放对象的实例,几乎所有的实例都是在这里分配内存的;注意这里说的是几乎,对象也可以不分配在堆上,这里涉及到逃逸分析等,之后再说;

堆可以物理上不连续,只要逻辑上连续即可;

Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆,在方法结束以后堆中的对象并不会立马被移除,而是等到GC的时候次才回收

针对堆的学习,除了他的内存细节还有以下几个问题
① 堆空间一定都是线程共享的么?
② 真的所有对象都分配在堆上么?

3.4.1 堆的细分内存

Java 7及以前堆内存逻辑上分为3部分:新生代+老年代+永久代

  • 新生代又被分为伊甸园区和幸存区
  • 永久代在逻辑上是堆的一部分,但是实际上是作为方法区的实现,也就是说实际上堆内存不包含永久代,我们设置堆内存大小的时候只包括新生代和老年代大小

Java 8及以后把堆内存区域逻辑上分为3部分:新生代+老年代+元空间

  • 使用了元空间替代了永久代
3.4.2 堆空间的大小设置

默认情况下:

  • 堆内存的初始内存大小是物理电脑内存/64
  • 堆内存的最大内存大小是物理电脑内存/4

也可以自己设置堆的大小

-Xms 设置堆的起始大小  -Xms:20m
-Xmx 设置堆的最大内存

建议将初始堆内存和最大堆内存设置为相同的值,避免频繁的内存的调整
3.4.3 堆空间一定都是共享的么?堆内存为每个线程分配的TLAB

堆是线程共享的区域,对象实例的创建在JVM中十分频繁,因此在并发环境下从堆中划分内存空间是线程不安全的,为了避免多个线程操作同一块地址,需要使用加锁等策略,但是效率会变低

为了解决这个问题,堆伊甸园区域再次划分,为每个线程分配一个私有的缓冲区域,多个线程同时分配内存的时候,先在自己的TLAB中分配,如果空间不够再使用大家共有的区域(TLAB空间只占整个伊甸园区域的1%)

3.4.5 真的所有对象都分配在堆上么?

对象是堆中分配内存的是一个普遍的常识,但是,有一种特殊的情况,如果经过逃逸分析后发现,一个对象没有逃逸出当前方法的话,那么可能会被分配为栈上分配;在栈上分配内存,也就无需进行垃圾回收了,随着栈帧的弹出对象就自动销毁,也不需要涉及到线程安全的问题,这就是最常见的堆外存储技术

逃逸分析
当一个对象在方法内部被定义,对象只在方法内部被使用,则认为没有发生逃逸

Jdk7及以后默认开启了逃逸分析,也可以使用-XX:+DoEscapeAnalysis显示打开逃逸分析

栈上分配是为了优化,开发中能使用局部变量就尽量不要在方法外部进行定义,除此之外基于逃逸分析还有同步省略,标量替换等优化方法

同步省略(锁消除)
线程的同步代价很大,在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步代码块中所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程;如果没有,那么JIT编译器在编译这个同步代码块的时候就会消除堆这部分代码的同步

标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分可以不存储在内存而是存储在CPU寄存器里,针对于Java来说就是不分配在堆儿分配在栈中

标量指的是一个无法再分解成更小的数据的数据,Java中的原始数据类型就是标量,相对的,那些可以分解的数据叫做聚合量,Java中的对象就是聚合量,它可以分解成其他的聚合量和标量

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外部访问,可以把对象分解成若干个成员变量来替换,这样大大降低了存储所需的空间,这个过程就是标量替换

标量替换为栈上分配提供了很好的支持

3.5 方法区

3.5.1 栈,堆,方法区的交互关系

在这里插入图片描述

3.5.2 方法区的基本理解

方法区是线程共享的内存区域,在Java规范中把方法区作为堆的一个逻辑部分,方法区的大小决定了系统可以保存多少类,如果加载了大量类可能会导致OOM

方法区用来存储已经被虚拟机加载的类信息,常量,静态变量,编译器编译后的代码缓存等数据

jdk1.6的内存结构中,使用永久代来实现了方法区(对于Hotspots)

在1.8方法区不再由JVM管理而是放在了元空间中,元空间放到本地内存中,而且相对于1.6来说,串池StringTable被放到了堆中

版本改进
jdk1.6及以前有永久代,静态变量存放在永久代上
jdk1.7有永久代,但已经逐步去永久代,字符串常量池,静态变量被移除,保存在堆中
jdk1.8及以后无永久代,类型信息,字段,方法信息,常量保存在本地内存的元空间,但字符串常量池,静态变量仍在堆中

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
做这样元空间替换永久代的动机有两点:
① 为永久代设置空间大小是很难确定的;
在某些场景下,如果动态加载类过多,会导致Perm区的OOM,而元空间和永久代的区别就在于:元空间并不限制在虚拟机,而是使用本地内存,因此元空间的大小仅受本地内存的限制

② 对永久代的调优是很困难的:垃圾回收的判断麻烦

关于静态变量:
none - final的类变量
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分;类变量被类的所有实例共享,即使没有类实例时也可以访问

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 void hello(){
		System.out.println("hello");
	}
}

final全局常量
被声明为fianl的类变量的处理方法则不同,每个全局常量在编译的时候就被分配了,看下面的代码

public class TestClass3 {
    public static int count = 1;
    public static final int number = 2;
}

反编译的部分结果如下
在这里插入图片描述
可以看到常量在类被编译成字节码的时候就被赋值了,而非final的类变量没有,是在类加载的过程才进行初始化

3.5.3 运行时常量池 Runtime Constant Pool

运行时常量池是方法区的一部分,Class文件中除了包括版本,字段,方法,接口等字段以外,还有一项信息就是常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载进入方法区的运行时常量池中存放(注意区分常量池和运行时常量池)

  • 常量池:放在二进制字节码文件,就是一张表,之后类的方法定义中的虚拟机指令根据这张表找到要执行的类名,方法名,参数类型,字面量等信息

  • 运行时常量池:当类被加载(加载到内存),他的常量池信息就会被放入运行是常量池,并把里面的符号地址变为真实地址

相对应Class文件的常量池来说,运行时常量池的一个重要特性就是动态性,Java语言并不要求常量一定是在编译期间产生的,也就是并非预先置入Class文件中的常量池的内容才能进入方法区运行时常量池,运行期间也能将新的常量放入池中,比如Stringintern方法

3.5.4 串池 StringTable

StringTable是运行时常量池的一部分,是String的享元模式的实现方式

常量池中的信息,都会被加载到运行时常量池中,但还没有变为java对象,当使用到时,会生成对象(延迟实例化);对于字符串来说,我们要在一个java程序中要使用要大量的字符串对象,而很多字符串对象的值都一样我们也把他们当作常量来使用,如果每次都分别创建字符串对象那么完全没有必要,所以我们要利用之前已经创建的字符串对象

以字面量直接赋值的String对象会被放到串池,下次如果有相同的字符串直接去串池中拿就行了,而不用生成新的对象

我们上面说过在jdk 1.6,串池StringTable被放到了永久代中,而在jdk1.8则是放到了堆中,为什么要做这个改变呢?

这个和JVM的垃圾回收机制有关,对于永久代来说,他的垃圾回收是发生在full GC,垃圾回收的几率很小,而堆的垃圾回收几率更多,为了尽快回收大量不用的String对象,才做出这个改变

3.5.5 方法区的大小设置
jdk7及以前:
-XX:PermSize来设置永久代初始分配空间 默认值是20.75M
-XX:MaxPermSize来设置永久代的最大可分配空间 32位虚拟机默认是64M 64位虚拟机是82M

jdk8及以后:
-XX:MetaSpaceSize来设置永久代初始分配空间 默认值是20.75M
-XX:MaxMetaSpaceSize来设置永久代的最大可分配空间 32位虚拟机默认是64M 64位虚拟机是82M

3.6 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,它属于系统内存,这部分内存区域被频繁使用,也可能导致OOM的异常

直接内存常用于NIO操作,用于数据缓冲区,分配回收成本较高,但读写性能也高,不受JVM内存回收管理

在我们传统文件IO的过程中,因为Java自己本身不能进行磁盘的操作,他是去调用操作系统的函数进行IO操作的,所以在CPU层面上有用户态到内存态的切换
在这里插入图片描述

对于读取的文件,需要先从磁盘读到系统内存中,而java文件不能直接操作系统内存,所以还需要从系统内存读取到java内存的缓冲区中
在这里插入图片描述
而直接内存时一块系统和java都能使用的内存,也就是说当使用直接内存进行文件的读取的时候,只需要将文磁盘文件读取到直接内存中,java直接操作直接内存的文件即可,相对传统IO省去了一次拷贝
在这里插入图片描述
直接内存不受虚拟机管理,所以GC并不会释放直接内存,直接内存是通过unsafe对象的方法来清除的

附录

1. 方法的调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程;

Class文件在编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址,这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用

将符号引用转换为直接引用和方法的绑定机制有关

1.1 静态链接(类加载的解析过程)

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用;这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution),也就是静态链接

Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

虚拟机中提供了以下几条方法调用指令

|--调用非虚方法
|---① `invokestatic`: 调用静态方法
|---② `invokespecial`:调用`<init>`方法,私有方法和父类方法
|--调用虚方法(finalc除外,是非虚方法,但也是用invokevirtual调用)
|---③ `invokevirtual`:调用所有虚方法
|---④ `invokeinterface`:调用接口方法,会在运行时再确定一个实现此接口的对象
|---⑤ `invokedynamic`:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的

前4条指令固化在虚拟机内部,方法调用执行不可人为干预,而第5条则支持由用户确定方法版本

只要能被 invokestaticinvokespecial指令调用的方法(静态方法,私有方法,实例构造器,父类方法),都可以在解析阶段确定唯一的调用版本,在类加载的时候就会把符号引用解析为该方法的直接引用,这类方法称为非虚方法

对于fianl方法来说,他也是非虚方法,因为他也是唯一版本,但是它时使用invokevirtual指令调用的

1.2 动态链接(分派)

如果被调用方法在编译期间无法被确定下来,只有在程序的运行期间才能将符号引用转换为直接引用,那么这个过程叫做动态链接,也就做分派

Java语言是面向对象的程序语言,具有封装,继承,多态三大特性;下面说的分派的调用过程会解释多态特性的一些最基本的体现,比如重写,重载在java虚拟机中是如何实现的

① 静态分派

静态分派指的就是依赖静态类型来定位方法的执行版本的过程,静态分派发生在编译阶段,典型应用就是重载
先看下面的代码

public class TestClass {
    static abstract class Human{

    }

    static class Man extends Human{

    }

    static class Woman extends Human{

    }

    void sayHello(Man man){
        System.out.println("hi man");
    }
    void sayHello(Woman woman){
        System.out.println("hi woman");
    }
    void sayHello(Human human){
        System.out.println("hi human");
    }

    public static void main(String[] args) {
        Human woman = new Woman();
        Human man = new Man();
        TestClass testClass = new TestClass();
        testClass.sayHello(woman);//hi human
    }
}

其中的 Human woman = new Woman();涉及到静态类型和实际类型这两个概念

  • 静态类型:Human就是变量的静态类型
  • 实际类型:Woman是实际类型

静态类型在编译期间就可知,而实际类型则需要等到程序运行才能确定下来;因此虚拟机在重载时是通过参数的静态类型而不是实际类型来作为判断依据的,编译器根据静态类型选择使用哪个重载的版本,所以上面实际调用的是void sayHello(Human human)

对于字面量来说,它不需要定义,没有显示的静态类型,所以对于这种情况来说,它是根据语言上的规则去确定一个更适合的版本

② 动态分派

动态分派指的是在运行期间根据实际类型确定方法的最终版本,这和和多态的另外一个特性,重写有关,看下面的代码

public class TestClass2 {
    static abstract class Human{
        protected abstract void sayHello();
    }

    static class Man extends Human{

        @Override
        protected void sayHello() {
            System.out.println("hi man");
        }
    }

    static class Woman extends Human{

        @Override
        protected void sayHello() {
            System.out.println("hi woman");
        }
    }

    public static void main(String[] args) {
        Human woman = new Woman();
        Human man = new Man();
        woman.sayHello();//hi woman
        man.sayHello();//hi man
        man = new Woman();
        man.sayHello();//hi woman
    }
}

这里不再根据静态类型来决定了,来看一下反编译后的字节码

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=3, args_size=1
       0: new           #2                  // class com/minifull/day_01/TestClass2$Woman
       3: dup
       4: invokespecial #3                  // Method com/minifull/day_01/TestClass2$Woman."<init>":()V
       7: astore_1
       8: new           #4                  // class com/minifull/day_01/TestClass2$Man
      11: dup
      12: invokespecial #5                  // Method com/minifull/day_01/TestClass2$Man."<init>":()V
      15: astore_2
      16: aload_1
      17: invokevirtual #6                  // Method com/minifull/day_01/TestClass2$Human.sayHello:()V
      20: aload_2
      21: invokevirtual #6                  // Method com/minifull/day_01/TestClass2$Human.sayHello:()V
      24: new           #2                  // class com/minifull/day_01/TestClass2$Woman
      27: dup
      28: invokespecial #3                  // Method com/minifull/day_01/TestClass2$Woman."<init>":()V
      31: astore_2
      32: aload_2
      33: invokevirtual #6                  // Method com/minifull/day_01/TestClass2$Human.sayHello:()V
      36: return

第0-15行是字节码的准备工作,对应着

Human woman = new Woman();
Human man = new Man();

建立manwoman的内存空间,调用他们的实例构造器,将实例放到局部变量表

第16和20句把刚刚创建的对象的引用压入到操作数栈,这两个对象是接下来要执行sayHello()的接收者;

接下来17和21句调用方法,使用的是invokevirtual #6,调用的是虚方法,而参数则指定的是常量池中Human.sayHello:()V,与最终的目标方法并不相同;

这里就要说一下invokevirtual的具体运行过程
① 找到操作数栈栈顶的第一个元素所指对象的实际类型,记为C;
② 如果在类型C中找到了一常量池中的描述符和简单名称都相同的方法,则进行访问权限校验,通过则直接返回这个方法的直接引用,否则返回java.lang.IllegalAccessError
③ 否则按照继承关系从下往上对C的父类进行第二步的搜索与验证
④ 如果始终没有找到合适的方法就抛出java.lang.AbstarctMethodError异常

③ 单分派和多分派

方法的接收者和方法的参数统称为宗量,根据分派基于多少种宗量可以划分为单分派和多分派

  • 单分派:根据一个宗量对目标方法进行选择
  • 多分配:根据多个宗量对目标方法进行选择
1.3 虚方法表

在前面说到的动态分派过程,要不断向父类查找去选择正确的目标方法,为了提高性能,JVM为类在方法区中建立了一个虚方法表,每个类都有一个虚方法表,存放各个方法的实际入口;
虚方法表是在类加载的链接阶段创建并开始初始化,在类变量初始值准备完成后,JVM会把该方法的方法表也初始化完毕

1.4 动态类型语言支持

静态语言和动态语言
动态语言和静态语言的区别在于对类型的检查是在编译期还是运行期间,前者是静态类型语言(Java),后者是动态类型的语言(js)

//java
String info = "minifull";
//js
var name = "minifull"

在jdk1.7,字节码指令集又加入了invokedynamic指令,为java实现动态类型语言进行提供了支持;
在jdk1.8,新增加了Lambda的语言特性,可以直接生成invokedynamic指令;

2. 对象的创建

在上面文章说到过,JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象

虚拟机在遇到new指令时,首先回去检查要实例化的这个类是否被加载,解析,初始化过,之后开始对象的创建

2.1 对象创建的方式
1. new 
2. Class的newInstance()反射创建,只能调用空参的构造器
3. Constructor的newInstance()反射方法,可以调用带参的构造器
4. clone()方法
5. 使用反序列化
6. 第三方类库Objenesis
2.2 对象创建的步骤

① 判断对象对应的类是否加载,链接,初始化
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否被加载,解析,初始化(即判断类元信息是否存在);
如果没有,就在双亲委派的机制下,使用类加载器以ClassLoader+包名+类名为key找对应的.class文件,找到了就进行加载并生成对应的Class类对象;找不到就抛出ClassNotFoundException异常

② 为对象分配内存
在对象创建的第一步是为将要新生成的对象分配内存,这个所需的内存在类加载完成后就能确定下来;

分配内存有两种策略,他们针对堆的内存是否规整而使用
① 指针碰撞:假如堆中的内存是规整的,使用过的内存放在一边,没有使用过的内存放在另一边,中间放一个指针作为分隔,当要分配内存的时候,将分割指针向没有使用的内存的那边对象大小的距离即可;

② 空闲列表:如果堆中的内存不是规整的,那么需要维护一个列表来记录哪些内存被使用过哪些没有使用从而分配

对于堆的内存是否规整则使用由垃圾收集器时候由压缩整理功能决定的

③ 处理并发安全问题
除了考虑如何分配内存以外还需要考虑分配内存时的线程安全问题,防止两个线程同时修改一块内存区域,JVM有两种方案
CAS+失败重试
② 把内存分配的动作按照线程划分在不同的空间进行(TLAB

④ 初始化分配的空间 - 属性的零值初始化
对应为什么成员变量可以不赋初值访问就是因为这一步执行的结果
内存分配完成以后,虚拟机需要将分配到的内存空间都初始化为零值,这一步操作保证了实例字段不赋初值也可以使用

⑤ 设置对象的对象头
将对象的所属类(类的元数据信息),对象的HashCode和对象的GC信息,锁信息等数据存储到对象的对象头之中

⑥ 执行<init>方法进行初始化 - 属性的显示初始化,语句块初始化,构造器初始化
关于 <init><cinit>
在上面的工作完成以后,从JVM的层面看,一个新的对象就已经产生了,但是从Java程序的视角看,对象创建才刚刚开始,因为还要执行构造方法等;

Java 在编译之后会在字节码文件中生成 init 方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作收敛到init这个方法中,收敛顺序为(这里只讨论非静态变量和语句块)为:

1.父类变量初始化 --> 2.父类语句块 --> 3.父类构造函数 --> 4.子类变量初始化 --> 5.子类语句块 --> 6.子类构造方法

注意不要和<cinit>方法搞混淆

Java在编译之后会在字节码文件中生成clinit方法,称之为类构造器。类构造器同实例构造器一样,也会将静态语句块,静态变量初始化,收敛clinit方法中。收敛顺序为:

1.父类静态变量初始化 --> 2.父类静态语句块 --> 3.子类静态变量初始化 --> 4.子类静态语句块

若父类为接口,则不会调用父类的clinic方法,一个类可以没有Clinit方法。
clinit 是在类加载过程中执行的,而 init 方法是在对象实例化中进行的,所以Clinit一定比 init 先执行。

3. 对象的内存布局

HotSpot虚拟机中,对象在内存中的存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding

Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)

Mark Word: 默认存储对象的HashCode,分代年龄和锁标志位信息。Mark Word用于存储对象自身的运行时数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point: 对象指向方法区中它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例(getClass()方法就可以得到Class对象)

以 32 位虚拟机为例
① 普通对象
在这里插入图片描述
其中的Klass Word结构
类型指针是对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象属于哪个类的实例

其中的Mark Word结构
在这里插入图片描述

从上图中可以看出,对象的状态一共有五种,分别是无锁态轻量级锁重量级锁GC标记偏向锁。在32位的虚拟机中有两个Bits是用来存储锁的标记为的,但是我们都知道,两个bits最多只能表示四种状态:00、01、10、11,那么第五种状态如何表示呢 ,就要额外依赖1Bit的空间,使用0和1来区分

在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,表示非偏向锁

实例数据就是程序代码中定义的各种类型的字段

以下面代码为例子,总结以下整体的内存布局

public class Customer{
	int id = 1001;
	String name;
	Account acct;
	{
		name = "minifull";
	}
	public Customer(){
		acct = new Account();
	}
	public static void main(String[] args){
		Customer cust = new Customer();
	}
} 
class Account{
}

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值