文章目录
JVM组成图
1. 运行时数据区域
组成图如下:
其中 本地方法栈、程序计数器、虚拟机栈 为线程私有,堆区和方法区(元空间) 为线程共享**区域
1.1 程序计数器
程序计数器是一块较小的内存区域,可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
-
当前线程所执行的字节码行号指示器。
-
每个线程都有一个自己的
PC
计数器。 -
线程私有的,生命周期与线程相同,随
JVM
启动而生,JVM
关闭而死。 -
线程执行
Java
方法时,记录其正在执行的虚拟机字节码指令地址。 -
线程执行
Native
方法时,计数器记录为空(Undefined
)。 -
唯一在Java虚拟机规范中没有规定任何
OutOfMemoryError
情况区域。
计数器操作流程: javap -c xx.class 反编译class文件
计数器存储指令的偏移地址
,执行引擎拿到偏移地址
后取出操作指令
,然后翻译成机器指令
交由CPU执行指令
1.2 虚拟机栈
线程私有内存空间,它的生命周期和线程相同。线程执行期间,每个方法被执行时,都会创建一个栈帧(Stack Frame
)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每个方法从被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
设置栈的大小 -Xss1000k
设置为1000kb
局部变量表
局部变量表局部变量表是 Java 虚拟机栈的一部分,是一组变量值的存储空间,用于存储方法参数和局部变量。 在 Class
文件的方法表的 Code
属性的 max_locals
指定了该方法所需局部变量表的最大容量。
局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:
基本数据类型 : boolean, byte, char, short, int, float, long, double等8种;
对象引用类型 :reference
,指向对象起始地址的引用指针;不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置
返回地址类型 :returnAddress
,返回地址的类型。指向了一条字节码指令的地址
变量槽(Variable Slot):
变量槽是局部变量表的最小单位,规定大小为32
位。对于64位的long
和double
变量而言,虚拟机会为其分配两个连续的Slot
空间
操作数栈
操作数栈(Operand Stack
)也常称为操作栈,是一个后入先出栈。在 Class
文件的 Code
属性的 max_stacks
指定了执行过程中最大的栈深度。
Java虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的栈就是指操作数栈。
- 和局部变量表一样,操作数栈也是一个以32字长为单位的数组。
- 虚拟机在操作数栈中可存储的数据类型:
int
、long
、float
、double
、reference
和returnType
等类型 (对于byte
、short
以及char
类型的值在压入到操作数栈之前,也会被转换为int
)。 - 和局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作压栈和出栈来访问。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
具体说明操作数和局部变量的操作流程:
第一步将15 push
入栈,然后sotre_1
出栈存储到局部变量表solt1
的位置
重复操作将8存储到局部变量表slot2
的位置
然后load1
,load2
出栈两个数相加入栈,最后取出栈中的数,放入变量表solt3
的位置
动态链接
每个栈帧
都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接
。
Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:
- 静态解析: 一部分会在类加载阶段或第一次使用的时候转化为直接引用(如
final
、static
域等),称为静态解析, - 动态解析: 另一部分将在每一次的运行期间转化为直接引用,称为动态链接。
虚方法和非虚方法:
-
非虚方法
方法在在编译时期就确定了具体的调用版本,这个版本在运行时期是不可以改变的。
静态方法、私有方法、final方法、构造器、父类方法都是非虚方法 -
虚方法
接口方法等
方法返回地址
当一个方法开始执行以后,只有两种方法可以退出当前方法:
- 正常返回: 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(
NormalMethodInvocation Completion
),一般来说,调用者的PC计数器可以作为返回地址。 - 异常返回: 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(
Abrupt Method Invocation Completion
),返回地址要通过异常处理器表来确定。
当一个方法返回时,可能依次进行以下3个操作:
- 恢复上层方法的局部变量表和操作数栈。
- 把返回值压入调用者栈帧的操作数栈。
- 将PC计数器的值指向下一条方法指令位置。
1.3 本地方法栈
本地方法栈和Java虚拟机栈发挥的作用非常相似,主要区别是Java虚拟机栈执行的是Java方法服务,而本地方法栈执行Native方法服务(通常用C编写)。
有些虚拟机发行版本(譬如Sun HotSpot
虚拟机)直接将本地方法栈和Java虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError
和OutOfMemoryError
异常。
1.4 堆
概述
对大多数应用而言,Java 堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一作用就是存放对象实例,几乎所有的对象实例都是在这里分配的(不绝对,在虚拟机的优化策略下,也会存在栈上分配、标量替换的情况)。
堆是 GC
回收的主要区域,因此很多时候也被称为 GC
堆。
从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以在Java堆被划分成两个不同的区域:
-
新生代 (
Young Generation
)
划分为三个区域:一个Eden
区和两个Survivor
区From Survivor
区和To Survivor
区。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然时对象实例。 -
老年代 (
Old Generation
)
新的对象分配是首先放在年轻代 (
Young Generation
)的Eden
区,Survivor
区作为Eden
区和Old
区的缓冲,在Survivor
区的对象经历若干次收集仍然存活的,就会被转移到老年代Old
中。
配置与组成比例
各个区域的比例比例:
GC介绍
内存分配策略
如果对象在Eden
出生并经过第一次 Minorgc
后仍然存活,并且能被 Survivor
容纳的话,将被移动到 Survivor
空间中,并将对象年龄设为1。对象在Survivor
区中每熬过一次 Minorgc
,年龄就增加1岁,当它的年龄増加到一定程度(默认为15岁,其实每个JVM
、每个GC
都有所不同)时,就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过选项-Xx: Maxtenuringthresho1d
来设置。
针对不同年龄段的对象分配原则如下所示:·
- 优先分配到Eden
- 大对象直接分配到老年代,尽量避免程序中出现过多的大对象·
- 长期存活的对象分配到老年代
动态对象年龄判断:
如果 Survivor
区中相同年龄的所有对象大小的总和大于 Survivor
空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到Maxtenuringthresho1d
中要求的年龄。
老年代的连续空间大于新生代对象总大小或者历次晋升的平均小就会进行 Minor gc
,否则将进行FullGC
。
误区:
相同年龄是值从小到大累加的总对象大小的占比,如果年龄1的占30%,年龄2的占30%,它们加起来占60%,所以大于等于年龄2的对象会进入老年代
参考博客:https://www.jianshu.com/p/989d3b06a49d
参数配置
逃逸分析
逃逸分析的基本行为就是分析对象动态作用域
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。
堆不是分配对象的唯一选择。如果经过逃逸分析( Escape Analysis
)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。
-Xx:+Doescapeanalysis
显式开启逃逸分析通过选项
-Xx:+Printescapeanalysis
查看逃逸分析的筛选结果
- 栈上分配: 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 同步省略: 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
在动态编译同步块的时候,JIT
编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT
编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略
,也叫锁消除
。 - 分离对象或标量替换: 有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
1.5 方法区
《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。但对于HotSpotJVM
而言,方法区还有一个别名叫做Non-heap(非堆)
,目的就是要和堆分开。所以,方法区可以看作是一块独立于Java堆
的内存空间。
方法区(Method Area
)与Java
堆一样,是各个线程共享的内存区域
- 方法区在
JVM
启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的 - 方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:
java.lang.OutOfMemoryError:PermGen space
或者java.lang,OutOfMemoryError:Metaspace
元数据区大小可以使用参数一XX:MetaspaceSize
和一XX :MaxMetaspaceSize
指定
内部结构
类型信息
对每个加载的类型( 类class
、接口interface
、枚举enum
、注解annotation
),JVM
必 须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
- 这个类型的修饰符(public, abstract, final的某个子集)
- 这个类型直接接口的一个有序列表
域信息(成员变量)
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括:域名称、 域类型、域修饰符(public
, private
,protected
, static
, final
, volatile
, transient
的某个子集)
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public, private, protected, static, final,synchronized, native , abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native 方法除外)
- 异常表( abstract和native方法除外)每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
静态变量
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例所共享,即使没有类实例你也可以访问它。
全局常量 static final
被声明为final
的类变量的处理方法则不同,每个全局常量在编译的时候就被分配了。
运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant Pool Table
),用于存放编译期生成的各种字面常量和符号引用
,这部分内容会在类加载后进入方法区的运行时常量池。
小结
永久代为什么要被元空间替换?
- 为永久代设置空间大小是很难确定的。
- 对永久代进行调优是很困难的。
元空间和永久代区别在于:
元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
StringTable 为什么要调整?
jdk7
中将StringTable
放到了堆空间中。因为永久代的回收效率很低,在full gc
的时候才会触发。而full GC
是老年代的空间不足、永久代不足时才会触发。这就导致了StringTable
回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存.
2. 内存对象
2.1 对象创建过程
几种对象创建方式:
Header | 解释 |
---|---|
使用new关键字 | 调用了构造函数 |
使用Class的newInstance方法 | 调用了构造函数 |
使用Constructor类的newInstance方法 | 调用了构造函数 |
使用clone方法 | 没有调用构造函数 |
使用反序列化 | 没有调用构造函数 |
对象创建的主要流程:
1.判断对象对应的类是否加载、链接、初始化
虚拟机接收到一条new指令时,首先会去检查这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初始化过。如果没有则先执行相应的类加载过程。
2.为对象分配内存
类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
- 指针碰撞: 如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
- 空闲列表: 如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
3.处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
- 对分配内存空间的动作进行同步处理(采用
CAS
+ 失败重试来保障更新操作的原子性); - 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为
本地线程分配缓冲
(Thread Local Allocation Buffer, TLAB
)。哪个线程要分配内存,就在哪个线程的TLAB
上分配。只有TLAB
用完并分配新的TLAB
时,才需要同步锁。通过-XX:+/-UserTLAB
参数来设定虚拟机是否使用TLAB
。
2.2 对象存储结构
HotSpot
虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头
(Header
)、实例数据
(Instance Data
)和对齐填充
(Padding
)。
对象头
在HotSpot
虚拟机中,对象头有两部分信息组成:运行时数据
和 类型指针
,如果是数组对象,还有一个保存数组长度的空间
。
-
Mark Word
(运行时数据):用于存储对象自身运行时的数据,如哈希码(hashCode)、GC分带年龄、线程持有的锁、偏向线程ID等信息。在32位系统占4字节,在64位系统中占8字节;HotSpot
虚拟机对象头Mark Word
在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示:
存储内容 | 标志位 | 标志状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
-
Class Pointer(类型指针):
用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节; -
Length:
如果是数组对象,还有一个保存数组长度的空间,占4个字节;
实例数据
实例数据 是对象真正存储的有效信息,无论是从父类继承下来的还是该类自身的,都需要记录下来,而这部分的存储顺序受虚拟机的分配策略和定义的顺序的影响。
默认分配策略:
long/double -> int/float -> short/char -> byte/boolean -> reference
如果设置了-XX:FieldsAllocationStyle=0(默认是1)
,那么引用类型数据就会优先分配存储空间:
reference -> long/double -> int/float -> short/char -> byte/boolean
结论:
分配策略总是按照字节大小由大到小的顺序排列,相同字节大小的放在一起。
对齐填充
无特殊含义,不是必须存在的,仅作为占位符。
HotSpot虚拟机要求每个对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(32位为1倍,64位为2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
2.3 对象的访问
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄
和 直接指针
两种方式。
指针:
指向对象,代表一个对象在内存中的起始地址。句柄:
可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。
句柄
Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:
优势: 引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。
直接指针
如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。
优势: 速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。
内存分配案例
日均百万级订单交易系统如何设置JVM参数