面试必看-JVM内存结构详解(堆栈常量池)

Posted LuckyWangxs

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试必看-JVM内存结构详解(堆栈常量池)相关的知识,希望对你有一定的参考价值。

一、概述

        JVM是中、高级开发人员必学的,虽然这玩意对平时的开发没有卵用,但是有助于你理解项目从加载到运行的整个流程,有助于你处理生产上出现的问题,比如我们常见的OOM,如果你对JVM一无所知,你会知道为什么会OOM吗?你知道如何监控吗?你懂得怎么处理吗?
        前面的文章讲解了类加载过程,类加载器,垃圾回收机制。而类加载就是将.class文件加载到JVM内存中,垃圾回收也是在替我们管理JVM内存,那么.class文件到底加载到JVM内存的什么区域呢?垃圾回收管理的又是哪些呢?区别于静态链接的动态链接到底发生在哪里呢?又或者是方法到底是怎样执行的,对象创建又是怎样的?这篇文章就带你深入了解JVM内存结构。
        翻脸警告:本篇文章介绍的是JVM内存结构,千万不要与JVM内存模型混为一谈,二者是完全不同的两个概念,如果你当着面试官的面将二者混为一谈,那么你可以直接滚蛋了(开玩笑啦,夸张一点而已~)

二、运行时数据区

在这里插入图片描述
        本篇文章基于JDK1.8。JVM内部设计如上图所示,其中执行引擎包含了解释器、即时编译器、垃圾回收器,另外还有本地库接口,主要是为了调用本地方法的。我们所研究的JVM内存结构主要是运行时数据区。
        JVM将运行时数据区划分成几个部分,每个部分都有各自的作用,主要包括:方法区、堆、虚拟机栈、本地方法栈、程序计数器。当然,方法区在JDK1.7已经取消了,后续文章会详细介绍。

1、程序计数器

        程序计数器(Program Counter Register) 是一块较小的内存空间,可以看作是当前线程所要执行命令的行号指示器,指向下一条将要执行的指令的地址。即线程执行流程是由 字节码解释器 来改变程序计数器的值来维持的。此外程序计数器是不存在内存溢出的。

        字节码解释器: 我们都知道Java代码需要进行编译,成为字节码文件,但是字节码文件并不是机器能够识别的语言,所以在执行的时候需要将字节码解释成机器语言去执行,那么字节码解释器就是将字节码文件解释成机器语言的,即解释执行。注: 同一份字节码文件在不同的JDK下能够被解释成不同的机器指令,此处也体现了Java语言平台无关性

程序计数器的作用:
        1、解释器解释执行当前指令,并将下一条指令的地址放入程序计数器
        2、执行完成以后再到程序技术器取到当前要执行的指令,并将下一条指令地址存入计数器
        3、循环步骤

        为了确保线程切换能恢复到正确的执行位置,所以每一个线程都有独立的程序计数器,各个线程的计数器互不影响,独立存储,所以说程序计数器与线程共存亡,线程结束,释放内存,无需垃圾回收管理。

        如果当前执行的是Java方法,那么程序计数器的值为字节码指令地址,如果执行的是native方法,那么计数器的值为undefined。

2、堆

        堆是JVM所管理的内存中最大的一块区域,由各个线程共享,所有的对象以及数组所用内存都要在堆中开辟。学过垃圾回收的同学,肯定多少都了解堆的内部结构。
        堆主要分为年轻代,老年代;年轻代分为伊甸区(Eden)、幸存区(Survivor),幸存区又分为From Survivor和To Survivor两块大小相等的区域,如下图所示(图片来源于网络)
在这里插入图片描述
        新生对象,也就是刚new出来的对象,会放到Eden区(翻译过来为伊甸园,是传说中亚当夏娃造人的地方,所以新生对象放这里),Eden满了之后会触发Minor GC,进行复制算法垃圾回收,熬过一次GC的对象会进入到From/To区,熬过n次(默认15次)Minor GC的对象,会进入到Old老年代,老年代存储长期存活的对象和大对象,老年代满了会触发Full GC,Full GC清理老年代和年轻代,这些东西在GC垃圾回收这篇文章都讲过,不再赘述。

