JVM — 虚拟机内存结构

来源:https://blog.csdn.net/A_zhenzhen/article/details/77917991?locationNum=8&fps=1

     https://blog.csdn.net/hxpjava1/article/details/55189077

 

关于Java 内存模型这块可以先看这篇文章:

  1:《深入理解 Java 内存模型》读书笔记

  2:备用地址:《深入理解 Java 内存模型》读书笔记

 

概述

  java的内存管理采用自动内存管理机制,这样就不需要程序员去写释放内存的代码,而且不容易出现内存泄漏问题。正是由于内存的申请和释放都交给了Java虚拟机,一旦出现内存泄漏和溢出问题时,在不了解Java虚拟机内存结构和自动管理机制的情况下,很难排查问题的所在。所以一个成熟的程序员和架构师,必须很好的掌握Java虚拟机的自动内存管理机制。

运行时数据区

  

  上图的虚拟机运行时数据区是Java虚拟机规范所规定的区域,不同的虚拟机有不同的实现

  下图是每个区域存储的内容:

Java虚拟机栈 / 本地方法栈

  Java栈也称作虚拟机栈(Java Vitual Machine Stack),也就是我们常常所说的栈,跟C语言的数据段中的栈类似。事实上,Java栈是Java方法执行的内存模型。为什么这么说呢?下面就来解释一下其中的原因。

  Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。下图表示了一个Java栈的模型:

  局部变量表,顾名思义,想必不用解释大家应该明白它的作用了吧。就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。

  操作数栈,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

  指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。

  方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

  由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰

  本地方法栈和虚拟机栈非常相似,不同的是虚拟机栈服务的是Java方法,而本地方法栈服务的是Native方法。HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。会抛出StackOverflowError和OOM异常。

  本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

程序计数器 

  程序计数器(Program Counter Register),也有称作为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

  虽然JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示 执行哪条指令的。

  由于在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的。

  在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。

  由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

 (1)程序计数器会随着线程的启动而创建,先来直观的看下计数器中会存哪些内容

有代码如下:

public class ShareCal {

    public int calc(){
        int a = 100;
        int b = 200;
        int c = 300;
        return ( a + b ) * c;
    }
}

  这是一段非常简单的计算代码,我们先编译成Class 文件再使用 javap 反汇编工具看下class 文件中数据格式,如下图

  图中使用红框框起来的就是字节码指令的偏移地址,偏移地址对应的bipush 等等是jvm 中的操作指令,这是入栈指令。这里不作详细分析,有机会再分享。 当执行到方法calc()时在当前的线程中会创建相应的程序计数器,在计数器中为存放执行地址 (红框中的)0 2 3…等等

  这也说明在我们程序运行过程中计数器中改变的只是值,而不会随着程序的运行需要更大的空间,也就不会发生溢出情况

 (2)举例理解程序计数器

  说线程恢复等基础功能都需要依赖这个程序计数器来完成,首先我们得知道:

  线程是CPU 最小的调度单元
  Java 虚拟机的多线程是通过切换线程并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令
如有如下图过程,当A 线程先向处理器发出指令,但当执行到中途一半时,B线程过来执行,且优先级高,此时处理器将A 挂起,B 执行,当B 执行结束需要唤醒A 同时得知道A 的执行位置,就可以查看线程A 中的计数器指令

 (3)为什么执行的是native 方法时,为undefined

  由上我们知道计数器记录的字节码指令地址,但是native 本地(如:System.currentTimeMillis()/ public static native long currentTimeMillis();)方法是大多是通过C实现并未编译成需要执行的字节码指令所以在计数器中当然是空(undefined).

  问: 那native 方法的多线程是如何实现的呢?

  答: native 方法是通过调用系统指令来实现的,那系统是如何实现多线程的则 native 就是如何实现的

 摘博客一段话:  

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

  Java堆用于存放对象实例:The heap is the runtime data area from which momory which memory for all class instances and arrays is allocated。是垃圾收集器管理的主要区域。可细分为:新生代和老年代;新生代又可分为Eden,from Survivor,to Survivor。会抛出StackOverflowError异常。

方法区  

  方法区存储虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。HotSpot中也称为永久代(Permanent Generation),(存储的是除了Java应用程序创建的对象之外,HotSpot虚拟机创建和使用的对象)。为什么称为永久代呢?? 各个地方说的都不清楚,查看官方文档,解释为:永久代中的对象并不是永久的,只是历史上被叫做永久代罢了。 In fact, the objects in it are not “permanent”, but that's what it has been called historically.
  方法区在不同虚拟机中有不同的实现,HotSpot在1.7版本以前和1.7版本,1.7后都有变化。

jdk7版本以前的实现

  

 jdk7版本的改动是把字符串常量池移到了堆中

 jdk8 MetaSpace jdk1.8中则把永久代给完全删除了,取而代之的是 MetaSpace

运行时常量池和静态变量都存储到了堆中,MetaSpace存储类的元数据,MetaSpace直接申请在本地内存中(Native memory),这样类的元数据分配只受本地内存大小的限制,OOM问题就不存在了。除此之外,还有其他很多好处:
  • Take advantage of Java Language Specification property : Classes and associated metadata lifetimes match class loader’s
  • Linear allocation only
  • No individual reclamation (except for RedefineClasses and class loading failure)
  • No GC scan or compaction
  • No relocation for metaspace objects

常量池

  当我们将 .java文件编译成为了class文件后,常量池就存储在静态class文件中,也就称为class文件常量池,用于保存编译时确定的数据,主要包含以下内容:

       保存的内容如下图:

运行时常量池:

  运行时常量池(Runtime Constant Pool)是方法区的一部分。

  当jvm加载class完成后,会将类的信息如常量池,字段,方法等数据装载进内存方法区,此时class文件里此时常量池就转变为了运行时常量池  

  为撒要生成 "运行时常量池"?

  因为常量不只是预先定义在class文件中的字面量等信息,还有运行时可以生成的常量
  比如 String.itern() 会生成运行时常量字符串

  jdk 1.7后,移除了方法区间,运行时常量池和字符串常量池都在堆中。

更详细的关于jvm常量池的介绍可以看这篇文章:JVM常量池浅析

对象的内存布局

 在 HotSpot 虚拟机中,分为 3 块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)
  对象头(Header):分两个部分 Mark Word 和 类型指针
    Mark Word: 用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。
    类型指针: 即对象指向它的类元数据的指针。并不是所有的虚拟机实现都必须在对象数据上保留类型指针(用句柄实现)。
  实例数据:  存储程序代码中定义的各种类型的字段内容,这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义的顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles,ints,shorts/chars,bytes/booleans,oop(Ordinary Object Pointers)。
  对齐填充: 并不是必然存在的,没用特别的含义。HotSpot的自动内存管理系统要求对象的起始地址必须是8字节的整数倍(对象的大小必须是8字节的整数倍)。

对象的创建

1. 类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2. 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

 内存分配的两种方式:(补充内容,需要掌握)

  选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

 内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块内存。JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

3. 初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5. 执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

 

对象的访问定位

 

  这两种对象的访问方式各有优势,使用句柄来访问的最大好吃就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改
使用直接指针访问的最大好处就是速度快,它节省了异常指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多也是一项可观的执行成本。
HotSpot 是通过直接指针访问对象的方式进行对象访问的。
 
辅助学习还可以看这篇  Java内存模型
posted @ 2018-04-01 23:48  myseries  阅读(1453)  评论(0编辑  收藏  举报