面试题相关

https://www.yuque.com/matteo-irbtp/iltd4u/euauq5#YI7mm

概述

什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?

Java 虚拟机其实是充当一个翻译的角色,就像人类中的翻译官,人类中有各种不同的语言,不同的操作系统有各自不同的机器码,虚拟机它就是将字节码文件翻译成各个系统对应的机器码,确保字节码文件被翻译后能在各个系统正确运行。这也是为什么同一份Java代码能在不同系统运行的原因。
无论在什么操作系统上编写的Java代码,都需要经过编译成为字节码文件,同一份字节码文件被不同操作系统的Java虚拟机翻译为对应系统能够识别机器码,这就使得Java被称为“与平台无关”。

牛客网答案:
Java虚拟机是一个可以执行Java字节码的虚拟机进程。Java源文件被编译成能被Java虚拟机执行的字节码文件。Java被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。

内存模型

Java 中会存在内存泄漏吗,请简单描述。

定义

内存泄漏的定义:对象不再被应用程序使用,但垃圾回收器却不能移除它们,因为它们还在被引用, 从而造成的内存空间的浪费称为内存泄露。

问题答案

答:理论上Java因为有垃圾回收机制(GC)不会存在内存泄露问题(这也是Java被广泛使用于服务器端编程的一个重要原因);然而在实际开发中,可能会存在无用但可达的对象,这些对象不能被GC回收,因此也会导致内存泄露的发生

原因

Java内存泄露根本原因是什么呢?那就是长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有短生命周期对象的引用而导致短生命周期对象不能被回收,这就是java中内存泄露的发生原因。
具体主要有如下几大类情况:
1、静态集合类引起内存泄露:
像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。
2、当集合里面的对象属性被修改后,再调用remove()方法时不起作用。
3、监听器
在java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如addXXXListener()等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会
4、各种连接
比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。对于Resultset 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,因为Connection 在任何时候都无法自动回收,而Connection一旦回收,Resultset 和Statement 对象就会立即为NULL。
但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭Resultset 、Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。
5、内部类和外部模块等的引用
内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。此外程序员还要小心外部模块不经意的引用,例如程序员A 负责A 模块,调用了B 模块的一个方法如:public void registerMsg(Object b);
这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B 是否提供相应的操作去除引用。
6、单例模式
不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被jvm正常回收,导致内存泄露。

垃圾回收机制

类加载机制

描述一下JVM加载class文件(类)的原理机制?

答:JVM中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java中的类加载器是一个重要的Java运行时系统组件,它负责在运行时查找和装入类文件中的类。由于Java的跨平台性,经过编译的Java源程序并不是一个可执行程序,而是一个或多个类文件。当Java程序需要使用某个类时,JVM会确保这个类已经被加载、连接(验证、准备和解析)和初始化。
类的加载是指把类的.class文件中的数据读入到内存中,通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。
最后JVM对类进行初始化,包括:1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;2)如果类中存在初始化语句,就依次执行这些初始化语句。
类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader的子类)。
从Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM更好的保证了Java平台的安全性,在该机制中,JVM自带的Bootstrap是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM不会向Java程序提供对Bootstrap的引用。下面是关于几个类加载器的说明:
Bootstrap:一般用本地代码实现,负责加载JVM基础核心类库(rt.jar);
Extension:从java.ext.dirs系统属性所指定的目录中加载类库,它的父加载器是Bootstrap;
System:又叫应用类加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。


原理机制:
JVM 虚拟机执行 class 字节码的过程可以分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载。 这7个阶段也是一个类的整个生命周期。
加载阶段JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口
当代码数据被加载到内存中后,虚拟机就会对代码数据进行校验,看看这份代码是不是真的按照JVM规范去写的。这就是验证阶段。
当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段(七阶段之一)才开始。
解析阶段的主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用
到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化
当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。



对象创建过程 与对象内存布局

推荐阅读文档:https://blog.csdn.net/justloveyou_/article/details/72466416?

1、对象创建的过程?(半初始化)

要理解对象创建、分配的过程,我们先来看下面这个程序

  1. public static void main(String[] args) {
  2. Object o = new Object();
  3. }

字节码角度