一些JVM常用的堆内存配置参数如下表

参数含义
-Xms设置JVM启动时申请的初始堆内存,也为最小堆内存,默认是物理内存的1/64。默认堆内存空闲量大于70%则堆内存缩减为最小堆内存
-Xmx设置JVM可申请的最大堆内存,默认是物理内存的1/4。默认堆内存空闲量小于40%则内存自动扩容到可申请的最大堆内存
-Xmn设置年轻代的大小
-XXSurvivorRatioEden区与Survivor区的比例,默认值为8,即Eden与Survivor内存大小的比值为8 : 1 : 1

        表格中-XX:PermSize实际为非堆内存,可以理解为方法区(永久代),在JDK1.7及以前JVM运行时数据区是存在方法区的,该参数是用于设置它的,在JDK1.8及以后,取消了方法区,取而代之的是元数据区,位于电脑物理内存,作为非堆内存。

3、栈

        在JVM中,有虚拟机栈与本地方法栈,虚拟机栈是为我们的Java方法服务的,而本地方法栈则是为native方法服务的,二者功能基本相似,而我们平时所说的栈就是虚拟机栈。
        虚拟机栈为Java方法服务,是一个方法执行的内存空间,栈是线程独享的,与线程生命周期一样,不需要GC管理,每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、返回地址等信息,每个方法从调用直至执行完成的过程都对应着一个栈帧的入栈与出栈,下图为栈的结构:
在这里插入图片描述
举例: 此时要执行a方法,a方法又调用了b方法,b方法又调用了c方法,那么执行过程为:创建栈帧a,入栈,到a调用b方法的代码时,创建栈帧b,入栈,到b调用c方法的代码时,创建栈帧c,入栈,c方法执行结束,栈帧c出栈,继续执行b方法,b方法执行结束,栈帧b出栈,继续调用a方法,a执行结束,栈帧a出栈,流程结束。

        局部变量表: 用于存储方法的局部变量,只存储引用,不存储内容,其内存空间在编译期确定,在运行期间大小不会发生改变。

        动态链接: 动态链接即将符号引用替换为直接引用(直接为内存中的引用地址),每一个栈帧都会有一个符号引用,这个符号引用指向运行时常量池的直接引用,为了支持方法的调用,需要将这些符号引用替换为直接引用,有一部分符号引用替换为直接引用是在类加载的解析阶段完成的,譬如一些编译期可知,运行期不可变的类方法或私有方法,这属于静态链接,而在栈帧中完成的符号引用替换为直接引用称之为动态链接。静态链接在Java类加载过程一文中有详细介绍。

        操作数栈: 操作数栈也是一个后进先出的数据结构,一个指令往操作数栈压入数据,另一个指令就可以从操作数栈弹出数据。虚拟机将操作数栈作为工作区,我个人认为,程序的执行是由操作数栈运行的,比如做一个运算,需要两个变量,首先将第一个变量压入操作数栈,然后压入第二个,然后操作数栈将两个变量推出,给到cpu的寄存器去运算,得到结果入操作数栈,然后再把结果推出,赋到局部变量表,完成运算过程,然后程序继续向下执行,如此反复。

        返回地址: 实际上就是方法的出口,本质上就是指向一个地址,该地址就是调用该方法的对象,在程序执行完方法后,需要知道接下来该继续执行哪里,在执行过程中,程序计数器也一直在记录着指令运行到哪里

4、方法区/元数据区

        在HotSpot JVM中,设计者将方法区归到了GC的分代收集,所以方法区就有了永久代这么一个说法。
        方法区是各个线程共享的内存区域,主要存储的是类的元信息(字段、方法、接口)、静态变量、常量、运行时常量池,可以理解为类被编译的数据加载到内存后的存储位置。需要注意的是,在JDK1.8取消了方法区,用元空间取代了方法区,元空间位于物理内存,其JVM配置参数有如下变化:

参数含义
-XX:PermSize设置永久代初始大小,默认为物理内存的1/64,为JDK1.8之前的参数
-XX:MaxPermSize设置永久代最大容量,默认为物理内存1/4,为JDK1.8之前的参数
-XX:MetaspaceSize设置元数据区的初始容量,默认值是20.74MB,元数据区位于物理内存,取代了永久代,为JDK1.8及之后的参数
-XX:MaxMetaspaceSize设置元数据区的最大容量,默认为无穷大,取决于物理内存大小,为JDK1.8及以后的参数

