【JVM 总述】JVM总述

JVM一共9篇:一篇四种引用对象,两篇常量池、四篇JVM、一篇jdk8 metaspace、一篇双亲委派模式破坏

Java考点:(一般面向大厂招聘JD:理解JVM,有JVM调优经验)
考点一:四种引用对象 + 常量池底层原理
考点二:Java 内存结构(运行时数据区)
考点三:销毁对象的时候,自动内存管理(Java与C/C++,Java中没有析构函数):GC垃圾回收时机 + GC垃圾回收对象 + GC操作(垃圾回收算法+垃圾回收器)
点四:创建对象五步骤 + 对象三部分 + 对象两种引用方式
3.1 新建对象的时候,类加载机制,即Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么) 类加载 + 分配内存 + 初始化零值 + 设置对象头 + 构造函数底层调用init方法
3.2 对象三部分(对象头 + 实例数据 + 对齐填充)
3.3 对象的访问定位的两种方式(句柄和直接指针两种方式)

考点五:JVM四篇
5.1 从.java文件到.class文件
5.2 方法执行
5.3 JVM对Java多线程并发的底层支持(单独有博客,不讲,synchronized + volatile)
考点六:双亲委派模型(单独有博客,不展示)、
考点七:jdk8单独拿出一个内存区域 元空间

JVM全部文章目录

【JVM】Java四种引用类型(使用层面:很简单)

【JVM】宏观层面:Java常量池

【JVM】JVM层面:常量池

JVM(一)——JVM自动内存管理

JVM(二)——JVM执行子系统,针丝千缕解析.class文件

JVM(三)——JVM优化(编译时优化+运行时优化)与JVM性能调优

JVM(四)——JVM高效并发,一点一滴解析多线程并发的底层实现

【JVM】元空间由来和本质 + 元空间参数配置 + 元空间监控测试

【JVM】双亲委派模式的破坏

考点1:JVM的内存结构(JVM第一篇博客有,这里不再总结)

问题:JVM中对象、引用和变量存储

金手指:对象和变量在JVM中的存储(JVM的结构 或 新建对象的时候,类加载机制)
问题:
对象:一个对象在哪里?一个对象的引用放在哪里?
变量:一个成员变量存在哪里?一个局部变量存在哪里?如果是局部变量是一个对象的引用存在哪里?
回答:
第一,对象在堆;对象的引用放到声明对象引用的方法栈中;
第二,成员变量在堆(因为成员变量是在类中定义的,是某一个对象的成员变量,所以也随对象放到堆里面);
第三,局部变量是在方法中定义的,所以在虚拟机栈的局部变量表,局部变量的引用也在虚拟机栈的局部变量表。
举个简单例子:
class BirthDate {
private int day; // 类中定义的成员变量
private int month; // 类中定义的成员变量
private int year; // 类中定义的成员变量
public BirthDate(int d, int m, int y) {
day = d;
month = m;
year = y;
}
// 省略get,set方法………
}
public class Test{
public static void main(String args[]){
int date = 9; // main()方法中的局部变量
Test test = new Test(); // test引用放到main()方法的方法栈中,new Test()对象放到堆中
test.change(date);
BirthDate d1= new BirthDate(7,7,1970);
}
public void change(int i){
i = 1234; // i放在change()方法栈的局部变量表中
}
}
对于上面这代码分析
date为局部变量,i,d,m,y都是形参为局部变量,day,month,year为成员变量。下面分析一下代码执行时候的变化:
1. main方法开始执行,创建栈帧并压栈
int date = 9;
date为局部变量,是基础类型,位于虚拟机栈的局部变量表中。
2. Test test = new Test();
test为对象引用,位于虚拟机栈的局部变量表中,对象new Test()存在堆中。
3. test.change(date); 看到public void change(int i)方法
i为局部变量,int是基础类型,位于虚拟机栈的局部变量表。当调用方法change时,创建栈帧(里面包含了局部变量表)并压栈,当方法change执行完成后,栈帧出栈,i也就消失了,该栈帧可以被回收。
4. BirthDate d1= new BirthDate(7,7,1970);
d1 为对象引用,位于虚拟机栈的局部变量表中,对象new BirthDate()存在堆中,调用构造方法,创建栈帧并压栈,栈帧中的局部变量表存储了基础类型int的d,m,y。而day,month,year为成员变量,它们存储在堆中(new BirthDate()里面)。当BirthDate构造方法执行完之后,栈帧出栈,d,m,y将从栈中消失,栈帧可被回收。
5.main方法执行完之后
date变量,test,d1引用都在栈帧中,栈帧出栈,可被回收,堆中的new Test(),new BirthDate()将等待垃圾回收。