其中涉及创建对象的关键代码对应字节码如下,主要是框中的四个指令
image.png
new指令
当程序遇到new关键字时,首先会去运行时常量池(方法区)中查找该引用所指向的类有没有被虚拟机加载,如果没有被加载,那么会进行类的加载过程将该类进行加载,如果已经被加载,那么进行下一步,为对象分配内存空间;
得出类被加载之后,需要在堆内存中为该对象分配一定的空间,该空间的大小在类加载完成时就已经确定下来了。
这里多说一点,为对象分配内存空间有两种方式:
(1)第一种是jvm将堆区抽象为两块区域,一块是已经被其他对象占用的区域,另一块是空白区域,中间通过一个指针进行标注,这时只需要将指针向空白区域移动相应大小空间,就完成了内存的分配,当然这种划分的方式要求虚拟机的对内存是地址连续的,且虚拟机带有内存压缩机制,可以在内存分配完成时压缩内存,形成连续地址空间,这种分配内存方式成为“指针碰撞”,但是很明显,这种方式也存在一个比较严重的问题,那就是多线程创建对象时,会导致指针划分不一致的问题,例如A线程刚刚将指针移动到新位置,但是B线程之前读取到的是指针之前的位置,这样划分内存时就出现不一致的问题,解决这种问题,虚拟机采用了循环CAS操作来保证内存的正确划分;
(2)第二种也是为了解决第一种分配方式的不足而创建的方式,多线程分配内存时,虚拟机为每个线程分配了不同的空间,这样每个线程在分配内存时只是在自己的空间中操作,从而避免了上述问题,不需要同步。当然,当线程自己的空间用完了才需要需申请空间,这时候需要进行同步锁定。为每个线程分配的空间称为“本地线程分配缓冲(TLAB)”,是否启用TLAB需要通过 -XX:+/-UseTLAB参数来设定。
分配完内存后,需要对对象的字段进行零值初始化,对象头除外,零值初始化意思就是对对象的字段赋0值,或者null值,这也就解释了为什么这些字段在不需要进程初始化时候就能直接使用;

dup指令
dup指令其实主要做的就是做一些复制,即将栈空间中的生成的引用复制一份变成两个引用,原来的引用主要用来进行赋值操作,复制出来的引用作为句柄去调用相关的方法。
invokespecial指令
这个指令跟方法调用指令中的一个,这里是涉及调用Object中的init构造初始化方法,当然在构造初始化之前可能会给对象的属性上一些初始值。
astore_1指令
该指令将当前变量从操作数栈中取出放到局部变量表中

执行步骤角度

image.png

1、判断是否能在常量池中能找到类符号引用,即检查是否已被加载、链接、初始化(判断类元信息是否存在)
当虚拟机遇到一条new指令时,首先去检查这个指令的参数能否在Metaspace元空间常量池中定位到一个类
的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。( 即判断类元信息是否存在)。
如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key(类的唯一)进行查找对应的.class文件以此来完成类的加载进内存,在这个过程中,要是找不到要加载的类文件,JVM就会抛出ClassNotFoundException异常,如果找到则进行类加载,并生成对应的Class类对象。

2、为对象分配内存空间
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节(不包括引用指向的对象)
分配内存时根据堆中内存是否规整分为:
①内存规整—指针碰撞:如果是规整的,那么虚拟机将采用指针碰撞法来为对象分配内存。
意思就是所有用过的内存在一边,空闲的内存在另一边,两部分内存中间的间隙放着一个指针作为分界点的指示器,分配内存就仅仅是把作为指示器的指针向空闲那一边挪动一段与对象大小相等的距离,以此来分配内存给对象。
如果垃圾收集器选择的是Serial、ParNew这种基于标记 - 压缩算法的,也就是一般使用带有compact(整理)过程的收集器时,就会使用指针碰撞分配方法。
② 内存不规整—空闲列表:如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将维护一个空闲的列表,并采用这种空闲列表法给对象分配内存。
意思就是虚拟机维护了一个列表,记录上哪些内存块是可用的,在分配的时候从列表找到一块足够大的空间划分给对象实例即可,这种分配方式就是空闲列表(Free List)。

3、处理并发问题
堆空间是多个线程共享的,当多个线程同时去操作堆空间数据时就有可能会存在并发线程安全问题,所以需要去解决并发的问题。
一般有两种方式去解决这种并发安全问题:
① 采用CAS失败重试、区域加锁更新的原子性。
CAS算法是这样的:CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包(Java.Util.Concurrent)中的原子类都使用了CAS技术。
CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。
CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
大白话:假如A线程要把共享变量sum的值从1修改为2,那么这时候A线程会先通过地址去获取一次sum的初始值1作为预期值,在修改之前会把之前获取的预期值与当前sum的值进行一次比较,假如两个值相同,那就将1修改为2,如果在A线程修改之前,B线程进入把sum的值提前修改为0了,那么A线程在用预期值去与当前的sum值0比较,显然两个值不相等,此时的A线程就不会修改,会继续去读取sum的值,直到sum为 1 时才进行修改操作。

