最详细的JVM 内存结构剖析

JVM 内存结构

Java 虚拟机的内存空间分为 5 个部分:

  • 程序计数器
  • Java 虚拟机栈
  • 本地方法栈
  • 方法区

jvm-memory-structure

知识体系:

 

详细分布: 

JDK 1.8 同 JDK 1.7 比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。

 

先详细介绍一下运行时数据区

 

运行时数据区

内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。

下图是 JVM 整体架构,中间部分就是 Java 虚拟机定义的各种运行时数据区域。

Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。

  • 线程私有:程序计数器、虚拟机栈、本地方法区
  • 线程共享:堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)

 

程序计数器(PC 寄存器Program Counter Register)

Register 之名源于CPU的寄存器,CPU只有把数据装载到寄存器才能够运行
寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?
每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常。

 

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器

 

程序计数器的定义

程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined

  • Java方法
    计数器记录的就是当前线程正在执行的字节码指令的地址
  • 本地方法
    那么程序计数器值为undefined

程序计数器的作用

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程情况下,程序计数器记录的是当前线程执行的位置,从而当线程切换回来时,就知道上次线程执行到哪了。

 

PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。

 

(分析:进入class文件所在目录,执行 javap -v xx.class 反解析(或者通过 IDEA 插件 Jclasslib 直接查看,上图),可以看到当前类对应的Code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。)

 

程序计数器的特点

  • 是一块较小的内存空间。
  • 线程私有,每条线程都有自己的程序计数器。
  • 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
  • 是唯一一个不会出现OutOfMemoryError的内存区域。

 

最后:

通过下面两个问题,理解下PC计数器

  • 使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

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

  • PC寄存器为什么会被设定为线程私有的?

多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。

相关总结如下:

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域

 

Java 虚拟机栈(Java 栈 JVM Stack)

Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。

Java 虚拟机栈的定义

Java 虚拟机栈是描述 Java 方法运行过程的内存模型。

相对于基于寄存器的运行环境,JVM是基于栈结构的运行环境。栈结构移植性更好,可控性更强。

JVM中的虚拟机栈是描述Java方法执行的内存区域,属【线程私有】。

栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程。

Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域,用于存放该方法运行过程中的一些信息,如:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息
  • ......

jvm-stack

 

作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  • JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈
  • 栈不存在垃圾回收问题

栈中可能出现的异常

Java 虚拟机规范允许 Java虚拟机栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
  • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个OutOfMemoryError异常

可以通过参数-Xss来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。

 

结构
栈帧是方法运行的基本结构。

在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧
正在执行的方法称为当前方法
在执行引擎运行时,所有指令都只能针对当前栈帧操作,StackOverflowError表示请求的栈溢出,导致内存耗尽,通常出现在递归方法。

当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵

操作栈的压栈与出栈

 

虚拟机栈通过压/出栈,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。
在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。

 

栈中存储什么?

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
  • 在这个线程上正在执行的每个方法都各自有对应的一个栈帧
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

 2.3 栈运行原理

  • JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈出栈,遵循“先进后出/后进先出”原则

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作

  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧

  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧

  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧

  • Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出

IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况

 

栈帧在整个JVM体系中的地位颇高,包括:局部变量表、操作栈、动态连接、方法返回地址等。

局部变量表
存放方法参数和局部变量。
相对于类属性变量的准备阶段和初始化阶段,局部变量没有准备阶段,必须显式初始化。
如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。
字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内。

 

下面介绍一下局部变量表:

 

局部变量表

  • 局部变量表也被称为局部变量数组或者本地变量表
  • 是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • 参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束

 槽 Slot

  • 局部变量表最基本的存储单元是 Slot(变量槽)

  • 在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot

    • byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
    • long 和 double 则占据两个 Slot
  • JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上

  • 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)

  • 如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)

  • 栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)

 

 

目录

JVM 内存结构

运行时数据区

程序计数器(PC 寄存器Program Counter Register)

程序计数器的定义

程序计数器的作用

 

PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。

程序计数器的特点

 

Java 虚拟机栈(Java 栈 JVM Stack)

Java 虚拟机栈的定义

 

结构栈帧是方法运行的基本结构。

操作栈的压栈与出栈

 2.3 栈运行原理

