JVM详细总结

1. JVM简介

2. JVM的内存模型JMM

2.1 JMM介绍

JVM内存模型主要是结合CPU及实际的物理内存特征定义了抽象概念。

为何需要这个模型呢?
因为CPU的运算速度都是远高于物理内存的读写速度的,为了降低IO对CPU运算效率的限制,需要通过一系列方案(如设置多个高速缓存,使用多线程等)来尽量满足CPU对IO的需求。但这样又可能引入多种并发问题(因为涉及到内存之间的数据拷贝与并发读写),因此定义了内存模型来解决。

JMM的概念图如下。其中各个内存部分是抽象概念,并非对应某一个固定的物理内存区域。比如线程工作内存既可能是CPU的寄存器、高速缓存、也可能是RAM。
在这里插入图片描述

2.2 内存屏障的概念

前面提到CPU的运算速度是远远高于内存读写的,因此在原有RAM基础上又增加了高速缓存Cache。

每个单独的CPU内核都有自己的高速缓存,一般分三级:L1、L2、L3缓存。而不同的CPU内核是不可以访问其他内核的高速缓存的,因此多线程共享数据需要CPU将其从主内存中拷贝出,临时存储在独立的内存中,更改后再更新主内存。这样就会引入一个多线程并发下的数据同步问题。为了解决这种同步问题,JMM引入了内存屏障的概念。

内存屏障是硬件层技术,是一个CPU指令。内存屏障既可以实现数据同步,又可以保证数据的有序性,防止指令重排(CPU为了充分利用性能,往往使用多级指令流水线,使指令执行的顺序稍微调整或者同时执行)。

内存屏障可以分为:Load Barrier 和 Store Barrier即读屏障和写屏障。

Load Barrier: 在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据;
Stroe Barrier: 在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

在真正使用这两种屏障时,一般是给予不同的组合,即:LoadLoad 屏障、StoreStore 屏障、LoadStore 屏障 、StoreLoad 屏障。

  1. LoadLoad屏障
    该指令一般在两次加载操作之间声明,以保证指令前的第一次加载操作一定先于该指令后面的加载指令(且保证是从主内存中加载)。一些CPU可能会具备预加载功能,或者支持指令的乱序处理,这时就需要LoadLoad显示声明了。
  2. StoreStore屏障
    保证该指令以前的存储操作在该指令后面的Store及其他指令开始前完成,并且数据需要刷新到主内存,使其他CPU也可见。
  3. LoadStore屏障
    保证Load操作先于该指令后面的Store操作完成。
  4. StoreLoad屏障
    确保Store的数据在被后面Load指令读取之前完成存储至主存且对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令不正确的使用了前面被Store的数据,而不是另一个处理器在相同内存位置写入的新数据。
    Storeload屏障在几乎所有的现代多处理器中都需要使用,但通常它的开销也是最昂贵的。

3. JVM内存结构

JVM的运行时数据内存结构与内存模型是不同的概念。内存结构更偏向于各个结构的数据定义,是逻辑上的概念。

JVM的运行时数据内存结构大概可参考下图。
JVM内存结构
由图中可以看到,JVM内存结构中可以分为:方法区(内部包含运行时常量池,在Java8以前HotSpot使用永久代实现,后面改为元数据),堆,虚拟机栈,本地方法栈以及程序计数器。其中方法区和堆为各线程公用内存区,而虚拟机栈、本地方法栈、程序计数器则是各线程私有的。下面将具体为每个单独的内存区做详细介绍。

3.1 程序计数器

首先简单来讲,程序计数器是线程私有的,是一个记录着当前线程所执行的字节码的行号指示器,用于帮助线程切换后能够正确返回到原来线程所执行的位置。

JAVA代码编译后的字节码在未经过JIT(Just In Time实时编译器,Dalvik VM每次运行程序前都需要通过JIT将字节码编译成机器码,后面ART已经将其优化。ART会在安装时使用预编译,AOT-> Ahead Of Time,将机器码提前存储,这样每次运行就不用再实时编译了,节省了时间。这一点其他文章会继续讨论)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。

