内存区域常见面试题:
介绍下 Java 的内存区域
Java 对象的创建过程
对象的访问定位的二种方式
一.JVM 内存区域
方法区和堆是线程共享的;虚拟机栈、程序计数器、本地方法栈是线程私有的。
1.1.程序计数器
定义:程序计数器是JVM内存区域当中一块较小的内存空间,用于记录线程下一次要执行的字节码指令的位置。
由于CPU在执行的时候会存在线程时间片切换的概念,所以CPU执行指令的时候是会中断的,程序计数器会记录当前线程执行停止的字节码指令位置,以便于再次切换到该线程时能够恢复到正确的执行位置而避免重新执行。程序计数器私有主要是为了线程切换后能恢复到正确的执行位置
执行字节码指令的线程是解释器的线程。
1.2.虚拟机栈
什么是虚拟机栈:虚拟机栈是在 Java 方法被调用时使用的,栈由一个个栈帧组成,每一次方法调用都会有一个栈帧被压入栈中,每一个方法调用结束,都会有一个栈帧被弹出。每个栈帧中都拥有:局部变量表、操作数栈、方法返回地址、动态链接。
-
局部变量表:局部变量表是一个数组,用于存储基本数据类型的值(例如int、float、boolean等)和对象引用。
-
操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。
-
方法返回地址:返回地址是指在方法执行完毕后需要返回到的位置,通常是调用该方法的指令的下一条指令的地址。
-
动态链接:主要用于一个方法调用其它方法的场景。Class 文件的常量池里保存着大量的符号引用,比如调用方法的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。
1.3.本地方法栈
什么是本地方法栈:本地方法栈和虚拟机栈相似。但是,本地方法栈是在 Java 虚拟机调用一些本地方法时使用的,本地方法栈的栈帧中同样存在局部变量表、操作数栈、动态链接、出口信息等。
虚拟机栈和本地方法栈是线程私有的是为了保证线程中的局部变量不被别的线程访问到。
本地方法栈中每个栈帧对应的方法是操作系统自身的方法,用户态切换到内核态简单理解就是,本来CPU执行的是应用程序中的方法,转而去执行了操作系统中的本地方法,在操作系统内核执行完相应的操作后,它会将结果返回给应用程序。
1.4.堆
什么是堆:堆是JVM内存区域中最大的一块,几乎所有的对象都在这里分配内存。
特点:
-
堆是所有线程共享的一块内存区域,需要考虑线程安全问题
-
堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
-
新生代内存(Young Generation)
-
老生代(Old Generation)
-
永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。(在方法区这部分内容详细介绍到)。
1.5.方法区
1.5.1 什么是方法区
方法区是一种规范,方法区当中存储的是类信息、字段信息、方法信息、运行时常量池、即时编译器编译后的代码缓存等数据。
在 HotSpot 虚拟机中,永久代和元空间是方法区的具体实现,在 JDK7 的时候,HotSpot 把原本放在永久代中的字符串常量池、静态变量等迁移到了 Java 堆中,到了 JDK8,完全放弃了永久代的概念,通过在本地内存中实现的元空间来代替,并把 JDK7 中永久代还剩余的内容全部移到元空间。也就是说:
-
在 JDK8 以前方法区的实现是永久代,永久代存在于堆内存中
-
在 JDK8 及 JDK8 以后方法区的实现是元空间,元空间存在于本地内存中
Note:
- 在 JDK7 时,静态变量被移至 Java 堆中的相应类对象中
- 即时编译器是Java虚拟机中的一部分,它将热点代码转换为本地机器代码,以提高程序的执行效率。当即时编译器对某个方法进行编译时,它会将编译生成的本地机器代码存储在代码缓存中,以便下次执行该方法时可以直接使用已编译的机器代码,而无需再次进行编译。
1.5.2 方法区的组成
1.5.3 运行时常量池
1.什么是运行时常量池
运行时常量池是方法区的一部分。字节码文件中的常量池表存放了编译期生成的各种字面量与符号引用,这部分内容在字节码被加载后会存放到方法区的运行时常量池中。
2.常量池和运行时常量池的区别
-
常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量 等信息
-
运行时常量池:常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池。
1.5.4 StringTable
1.什么是 StringTable
串池存储的是字符串对象,并且存储的字符串对象都是唯一的,是JVM为了减少字符串对象的重复创建而维护的一块内存空间,在 JDK7 及 JDK7 以后,串池从方法区的运行时常量池转移到了堆内存中。
2.String a = new String("abc") 的原理
当我们通过 String a = new String("abc"); 创建一个字符串对象时,会检查字符串常量池中是否存在字符串对象"abc",
-
如果存在,就直接在堆内存中创建一个新的字符串对象"abc",并将对象地址赋值给 a,
-
如果不存在,则先在字符串常量池创建一个字符串对象"abc",然后继续在堆内存创建一个字符串对象"abc",并把这个对象的地址赋值给变量 a。
3.StringTable 特性
常量池中的字符串仅是符号,第一次用到时才变为对象
利用串池的机制,来避免重复创建字符串对象
字符串变量拼接的原理是 StringBuilder(1.8)
字符串常量拼接的原理是编译期优化
可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
-
JDK1.7 及以后将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
-
JDK1.6 及以前将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回
注意:
JDK1.8 以后 StringTable 在堆内存中,会受垃圾回收器的管理。也就是说,StringTable 中那些长时间没有被引用的字符串常量,是会被垃圾回收器回收的。
运行时常量池里存储的还是符号地址,不是符号引用。
1.6.直接内存
1.6.1 直接内存概述
1.什么是直接内存
直接内存并不是 Java 虚拟机内存区域中的一部分。但是这部分内存被频繁地使用,而且也可能导致 OutDfMemoryError 异常出现。在 JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
2.作用
常见于 NIO 操作时,用于数据缓冲区
3.优缺点
分配回收成本较高、但读写性能高
1.6.2 直接内存的分配和回收原理
-
使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法。
-
ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调 用 freeMemory 来释放直接内存
二.JVM 内存区域面试题
2.1 Java 对象的创建过程
Step1:类加载检查
当虚拟机遇到一条 new 指令时,首先去检查这个指令的参数是否能在常量池中找到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果都有,就执行下一步;如果没有,那必须先执行相应的类加载过程。
Note:
这个 new 指令是指向了常量池中的符号引用,本身并不是符号引用
无论是通过关键字new直接创建对象,还是通过反射、序列化等方式创建对象,底层字节码指令都是new
Step2:分配内存
虚拟机会根据类加载过程中确定下来的对象内存大小,从堆内存中划分出一块内存分配给新对象。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 (补充内容,需要掌握):
指针碰撞:
-
适用场合:堆内存规整,即没有内存碎片的情况下。
-
原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
-
使用该分配方式的 GC 收集器:Serial, ParNew
空闲列表:
-
适用场合:堆内存不规整的情况下。
-
原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录。
-
使用该分配方式的 GC 收集器:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理",值得注意的是,复制算法内存也是规整的。
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
-
CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
-
TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
Step3:初始化零值
虚拟机会将分配给对象的内存空间,除对象头之外都初始化为零值。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,在程序中能访问到这些字段所对应的零值。
这些零值由字段的数据类型所决定
Step4:设置对象头
虚拟机要对对象头进行设置,需要设置的信息包括类型指针、对象的哈希码、对象的 GC 分代年龄等。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
类型指针:
类型指针指向所属类的类对象,通过这个指针可以找到类的元数据信息,并确定对象的类型信息。
通过类型指针,虚拟机可以根据对象的实际类型来选择正确的方法版本进行调用,从而实现了多态的方法调用。
通过对象的getClass方法得到类对象时,本质是通过类型指针得到的。
Step5:执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行完 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
2.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
-
对象头:Hotspot 虚拟机的对象头用来存储类型指针、哈希码、GC 分代年龄、锁状态标志等等,类型指针可以找到对象所对应的类元数据信息,来确定这个对象属于哪个类的实例。
-
实例数据:实例数据部分是对象存储成员变量内容的地方。
-
对齐填充部分:对齐填充部分不是必然存在的,也没有什么特别的含义,仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.3 对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄:如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针:如果使用直接指针访问,reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。HotSpot 虚拟机主要使用直接指针来访问对象。