局部变量表存放方法参数和局部变量。相对于类属性变量的准备阶段和初始化阶段,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内。

 

操作数栈一个初始状态为空的桶式结构栈。由于 Java 没有寄存器,所有参数传递使用操作数栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。

操作栈与局部变量表交互

动态连接每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。

方法返回地址方法执行时有两种退出情况:

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

Java 虚拟机栈的特点

本地方法栈(C 栈Native Method Stack)

本地方法栈的定义

栈帧变化过程

堆(Java Heap)

堆出现OOM的概率是所有内存耗尽异常中最高的出错时的堆内信息对解决问题非常有帮助,所以给JVM设置运行参数-

XX:+HeapDumpOnOutOfMemoryError让JVM遇到OOM异常时能输出堆内信息

堆的定义

堆的特点

方法区

方法区的定义

方法区的特点

运行时常量池(Runtime Constant Pool)

特性运行时常量池相对于class文件常量池的另外一个特性是具备动态性,Java语言并不要求常量一定只有编译器才产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。

是则返回池中的字符串否将此 String 对象添加到池中,并返回此 String 对象的引用 可能抛出的异常运行时常量池是方法区的一部分,所以会受到方法区内存的限制,因此当常量池无法再申请到内存时就会抛出OutOfMemoryError异常.

直接内存(堆外内存Direct Memory)

操作直接内存

直接内存与堆内存比较

Metaspace (元空间)到了JDK8,元空间的前身Perm区(永久代)被淘汰,在JDK7及之前的版本中,只有Hotspot才有Perm区,它在启动时固定大小,很难进行调优,并且Full GC时会移动类元信息。

在JDK8里,Perm 区所有内容中

字符串常量移至堆内存其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间

比如上图中的Object类元信息、静态属性System.out、整型常量000000等图中显示在常量池中的String,其实际对象是被保存在堆内存中的。元空间特色充分利用了Java语言规范:类及相关的元数据的生命周期与类加载器的一致每个类加载器都有它的内存区域-元空间只进行线性分配不会单独回收某个类(除了重定义类 RedefineClasses 或类加载失败)没有GC扫描或压缩元空间里的对象不会被转移如果GC发现某个类加载器不再存活,会对整个元空间进行集体回收GCFull GC时,指向元数据指针都不用再扫描,减少了Full GC的时间很多复杂的元数据扫描的代码(尤其是CMS里面的那些)都删除了元空间只有少量的指针指向Java堆这包括:类的元数据中指向java.lang.Class实例的指针;数组类的元数据中,指向java.lang.Class集合的指针。没有元数据压缩的开销减少了GC Root的扫描(不在扫描虚拟机里面的已加载类的目录和其它的内部哈希表)G1中,并发标记阶段完成后就可以进行类的卸载元空间内存分配模型绝大多数的类元数据的空间都在本地内存中分配用来描述类元数据的对象也被移除为元数据分配了多个映射的虚拟内存空间为每个类加载器分配一个内存块列表块的大小取决于类加载器的类型Java反射的字节码存取器(sun.reflect.DelegatingClassLoader )占用内存更小空闲块内存返还给块内存列表当元空间为空,虚拟内存空间会被回收减少了内存碎片最后,从线程共享的角度来看

​8 从GC角度看Java堆堆和方法区都是线程共享的区域,主要用来存放对象的相关信息。一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,因此, 这部分的内存和回收都是动态的,垃圾收集器所关注的就是这部分内存(本节后续所说的“内存”分配与回收也仅指这部分内存)。而在JDK1.7和1.8对这部分内存的分配也有所不同:

9 JVM关闭正常关闭:当最后一个非守护线程结束或调用了System.exit或通过其他特定于平台的方式,比如ctrl+c。强制关闭:调用Runtime.halt方法,或在操作系统中直接kill(发送single信号)掉JVM进程。异常关闭:运行中遇到RuntimeException 异常等在某些情况下,我们需要在JVM关闭时做一些扫尾的工作,比如删除临时文件、停止日志服务。为此JVM提供了关闭钩子(shutdown hocks)来做这些事件。


操作数栈


一个初始状态为空的桶式结构栈。由于 Java 没有寄存器,所有参数传递使用操作数栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。

 

 

  • 每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)

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

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作