JVM内存结构:运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。下图就是运行时数据区,一共包括

在这里插入图片描述

这些组成部分一些是线程私有的,其他的则是线程共享的。

线程私有的:程序计数器、虚拟机栈、本地方法栈

线程共享的:堆、方法区、直接内存

第一,程序计数器(普通方法和native方法 + 帧栈 + 当前帧栈 + 帧栈四个部分)

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

执行普通方法(非native方法)时,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
当执行本地Native方法时,计数器的值为空(undefined)。

金手指:和程序计数器相关的一个问题
问题:程序计数器中,如果当前执行的方法是native的,那pc寄存器就保存java虚拟机正在执行的字节码指令的地址,如果该方法是native的,那pc寄存器的值是undefined。那就有疑问了,在之前学过的C语言中,pc指针都是指向下一行指令的地址后才执行上一行指令的,这样调用其他方法时,压栈保存现场,这样返回后便于恢复现场和进行接下来的操作。那这里似乎解释不通?
回答:
第一,引入栈帧:在java虚拟机中,有个叫栈帧的东西,栈帧是用来存储数据结构和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。栈帧随着方法调用而创建,随着方法结束而销毁(无论是方法正常执行完成还是抛出了在方法内未被捕获的异常都算方法结束)。每一个栈帧都有自己的本地变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用。本地变量表和操作数栈的容量在编译期确定。
第二,引入当前帧栈:来解释一下pc为什么不是指向下一行指令地址而是指向当前指令:标准答案:当前帧栈。
如果当前方法调用了其他方法,或者当前方法执行结束。那这个方法的栈帧就不再是当前栈帧了。调用新的方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,然后,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
第三,方法正常返回+方法异常返回
如果方法正常完成,它很可能会返回一个值给调用它的方法。方法正常完成发生在一个方法执行过程中遇到了方法返回的字节码指令时,使用哪种返回指令取决于方法返回值的数据类型(如果有返回值)。在这种场景下,当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器,以跳过刚才执行的方法调用指令等。调用者的代码在被调用方法的返回值压入调用者栈帧的操作数栈后,会继续正常执行。
如果方法调用异常完成,也就是说,虚拟机抛出的异常在该方法中没有办法处理,或者在执行过程中直接遇到athrow字节码指令来显式抛出异常,同时在该方法内部没有捕获异常。那一定不会有返回值返回给其调用者。

局部变量表:

在这里插入图片描述

操作数栈:

在这里插入图片描述

动态链接:

在这里插入图片描述

方法调用完成(方法出口):

在这里插入图片描述

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意:程序计数器是唯一不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

第二,Java 虚拟机栈(记住三点:属于线程 + 方法调用压栈出栈 + 栈溢出和内存泄露)

虚拟机栈和帧栈的关系
第一,属于线程:一个线程中有一个虚拟机栈,一个虚拟机栈中有很多个帧栈,一个方法调用对应一个帧栈,每个栈帧中都拥有局部变量表、操作数栈、动态链接、方法出口信息。
第二,方法调用压栈出栈:每一次方法调用创建一个帧,表示帧栈压栈;每一个方法执行完成,表示帧栈出栈。
第三,两种异常:Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack)其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有局部变量表、操作数栈、动态链接、方法出口信息)