② 每个线程预先分配一块TLAB区域,可以通过-XX:+ /-UseTLAB参数来设定
TLAB这一块区域是在堆当中Eden(伊甸园)区中的,大概占整个Eden区的1%左右,很小的一块区域,堆为每一个线程单独分配一块TLAB,以此来保证多线程共享数据时是线程安全的。

4、初始化分配到的空间(对属性初始化,零值初始化)
这个过程其实就是给所有属性设置默认值,保证对象实例字段在不程序员没有赋值时可以直接使用;比如int的默认值是0;引用类型的默认值是null。

5、设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode、对象的GC信息、锁信息等数据存储在对象头中。这个过程的具体设置方式取决于JVM实现。

6、执行init方法进行初始化
可以简单理解成调用构造函数进行显示的赋值,代码中和构造器中初始化。在Java程序的视角看来,初始化才正式开始,初始化成员变量,显示赋值,执行实例化代码块,调用类的构造器,并把堆内对象的首地址赋值给引用变量。
一般来说(由字节码中是否跟随有invokspecial 指令所决定), new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
扩展:还有一个clinit方法,这个方法是加载时对类中static静态修饰的属性和静态代码块进行初始化(此过程是在链接阶段的准备阶段)。这是JVM相关的知识了。
image.png
该类编译之后
image.png

小结
image.png

2、对象被创建后在内存中的存储布局(对象头包括了哪些内容)

image.png

在虚拟机中,对象在内存中的存储布局可分为三块:对象头、实例数据和对齐填充。
首先,一个普通对象包含:markwork class pointer组成对象头,其中markword在64位的虚拟机中占8字节,跟synchronized加锁有关的锁状态标志,GC分代年龄、线程持有的锁、偏向线程ID、偏向时间戳等 ;class pointer类型指针(正常4个字节),表示该对象属于什么类型的对象(注意并不是所有的类型都会保留类型指针)。
instance data实例数据用来存储诸如成员变量的值,无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。父类定义的变量会出现在子类定义的变量的前面;
然后是padding对齐,比如在64位虚拟机中8字节对齐由于hotspot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,倘若整个对象的字节不能被8整除的话,就用这第四个部分padding进行对齐;这样的话按照一块块有规则的区域读取起来效率较高,属于用空间换时间的一种
如果是数组对象的话会多一个部分,多了一个4字节的length数组长度。因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
所以默认情况下,正常对象头的大小是12字节,数组情况下对象头的大小是16字节。

因为对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。也就是说,Mark Word会随着程序的运行发生变化,32位虚拟机对应的变化状态如下
image.png

代码示例:
(在项目中引入jol依赖可以验证对象的几个组成部分)

  1. <dependency>
  2. <groupId>org.openjdk.jol</groupId>
  3. <artifactId>jol-core</artifactId>
  4. <version>0.11</version>
  5. </dependency>

编写程序

  1. public static void main(String[] args) {
  2. Object o = new Object();
  3. //ClassLayout 类布局
  4. String s = ClassLayout.parseInstance(o).toPrintable();
  5. System.out.println(s);
  6. }

image.png
OFFSET表示偏移量,SIZE表示大小,DESCRIPTION描述;故第一行从0开始占4字节属于对象头,第二行从第4个字节开始也是占4字节,还是属于对象头;

3、对象怎么定位?

我们创建对象的目的肯定是为了使用它,要使用它就得知道它在哪以及如何访问到它,所以这里我们介绍一下如何去定位我们需要使用的对象。
我们的Java程序一般是通过栈上的reference数据来操作堆上具体对象的,而由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位访问堆中的对象的具体位置,所以对象的访问方式取决于具体的虚拟机实现而定
下面我们来学习一下目前主流的两种访问方式:句柄方式和直接指针。
句柄方式
image.png
如图所示,如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,句柄池中放的是一个一个的句柄,句柄中存的是对象实例数据的指针引用与对象类型数据的指针引用。
栈中局部变量里reference中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。

直接引用(Hotspot用法)
image.png
如图所示,栈中局部变量里reference中存储的就是对象地址,相当于一级指针,直接指向堆中的对象;而对象又有一个指针指向堆中表示该对象类型的数据。具体情况如下图所示。
image.png


这两种对象访问方式各有利弊;
从垃圾回收的角度,使用
句柄访问的最大好处就是在移动对象时(如垃圾回收的标记整理算法在回收完垃圾对象后需要把剩下存活的对象进行整理移动,以减少内存碎片),reference中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址;但是如果使用直接指针方式的话,在对象被移动的时候需要修改reference中存储的地址**。
从访问效率方面比较的话,直接指针的效率要高于句柄,因为直接指针的方式只进行了一次指针定位,节省了时间开销,HotSpot采用的直接指针的实现方式。
从空间大小来说,直接指针并没有像句柄访问那样额外在堆中开辟一个句柄访问池,在一定程度上节省空间。