从上面的描述中,可能会产生程序计数器是否是多余的疑问。因为沿着指令的顺序执行下去,即使是分支跳转这样的流程,跳转到指定的指令处按顺序继续执行是完全能够保证程序的执行顺序的。假设程序永远只有一个线程,这个疑问没有任何问题,也就是说并不需要程序计数器。但实际上程序是通过多个线程协同合作执行的。

首先要理解JVM的多线程实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。

3.2 虚拟机栈

虚拟机栈是线程私有的,是一个后入先出的栈,与线程数量是1对1的关系。虚拟机栈的生命周期与线程保持一致,当线程终结时,对应的栈也会被释放。因此不存在垃圾回收问题。

栈内的基本单位是栈帧。在虚拟机栈的所有栈帧中,只有栈顶的栈帧是当前有效的,与该栈帧关联的方法为当前方法。

3.2.1 虚拟机栈帧

在这里插入图片描述
图片来源虚拟机栈与本地方法栈

一个栈帧包括:局部变量表、操作数栈、动态链接、方法返回地址等。

3.2.1.1 局部变量表

局部变量表存放了编译期可知的各种primitive types(boolean、byte、char、short、int、float、long、double)、对象的引用(reference类型,不等同于对象本身,根据不同的虚拟机实现,可能是一个指向对象起始地址的引用指针,也可能是一个代表对象的句柄或者其他与对象相关的位置)和 returnAdress类型(指向下一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,方法运行之前,该局部变量表所需要的内存空间是固定的,运行期间也不会改变。

局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

3.2.1.2 操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中

操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。

当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

3.2.1.3 动态连接

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。

这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接

3.2.1.4 方法返回地址

当一个方法开始执行时,可能有两种方式退出该方法:

  • 正常完成出口
  • 异常完成出口

正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。

异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论是Java虚拟机抛出的异常还是代码中使用throw指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。异常处理方式参考文章Java Exception的使用及原理

无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。

方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压如调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

3.2.2 虚拟机栈可能抛出的错误

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError 若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。
  • OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
3.3 本地方法栈

本地方法栈是线程私有的,用来存储本地方法信息的栈。

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。

如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那么它的本地方法栈就是C栈。当C程序调用一个C函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。

很可能本地方法接口需要回调Java虚拟机中的Java方法,在这种情况下,该线程会保存本地方法栈的状态并进入到另一个Java栈。
虚拟机栈和本地方法栈的关系
图片来自Inside the Java Virtual Machine

本地方法栈可能会抛出 StackOverFlowError 和 OutOfMemoryError 异常。

3.4 堆

堆是线程共享的数据存储区。
一个对象的成员变量(无论是基本类型还是其他类的对象引用)都与该对象统一存放在堆中;
一个对象的成员方法中的局部变量是存放在栈中的;
静态变量及其类的定义存放在堆中;
堆是线程共享的数据存储区,但是当多个现成都对堆中的某个对象具有访问权限时,它们对该对象内部数据的访问,实际上是先要将其拷贝在各自线程的栈中去的。(这里涉及到了线程安全问题,可以参考文章)详谈Java中的锁机制

3.5 方法区

方法区,是线程共享的数据存储区。
在Java8以前,HotSpot使用永久代(Perm Gen)的实现形式,与新生代、老年代相邻,使用堆存储所有不会被GC回收的数据。但是Java8以后将方法区内的数据分开存储,一部分依然存储在堆中(常期存在的对象实例),另一部分(元数据)则存在本地内存中。
方法区包括常量(运行时常量、整型常量、字符串常量、静态常量),已经被虚拟机加载的类信息,静态变量,即时编译器编译后的代码。

4. 数据存储

//TODO

4.1 类的加载

//TODO

4.2 对象的初始化

//TODO

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值