概述

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的 max_stack 数据项中
  • 栈中的任何一个元素都可以是任意的 Java 数据类型
    • 32bit 的类型占用一个栈单位深度
    • 64bit 的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

 栈顶缓存(Top-of-stack-Cashing)

 

HotSpot 的执行引擎采用的并非是基于寄存器的架构,但这并不代表 HotSpot VM 的实现并没有间接利用到寄存器资源。寄存器是物理 CPU 中的组成部分之一,它同时也是 CPU 中非常重要的高速存储资源。一般来说,寄存器的读/写速度非常迅速,甚至可以比内存的读/写速度快上几十倍不止,不过寄存器资源却非常有限,不同平台下的CPU 寄存器数量是不同和不规律的。寄存器主要用于缓存本地机器指令、数值和下一条需要被执行的指令地址等数据。

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

 

字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的stack属性中。

操作栈与局部变量表交互


详细的字节码操作顺序如下:

第1处说明:局部变量表就像个中药柜,里面有很多抽屉,依次编号为0, 1, 2,3,.,. n
字节码指令istore_ 1就是打开1号抽屉,把栈顶中的数13存进去
栈是一个很深的竖桶,任何时候只能对桶口元素进行操作,所以数据只能在栈顶进行存取
某些指令可以直接在抽屉里进行,比如inc指令,直接对抽屉里的数值进行+1操作
程序员面试过程中,常见的i++和++i的区别,可以从字节码上对比出来

i++和++i的区别
iload_ 1 从局部变量表的第1号抽屉里取出一个数,压入栈顶,下一步直接在抽屉里实现+1的操作,而这个操作对栈顶元素的值没有影响
所以istore_ 2只是把栈顶元素赋值给a
表格右列,先在第1号抽屉里执行+1操作,然后通过iload_ 1 把第1号抽屉里的数压入栈顶,所以istore_ 2存入的是+1之后的值
i++并非原子操作。即使通过volatile关键字进行修饰,多个线程同时写的话,也会产生数据互相覆盖的问题。

动态连接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。

方法返回地址
方法执行时有两种退出情况:

正常退出
正常执行到任何方法的返回字节码指令,如RETURN、IRETURN、ARETURN等。
异常退出
无论何种,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧。

退出可能有三种方式:

返回值压入,上层调用栈帧
异常信息抛给能够处理的栈帧
PC计数器指向方法调用后的下一条指令
Java虚拟机栈是描述Java方法运行过程的内存模型。Java虚拟机栈会为每一个即将运行的Java方法创建“栈帧”。用于存储该方法在运行过程中所需要的一些信息。

局部变量表
存放基本数据类型变量、引用类型的变量、returnAddress类型的变量
操作数栈
动态链接
当前方法的常量池指针
当前方法的返回地址
方法出口等信息
每一个方法从被调用到执行完成的过程,都对应着一个个栈帧在JVM栈中的入栈和出栈过程

注意:人们常说,Java的内存空间分为“栈”和“堆”,栈中存放局部变量,堆中存放对象。
这句话不完全正确!这里的“堆”可以这么理解,但这里的“栈”就是现在讲的虚拟机栈,或者说Java虚拟机栈中的局部变量表部分.
真正的Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息.

动态链接(指向运行时常量池的方法引用)就是:

  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
  • 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。

由于 Java 虚拟机栈是与线程对应的,数据不是线程共享的,因此不用关心数据一致性问题,也不会存在同步锁的问题。

 

最后说明一下

JVM 是如何执行方法调用的

方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。

【这一块内容,除了方法调用,还包括解析、分派(静态分派、动态分派、单分派与多分派),这里先不介绍,后续再挖】

在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关

  • 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

  • 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定。

虚方法和非虚方法

  • 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
  • 其他方法称为虚方法

虚方法表

在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。

每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。

 方法返回地址(return address)

用来存放调用该方法的 PC 寄存器的值。

一个方法的结束,有两种方式

  • 正常执行完成
  • 出现未处理的异常,非正常退出

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

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口

    一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定

    在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。

  2. 在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口

    方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

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

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值

附加信息

栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。

 

Java 虚拟机栈的特点

  • 局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变。
  • Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
    • StackOverFlowError 若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。
    • OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
  • Java 虚拟机栈也是线程私有,随着线程创建而创建,随着线程的结束而销毁。