4、对象如何分配?

这个问题问的是在我们new出一个对象之后,这个对象是如何在内存中进行分配的?
首先,当我们new出一个对象的时候优先考虑在占中进行分配,栈一弹出对象就没了。
这是怎么回事?不是说对象是分配在堆空间中的吗?这是因为一些不存在逃逸的对象就可以分配在栈空间,在栈空间中的对象在进行回收时不需要GC的介入,这是栈上分配对象的好处。
那什么叫不存在逃逸?假如某个对象的引用只在某个方法中,除了在方法中访问外没有任何其他的方式、其他的引用能够访问它,这个对象就叫做不存在逃逸的对象。
如果栈上分配不了这个对象,首先去考虑该对象是否过大,对象过大的话就直接将其放入老年代中(大对象直接进入老年代),老年代经过Full Gc 回收该对象;
假如对象没有大到直接进入老年代,或者说存在逃逸,这个时候就会尝试将其分配在线程本地内存,本地内存也放不下的话就将其放在伊甸园,但是由于线程本地内存也是放在伊甸园,所以本质上都是往伊甸园上分配。
在伊甸园中,经过一次GC清理,如果该对象被清理了,则该对象无了;倘若没被清理的话进入幸存区且生命值+1,(之后幸存区的对象也要经过GC的洗礼),经过一定次数的GC后,即生命值达到一定大小后该对象还存活就尝试将其放入老年代。
image.png

5、DCL单例(Double Check Lock) 到底需不需要volatile?

首先说结论,需要用到该关键字禁止指令重排,简单来说并发情况下,由于底层的优化导致指令重排,可能会出现
对象初始化不完全的情况,跟对象的半初始化过程相关,具体内容看我的另一篇关于JUC并发文章中单例的双重检查;
https://www.yuque.com/matteo-irbtp/ab9dn4/ax9ekr#GwcXj

6、Class对象是位于堆中还是方法区中?

我们知道在类加载(Class Loading)的 5 个过程中,加载(Loading)的最终产物是一个 java.lang.Class 对象,它是我们访问方法区中的类型数据的外部接口,我们需要通过这个对象来访问类的字段、方法、运行时常量池等。那么问题来了,这个 Class 对象到底是保存在堆中呢,还是在方法区中呢?

其实这个是跟具体的jdk版本有一定的关系的,这里我们先纠正一个关于方法区、永久代、元空间这三个概念间的常见误区;首先,方法区是一个接口的概念,而永久代、元空间这两个是方法区的实现。即方法区是一个逻辑概念,而永久代跟元空间是其具体实现。
如果是在jdk7版本之前,方法区的实现就叫做永久代;而在jdk8版本之后,方法区的实现就叫做元空间;

在jdk8中,一般情况下,我们创建一个Object对象obj时,在栈的局部变量中reference引用指向堆中的对象,而在上面解释对象定位的时候讲过Hotspot中对象定位采用的是直接指针定位,所以堆中的对象会有一个引用指向该对象代表的类型,即Object.class。平时一般的csdn文章中的图都是直接将堆中对象的引用直接指回堆中的Object.class,但是实际情况中,堆对象的class pointer并不是直接指向Object.class,而是先指向方法区中的一个C++对象,而在该C++对象内部又有一个指针指回到堆空间中的Object.class。之所以放在堆中是为了方便我们利用反射去获取该Class对象。
所以在jdk8中,Class对象是位于堆中的。

其他文档:https://blog.csdn.net/Xu_JL1997/article/details/89433916?

https://www.bilibili.com/video/BV12b4y167Mb?spm_id_from=333.337.search-card.all.click

Java类加载机制

Java程序在运行时,类是在什么时候开始被加载的?

其实在《Java虚拟机规范》中并没有对类什么时候加载进行强制的约束,而是交给不同的虚拟机自己去自由实现,其中HotSpot实现的虚拟机是按需加载,也就是说只有在需要用到该类的时候才会去加载这个类;
这里我们可以实验—XX:+TracecLassLoading参数来监控类的加载时机;

类的加载过程是什么?

