JVM内存的基本问题
发布日期:2021-06-30 19:56:31 浏览次数:2 分类:技术文章

本文共 10615 字,大约阅读时间需要 35 分钟。

(常见面试题可能的提问点)

个人感觉学习JVM基本要点就是垃圾回收算法、垃圾回收原理、常见垃圾回收器、类加载机制、双亲委派模型、Java的逻辑分区,堆栈方法区啥的弄清楚。

个人相关博客:

读书笔记

  • 介绍下 Java 内存区域(运行时数据区)
  • Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)

以下是关于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;            Test test = new Test();                  test.change(date);             BirthDate d1= new BirthDate(7,7,1970);               }              public void change(int i){            i = 1234;        }}

对于上面这代码分析

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()将等待垃圾回收。

 

以下是学习深入理解java虚拟机读书笔记。。。。。。。

1 概述

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为内一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

2 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。

 

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

线程私有的:

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

线程共享的:

  • 方法区
  • 直接内存

2.1 程序计数器

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

以上是深入理解jvm的内容,我又查了《Java虚拟机规范 Java SE 8》,上面的解释是如果当前执行的方法是native的,那pc寄存器就保存java虚拟机正在执行的字节码指令的地址,如果该方法是native的,那pc寄存器的值是undefined。那就有疑问了,在之前学过的C语言中,pc指针都是指向下一行指令的地址后才执行上一行指令的,这样调用其他方法时,压栈保存现场,这样返回后便于恢复现场和进行接下来的操作。那这里似乎解释不通。在java虚拟机中,有个叫栈帧的东西,栈帧是用来存储数据结构和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。栈帧随着方法调用而创建,随着方法结束而销毁(无论是方法正常执行完成还是抛出了在方法内未被捕获的异常都算方法结束)。每一个栈帧都有自己的本地变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用。本地变量表和操作数栈的容量在编译期确定。

来解释一下pc为什么不是指向下一行指令地址而是指向当前指令如果当前方法调用了其他方法,或者当前方法执行结束。那这个方法的栈帧就不再是当前栈帧了。调用新的方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,然后,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

       如果方法正常完成,它很可能会返回一个值给调用它的方法。方法正常完成发生在一个方法执行过程中遇到了方法返回的字节码指令时,使用哪种返回指令取决于方法返回值的数据类型(如果有返回值)。在这种场景下,当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器,以跳过刚才执行的方法调用指令等。调用者的代码在被调用方法的返回值压入调用者栈帧的操作数栈后,会继续正常执行。

       如果方法调用异常完成,也就是说,虚拟机抛出的异常在该方法中没有办法处理,或者在执行过程中直接遇到athrow字节码指令来显式抛出异常,同时在该方法内部没有捕获异常。那一定不会有返回值返回给其调用者。

局部变量表:

操作数栈:

动态链接:

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

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

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

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

注意:程序计数器是唯一不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.2 Java 虚拟机栈

与程序计数器一样,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虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

2.3 本地方法栈

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

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

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

2.4 堆

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。

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

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

2.5 方法区

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

很多人愿意把HotSpot 虚拟机中方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,但永久代不等同于方法区。这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了,能省去专门为方法区编写的内存代码的工作。对于其他虚拟机(如BEA HRockit、IBM J9等)来说是不存在永久代的概念的。现在来看,使用永久代来实现方法区并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因为这个原因导致不同虚拟机下有不同的表现。JDK6时,String等常量信息置于方法区,但是JDK7的HotSpot中,已经把原本放在永久代的字符串常量池移出。

java虚拟机规范对方法区的限制非常宽松,除了和java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区和“永久代”名字一样“永久”存在了,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。方法区是保存相对静止稳定的数据,但并非数据进入方法区后就“永久存在”了,可能会删除后重新加载,比如热加载、热替换。当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。

2.6 运行时常量池

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

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

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

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

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

2.7 直接内存

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

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

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

3 HotSpot 虚拟机对象探秘

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

3.1 对象的创建

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

 

Java创建对象过程

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

2. 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 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见这里:

关于内存分配的区域:

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

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

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

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

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

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充

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 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

 

通过句柄访问对象

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

       这两种对象访问方式各有优势,使用句柄来访问最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据的指针,而reference本身不需要修改。

       使用直接指针访问方式最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就HotSpot而言,它是使用直接指针的访问方式进行对象访问的,但是从整个软件开发范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

 

==============Talk is cheap, show me the code==============

 

转载地址:https://liuchenyang0515.blog.csdn.net/article/details/86536409 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:String类和常量池内存分析例子以及8种基本类型
下一篇:闲聊HTTP/2.0

发表评论

最新留言

能坚持,总会有不一样的收获!
[***.219.124.196]2024年04月17日 03时38分48秒