每一次方法调用创建一个帧,并压栈。

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

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

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

Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

在这里插入图片描述

第三,本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。在虚拟机规范中,对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由的实现它。比如在 HotSpot 虚拟机中本地方法栈和虚拟机栈合二为一。

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

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

第四,堆(记住两个,堆共享 + 堆GC操作)

第一,堆共享:整个HotSpot实现的JVM中,只有一个堆,存放具体的引用类型对象,类、接口、数组,所有线程共享这一个堆;
第二,堆的GC操作:堆就是GC回收的区域,堆分为三个区域,新生代、老年代、永生代,jdk8变为新生代、老年代、元空间,永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,对于分代GC来说,堆也是分代的,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。从内存分配的角度来看,线程共享的java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是更好地回收内存,或者更快地分配内存。

在这里插入图片描述

在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

咱们直接试试看!!!!eclipse–>Run Configurations–>Arguments–>VM arguments里面输入-XX:+PrintGCDetails,然后随便运行一段程序会看到如下:

jdk1.7:

在这里插入图片描述

jdk1.8:

在这里插入图片描述

eden space就是对象创建的地方,

我们以jdk1.8为例来讲解

在这里插入图片描述

低边界为起始位置,当前边界为当前所申请分配到的可使用的位置,最高边界为所能申请到的最高的位置

用当前边界0x0000000784980000减去低边界0x0000000780980000除以1024除以1024=64M,意味着新生代分配了64M=65536K,正好eden space49152K + from space8192K + to space 8192K=65536K=64M。

但是64M又和total 57344K不一样,说明分配的65536K的新生代,可用的只有57344K,57344K=49152K+8192K(eden space+ from space)

官方推荐新生代占堆的的3/8,幸存代占新生代的1/10。

更多了解请见这里JVM堆内存设置参数分析(这些分析是在葛一鸣的视频上摘录下的,我看过视频,作者的例子和视频一样)

根据java虚拟机规范规定,java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制,如果两个设置成一样说明不可扩展)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

第五,方法区(记住四点,方法区存放内容、仅限于HotSpot虚拟机、String常量池、方法区存放相对静止数据但是不使用永久)

第一,方法区存放内容
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

第二,方法区仅限于HotSpot虚拟机
对于其他虚拟机(如BEA HRockit、IBM J9等)来说是不存在永久代的概念的。现在来看,使用永久代来实现方法区并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因为这个原因导致不同虚拟机下有不同的表现。

第三,String
JDK6时,String等常量信息置于方法区,但是JDK7的HotSpot中,已经把原本放在永久代的字符串常量池移出。

第四,方法区存放相对静止的数据但是并不永久
很多人愿意把HotSpot 虚拟机中方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机设计团队选择把GC分代收集扩展至方法区,但永久代不等同于方法区。这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了,能省去专门为方法区编写的内存代码的工作。
java虚拟机规范对方法区的限制非常宽松,除了和java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区和“永久代”名字一样“永久”存在了,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。方法区是保存相对静止稳定的数据,但并非数据进入方法区后就“永久存在”了,可能会删除后重新加载,比如热加载、热替换。当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。

第六,运行时常量池(单独有博客将JVM常量池技术,记住一点存放,运行时方法区存放位置)

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,java语言并不要求常量一定只要编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多是String类的intern()方法。

当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

jdk1.6运行时常量池存在于方法区,jdk1.7移到了堆区,而jdk1.8运行时常量池其实是存在于与方法区和堆区相对独立的元空间

在这里插入图片描述

第七,直接内存(记住三点,直接内存定义、直接内存使用、直接内存限制)

第一,直接内存定义
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

第二,直接内存的使用
JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

第三,直接内存限制
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小(包括RAM以及SWAP区或分页文件)以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