image.png
一个类的生命周期从被加载到JVM内存空间到从JVM内存空间中卸载,总共经历了以下几个阶段:
首先是加载阶段,主要目的是将类的字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类创建一个对应的 Class 对象然后在 JVM 的方法区中(1.8之后方法区的实现是元空间),这个 Class 对象就是这个类各种数据的访问入口。
接下来是验证阶段,当 JVM 加载完 Class 字节码文件并在方法区创建对应的 Class 对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行。
中间经过一个重要的准备阶段,当完成字节码文件的校验之后,JVM 便会开始为类变量(静态变量)分配内存并初始化(程序的默认值,但是不是人为手动赋的值)
这里需要注意两个关键点,即内存分配的对象以及初始化的类型JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存,「类成员变量」的内存分配需要等到初始化阶段(七阶段之一)才开始;
还有,这里的初始化指的是为静态变量赋予 Java 语言中该数据类型的对应的零值,而不是用户代码里手动初始化的值,但如果一个变量是常量(即被 static final 修饰)的话,那么在准备阶段,属性便会被赋予用户希望的值。
然后是解析阶段,JVM 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类引用进行解析。这个阶段的主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用
即所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。
解析之后又迎来一个重要的初始化阶段,主要是为类的静态变量赋予正确的手动赋予的初始值,而不是程序默认的值;在这个阶段,JVM 会根据类中Java语句执行的顺序进行收集,然后对类对象进行初始化,即执行类的clinit>()初始化方法,该方法是由类的静态成员对应的赋值语句以及static代码块合并产生的
然后是使用阶段,当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。
最后是卸载阶段,一般情况下是不会去卸载一个类的,但仍会卸载;而要卸载一个已经被加载的类,需要满足以下条件,
1、该类所有的实例对象都已经被GC回收了,也就是JVM中不存在该Class 的任何实例;
⒉、加载该类的ClassLoader类加载且已经被GC回收;
3、该类的对应的java.lang.Class对象没有在任何地方被引用,也不能在任何地方通过反射访问该类的方法;

一个类被初始化的过程是怎样的,什么情况下会触发类的初始化?

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,才真正初始化类变量和其他资源,即执行类的clinit>()初始化方法;在讲这个方法之前我们先来看看哪些情况会触发类的初始化过程,

类的主动使用触发类的初始化

类的使用情况分为主动使用与被动使用 ,而Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况,即如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备己经完成,
1 当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化。
2 当调用类的静态方法时,即当使用了字节码 invokestatic指令
3 当使用类、接口的静态字段时(fina修饰特殊考虑),比如,使用 getstatic或者 putstatic指令。(对应访问变量、赋值变量操作)
4 当使用java.lang. reflect包中的方法反射类的方法时。比如:Class.forName(“com. atgulgu.java.Test”)
5 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
6 如果一个接口定义了 default方法,那么在直接实现或者间接实现该接口的实现类的初始化时,该接口要在其之前被初始化。
7 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
8 当初次调用 Methodhandle实例时,初始化该 Methodhandle指向的方法所在的类。(涉及解析REF getstatic、REF putstatic、REF_ invokestatic方法句柄对应的类)

类被动使用不会触发类的初始化

除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化。也就是说,并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主如动使用的条件,类就不会初始化,以下情况不属于类的主动使用,
1 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。比如当通过子类引用父类的静态变量,不会导致子类初始化
2 通过数组定义类引用,不会触发此类的初始化
3 引用常量(final修饰)不会触发此类或接口的初始化。因为常量在链接阶段就己经被显式赋值了。
4 调用classloader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

类的clinit>()初始化方法

在类的初始化被触发之后,JVM就会收集该类内部由类静态成员的赋值语句以及static语句块,且按照声明的顺序进行合并产生一个clinit方法;
而在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的方法总是在子类方法之前被调用。也就是说,父类的 static块优先级高于子类的。
但是Java编译器并不会为所有的类都产生< clinit>()初始化方法,比如以下三种情况,
一个类中并没有声明任何的类变量,也没有静态代码块时
一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时
* 一个类中包含 static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式

静态代码块

关于类的静态代码块,这里需要注意静态代码块的代码只会在类第一次初始化的时候执行一次。
当类加载器将类加载到JVM中的时候就会创建静态变量,这跟对象是否创建无关。
静态变量加载的时候就会分配内存空间。而且静态代码块的代码只会在类第一次初始化的时候执行一次。
一个类可以有多个静态代码块,它并不是类的成员,也没有返回值,并且不能直接调用。
静态代码块不能包含this或者super,它们通常被用初始化静态变量。

静态代码块被执行的时机:

  1. Class A{
  2. static {
  3. System.out.println("static block invoked!")
  4. }
  5. }

1、第一次new A()的过程会打印””;因为这个过程包括了初始化
2、第一次Class.forName(“A”)的过程会打印””;因为这个过程相当于Class.forName(“A”,true,this.getClass().getClassLoader());
3、第一次Class.forName(“A”,false,this.getClass().getClassLoader())的过程则不会打印””。因为false指明了装载类的过程中,不进行初始化。不初始化则不会执行static块。