出现 StackOverFlowError 时,内存空间可能还有很多。

本地方法栈(C 栈Native Method Stack)

 

 

本地方法接口

简单的讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。

为什么要使用本地方法(Native Method)?

Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来也不容易,或者我们对程序的效率很在意时,问题就来了

  • 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
  • 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
  • Sun's Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。比如,类 java.lang.ThreadsetPriority() 的方法是用Java 实现的,但它实现调用的是该类的本地方法 setPrioruty(),该方法是C实现的,并被植入 JVM 内部。

本地方法栈(Native Method Stack)

Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用

和虚拟机栈功能类似,虚拟机栈是为虚拟机执行JAVA方法而准备的。
虚拟机规范没有规定具体的实现,由不同的虚拟机厂商去实现。
HotSpot虚拟机中虚拟机栈和本地方法栈的实现式一样的。同样,超出大小以后
也会拋出StackOverflowError。

本地方法栈和Java虚拟机栈实现的功能与抛出异常几乎相同。只不过

虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务
本地方法栈则为虚拟机使用到的Native方法服务
在JVM内存布局中,也是线程对象私有的,但是虚拟机栈“主内”,而本地方法栈“主外”
这个“内外”是针对JVM来说的,本地方法栈为Native方法服务
线程开始调用本地方法时,会进入一个不再受JVM约束的世界
本地方法可以通过JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和JVM相同的能力和权限
当大量本地方法出现时,势必会削弱JVM对系统的控制力,因为它的出错信息都比较黑盒.
对于内存不足的情况,本地方法栈还是会拋出native heap OutOfMemory

最著名的本地方法应该是System.currentTimeMillis(),JNI 使Java深度使用OS的特性功能,复用非Java代码
但是在项目过程中,如果大量使用其他语言来实现JNI,就会丧失跨平台特性,威胁到程序运行的稳定性
假如需要与本地代码交互,就可以用中间标准框架进行解耦,这样即使本地方法崩溃也不至于影响到JVM的稳定
当然,如果要求极高的执行效率、偏底层的跨进程操作等,可以考虑设计为JNI调用方式
 

 

 

本地方法栈的定义

本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。它与 Java 虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型。

栈帧变化过程

本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。

方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。

如果 Java 虚拟机本身不支持 Native 方法,或是本身不依赖于传统栈,那么可以不提供本地方法栈。如果支持本地方法栈,那么这个栈一般会在线程创建的时候按线程分配。

总结

  • Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用

  • 本地方法栈也是线程私有的

  • 允许线程固定或者可动态扩展的内存大小

    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个OutofMemoryError异常
  • 本地方法是使用 C 语言实现的

  • 它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存

  • 并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈

  • 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一


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

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。

 

堆(Java Heap)

 

内存划分

对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。

为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):

  • 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
  • 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
  • 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存

JDK7

Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx-Xms 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。

 年轻代 (Young Generation)

年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1

  • 大多数新创建的对象都位于 Eden 内存空间中
  • 当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中
  • Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
  • 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代

老年代(Old Generation)

旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。

大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝

元空间

不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。

虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。

所以元空间放在后边的方法区再说。

 

JVM启动时创建,存放对象的实例。垃圾回收器主要就是管理堆内存。
Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用
通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间

 

设置堆内存大小和 OOM

Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx-Xms 来设定

  • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
  • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。

我们通常会将 -Xmx-Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能

  • 默认情况下,初始堆内存大小为:电脑内存大小/64

  • 默认情况下,最大堆内存大小为:电脑内存大小/4

可以通过代码获取到我们的设置值,当然也可以模拟 OOM:

public static void main(String[] args) {

  //返回 JVM 堆大小
  long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
  //返回 JVM 堆的最大内存
  long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;

  System.out.println("-Xms : "+initalMemory + "M");
  System.out.println("-Xmx : "+maxMemory + "M");

  System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
  System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
}

堆的内存空间既可以固定大小,也可运行时动态地调整,通过如下参数设定初始值和最大值,比如

-Xms256M. -Xmx1024M

其中-X表示它是JVM运行参数

ms是memorystart的简称 最小堆容量
mx是memory max的简称 最大堆容量

但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,JVM的Xms和Xmx设置成一样大小,避免在GC后调整堆大小时带来的额外压力