4.1 常量池

常量池有三类,分别是类常量池、字符串常量池、运行时常量池:
        类常量池: 用于存储类的符号引用与常量,JDK1.8位于堆中

        字符串常量池: 用于存储字符串对象的符号引用与字符串常量,在JDK1.7时,将字符串常量池从方法区移除,在堆中开辟了一块空间作为字符串常量池。

        运行时常量池: 用于存储类的元信息,编译后的代码数据以及类常量池中符号引用相对应的直接引用。JDK1.8常量池位于元空间,即物理内存。

常量池的作用
        常量池不仅避免了频繁的创建和销毁对象而影响系统性能,而且还实现了对象的共享,说到这个就不得不提到基本数据类型的自动拆箱与自动装箱了,
Integer缓存/Integer常量池

Integer i1 = 1;
Integer i2 = 1;
System.out.println(i1==i2)

请问i1与i2相等吗?答案是相等的。
        我们都知道Java数值整型默认是int类型,那么int类型如何赋值给Integer呢?实际上i1=1有一步装箱操作,即将int类型的1封装成Integer类型。Integer设计是这样的,如果所给的值在-128到127,那么会直接返回常量池中的值,即此处的1为常量池中的Integer类型的1,否则重新new一个对象,所以i1与i2指向的实际上是一个引用,所以返回true。

再看下面代码:

Integer i1 = new Integer(2);
Integer i2 = new Integer(3);
Integer i3 = new Integer(5);
System.out.print(i3 == i1+i2);

        结果也为true,其实Integer类型是不能直接相加的,需要先进行拆箱,拆成int类型进行计算,所得的值如果在[-128,127],也会直接返回常量池的内容,那上述代码就不难理解,首先Integer类型的2、3先拆箱成int类型,然后进行加法,所得结果在常量池存在,那么直接返回Integer类型的5,i3也是常量池的引用,所以i3==i1+i2

String常量池
        在编译期就能确定的字符串,则被认为是直接量/常量,会存放到常量池,如果第二次再用到直接从字符串常量池取即可,举例说明
        首先String有两种创建方式,一个是new对象,直接在堆中开辟空间,另一种是直接赋值,这种方式会先去字符串常量池检查有无,如果有,直接拿来用,如果没有,则把值放进去

String s1 = "a";
String s2 = "a";

        在创建s1时,会去常量池检查有没有a字符串,此时没有,所以将a放进去,在创建s2时,去常量池检查,发现有a,则直接指向a,所以s1与s2指向同一个引用,它们相等。

拓展: String是比较常用的类,Java对字符串是有优化的
        优化1:

String s = "ab"
String f = "a" + "b"

        在虚拟机中会将上述字符串相加优化,虚拟机认为你这种相加无意义,f直接等于"ab",且s==f。
        优化2:

final String str1 = "a";
final String str2 = "b";
String stri = "ab";
String str3 = str1 + str2;
System.out.println(str3 == stri); // true

        str1与str2都由final修饰,而且在定义的时候也没有什么可变因素,只是单纯的赋值,也可以单纯的直接字符串相加(“a”+“b”),这种被叫做直接量,也就是常量,而str3,有两个直接量相加,那么str3就能在编译期确定值,且str3与stri指向同一个引用。

好了,这篇文章就介绍到这里了,最后,给大家分享一个面试经历,面试官问我,String为什么会被设计成final的呢?大家可以结合字符串常量考虑考虑O(∩_∩)O

以上是关于面试必看-JVM内存结构详解(堆栈常量池)的主要内容,如果未能解决你的问题,请参考以下文章

14.VisualVM使用详解15.VisualVM堆查看器使用的内存不足19.class文件--文件结构--魔数20.文件结构--常量池21.文件结构访问标志(2个字节)22.类加载机制概(代码片段

不止面试02-JVM内存模型面试题详解

Java常量池详解

JVM的基本结构及其各部分详解

Java内存的堆栈与常量池

JVM中的常量池详解