总结初始化的执行流程

编译器会收集类变量的赋值语句、静态代码块,按照其出现顺序,最终组成类的初始化方法,一般在类初始化的时候执行。
同样对象实例也有一个初始化方法,编译器会收集成员变量的赋值语句、普通代码块,最后收集构造函数的代码按照其出现顺序,最终组成对象的初始化方法,一般在实例化类对象的时候执行。

父类的 static静态代码块——>子类的 static静态代码块——> 父类的对象初始化方法——>子类的初始化方法;
更详细一点是,父类静态变量—>父类静态代码块—>子类静态变量—>子类静态代码块—
—>父类变量—>父类初始化块—>父类构造器—>子类变量—>子类初始化块—>子类构造器

  1. public class Book extends SuperBook{
  2. public static void main(String[] args)
  3. {
  4. new Book();
  5. }
  6. Book()
  7. {
  8. System.out.println("书的构造方法");
  9. System.out.println("price=" + price +",amount=" + amount);
  10. }
  11. {
  12. System.out.println("书的普通代码块");
  13. }
  14. int price = 110;
  15. static
  16. {
  17. System.out.println("书的静态代码块");
  18. }
  19. static int amount = 112;
  20. }
  21. class SuperBook{
  22. SuperBook()
  23. {
  24. System.out.println("SuperBook书的构造方法");
  25. System.out.println("SuperBook price=" + price +",amount=" + amount);
  26. }
  27. {
  28. System.out.println("SuperBook书的普通代码块");
  29. }
  30. int price = 110;
  31. static
  32. {
  33. System.out.println("SuperBook书的静态代码块");
  34. }
  35. static int amount = 112;
  36. }

image.png

说说你对类的加载器的理解

是什么?

在类加载过程中的第一阶段里,也就是加载阶段,Java虚拟机必须完成3件事,第一件事就通过类的全限定名,获取类的二进制数据流。而 Java虚拟机在设计的时候把类加载阶段的“通过类的全限定类名来获取描述此类的二进制字节流”这个动作放到Java虚拟机的外部去实现,以便让应用程序自己去决定如何获取二进制流文件(可以用c++实现,也可以是Java实现的自定义类加载器),而实现这个动作的代码模块就叫类加载器。
简单来说,类加载器也是一段代码,只不过它即可以是Java实现,也可以是C++语言实现,用来完成“通过类的全限定类名来获取描述此类的二进制字节流”这个动作。

有哪些

站在Java 虚拟机的角度来看,只存在两种不同的类加载器,
1、启动类加载器(Bootstrap ClassLoader),使用C++语言实现,是虚拟机自身的一部分;
2、其他所有的类加载器,由Java语言实现,独立存在于虚拟机外部类java.lang.ClassLoader;
站在Java开发者的角度来看,自JDK1.2开始,Java一直保持着三层类加载器架构,(就是自己熟悉的说法)
Java虚拟机进阶之高频进阶面试题详解 - 图15

不同的类加载器分别加载哪些文件

引导类加戟器
启动类加载器(引导类加载器, Bootstrap Classloader)这个类加载是使用C/C++语言实现的,嵌套在JVM内部用来加载Java的核心库(即 JAVA HOME/jre/lib/rt.jar或sun.boot.class.path路径下的内容)。
用于提供JVM自身需要的类,并不继承自java.lang.ClassLoader, 没有父加载器。出于安全考虑, Bootstrap启动类加载器只加载包名为java、 javax、sun等开头的类。
用于加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
可以使用-XX:+TraceclassLoading参数追踪类的加载过程

扩展类加载器
1. Java语言编写,由sun.misc. Launcher$ExtClassloader实现
2. 继承于Class Loader类,其父类加载器为启动类加载器
3. 从java.ext.dirs系统变量所指定的目录中加载类库,或从JDK的安装目录jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

系统类加载器
应用程序类加载器(系统类加载器,AppClassloader):
是由java语言编写,sun.misc. Launchers$AppClassloader实现
继承于ClassLoader类,其父类加载器为扩展类加载器。
它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
应用程序中的类加载器默认是系统类加载器。
它是用户自定义类加载器的默认父加载器
通过Classloader的getSystemClassLoader()方法可以获取到该类加载器

用户自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。
通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。
同时,自定义加载器能够实现应用隔离,例如 Tomcat, Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C程序要好太多,想不修改C/C程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想
自定义类加载器通常需要继承于ClassLoader.

双亲委派模型

