Java内存结构与类加载机制

Java 虚拟机(Java Virtual Machine,JVM)是 Java 程序员进阶必备的知识,也几乎是中高级岗位程序员的必考题目,具有一定的深度和区分度。但 JVM 是一个很庞大的主题,通常是一本专著的体量,专栏将 JVM 面试高频题精简为三方面的内容:

Java 内存结构与类加载机制;
JVM 与垃圾加收;
JVM 进阶,主要是 JVM 知识与应用结合的问题。

这三个章节又可以为两部分内容:基础知识 + 应用结合。基础知识不必多说,而所谓应用结合又有两重境界:一是和工程实践项目结合,重点考察工程能力;二是和横向其他知识点结合,重点考察灵活性。至于如何结合,将在第三个章节《JVM 进阶面试题》再展开,先看 Java 内存模型与类加载机制以及 JVM 与垃圾回收这两部分的内容。

一、知识结构及面试题目分析

JVM 对 java 类的使用总体上可以分为两部分:一是把静态的 class 文件加载到 JVM 内存,二是在 JVM 内存中进行 Java 类的生命周期管理。二者一静一动,分别对应的就是 java 内存结构与类加载机制、 JVM 与垃圾回收这两个章节,可以看到二者有千丝万缕的关系。

本章节以 java 内存结构与类加载机制为主题,这部分面试题以基础概念为主,但同时和之前章节的基础概念题又有些许不同,有如下特点:

概念易于混淆,比如说内存结构与内存模型等;
比较抽象,属于人为的规范,不像 List/String 这些 Java 类可以直观查看源代码;
灵活度较高,和 JVM 垃圾回收等题目结合有一定有难度,需要候选人真正理解。

二、典型面试例题及思路分析

问题 1:"JVM 内存结构是什么样的?”

JVM 内存的主要分为五个区:方法区(Method Area),虚拟机栈(VM Stack),本地方法栈(Native method stack),堆(Heap),程序计数器(Program Counter Register)。


在虚拟机启动时创建,几乎所有的对象实例都在这里创建,是垃圾收集器管理的主要区域;
线程共享。

方法区
主要用来存储 JVM 加载的类信息,包括类的方法(如类的接口 / 父类等)、常量、静态变量、即时编译器编译后的代码等数据,还包括运行时常量池(Runtime Constant Pool),用于存放静态编译产生的字面量和符号引用;
很少发生 GC(Garbage Collection,垃圾回收),偶尔发生的 GC 主要是对常量池回收和类型的卸载;
线程共享。

虚拟机栈
又被称为栈内存,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈桢在虚拟机栈中从入栈到出栈的过程;
线程私有。

本地方法栈
类似于虚拟机栈,不过本地方法栈为 Native 方法服务,而虚拟机栈为 java 方法服务;
线程私有。

程序计数器
内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个 java 虚拟机规范没有规定任何 OOM 情况的区域;
线程私有。

点评:

JVM 内存的五大模块可以用下图示意。图中蓝色部分(方法区和堆)的两个区域是所有线程共享;浅紫色部分(虚拟机栈、本地方法栈、程序计数器)的三个区域是线程私有。
图片描述另外一个值得注意的就是各个区域抛出的异常。

堆:如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常;

方法区:当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常;

虚拟机栈 / 本地方法栈:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;对于支持动态扩展的虚拟机,当扩展无法申请到足够的内存时会抛出 OutOfMemory 异常。

程序计数器:此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

注意区分两个概念:Java 内存结构(或者说 Java 内存分布)和 Java 内存模型(Java Memory Model)。
java 内存结构(或者说 java 内存分布)就是上文提到的五大区块的划分。

java 内存模型则主要是由 JSR-133: JavaTM Memory Model and Thread Specification 描述,它是为了屏蔽各种硬件和操作系统差异,保证 Java 程序在各种平台下对内存的访问都能保证效果一致而提出的一套规范。其主要解决问题的手段是 限制处理器优化和使用内存屏障。

问题 2:"JVM 的类加载机制是什么样的?有几类加载器?"

JVM 通过双亲委派模型进行类的加载,即当某个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
有 3 类加载器:

启动类加载器 (Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过 - Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录也不会被加载)的类。启动类加载器无法被 Java 程序直接引用;

扩展类加载器 (Extension ClassLoader):负责加载 JAVA_HOME\jre\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库;

应用程序类加载器 (Application ClassLoader):负责加载用户路径(classpath)上的类库。

除此之外,还可以通过继承 java.lang.ClassLoader 类实现自己的类加载器(主要是重写 findClass 方法)。

点评:

通过一个类的全限定名来获取描述此类的二进制字节流,这个动作的代码模块被称为类加载器,而此类的二进制字节流即是程序存储格式的字节码。类加载器和字节码是 java 平台无关性的基石。对于任意一个类,都需要由加载它的类加载器和这个类本身 (字节码进行描述)一同确立其在 Java 虚拟机中的唯一性。

再说到双亲委派模型,该模型主要解决了两个问题:

基础类的统一加载问题(越基础的类由越上层的加载器进行加载)。如类 java.lang.String,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,所以在程序的各种类加载器环境中都是同一个类。
提高 java 代码的安全性。比如说用户自定义了一个与系统库里同名的 java.lang.String 类,那么这个类就不会被加载,因为最顶层的类加载器会首先加载系统的 java.lang.String 类,而不会加载自定义的 String 类,防止了恶意代码的注入。
类加载还有一个比较重要的知识点是类加载过程。一个类的生命周期可以分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中前五个阶段即类加载,各个阶段(验证、准备、解析这三个阶段又统称为连接)的主要作用如下图所示:
图片描述

三、总结

Java 的内存结构和类加载机制可以认为是 JVM 垃圾的前序知识,但是更强调基础概念,灵活性弱一些。而且有一些概念在业界并没有完全统一的认知,有时候面试官和候选人说的可能不是同一回事,所以候选人听了问题后如果有不确认的点可以首先和面试官再次确认,然后再展开作答,这点很重要。

四、扩展阅读

问:可以不可以自己写个String类?

不可以,因为 根据类加载的双亲委派机制,会去加载父类,父类发现冲突了 String 就不再加载了;

问:什么是类加载器?

负责读取 Java 字节代码,并转换成java.lang.Class类的一个实例。

问:程序计数器作用

记录当前线程锁执行的字节码的行号。

程序计数器是一块较小的内存空间。
处于线程独占区。
执行java方法时,它记录正在执行的虚拟机字节码指令地址。执行native方法,它的值为undefined
该区域是唯一一个没有规定任何OutOfMemoryError的区域

问:如果判断两个类是否“相同”

类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性。即便是同样的字节代码,被不同的类加载器加载之后所得到的类,也是不同的。

通俗一点来讲,要判断两个类是否“相同”,前提是这两个类必须被同一个类加载器加载,否则这个两个类不“相同”。 这里指的“相同”,包括类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法、instanceof关键字等判断出来的结果。

问:Java虚拟机如何结束生命周期

执行System.exit()方法;
程序正常执行结束;
程序执行遇到异常或Error终止;
操作系统出错而导致java虚拟机运行终止。

问:何时触发初始化

为一个类型创建一个新的对象实例时(比如new、反射、序列化);
调用一个类型的静态方法时(即在字节码中执行invokestatic指令);
调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行getstatic或者putstatic指令),不过用final修饰的静态字段除外,它被初始化为一个编译时常量表达式;
调用JavaAPI中的反射方法时(比如调用java.lang.Class中的方法,或者java.lang.reflect包中其他类的方法);
初始化一个类的派生类(子类)时(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外);
JVM启动包含main方法的启动类时。
注意:通过子类引用付了的静态字段,不会导致子类初始化

问:JAVA热部署实现

首先谈一下何为热部署(hotswap),热部署是在不重启 Java 虚拟机的前提下,能自动侦测到 class 文件的变化,更新运行时 class 的行为。Java 类是通过 Java 虚拟机加载的,某个类的 class 文件在被 classloader 加载后,会生成对应的 Class 对象,之后就可以创建该类的实例。默认的虚拟机行为只会在启动时加载类,如果后期有一个类需要更新的话,单纯替换编译的 class 文件,Java 虚拟机是不会更新正在运行的 class。如果要实现热部署,最根本的方式是修改虚拟机的源代码,改变 classloader 的加载行为,使虚拟机能监听 class 文件的更新,重新加载 class 文件,这样的行为破坏性很大,为后续的 JVM 升级埋下了一个大坑。

另一种友好的方法是创建自己的 classloader 来加载需要监听的 class,这样就能控制类加载的时机,从而实现热部署。

问: String s1 = new String("abc");这句话创建了几个字符串对象?

将创建 1 或 2 个字符串。如果池中已存在字符串文字“abc”,则池中只会创建一个字符串“s1”。如果池中没有字符串文字“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

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

false
true

String 类和常量池

String 对象的两种创建方式:

String str1 = “abcd”;//先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";

String str2 = new String("abcd");//堆中创建一个新的对象
String str3 = new String("abcd");//堆中创建一个新的对象
System.out.println(str1==str2);//false
System.out.println(str2==str3);//false

这两种不同的创建方法是有差别的。

第一种方式是在常量池中拿对象;
第二种方式是直接在堆内存空间创建一个新的对象。
记住一点:只要使用 new 方法,便需要创建新的对象。

再给大家一个图应该更容易理解:
在这里插入图片描述
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 对象
字符串拼接:

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

扩展阅读_String内存分配示意图2.png

尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。

问:说说Java对象创建过程

Java创建对象的过程.pngStep1:类加载检查

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

Step2:分配内存

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

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配的两种方式.jpeg内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

**CAS+失败重试:**CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
**TLAB:**为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
Step3:初始化零值

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

Step4:设置对象头

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

Step5:执行 init 方法

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

问:Java对象结构

Java对象由三个部分组成:对象头、实例数据、对齐填充。

对象头由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。

实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)

对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)

问:Java对象的定位方式

句柄和直接指针两种:

句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
在这里插入图片描述直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
在这里插入图片描述这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

问:JDK8中MetaSpace代表什么?

MetaqSpace是JDK8才诞生的名词,它是一种新的内存空间,中文译为:元空间;JDK8 HotSpot中移除了永久代(PermGen Space),使用MetaSpace来代替,MetaSpace是使用本地内存来存储类元数据信息。内存容量取决于操作系统虚拟内存大小,通过参数MaxMetaspaceSize来限制MetaSpace的大小。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值