JVM内存模型及相关面试题解析

 2022-09-10
原文地址:https://blog.csdn.net/qq_27787701/article/details/124069387

一、JVM运行时区域

202209102333405221.png

其中:

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

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

1 程序计数器

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

java虚拟机的多线程是通过线程轮流切换并分配CPU的时间片的方式实现的。因此在任何时刻一个处理器(如果是多核处理器,则只是一个核),为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,因此此类内存区域为“线程私有”的内容。

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

1.字节码解释器通过改变程序计数器以此读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

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

2 Java 虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的事Java方法执行的内存模型,Java虚拟机是由一个个栈帕组成,线程在执行一个方法时,便会向栈放一个栈帕,每个栈帕都拥有局部变量表、操作数栈、动态链接、方法出口信息、局部变量表主要存放了编译器可知基本数据类型(boolean、byte、char、short、int、float、long、double)和对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)。

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

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

OutOfMemoryError:若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMenmoryError异常。

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

3 本地方法栈

和虚拟机栈锁发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务,在HotSpot虚拟机中和Java虚拟机合二为一。

本地方法被执行的时候,在本地方法也会创建一个栈帕,用于存放本地方法的局部变量表,操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帕也会出栈并释放内存空间,也会出现StackOverFlowError 和 OutOfMenmoryError两种异常。

4 堆

堆是Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例及数组都在这里分配内存(目前由于编译器的优化,对象在堆上分配已经没有那么绝对了,参考【JVM 逃逸分析】)

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java 堆还可以细分为:新生代和老年代:其中新生代分为:Eden空间、Form Survivor、To Survivor空间。进一步划分的目的是更好的地回收内存。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。从内存分配的角度来看,线程共享的Java堆中可能会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer TLAB)。

202209102333421442.png

如上图所示,JVM内存主要由新生代、老年代、永久代构成。

1)新生代 (Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量复制成本就可以完成回收。

新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区,对象每经历一次Minor GC,年龄增加1,达到“晋升年龄阀值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阀值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阀值”通过参数MaxTenuringThreshold设定,默认值为15。

2)老年代 (Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot vm里,出了CMS之外,其他能收集老年代的GC都会同时收集整个GC堆,包括新生代)。

3)永久代 (Perm Generation):主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和老年代来说,该区域的划分堆垃圾回收影响比较小。

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

5 方法区

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

HotSpot 虚拟机中方法区也常被称为“永久代”,本质上两者并不等价。仅仅是因为HotSpot虚拟机设计团队用永久代来实现方法区而已,这样HotSpot虚拟机的垃圾收集器就可以像管理Java堆一样管理这部分内存了。这是这并不是一个好主意。因为这样更容易遇到内存溢出问题。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就”永久存在“了。

6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)既然运行时常量池是方法区的一部分,自然受到方法区内存限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

JDK1.7及之后版本的JVM已经将运行时常量池从方法区中移了出来,在Java堆(heap)中开辟了一块区域存放运行时常量池。

202209102333429143.png

7 直接内存

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

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

本机直接内存分配不会收到Java堆限制,但是,既然是内存就会收到本机总内存大小及处理器寻址空间的限制。

二、对象创建过程

202209102333439974.png

1 类加载检查

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

2 分配内存

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

(1)指针碰撞法

假设Java堆中内存时完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。

(2)空闲列表法

事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个空闲列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内控块分配给对象实例,并更新列表上的记录,使用GC收集器:CMS,适用堆内存不规整的情况下

Java 堆内存是否规整,取决于GC 收集器的算法是“标记-清理”,还是“标记-压缩”(也称作为“标记-压缩”),值得注意的事,复制算法内存也是规整的,在使用Serial、ParNew等待整理过程的收集器是,采用的事指针碰撞,在使用CMS这种mark-sweep算法的收集器时,使用的事空闲列表。

总结

而Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

使用GC收集器:CMS,适用堆内存不规整的情况下

值得注意的事,复制算法内存也是规整的

使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,例如正在给A对象分配内存,但是指针还没修改,这时候对象B可能使用原来的指针来分配内存的情况。作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