引导类加载器、扩展类加载器、应用程序类加载器这三者之间的关系是什么?

Java虚拟机进阶之高频进阶面试题详解 - 图16
上图展示的类加载器之间的层次关系称为类加载器的双亲委派模型,双亲委派模型要求 除了顶层的启动类加载器外,其余的类加载器都应当有自己的父加载器,但是注意这里的父加载器并不是继承关系,而是组合的关系。也就是说,虽然在双亲委派模型图中三者看起来是父子继承关系,但是实际上它们并不是继承关系,而是组合的关系:在应用类加载器中有一个parent引用指向了扩展类加载器,形成组合关系,同理在扩展类加载器中也有一个parent引用指向引导类加载器。

双亲委派模型是什么?

而双亲委派模型用一句话来概括就是向上依次检查是否已经加载,然后向下加载。
双亲委派模型的工作过程&定义:
具体就是:如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,故所有类的加载请求最终都应该传送到顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。
也就是说类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。

为什么要设计成双亲委派模型呢?优点是什么?

1、避免类的重复加载;当父加载器已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的全局唯一性(唯一性由类的全限定类名以及加载它的类加载器保证)
2、沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改 ,即自己些的类都不能放在诸如java.lang包下;

双亲委派模型的弊端是什么?

在检查类是否已经被加载的委托过程是单向的,这样虽然各个加载器的职责非常明确,但是同时也会带来一个问题,即顶层的加载器无法访问底层加载器所加载的类。

通常情况下,启动类加载器中加载的类为系统核心类,包括一些重要的系统接口;而在应用类加载器中,加载的类是类路径下的类(应用类)。按照这种双亲委派模式,应用类去访问系统类自然是没有问题的,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个系统类接口,该系统类接口需要在应用类中得以实现,该系统类接口还绑定一个工厂方法,用于创建该系统接口的实例,而系统类接口和工厂方法都在启动类加载器中。由于上层加载器中的接口等无法访问底层加载器的加载的类实例,这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题

所以Java虚拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已。比如在Tomcat中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Servlet规范推荐的十种做法。

为什么类加载的顺序不直接从引导类加载器开始而是从应用类加载器开始呢? 这样不是同样可以解决类的重复加载,保证类的唯一性以及沙箱安全机制吗?

虽然第一次加载类的时候从应用类加载器一路问道引导类加载器是否已经加载该类,再从引导类加载器开始尝试加载类到应用类加载器尝试加载类确实很麻烦,但是由于我们开发的系统大多是自己编写的代码,且放在类路径下,在第一次加载过该类之后,往后再次判断是否加载过该类的时候可以直接从应用类加载器这里得到答案了,因为类路径下的类都是由应用类加载器进行加载的。
往后的速度比直接从引导类加载器开始要快的多。

如何打破双亲委派机制

假设我们需要加载的某个类不需要向上委托,而是直接指定只需要某个类加载器去加载的话,就需要去打破双亲委派机制。所以现在的问题是如何去打破双亲委派机制? 答案就在ClassLoader类的loadClass方法中的逻辑,因为双亲委派机制的体现都是在该方法中,我们只需要改变该方法中的某些关于双亲委派模型的逻辑即可。
既然双亲委派机制是通过loadClass方法体现的,要打破双亲委派模型,需要自定义一个类加载器继承java.lang.ClassLoader类,重写loadClass方法,而重写findClass(String name)方法不会打破双亲委派;因为双亲委派模型的核心就体现在loadClass方法中,如果我们重写了这个方法,按照自己的思路去加载类就可以打破双亲委派机制;
但是如果在自定义的类加载器中重写loadclass(String)或loadClass(String,boolean)方法,抹去其中的双亲委派机制,就不能够加载核心类库,因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用defineClass方法,该方法执行的preDefineClass()接口,提供了对JDK核心类库的保护。
所以由于某些核心类的加载还是需要双亲委派模型中的加载器去加载,我们的自定义类加载器要打破双亲委派机制的话,我们具体的思路是关于jdk底层的源码以及核心类的加载让其遵守双亲委派模型,而我们需要打破双亲委派模型的类就由我们自己去加载。

自定义类加载器

如何让自定义类加载器

自定义类加载器只需要继承java.lang.ClassLoader类,该类有两个核心方法,一个是loadClass(String, boolean),该方法的作用是判断该类是否已经被加载过,双亲委派机制跟其有很大关系;
还有一个方法是findClass,默认实现是空方法,所以我们自定义类加载器主要是重写findClass方法,这个findClass方法的作用是将我们要加载的类转换为字节数据流(将.java文件转化为.class文件,然后利用IO流等技术将其变为字节数据),这样就意味着我们用户自定义的加载器可以加载任意路径下的类。
然后调用JDK的defineClass方法将字节数据流转化为一个Class对象。
image.png
这里注意,我们自定义的类加载器其父类加载器是应用类加载器,这是底层源码默认的;