堆分成两大块:新生代和老年代
对象产生之初在新生代,步入暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象

新生代= 1个Eden区+ 2个Survivor区
绝大部分对象在Eden区生成,当Eden区装填满的时候,会触发Young GC。垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收。依然存活的对象会被移送到Survivor区,这个区真是名副其实的存在
Survivor 区分为S0和S1两块内存空间,送到哪块空间呢?每次Young GC的时候,将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态
如果YGC要移送的对象大于Survivor区容量上限,则直接移交给老年代
假如一些没有进取心的对象以为可以一直在新生代的Survivor区交换来交换去,那就错了。每个对象都有一个计数器,每次YGC都会加1。

-XX:MaxTenuringThreshold 

参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升至老年代。如果该参数配置为1,那么从新生代的Eden区直接移至老年代。默认值是15,可以在Survivor 区交换14次之后,晋升至老年代

对象分配与简要GC流程图

若Survivor区无法放下,或者超大对象的阈值超过上限,则尝试在老年代中进行分配;
如果老年代也无法放下,则会触发Full Garbage Collection(Full GC);
如果依然无法放下,则抛OOM.

堆出现OOM的概率是所有内存耗尽异常中最高的
出错时的堆内信息对解决问题非常有帮助,所以给JVM设置运行参数-

XX:+HeapDumpOnOutOfMemoryError

让JVM遇到OOM异常时能输出堆内信息

在不同的JVM实现及不同的回收机制中,堆内存的划分方式是不一样的
 

 

堆的定义

堆是用来存放对象的内存空间,几乎所有的对象都存储在堆中。

存放所有的类实例及数组对象
除了实例数据,还保存了对象的其他信息,如Mark Word(存储对象哈希码,GC标志,GC年龄,同步锁等信息),Klass Pointy(指向存储类型元数据的指针)及一些字节对齐补白的填充数据(若实例数据刚好满足8字节对齐,则可不存在补白)

 

 

查看 JVM 堆内存分配

  1. 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小

  2. 默认情况下新生代和老年代的比例是 1:2,可以通过 –XX:NewRatio 来配置

    • 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过 -XX:SurvivorRatio 来配置
  3. 若在 JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄

    此时 –XX:NewRatio-XX:SurvivorRatio 将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy

    在 JDK 8中,不要随意关闭-XX:+UseAdaptiveSizePolicy,除非对堆内存的划分有明确的规划

每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小

计算依据是GC过程中统计的GC时间吞吐量内存占用量

 

java -XX:+PrintFlagsFinal -version | grep HeapSize
    uintx ErgoHeapSizeLimit                         = 0                                   {product}
    uintx HeapSizePerGCThread                       = 87241520                            {product}
    uintx InitialHeapSize                          := 134217728                           {product}
    uintx LargePageHeapSizeThreshold                = 134217728                           {product}
    uintx MaxHeapSize                              := 2147483648                          {product}
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

 

 

对象在堆中的生命周期

  1. 在 JVM 内存模型的堆中,堆被划分为新生代和老年代
    • 新生代又被进一步划分为 Eden区Survivor区,Survivor 区由 From SurvivorTo Survivor 组成
  2. 当创建一个对象时,对象会被优先分配到新生代的 Eden 区
    • 此时 JVM 会给对象定义一个对象年轻计数器-XX:MaxTenuringThreshold
  3. 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
    • JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
    • 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
  4. 如果分配的对象超过了-XX:PetenureSizeThreshold,对象会直接被分配到老年代

 对象的分配过程

为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new 的对象先放在伊甸园区,此区有大小限制
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区
  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区
  6. 什么时候才会去养老区呢? 默认是 15 次回收标记
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常

GC 垃圾回收简介

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有 CMS GC 会有单独收集老年代的行为
      • 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾

TLAB

什么是 TLAB (Thread Local Allocation Buffer)?

  • 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
  • OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计

为什么要有 TLAB ?

  • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

堆是分配对象存储的唯一选择吗

随着 JIT 编译期的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。 ——《深入理解 Java 虚拟机》

逃逸分析

逃逸分析(Escape Analysis)是目前 Java 虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。

例如

public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}

 

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个 StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

上述代码如果想要 StringBuffer sb不逃出方法,可以这样写:

public static String createStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb.toString();
}

 

 