CAS+失败重试:CAS是乐观锁的一直实现方式。所谓乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重启,直到成功为止。虚拟机采用CAS 配上失败重试的方式保证更新操作的原子性。TLAB:为每一个线程预先在Eden 区分配一块内存。JVM 在给线程中的对象分配内存时,首先在各个线程的TLAB分配,当对象大于TLAB 中剩余内存或者 TLAB 的内存已用尽时,在采用上述的CAS进行内存分配。虚拟机是否启用TLAB,可以通过 -XX:+/-UseTlab参数来设定

3 初始零值

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

4 设置对象头

接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息,这些信息存放在对象的对象头中。根据虚拟机当前的运行状态不同,对象头会有不同的设置方式。

5 执行init方法

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

三、对象的内存分布

在HotSpot 虚拟机中,对象在内存中存储的布局可以分为三个区域:对象头(Header)、实例数据(Instance Data)和对其填充(Padding)

(1)HotSpot虚拟机的对象头包括两部分信息:

第一部分用来存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志、线程 持有的锁、偏向线程ID、偏向时间戳等,这部分数据在32位和64位虚拟机中分别为32bit和64bit,成为Mark Word。

另一部分是类型指针,即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

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

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

四 对象的访问定位

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

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

202209102333448965.png

(2)直接指针。如果使用直接指针,那么Java堆中的对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的就是对象的直接地址。

202209102333460466.png

使用句柄访问最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中实例数据指针,而reference本身不需要修改。

使用直接指针最大的好处就是速度更快,节省了一次指针定位的时间开销。

五 String 类和常量池常见面试题

1 String的两种创建方式的区别

    
    String str1 = "abc";
    String str2 = new String("abc");
    System.out.println(str1 = str2);//false

其中,第一种方式是从常量池中获取对象,第二种方式是直接在堆内存中创建一个新的对象。

202209102333474527.png

2 String 类型的常量池

它的主要使用方式有两种:

直接使用双引号声明出来的 String 对象会直接存储在常量池中。

如果不是用双引号声明的 String 对象。可以使用 String 提供的 intern 方法。String.intern()是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串。则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此String 内容相同的字符串,并返回常量池中创建的字符串引用。

    
    String s1 = new String("计算机");
    String s2 = s1.intern();
    String s3 = "计算机";
    System.out.println(s2);// 计算机
    System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量池中的String对象,
    System.out.println(s3 == s2);//true,因为两个都是常量池中的String对象

3 String 字符串拼接

    
    String str1 = "str";
    String str2 = "ing";
     
    String str3 = "str" + "ing";//常量池中的对象
    String str4 = str1 + str2; //在堆上创建的新的对象     
    String str5 = "string";//常量池中的对象
    System.out.println(str3 == str4);//false
    System.out.println(str3 == str5);//true
    System.out.println(str4 == str5);//false

202209102333483668.png

问题:

String s1 = new String("abc");// 堆内存的地址值

解答:

创建了2个对象

    String s1 = new String("abc");// 堆内存的地值值
    String s2 = "abc";
    System.out.println(s1 == s2);// 输出false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
    
    System.out.println(s1.equals(s2));// 输出true

解释:

  先有字符串 “abc” 放入常量池,然后 new 了一份字符串 “abc” 放入 Java 堆(字符串常量 “abc” 在编译期就已经确定放入常量池,而 Java 堆上的 “abc” 是在运行期初始化阶段才确定),然后 Java 栈的 s1 指向 Java 堆上的 “abc”。

总结:

常量赋值存于池

常量相加存于池

变量相加存于堆

    
    String str1 = new String("abc"); // 栈中的地址指向最新的new出来的堆地址
    String str2 = "abc"; // 存入常量池
    System.out.println(str1.equals(str2)); // String重写了"equals"是判断值是否相同所以true
    System.out.println(str1 == str2); // "=="是判断地址所以 false

六、八种基本类型的包装类和常量池

Java基本类型的包装类的大部分都实现了常量池技术,即Byte、Integer、Long、Character、Boolean;这5种包装类型默认创建了数值[-128,127]的相应类型的缓存数据

但是超过此范围任然会去创建新的对象。

两种浮点数类型的包装类Float、Double 并没有实现常量池技术。

    
    Integer i1 = 33;
    Integer i2 = 33;
    System.out.println(i1 == i2);// 输出true
    Integer i11 = 333;
    Integer i22 = 333;
    System.out.println(i11 == i22);// 输出false
    Double i3 = 1.2;
    Double i4 = 1.2;
    System.out.println(i3 == i4);// 输出false