自定义加载器的应用场景

image.png
比如说,在tomcat中有两个不同spring版本的war程序,这两个war都是spring只是版本不一样肯定会存在大量相同的全限定类名的类,这个时候如果不采用自定义类加载器的话,由于这两个war程序都是我们自己编写的,所以由应用程序类加载器进行加载,假设现在先加载spring4的war,由于双亲委派模型,这个时候再想去加载spring5版本的war包就不能进行加载了,因为相同类名的类已经在加载spring4的时候加载过了,在你想加载spring5的时候只会返回spring4版本的类。
而使用自定义的类加载器就可以解决这个问题。

ClassLoader中的loadClass()、findClass()、defineClass()区别?

loadClass()是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中;
在loadClass方法会调用findClass()方法,根据名称或位置加载.class字节码;
findClass方法内部最终会调用definclass()把字节码转化为Class对象;
1、当我们想要自定义一个类加载器的时候,并且想破坏双亲委派模型时,我们会重写loadClass方法;
2、如果我们想定义一个类加载器,但是不想破坏双亲委派模型的时候呢?可以可以重写findClass方法,即不想打破双亲委派机制的自定义加载器,直接重写findClass方法即可,不建议重写loadClass方法;

加载一个类采用Class.forName()和 ClassLoader()有什么区别?

字节码 —->类加载 —>链接 (验证 —>准备 —>解析) —>初始化 —>使用 —>卸载
上面是类加载的七个阶段,当类被加载完成之后,类的信息就会被放到方法区中;
使用Class.forName(“全限定类名”)时,会初始化类;而使用getClassLoader.loadClass(“全限定类名”)不会执行类的初始化阶段,只有在创建类的时候(主动使用类)才会进行类的初始化;

说说你对Tomcat的类加载机制的理解

image.png
如上图所示,tomcat类加载机制里的前三个类加载器跟Java里的一样,下面多出来的5个就是tomcat中的类加载器;
也就是说,在原来的Java的类加载机制基础上,Tomcat新增了3个基础类加载器和一个Web应用的类加载器跟一个JSP类加载器;3个基础类加载器在tomcat按照目录中的conf/catalina.properties中进行配置。
在5.6版本之后,Catalina、Share类加载器就合并在一起了;
Webapp类加载器加载的目录是webapps目录下的Java类;Jsp类加载器就负责加载Jsp类的文件(运行时会被转化为Java字节码);
Java虚拟机进阶之高频进阶面试题详解 - 图20

tomcat为什么要破坏双亲委派

什么是热加载和热部署,如何自己实现一个热加载?

热加载是指可以在不重启服务的情况下让更改的代码生效,热加载可以显著的提升开发以及调试的效率,它是基于Java的类加载器实现的,但是由于热加载的不安全性,一般不会用于正式的生产环境;
热部署是指可以在不重启服务的情况下重新部署整个项目,比如Tomcat热部署就是在程序运行时,如果我们修改了War包中的内容,那么Tomcat就会删除之前的War包解压的文件夹,重新解压新的War包生成新的文件夹;
即,
1、热加载是在运行时重新加载class,后台会启动一个线程不断检测你的class是否发生改变;
2、热部署是在运行时重新部署整个项目,耗时相对较高;

image.png
简单来说就是,弄一个定时任务,每隔一段时间就去查看是否有class文件更新,已决定是否重新触发该类的加载;

Java内存模型

Java代码到底是如何运行起来的?

image.png
image.png

JVM的运行原理图

image.png
上图是每个Java程序员都应该牢牢记在心中的Java运行原理图,首先是一个Mall.java源程序文件经过javac编译之后得到一个Mall.class文件,然后通过java命令将字节码文件运行起来,其实就是将字节码文件通过类加载系统加载到JVM的内存中的元空间(jdk1.8之后方法区的实现是元空间)里;
然后main主方法开始执行,由于main方法也是一个线程,故会生成一个Java虚拟机栈并在其内部执行main方法的代码,方法的局部变量就放在自己的方法栈帧中,其中的实例对象就就放在堆中;
同时在代码执行的时候有一个程序计数器,专门记录代码执行的行号;如果在方法执行过程中还需要调用本地方法,还会创建一个本地方法栈来执行本地方法;
若果运行过程中某个对象不再被引用,就会变成垃圾被垃圾回收器进行回收;

JVM内存结构的划分

Java垃圾回收机制