考点2:销毁对象的时候,自动内存管理(JVM第一篇博客有,这里一个回答小结)

JVM所谓的自动内存管理,就是自动内存回收机制。
Java和C/C++区别:new 新建对象是一样的,但是释放对象,C/C++使用 delete/free 操作,程序员自己负责,Java使用自动内存回收机制,程序员不干预。

问题:Java使用垃圾收集器实现自动内存回收机制,它让创建的对象不需要像c/cpp那样delete、free掉,你能不能谈谈,GC是在什么时候?对什么东西?做了什么事情?

回答:

1、在什么时候GC?
1.1 说明minor gc/full gc的触发条件:
GC分为两种minor gc和full gc,eden满了就执行minor gc,升到老年代的对象大于老年代剩余空间就执行full gc,或者小于时被HandlePromotionFailure参数强制full gc;
1.2 OOM的触发条件:OOM:gc与非gc时间耗时超过了GCTimeRatio的限制引发OOM
1.3 降低GC的调优的策略:
调优诸如通过NewRatio控制新生代老年代比例,通过MaxTenuringThreshold控制进入老年前生存次数等

2、对什么东西GC?
标准答案:从gc root开始搜索,搜索不到(root根对象查找、标记),即GC不可达,而且经过第一次标记、清理后,仍然没有复活的对象。
补充:对于不使用的对象进行GC操作,然后补上JVM四种引用对象,优先级:。
错误回答:超出作用域的对象/引用计数为空的对象。
gc到底怎么判断哪些对象在不在作用域的?至于引用计数来判断对象是否可收集的,我可以会补充一个下面这个例子让面试者分析一下obj1、obj2是否会被GC掉?
class C{
public Object x;
}
C obj1、obj2 = new C();
obj1.x = obj2;
obj2.x = obj1;
obj1、obj2 = null;

3、“GC做什么事情”,这个问发挥的空间就太大了,不同年代、不同收集器的动作非常多。
宏观上说:删除不使用的对象,腾出内存空间。
从JVM上说:整个过程,达到GC stop point点,停止其他线程执行、运行finalize,
两种不同的垃圾收集算法
然后对于新生代和老年代的清理方式是不同的,
新生代做的是复制清理,因为新生代99%的对象都是朝生夕死的,所以在结构上,没有必要留下50%的空闲区作为复制,只要留下10%就好(就算超出也有老年代担保机制,从老年代借一些空间来),结构上,eden:survivor=8:2,将存活的对象放到from survivor区,复制到to survivor,整理内存,就完成了复制清理。
老年代做的是标记清理、标记清理后碎片要不要整理、复制清理和标记清理有有什么优劣势等。
基于垃圾收集算法的垃圾收集器
进一步:垃圾收集算法是理论基础,垃圾收集器是具体实现,新生代和老年代都有垃圾收集器,
串行、并行(整理/不整理碎片)、CMS等搜集器可作用的年代、特点、优劣势,并且能说明控制/调整收集器选择的方式。

