JVM 常见面试题汇总

1. JVM 的内存结构

1.1 JVM 的主要组成部分及其作用

JVM包含两个子系统和两个组件,两个子系统为 Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
  • Execution engine(执行引擎):执行classes中的指令。
  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

image-20220225111630531

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

image-20210816225907214

1.2 JVM 运行时数据区

image-20210816161959892

  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;
  • Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息;
  • 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;
  • Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;
  • 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

1.3 堆栈的区别

物理地址

堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)

栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

内存分别

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。

栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。

存放的内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储

栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

  1. 静态变量放在方法区
  2. 静态的对象还是放在堆。

程序的可见度

堆对于整个应用程序都是共享、可见的。

栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

参考:JVM的内存结构

1.4 内存溢出

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出,有时候会自动关闭软件,重启电脑或者软件后释放掉一部分内存又可以正常运行该软件,而由系统配置数据流、用户代码等原因而导致的内存溢出错误,即使用户重新执行任务依然无法避免。

顺便再说说内存溢出和内存泄漏的区别,个人理解:

  • 内存溢出:程序运行所需要的内存大于所提供的内存。
  • 内存泄漏:程序运行时划分了内存,但是程序执行完成后对象没有被回收,处于一直存活的状态,比如使用ThreadLocal之后没有remove
  • 两者关系:内存泄漏过多之后就会造成内存溢出。怎么理解?多线程执行同一个内存泄漏的程序,也就是占用过多的内存之后,超出了规定的内存大小,自然就溢出了。

① 栈溢出

我们把栈的内存大小默认为1m:-Xss1m,下面的代码可演示栈溢出

image-20210818135656259

HotSpot 版本中栈的大小是固定的,是不支持拓展的。

java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归

虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。

OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)

同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。

② 堆溢出

内存直接溢出:申请内存空间,超出最大堆内存空间。如果是内存溢出,则通过 调大 -Xms,-Xmx 参数。

设置VM Args:-Xms30m -Xmx30m -XX:+PrintGCDetails

image-20210818135500849

在工作中还可能会遇到这样的一个异常:GC overhead limit exceeded,如下面的代码

image-20210818143022006

这种情况不是内存直接溢出,就是说内存中的对象却是都是必须存活的,也就是达到一定的量才会溢出,就好比水杯装水,刚开始是空的,接水的时候不满就会一直接,但是如果你没注意,当水满了,这个时候就溢出了,这个过程就类似于内存溢出。但是如果在要满的时候你喝几口再去接,那杯子又可以重新接水,这个过程可以在逻辑上理解为GC调优。

但是既然有GC调优为什么还会溢出呢?官方给出的原因是:

超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。

怎么解决?

  1. 那么就应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间。
  2. 查看项目中是否有大量的死循环或有使用大内存的代码,优化代码。
  3. 增大堆内存。

2. JVM 中的对象

对于 JVM 中的对象我们需要掌握这几个问题,对象怎么创建,对象的内部构造布局,对象如何访问(定位),怎么判断对象的存活,对象的四大引用以及对象在堆栈中是如何分配的。

2.1 对象的创建过程

说到对象的创建,首先让我们看看 Java 中提供的几种对象创建方式:

image-20220225114142583

也就是说当JVM 遇到一条字节码 new 的指令,就相当于告诉它要创建对象了,如下:

image-20210818235458672

  1. 类加载,当 JVM 遇到一条 new 指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。

  2. 检查加载,该过程实际上还是检查类加载的过程是否正常,检查类是否已经被加载、解析和初始化过。

  3. 分配内存,类加载产生的新对象需要在堆中分配内存,而分配内存的方式有两种:

    • 指针碰撞

      如果 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。就是说顺序存放,如下:

      img

    • 空闲列表

      如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。可以理解为乱序存放,如下:

      image-20210818230310908

    也就是说,对象通过那种方式存放主要是通过堆的内存是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

    如果是 Serial、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。如果是使用 CMS 这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

  4. 内存空间初始化

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

  5. 设置

    接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在 Java hotspot VM 内部表示为类元数据)、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。

  6. 对象初始化

    一般来说,执行 new 指令后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化。

2.2 对象创建过程中的并发问题

我们知道 Java 天生就是多线程的,所以对象创建在虚拟机中是非常频繁的行为,在划分空间的时候仅仅是修改指针所指向的位置也是线程不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。怎么解决?解决这个问题有两种方案:

image-20220225141024064

1、CAS 机制

CAS,Compare and Swap,即比较再交换。一个 CAS 操作过程都包含三个运算符:一个内存地址 V,一个期望的值 A 和一个新值 B,操作的时候如果这个地址上存放的值等于这个期望的值 A,则将地址上的值赋为新值 B,否则不做任何操作。CAS 的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环 CAS 就是在一个循环里不断的做 CAS 操作,直到成功为止(想仔细了解可以看我这篇文章:CAS详解 )。

image-20210818234521043

2、分配缓冲

另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM 在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块继续使用。

TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。

TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB。

2.3 对象的内存布局

根据 Java 虚拟机规范里面的描述,Java 对象分为三部分:对象头(Object Header),实例数据(instance data),对齐填充(padding)。

image-20210819094725889

1、对象头

HotSpot 虚拟机的对象头主要包括两部分(若是数组对象还包括一个数组的长度)信息,对象头在32位系统上占用8bytes,64位系统上占用16bytes(开启压缩指针)。

  • Mark Word,主要存储哈希码(HashCode)、GC 分代年龄、锁状态标识、线程持有的锁、偏向线程 ID、偏向时间戳。
  • 类型指针(klass pointer),即对象指向它的类元数据的指针(即存在于方法区的Class类信息),虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 数组长度,如果对象是一个数组,那么在对象头中还有一块用于记录数组长度的数据(这里不考虑)。

HotSpot对对象头的定义为:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html

Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object’s layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

谷歌翻译:

每个 GC 管理的堆对象开头的公共结构。 (每个 oop 都指向一个对象头。)包括关于堆对象的布局、类型、GC 状态、同步状态和身份哈希码的基本信息。 由两个词组成。 在数组中,它紧跟一个长度字段。 请注意,Java 对象和 VM 内部对象都有一个共同的对象头格式。

因此,HotSpot 虚拟机的对象头主要包括Mark Word和**类型指针(klass pointer)**两部分:

image-20210914093243514

而对于Mark Word的大小在 64 位的 HotSpot 虚拟机中 markOop.cpp 中有很好的注释,其大小为64 bits,而klass pointer在开启压缩指针的情况下为32 bits

PS:1byte = 8bit,即1字节为8位

image-20210913225358187

把它转化为下面的表格:

image-20210914150807934

2、实例数据

实例数据部分是对象真正存储的有效信息(也就是被 new 出来的对象信息),也是在程序代码中所定义的各种类型的字段内容。基本数据类型的内存占用如下:

image-20210914085902311

引用类型在64位系统上每个占用 4 bytes。

3、对齐填充

对齐填充不是必然存在的,没有特别的含义,它仅起到占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是说对象的大小必须是 8 字节的整数倍(这是个规定)。对象头部分是 8 字节的倍数,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

一个有趣的面试题:Object o = new Object()占多少字节?

按照上面的说法,Object 对象在没有任何属性的情况下(开启压缩指针),应该是对象头 + 实例数据 + 对其填充 = 12 byte + 0 + 4 byte = 16 byte。

解释:其中对象头占用12 byte,还有

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LBXX_1

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值