不直接返回 StringBuffer,那么 StringBuffer 将不会逃逸出方法。

参数设置:

  • 在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析
  • 如果使用较早版本,可以通过-XX"+DoEscapeAnalysis显式开启

开发中使用局部变量,就不要在方法外定义。

使用逃逸分析,编译器可以对代码做优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  • 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。

常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递

代码优化之同步省略(消除)

  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能
  • 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除
public void keep() {
  Object keeper = new Object();
  synchronized(keeper) {
    System.out.println(keeper);
  }
}

 

如上代码,代码中对 keeper 这个对象进行加锁,但是 keeper 对象的生命周期只在 keep()方法中,并不会被其他线程所访问到,所以在 JIT编译阶段就会被优化掉。优化成:

public void keep() {
  Object keeper = new Object();
  System.out.println(keeper);
}

 

代码优化之标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。

相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。

在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换

通过 -XX:+EliminateAllocations 可以开启标量替换,-XX:+PrintEliminateAllocations 查看标量替换情况。

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point(1,2);
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}


 

以上代码中,point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point 对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

 

代码优化之栈上分配

我们通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

总结:

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。

 

堆的特点

  • 线程共享,整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆,它是被所有线程共享的一块内存区域,在虚拟机启动时创建.。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个。
  • 在虚拟机启动时创建,Java虚拟机所需要管理的内存中最大的一块.。
  • 是垃圾回收的主要场所,堆内存物理上不一定要连续,只需要逻辑上连续即可,就像磁盘空间一样.。
  • 进一步可分为:新生代(Eden 区:From SurviorTo Survivor)、老年代,堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的(通过-Xmx和-Xms控制),因此当线程请求分配内存,但堆已满,且内存已满无法再扩展时,就抛出OutOfMemoryError.。

不同的区域存放不同生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,更具有针对性。

堆的大小既可以固定也可以扩展,但对于主流的虚拟机,堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已无法再扩展时,就抛出 OutOfMemoryError 异常。

Java 堆所使用的内存不需要保证是连续的。而由于堆是被所有线程共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性。

方法区

方法区的定义

Java 虚拟机规范中定义方法区是堆的一个逻辑部分。

具体实现根据不同虚拟机来实现。
HotSpot在:

JDK7时,方法区放在永久代
JDK8时,放在元数据空间,通过GC对该区域进行管理
别名Non-Heap(非堆),以与Java堆区分。
 

 

  • 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
  • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern()方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
  • 方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
  • JVM 关闭后方法区即被释放

 

方法区存放以下信息:

  • 已经被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码

方法区的特点

  • 线程共享。 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
  • 永久代。 方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,把方法区称为“永久代”。
  • 内存回收效率低。 方法区中的信息一般需要长期存在,回收一遍之后可能只有少量信息无效。主要回收目标是:对常量池的回收;对类型的卸载。
  • Java 虚拟机规范对方法区的要求比较宽松。 和堆一样,允许固定大小,也允许动态扩展,还允许不实现垃圾回收。

 

  • 方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
  • 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)
  • Java7 中我们通过-XX:PermSize-xx:MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize-XX:MaxMetaspaceSize 用来设置元空间参数
  • 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
  • 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出 OutOfMemoryError
  • JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)

所以对于方法区,Java8 之后的变化:

  • 移除了永久代(PermGen),替换为元空间(Metaspace);
  • 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
  • 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
  • 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)

设置方法区内存的大小

 

JDK8 及以后:

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 指定,替代上述原有的两个参数
  • 默认值依赖于平台。Windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspacaSize 的值是 -1,即没有限制
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize :设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize 的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。

 方法区内部结构

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

 类型信息

对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
  • 这个类型的修饰符(public,abstract,final 的某个子集)
  • 这个类型直接接口的一个有序列表

域(Field)信息

  • JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
  • 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)

方法(Method)信息