JVM垃圾回收机制:
标准答案:Java中有没有析构函数不重要,应该说,Java作为一门后端编程语言,有能够完成C/C++中析构函数相同功能的内存回收机制,就是重写finalize()方法 和 垃圾收集器,就像Java没有格式化输出,而是提供System.out.println()方法通过字符串拼接提供相同的功能一样。
解释1:java并没有提供析构函数或相似的概念,代之以java的垃圾回收机制,但是垃圾回收并不等同于“析构”,而作为垃圾回收机制的一部分,finalize()方法更不能视为c++中的析构函数。这是因为如果C++程序没有缺陷,那么析构函数会被自动调用并完成清理工作,这一动作是一定会执行的;而java的垃圾回收只与内存有关,只要程序没有濒临存储空间耗尽,那么与垃圾回收有关的任何行为(尤其是finalize()方法)都不会执行,换言之,java里的对象并非一定会被垃圾回收!进而,如果java想要实现类似c++析构函数的清理效果,必须编写恰当的清理方法,并明确调用,而不是依靠finalize()方法,还是那句话,JVM如果没有面临内存耗尽,它不会浪费时间执行垃圾回收以恢复内存,遑论这期间调用finalize()方法的环节,所以并不能保证finalize()方法一定执行,这(极大)可能会达不到你想要的清理效果。关于finalize()方法的应用场景,我觉得java编程思想中将其作为“终结条件”来验证对象清理回收前的状态以检查编程缺陷是很有启发性的。
解释2:java中有析构函数,但我们一般用不到它,因为java有自动内存回收机制,无需程序员来释放,也就不要担心内存泄露,只不过java中析构函数所采用的方式不是C++中的那样前加~号,在java中
对象析构时会调用void finalize()方法,因此你
如果确实需要析构的话就可以为你写的类添加一个void finalize(){}方法,来完成你所需要的工作

小结:解释1的意思是,C/C++中内存回收的时机(析构函数的调用时机)是程序员自己指定,Java中内存回收的时机(垃圾收集器收集内存的时机)由JVM自动确定,所以Java和C/C++不同。
解释2的意思是,C/C++中内存回收前的操作,程序员可以自己指定,写在析构函数中;Java中内存回收前的操作,程序员可以自己指定,写在重写的finalize()方法中,所以Java和C/C++相同。
一个回收内存的时机,一个是回收内存的操作。

附:Java和C/C++函数对应关系


考点3: HotSpot 虚拟机:对象创建五步骤 + 对象布局 + 对象两种引用方式

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

3.1 新建对象的时候,类加载机制 五步骤(类加载+分配内存+初始化零值+设置对象头+构造函数底层调用init方法)

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。

在这里插入图片描述

Java创建对象过程

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

附:分配内存(指针配置 + 空闲列表)

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

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

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

在这里插入图片描述

内存分配原理见上图。

要知道,创建对象在JVM中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发的情况下也不是线程安全的,可能正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来指针指向的位置去分配内存。解决这个问题方案有2种,如下↓↓↓↓↓↓↓↓↓↓↓↓↓↓

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

CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
TLAB: 为每一个线程预先在 Eden 区分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,当对象大于TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。
关于TLAB见这里:JVM源码分析之线程局部缓存TLAB

关于内存分配的区域:

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。如果设置了-XX:PretenureSizeThreshold参数,则就会将大于这个设置值的对象直接在老年代分配。这样做到目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。因为虚拟机采用分代收集的思想管理内存,所以内存回收的时候就必须要识别哪些对象应放在新生代,哪些对象放在老年代。为了实现,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间,并且对象年龄设为1。对象在Survivor区每“熬过”一次GC,年龄就增加一岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代,无须等到MaxTenuringThreshold中要求的年龄。

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

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

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

3.2 对象的内存布局(对象头+实例数据+对齐填充)

在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头(mark word + class metadata address + 数组长度)、实例数据和对齐填充。

Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等),这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark Word”。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。并不是所有的虚拟机实现都必须要在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,这涉及到对象的访问定位,我们稍后再讲。另外,如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为JVM可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。无论是从父类继承下来的,还是子类中定义的,都需要记录下来。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

3.3 对象的两种访问方式(句柄访问+直接指针访问)

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式取决于虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

在这里插入图片描述

通过句柄访问对象

  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。下图所示,reference指向对象,对象里面有到对象类型数据的指针。

在这里插入图片描述

句柄来访问好处:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据的指针,而reference本身不需要修改。
直接指针访问好处:速度更快,它节省了一次指针定位的时间开销,由于对象的访问在java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
就HotSpot而言,它是使用直接指针的访问方式进行对象访问的,但是从整个软件开发范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

©️2020 CSDN 皮肤主题: 终极编程指南 设计师:CSDN官方博客 返回首页