JVM 必须保存所有方法的

  • 方法名称
  • 方法的返回类型
  • 方法参数的数量和类型
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
  • 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
  • 异常表(abstract 和 native 方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

 

 

 

运行时常量池(Runtime Constant Pool)

是方法区的一部分。

方法区中存放:类信息、常量、静态变量、即时编译器编译后的代码。常量就存放在运行时常量池中。

当类被 Java 虚拟机加载后, .class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如 String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。

JDK6、7、8三个版本中, 运行时常量池的所处区域一直在不断的变化:

  • 6时,是方法区的一部分
  • 7时,又放到堆内存
  • 8时,出现了元空间,又回到了方法区

这也说明了官方对“永久代”的优化从7就已经开始了。

移除永久代原因

 

  • 为永久代设置空间大小是很难确定的。

    在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现 OOM。而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制

  • 对永久代进行调优较困难

 

 

理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)

常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。

为什么需要常量池?

一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。

如下,我们通过 jclasslib 查看一个只有 Main 方法的简单类,字节码中的 #2 指向的就是 Constant Pool

著作权归https://pdai.tech所有。 链接:https://www.pdai.tech/md/java/jvm/java-jvm-struct.html

常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

运行时常量池

  • 在加载类和结构到虚拟机后,就会创建对应的运行时常量池
  • 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
  • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
  • 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern() 方法就是这样的
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。

 

只有 HotSpot 才有永久代的概念

 

 

方法区的垃圾回收

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading 查看类加载和卸载信息。

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

 

特性
运行时常量池相对于class文件常量池的另外一个特性是具备动态性,Java语言并不要求常量一定只有编译器才产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。

String类的intern()方法就是采用了运行时常量池的动态性。当调用 intern 方法时,看池中已包含一个等于此 String 对象的字符串:


则返回池中的字符串

将此 String 对象添加到池中,并返回此 String 对象的引用
 可能抛出的异常
运行时常量池是方法区的一部分,所以会受到方法区内存的限制,因此当常量池无法再申请到内存时就会抛出OutOfMemoryError异常.

我们一般在一个类中通过public static final来声明一个常量。这个类被编译后便生成Class文件,这个类的所有信息都存储在这个class文件中。

当这个类被Java虚拟机加载后,class文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如:String类的intern()方法就能在运行期间向常量池中添加字符串常量。

当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用,那么就需要垃圾收集器回收。
 

直接内存(堆外内存Direct Memory)

直接内存是除 Java 虚拟机之外的内存,但也可能被 Java 使用。

直接内存不是虚拟机运行时数据区的一部分,也不是JVM规范中定义的内存区域,但在JVM的实际运行过程中会频繁地使用这块区域.而且也会抛OOM

在JDK 1.4中加入了NIO(New Input/Output)类,引入了一种基于管道和缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在堆里的DirectByteBuffer对象作为这块内存的引用来操作堆外内存中的数据.
这样能在一些场景中显著提升性能,因为避免了在Java堆和Native堆中来回复制数据.

综上看来
程序计数器、Java虚拟机栈、本地方法栈是线程私有的,即每个线程都拥有各自的程序计数器、Java虚拟机栈、本地方法区。并且他们的生命周期和所属的线程一样。
而堆、方法区是线程共享的,在Java虚拟机中只有一个堆、一个方法栈。并在JVM启动的时候就创建,JVM停止才销毁。
 

操作直接内存

在 NIO 中引入了一种基于通道和缓冲的 IO 方式。它可以通过调用本地方法直接分配 Java 虚拟机之外的内存,然后通过一个存储在堆中的DirectByteBuffer对象直接操作该内存,而无须先将外部内存中的数据复制到堆中再进行操作,从而提高了数据操作的效率。

直接内存的大小不受 Java 虚拟机控制,但既然是内存,当内存不足时就会抛出 OutOfMemoryError 异常。

直接内存与堆内存比较

  • 直接内存申请空间耗费更高的性能
  • 直接内存读取 IO 的性能要优于普通的堆内存。
  • 直接内存作用链: 本地 IO -> 直接内存 -> 本地 IO
  • 堆内存作用链:本地 IO -> 直接内存 -> 非直接内存 -> 直接内存 -> 本地 IO

服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

Metaspace (元空间)
到了JDK8,元空间的前身Perm区(永久代)被淘汰,在JDK7及之前的版本中,只有Hotspot才有Perm区,它在启动时固定大小,很难进行调优,并且Full GC时会移动类元信息。

在某些场景下,若动态加载类过多,容易产生Perm区的OOM。比如某工程因为功能点较多,运行过程中,要不断动态加载很多类,经常出现错误:

Exception in thread ‘dubbo client x.x connector' 
java.lang.OutOfMemoryError: PermGenspac

为解决该问题,需要设定运行参数

-XX:MaxPermSize= l280m

如果部署到新机器上,往往会因为JVM参数没有修改导致故障再现。不熟悉此应用的人排查问题时都苦不堪言。此外,永久代在GC过程中还存在诸多问题。

所以,JDK8使用元空间替换永久代。区别于永久代,元空间在本地内存中分配。即,
只要本地内存足够,它不会出现类似永久代的java.lang.OutOfMemoryError: PermGen space

对永久代的设置参数 PermSize 和MaxPermSize也会失效。在JDK8及以上版本,设定MaxPermSize参数,JVM在启动时并不会报错,但会提示:

Java HotSpot 64Bit Server VM warning:ignoring option MaxPermSize=2560m; support was removed in 8.0

默认情况下,“元空间”的大小可以动态调整,或者使用新参数MaxMetaspaceSize来限制本地内存分配给类元数据的大小。

在JDK8里,Perm 区所有内容中

字符串常量移至堆内存
其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间

比如上图中的Object类元信息、静态属性System.out、整型常量000000等
图中显示在常量池中的String,其实际对象是被保存在堆内存中的。
元空间特色
充分利用了Java语言规范:类及相关的元数据的生命周期与类加载器的一致
每个类加载器都有它的内存区域-元空间
只进行线性分配
不会单独回收某个类(除了重定义类 RedefineClasses 或类加载失败)
没有GC扫描或压缩
元空间里的对象不会被转移
如果GC发现某个类加载器不再存活,会对整个元空间进行集体回收
GC
Full GC时,指向元数据指针都不用再扫描,减少了Full GC的时间
很多复杂的元数据扫描的代码(尤其是CMS里面的那些)都删除了
元空间只有少量的指针指向Java堆
这包括:类的元数据中指向java.lang.Class实例的指针;数组类的元数据中,指向java.lang.Class集合的指针。
没有元数据压缩的开销
减少了GC Root的扫描(不在扫描虚拟机里面的已加载类的目录和其它的内部哈希表)
G1中,并发标记阶段完成后就可以进行类的卸载
元空间内存分配模型
绝大多数的类元数据的空间都在本地内存中分配
用来描述类元数据的对象也被移除
为元数据分配了多个映射的虚拟内存空间
为每个类加载器分配一个内存块列表
块的大小取决于类加载器的类型
Java反射的字节码存取器(sun.reflect.DelegatingClassLoader )占用内存更小
空闲块内存返还给块内存列表
当元空间为空,虚拟内存空间会被回收
减少了内存碎片
最后,从线程共享的角度来看

堆和元空间是所有线程共享的
虚拟机栈、本地方法栈、程序计数器是线程内部私有的
从这个角度看一下Java内存结构

Java 的线程与内存
8 从GC角度看Java堆
堆和方法区都是线程共享的区域,主要用来存放对象的相关信息。一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,因此, 这部分的内存和回收都是动态的,垃圾收集器所关注的就是这部分内存(本节后续所说的“内存”分配与回收也仅指这部分内存)。而在JDK1.7和1.8对这部分内存的分配也有所不同:

Java8中堆内存分配如下图:

Java 的线程与内存

9 JVM关闭
正常关闭:当最后一个非守护线程结束或调用了System.exit或通过其他特定于平台的方式,比如ctrl+c。
强制关闭:调用Runtime.halt方法,或在操作系统中直接kill(发送single信号)掉JVM进程。
异常关闭:运行中遇到RuntimeException 异常等
在某些情况下,我们需要在JVM关闭时做一些扫尾的工作,比如删除临时文件、停止日志服务。为此JVM提供了关闭钩子(shutdown hocks)来做这些事件。

Runtime类封装java应用运行时的环境,每个java应用程序都有一个Runtime类实例,使用程序能与其运行环境相连。

关闭钩子本质上是一个线程(也称为hock线程),可以通过Runtime的addshutdownhock (Thread hock)向主jvm注册一个关闭钩子。hock线程在jvm正常关闭时执行,强制关闭不执行。

对于在jvm中注册的多个关闭钩子,他们会并发执行,jvm并不能保证他们的执行顺序。

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值