JVM 常见面试题汇总

1. JVM 的内存结构

1.1 JVM 的主要组成部分及其作用

JVM包含两个子系统和两个组件,两个子系统为 Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
  • Execution engine(执行引擎):执行classes中的指令。
  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

image-20220225111630531

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

image-20210816225907214

1.2 JVM 运行时数据区

image-20210816161959892

  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;
  • Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息;
  • 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;
  • Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;
  • 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

1.3 堆栈的区别

物理地址

堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)

栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

内存分别

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。

栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。

存放的内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储

栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

  1. 静态变量放在方法区
  2. 静态的对象还是放在堆。

程序的可见度

堆对于整个应用程序都是共享、可见的。

栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

参考:JVM的内存结构

1.4 内存溢出

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出,有时候会自动关闭软件,重启电脑或者软件后释放掉一部分内存又可以正常运行该软件,而由系统配置数据流、用户代码等原因而导致的内存溢出错误,即使用户重新执行任务依然无法避免。

顺便再说说内存溢出和内存泄漏的区别,个人理解:

  • 内存溢出:程序运行所需要的内存大于所提供的内存。
  • 内存泄漏:程序运行时划分了内存,但是程序执行完成后对象没有被回收,处于一直存活的状态,比如使用ThreadLocal之后没有remove
  • 两者关系:内存泄漏过多之后就会造成内存溢出。怎么理解?多线程执行同一个内存泄漏的程序,也就是占用过多的内存之后,超出了规定的内存大小,自然就溢出了。

① 栈溢出

我们把栈的内存大小默认为1m:-Xss1m,下面的代码可演示栈溢出

image-20210818135656259

HotSpot 版本中栈的大小是固定的,是不支持拓展的。

java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归

虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。

OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)

同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。

② 堆溢出

内存直接溢出:申请内存空间,超出最大堆内存空间。如果是内存溢出,则通过 调大 -Xms,-Xmx 参数。

设置VM Args:-Xms30m -Xmx30m -XX:+PrintGCDetails

image-20210818135500849

在工作中还可能会遇到这样的一个异常:GC overhead limit exceeded,如下面的代码

image-20210818143022006

这种情况不是内存直接溢出,就是说内存中的对象却是都是必须存活的,也就是达到一定的量才会溢出,就好比水杯装水,刚开始是空的,接水的时候不满就会一直接,但是如果你没注意,当水满了,这个时候就溢出了,这个过程就类似于内存溢出。但是如果在要满的时候你喝几口再去接,那杯子又可以重新接水,这个过程可以在逻辑上理解为GC调优。

但是既然有GC调优为什么还会溢出呢?官方给出的原因是:

超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。

怎么解决?

  1. 那么就应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间。
  2. 查看项目中是否有大量的死循环或有使用大内存的代码,优化代码。
  3. 增大堆内存。

2. JVM 中的对象

对于 JVM 中的对象我们需要掌握这几个问题,对象怎么创建,对象的内部构造布局,对象如何访问(定位),怎么判断对象的存活,对象的四大引用以及对象在堆栈中是如何分配的。

2.1 对象的创建过程

说到对象的创建,首先让我们看看 Java 中提供的几种对象创建方式:

image-20220225114142583

也就是说当JVM 遇到一条字节码 new 的指令,就相当于告诉它要创建对象了,如下:

image-20210818235458672

  1. 类加载,当 JVM 遇到一条 new 指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。

  2. 检查加载,该过程实际上还是检查类加载的过程是否正常,检查类是否已经被加载、解析和初始化过。

  3. 分配内存,类加载产生的新对象需要在堆中分配内存,而分配内存的方式有两种:

    • 指针碰撞

      如果 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。就是说顺序存放,如下:

      img

    • 空闲列表

      如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。可以理解为乱序存放,如下:

      image-20210818230310908

    也就是说,对象通过那种方式存放主要是通过堆的内存是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

    如果是 Serial、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。如果是使用 CMS 这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

  4. 内存空间初始化

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

  5. 设置

    接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在 Java hotspot VM 内部表示为类元数据)、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。

  6. 对象初始化

    一般来说,执行 new 指令后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化。

2.2 对象创建过程中的并发问题

我们知道 Java 天生就是多线程的,所以对象创建在虚拟机中是非常频繁的行为,在划分空间的时候仅仅是修改指针所指向的位置也是线程不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。怎么解决?解决这个问题有两种方案:

image-20220225141024064

1、CAS 机制

CAS,Compare and Swap,即比较再交换。一个 CAS 操作过程都包含三个运算符:一个内存地址 V,一个期望的值 A 和一个新值 B,操作的时候如果这个地址上存放的值等于这个期望的值 A,则将地址上的值赋为新值 B,否则不做任何操作。CAS 的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环 CAS 就是在一个循环里不断的做 CAS 操作,直到成功为止(想仔细了解可以看我这篇文章:CAS详解 )。

image-20210818234521043

2、分配缓冲

另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM 在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块继续使用。

TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。

TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB。

2.3 对象的内存布局

根据 Java 虚拟机规范里面的描述,Java 对象分为三部分:对象头(Object Header),实例数据(instance data),对齐填充(padding)。

image-20210819094725889

1、对象头

HotSpot 虚拟机的对象头主要包括两部分(若是数组对象还包括一个数组的长度)信息,对象头在32位系统上占用8bytes,64位系统上占用16bytes(开启压缩指针)。

  • Mark Word,主要存储哈希码(HashCode)、GC 分代年龄、锁状态标识、线程持有的锁、偏向线程 ID、偏向时间戳。
  • 类型指针(klass pointer),即对象指向它的类元数据的指针(即存在于方法区的Class类信息),虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 数组长度,如果对象是一个数组,那么在对象头中还有一块用于记录数组长度的数据(这里不考虑)。

HotSpot对对象头的定义为:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html

Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object’s layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

谷歌翻译:

每个 GC 管理的堆对象开头的公共结构。 (每个 oop 都指向一个对象头。)包括关于堆对象的布局、类型、GC 状态、同步状态和身份哈希码的基本信息。 由两个词组成。 在数组中,它紧跟一个长度字段。 请注意,Java 对象和 VM 内部对象都有一个共同的对象头格式。

因此,HotSpot 虚拟机的对象头主要包括Mark Word和**类型指针(klass pointer)**两部分:

image-20210914093243514

而对于Mark Word的大小在 64 位的 HotSpot 虚拟机中 markOop.cpp 中有很好的注释,其大小为64 bits,而klass pointer在开启压缩指针的情况下为32 bits

PS:1byte = 8bit,即1字节为8位

image-20210913225358187

把它转化为下面的表格:

image-20210914150807934

2、实例数据

实例数据部分是对象真正存储的有效信息(也就是被 new 出来的对象信息),也是在程序代码中所定义的各种类型的字段内容。基本数据类型的内存占用如下:

image-20210914085902311

引用类型在64位系统上每个占用 4 bytes。

3、对齐填充

对齐填充不是必然存在的,没有特别的含义,它仅起到占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是说对象的大小必须是 8 字节的整数倍(这是个规定)。对象头部分是 8 字节的倍数,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

一个有趣的面试题:Object o = new Object()占多少字节?

按照上面的说法,Object 对象在没有任何属性的情况下(开启压缩指针),应该是对象头 + 实例数据 + 对其填充 = 12 byte + 0 + 4 byte = 16 byte。

解释:其中对象头占用12 byte,还有4 byte是对齐的字节(因为在64位虚拟机上对象的大小必须是 8 的倍数,也就是对齐填充),由于这个对象里面没有任何字段,故而对象的实例数据为 0 ,所以一个没有任何属性的对象的内存占用为 16 byte。

同理,如果一个对象存在属性,基本数据类型的内存占用如上,引用类型在 64 位系统上每个占用 4 byte。

如下面这个类:

public class A {
    
    

    private int a;
    
    private Object object;
}

对象头:12 byte

实例数据:因为基本数据类型都是有默认值的,int = 4byte,object = 4byte,一起为 8byte。

对齐填充:对象头 + 实例数据 = 12 + 8 = 20byte,不满足 8 的倍数,因此需要填充 4byte 。

所以A a = new A(),这个对象占用的内存大小为 12+8+4 = 24byte

2.4 对象的访问

建立对象的目的是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有 句柄直接指针 两种。

1、句柄

句柄(Handle)是什么?

举个栗子,比如我们开门(Door)的时候是通过扭动门把手(Door Handle)来控制的,但是Door Handle 又不是Door本身,但是确实需要这个Handle去操作,所以可以把句柄理解为一个中间媒介。

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

使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

image-20210819114433415

2、直接指针

如果使用直接指针访问, reference 中存储的直接就是对象地址。 就相当于我开门的时候不需要门把手去控制,直接用手一推就开了。

image-20210819114725599

这两种对象访问方式各有优势,使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

对 Sun HotSpot 而言,它是使用直接指针访问方式进行对象访问的。

2.5 对象存活的判断

在堆里面存放着几乎所有的对象实例(为什么是几乎,因为栈也可能存在对象,后续会讲到),垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是存活着,哪些已经死去(死去代表着不可能再被任何途径使用得对象了) 。

一般有以下 2 种:

1、引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1,当引用失效时,计数器减 1。

主流 Java 虚拟机并未采用该算法,很难解决对象之间相互循环引用的问题,如:对象 A 指向 B,对象 B 反过来指向 A,此时它们的引用计数器都不为 0,但它们俩实际上已经没有意义因为没有任何地方指向它们。

简单来说就是假如公司要搜集员工意见,员工A和员工B相互讨论了很多意见,但是最后公司上层压根就没有采纳A和B的意见,等于 A 和 B 相互在玩。

我们可以验证一下HotSpot VM是否采用了该算法,如下:

public class ReferenceCount {
    
    

    static class A {
    
    
        private B b;
		//get/set
    }

    static class B {
    
    
        private A a;
		//get/set
    }

    public static void main(String[] args) {
    
    
        A a = new A();
        B b = new B();
        a.setB(b);
        b.setA(a);
        a = null;
        b = null;
        System.gc();//虽然相互引用,但在GC之后还是被回收了
    }
}

2、可达性分析

Java是通过可达性分析来判定对象是否存活的(可以叫做根可达)。这个算法的基本思路就是通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可达的。

怎么理解?我们从3个方面去解释

image-20210819151330861

什么是GC Roots

垃圾回收时,JVM 首先要找到所有的GC Roots,这个过程称作 「枚举根节点」 ,这个过程是需要暂停用户线程的,即触发 STW。然后再从 GC Roots 这些根节点向下搜寻,可达的对象就保留,不可达的对象就回收。

GC Roots其实就是对象,而且是JVM 确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象 ),只有找到这种对象,后面的搜寻过程才有意义,不能被回收的对象所依赖的其他对象肯定也不能回收。

当 JVM 触发 GC 时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW(垃圾回收器再去细讲),然后枚举根节点,即找到所有的GC Roots,然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。

即使是号称几乎不停顿的CMS、G1等收集器,在枚举根节点时,也是要暂停用户线程的。

GC Roots是一种特殊的对象,是Java程序在运行过程中所必须的对象,而且是根对象。

什么是对象可达

对象可达:对象双方存在直接或间接的引用关系。

根可达(GC Roots可达):对象到GC Roots存在直接或间接的引用关系。

image-20210819150900892

那些对象可作为GC Roots

作为 GC Roots 的起始点对象,这个对象主要是下面前四种:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象,各个线程在执行方法时会打包为一个栈帧,堆栈中使用到的参数、局部变量、临时变量会存放到栈帧的局部变量表中。只要方法还在运行,还没出栈,就意味这本地变量表的对象还会被访问,GC就不应该回收,所以这一类对象也可作为GC Roots

  • 方法区中类静态属性引用的对象,java 类的引用类型静态变量属于 Class 对象,Class 对象本身很难被回收,回收的条件非常苛刻,只要 Class 对象不被回收,静态成员就不能被回收。

  • 方法区中常量引用的对象,比如:字符串常量池里的引用,常量本身初始化后不会再改变。

  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

  • JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。

  • 所有被同步锁(synchronized 关键)持有的对象。

  • JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

  • JVM 实现中的临时性对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象)。

上面都是对象的回收,对于类Class的回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。

  2. 加载该类的 ClassLoader 已经被回收。

  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

  4. 参数控制-Xnoclassgc:关闭虚拟机对class的垃圾回收功能。

    image-20210819154837913

2.6 对象的引用类型

Object o=new Object(),这个 o,我们可以称之为对象引用,而 new Object()我们可以称之为在内存中产生了一个对象实例。

image-20210819165108136

当写下 o = null 时,只是表示 o 不再指向堆中 object 的对象实例,不代表这个对象实例不存在了。

1、强引用

就是指在程序代码之中普遍存在的,类似Object obj=new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。

2、软应用

是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。

/**
 * 软引用
 * VM:-Xms20m -Xmx20m
 */
public class TestSoftRef {
    
    
    //对象
    public static class User {
    
    
        public int id = 0;
        public String name = "";

        public User(int id, String name) {
    
    
            this.id = id;
            this.name = name;
        }

        @Override
        public String toString() {
    
    
            return "User [id=" + id + ", name=" + name + "]";
        }
    }

    public static void main(String[] args) {
    
    
        User u = new User(1, "Ayue"); //new是强引用
        SoftReference<User> userSoft = new SoftReference<User>(u);//软引用
        u = null;//干掉强引用,确保这个实例只有userSoft的软引用
        System.out.println(userSoft.get()); //看一下这个对象是否还在
        System.gc();//进行一次GC垃圾回收
        System.out.println("After gc");
        System.out.println(userSoft.get());
        //往堆中填充数据,导致OOM
        List<byte[]> list = new LinkedList<>();
        try {
    
    
            for (int i = 0; i < 100; i++) {
    
    
                //System.out.println("第" + (i+1) + "次" + userSoft.get());
                list.add(new byte[1024 * 1024 * 1]); //1M的对象 100m
            }
        } catch (Throwable e) {
    
    
            //抛出了OOM异常时打印软引用对象
            System.out.println("Exception:" + userSoft.get());
        }
    }
}

输出:

User [id=1, name=Ayue]
After gc
User [id=1, name=Ayue]
第1次User [id=1, name=Ayue]
第2次User [id=1, name=Ayue]
......
第18次User [id=1, name=Ayue]
Exception:null

可以看到,尽管调用GC也没有立即回收掉,大概在第18次之后就溢出了,此时会把软引用对象给回收掉。

3、弱引用

也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时, 无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用。

public class TestWeakRef {
    
    
	public static class User{
    
    
		public int id = 0;
		public String name = "";
		public User(int id, String name) {
    
    
			super();
			this.id = id;
			this.name = name;
		}
		@Override
		public String toString() {
    
    
			return "User [id=" + id + ", name=" + name + "]";
		}
	}

	public static void main(String[] args) {
    
    
		User u = new User(1,"Ayue");
		WeakReference<User> userWeak = new WeakReference<User>(u);
		u = null;//干掉强引用,确保这个实例只有userWeak的弱引用
		System.out.println(userWeak.get());
		System.gc();//进行一次GC垃圾回收
		System.out.println("After gc");
		System.out.println("发生GC后:"+userWeak.get());
	}
}

输出:

User [id=1, name=Ayue]
After gc
发生GC后:null

弱引用的引用比较常见的就是ThreadLocal,感兴趣的可以去看我的这篇文章:ThreadLocal详解

4、虚引用

也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了PhantomReference 类来实现虚引用。

2.7 对象的分配策略

之前将了JVM的内存布局,以及对象在JVM中的分布,但是对象在JVM中的分配是通过什么决定的呢?比如常说的一句话就是:几乎所有的对象都分配在堆中,那为什么不能说是一定呢?难道出了堆,其他地方也能分配对象吗?栈能不能分配对象?而我们知道就算是堆又细分为新生代(Eden,From,To)和老年代,那么每个区域都会存在对象吗?其实这取决于我们JVM的对象分配策略。

1、栈上分配

几乎所有的对象都分配在堆中,都说了是几乎,那几乎堆之外的栈是不是也能分配对象?答案是可以的,但是肯定是需要条件的,满足什么条件可以使得对象在栈上分配呢?

如果方法中的对象没有发生逃逸,对象可以在栈上分配。

逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。

方法逃逸:调用参数传递到其他方法中,这种称之为方法逃逸,甚至还有可能被外部线程访问到,如:赋值给其他线程中访问的变量,这个称之为线程逃逸。

从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率,因为不需要进行垃圾回收了。

2、堆上分配

一般来说,对象都分配在堆中,我们知道堆被划分为新生代和老年代(Tenured),新生代又被进一步划分为 EdenSurvivor 区,最后 SurvivorFrom SurvivorTo Survivor 组成。

image-20210817173644553

① 对象优先分配在Eden区

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间分配时,虚拟机将发起一次 Minor GC。

② 大对象直接进入老年代

大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群朝生夕死的短命大对象,我们写程序的时候应注意避免。

在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们。而当复制对象时,大对象就意味着高额的内存复制开销。

HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor区之间来回复制,产生大量的内存复制操作。

这样做的目的是什么?

1.避免大量内存复制

2.避免提前进行垃圾回收,明明内存有空间进行分配。
注意:PretenureSizeThreshold 参数只对 SerialParNew 两款收集器有效。

③ 长期存活对象进入老年区

HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中,也就是对象的内存布局中的GC分代年龄

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 区容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor区中每熬过一次 Minor GC,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15,CMS 是 6 ,可通过-XX:MaxTenuringThreshold调整)时,就会被晋升到老年代中。

④ 对象年龄动态判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

⑤ 空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次 Full GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GCimage-20210819223227878

image-20210819230204878

3. 垃圾回收

在 Java 中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

3.1 GC是什么?为什么要GC

在 C++ 中,对象所占的内存在程序结束运行之前一直被占用,在明确释放之前不能分配给其它对象;而在 Java中,当没有对象引用指向原先分配给某个对象的内存时,该内存便成为垃圾。 垃圾回收能自动释放内存空间,减轻编程的负担,JVM 的一个系统级线程会自动释放该内存块。垃圾回收意味着程序不再需要的对象是"无用信息",这些信息将被丢弃。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用。事实上,除了释放没用的对象,垃圾回收也可以清除内存碎片。

由于创建对象和垃圾回收器释放丢弃对象所占的内存空间,内存会出现碎片。碎片是分配给对象的内存块之间的空闲内存洞。碎片整理将所占用的堆内存移到堆的一端,JVM将整理出的内存分配给新的对象。

PS:什么是内存碎片

内存碎片一般是由于空闲的连续空间比要申请的空间小,导致这些小内存块不能被利用。产生内存碎片的方法很简单,举个栗子:

假设有一块一共有100个单位的连续空闲内存空间,范围是0~99

  • 如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为0~9区间。
  • 这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为10~14区间。
  • 如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。
  • 现在整个内存空间的状态是0~9空闲,10~14被占用,15~24被占用,25~99空闲。其中0~9就是一个内存碎片了。
  • 如果10~14一直被占用,而以后申请的空间都大于10个单位,那么0~9就永远用不上了,造成内存浪费。
  • 如果你每次申请内存的大小,都比前一次释放的内村大小要小,那么就申请就总能成功。 通常来说,内存碎片是难以避免的,但却可以清除。

3.2 怎么判断对象是否可以被回收

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

一般有两种方法来判断:

  • 引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
  • 可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。

参考 2.5

3.3 JVM 运行时堆内存如何分代

当前商业虚拟机的垃圾回收器,大多遵循分代收集的理论来进行设计,这个理论大体上是这么描述的:

  1. 绝大部分的对象都是朝生夕死
  2. 熬过多次垃圾回收的对象就越难回收。

根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代老年代

image-20210820135131312

从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。

默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),

即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小

其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分

默认的,Eden: from : to = 8 :1 : 1 ( 可以通过参数**–XX:SurvivorRatio** 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小

JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块Survivor区域是空闲着的

因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。

3.4 GC 分类

根据分区的不同,GC 也有不同的分类,如下:

  • 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
  • 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。(Major GC 定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法) 。
  • 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)。

3.4 垃圾回收算法

针对堆中存在的垃圾对象,JVM 提出了 3 种垃圾回收算法来处理,复制算法,标记清除算法和标记整理算法。

复制算法

复制算法(Copying),将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可。其大致步骤为:

  1. 假设将内存分为A、B两块,开始A中且内存没有使用完,新对象o1~o4被分配到A中,如下:

    image-20210820144125681

  2. 当再有一个对象o5想添加到A中,发现内存已满,就把A中还存活的对象赋值到B中,把A内存清理(格式化),并把o5分配到B中,如下:

    image-20210820150100605

  3. 当后续对像添加发现内存满了之后继续上面的操作。

优点:实现简单,运行高效,没有内存碎片。

缺点:这种算法的代价是将内存缩小为了原来的一半。

优化

复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。所以,复制算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么复制算法的效率将会大大降低,因为能够使用的内存缩减到原来的一半。

那么有没有 什么方式去解决内存利用率的问题?当然有,一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1 和Survivor2)

专门研究表明,新生代中的对象 98%是朝生夕死的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1]。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被浪费。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

image-20210820151414230

对象可以无限制的在新生代复制吗,答案是否定的,新生代采用复制算法的原因是因为其中的对象基本都是朝生夕死的,那对于存活的对象每次发生Young GC时,对象头中的分代年龄便会增加 1 岁,当它的年龄增加到一定程度时(一般是15岁),就会被移动到老年代中。

看到一个有趣的解释,一个对象的这一辈子(opens new window)

我是一个普通的Java (opens new window)对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的From区,自从去了Survivor区,我就开始漂了,有时候在Survivor的From区,有时候在Survivor的To区,居无定所。直到我15岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了老年代那边,老年代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我也不知道能活多久,可能很久,也可能…

标记清除算法

标记清除算法(Mark-Sweep),分为标记和清除两个阶段:

  1. 首先扫描所有对象标记出需要回收的对象。
  2. 在标记完成后扫描回收所有被标记的对象。

优点:算法简单、容易实现,且不会移动对象

缺点:回收效率略低,因为需要扫描两遍。如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。 最主要的问题是标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代(简单来说就是新生代的对象太多了,影响效率)。

image-20210820162345316

标记整理算法

标记整理算法(Mark-Compact),算法分为标记、整理和清除三个阶段:

  1. 首先标记出所有需要回收的对象。
  2. 在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动。
  3. 然后直接清理掉端边界以外的内存。

优点:不会产生内存碎片。

缺点:两遍扫描、指针需要调整,因此效率偏低。

我们知到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。

image-20210820163318860

通常,老年代采用的标记整理算法与标记清除算法。

3.5 JVM中一次完整的GC流程

先描述一下Java堆内存划分,再解释Minor GC,Major GC,full GC,描述它们之间转化流程。

  • Java堆 = 老年代 + 新生代
  • 新生代 = Eden + S0 + S1
  • 当 Eden 区的空间满了, Java虚拟机会触发一次 Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到 Survivor区。
  • 大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年代
  • 如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一次Minor GC,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年代
  • 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和年老代
  • Major GC 发生在老年代的GC清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上

3.6 垃圾回收器

在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清理或者标记—整理算法来进行回收。

因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器,如下:

image-20210826234601731

对于算法的实现主要是垃圾回收器在不同条件下有不同的分类,这些条件包括内存大小,是否多线程,STW(Stop The World)的时间等等。

PS:Stop The World(STW),单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为Stop The World,但是这种 STW 带来了恶劣的用户体验,例如:

应用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。

  1. Serial收集器(复制算法)

    新生代单线程收集器,标记和清理都是单线程,优点是简单高效;

  2. ParNew收集器 (复制算法)

    新生代收并行集器,实际上是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现;

  3. Parallel Scavenge收集器 (复制算法)

    新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用 CPU 时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;

  4. Serial Old收集器 (标记-整理算法)

    老年代单线程收集器,Serial收集器的老年代版本;

  5. Parallel Old收集器 (标记-整理算法)

    老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

  6. CMS(Concurrent Mark Sweep)收集器(标记-清除算法)

    老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。

  7. G1(Garbage First)收集器 (标记-整理算法)

    Java 堆并行收集器,G1 收集器是 JDK1.7 提供的一个新收集器,G1 收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

4. 类加载机制

4.1 简述java类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的java类型。

4.2 描述一下JVM加载Class文件的原理机制

Java中的所有类,都需要由类加载器装载到 JVM 中才能运行。类加载器本身也是一个类,而它的工作就是把 class 文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。

类装载方式,有两种 :

  1. 隐式装载, 程序在运行过程中当碰到通过 new 等方式生成对象时,隐式调用类装载器加载对应的类到 JVM 中.
  2. 显式装载, 通过class.forname()等方法,显式加载需要的类。

Java 类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到 JVM 中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。

4.3 类加载过程

我们知道JVM是执行class文件的,而我们的Java程序是一个.java文件,实际上每个.java文件编译后(包括类或接口等)都对应一个 .class 文件。当Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。具体如下:

  • 加载,类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的Class 对象。加载完成后,Class 对象还不完整,所以此时的类还不可用。
  • 连接,当类被加载后就进入连接阶段,这一阶段包括验证准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。
  • 初始化,最后JVM 对类进行初始化,包括:如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;如果类中存在初始化语句,就依次执行这些初始化语句。

类加载完成之后,就是使用了,用完之后自然就是卸载。

怎么记住?

知道了加载过程其实还是不明白每一步具体是做什么的,不急我们慢慢来分析,但前提是为了方便记忆我们要记住这五个字:家宴准姐出。(因为少数国家的习俗就是比较大型的宴会是不允许女子出席的,所以可以通过这种方式来记)

家(加载)宴(验证)准(准备)姐(解析)出(初始化)

image-20210826102707398

加载

加载阶段是整个类加载过程的一个阶段。 加载阶段虚拟机需要完成以下 3 件事情

  1. 通过一个类的全限定名来获取定义此类的二进制字节流,即将class字节码文件加载到内存中。
  2. 将这个字节流所代表的静态存储结构(数据)转化为方法区的运行时数据结构(如静态变量,静态代码块,常量池等)。
  3. 在内存中(一般是堆)生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

:第一点“通过一个类的全限定名来获取定义此类的二进制字节流”并不是说一定得从某个 class 文件中获取,我们可以从 zip 压缩包、从网络中获取、运行时计算生成、数据库中读取、或者从加密文件中获取等等。

验证

验证的目的是为了确保 Class 文件的字节流中的信息不会危害到虚拟机,在该阶段主要完成以下四种验证:

  1. 文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型。
  2. 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。
  3. 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
  4. 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。

准备

准备阶段是正式为类中定义的变量(被 static 修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

这个阶段中有两个容易产生混淆的概念需要强调一下:

  1. 首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中
  2. 其次,这里所说的初始值通常情况下是数据类型的零值,假设一个类变量的定义为: public static int value = 666,那变量 value 在准备阶段过后的初始值为 0 而不是 666,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 666 是后续的初始化环节。

基本数据类型的零值表,来自八大基本数据类型

image-20210826114915482

解析

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程(这一步可选,解析动作并不一定在初始化动作完成之前,也有可能在初始化之后) 。

怎么理解符号引用和直接引用?

在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,多以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。

我们可以这么理解:比如在在开会的时候,会上领导说会后要给小王发邮件,秘书于是就记录下来,会后秘书并不知道小王的邮箱地址具体是多少,只知道要给他发,于是就找小王要了邮箱地址。这里给小王发邮箱就相当于符号引用,而小王的邮箱地址就是直接引用

解析大体可以分为:

  1. 类或接口的解析
  2. 字段解析
  3. 类方法解析
  4. 接口方法解析

我们了解几个经常发生的异常,就与这个阶段有关。

  • NoSuchFieldError:根据继承关系从下往上,找不到相关字段时的报错。(字段解析异常)
  • IllegalAccessError:字段或者方法,访问权限不具备时的错误。(类或接口的解析异常)
  • NoSuchMethodError:找不到相关方法时的错误。(类方法解析、接口方法解析时发生的异常)

初始化

初始化主要是对一个 class 中的 static{}语句进行操作(对应字节码就是 <clinit>() 方法)。

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):

  1. 遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的Java 代码场景是:
    • 使用 new 关键字实例化对象的时候。
    • 读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候 。
    • 调用一个类的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStaticREF_putStaticREF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了 JDK1.8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

通过下面的例子来了解初始化的一些场景:

场景一

子类引用父类中的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

image-20210826145113740

场景二

使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载)

image-20210826152031692

场景三

调用一个常量,不会触发初始化,也不会加载。

image-20210826153610663

**为什么不会加载?**因为在编译的过程中常量就已经加载到常量池中,如下,在还没有运行代码的情况下看常量池的数据(这里是通过jclasslib看的)

image-20210826153411938

场景四

如果使用常量去引用另外一个常量(这个值编译时无法确定,所以必须要触发初始化)。

image-20210826154654559

4.4 类加载器

实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。

一个类的加载是比较复杂的,所以就有专门的加载器来负责,类加载器就是来做上面的事的(家宴准姐出这5个步骤)。而作为类加载器,JDK提供了三层类加载器,毕竟底层是它,所以他自己与自己的一套规则。

image-20210829125002285

  1. 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;
  2. 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库;
  3. 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
  4. 用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现。

4.5 双亲委派模型

该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。

image-20210830135442884

双亲委派模型工作过程

如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。

4.6 为什么需要双亲委派模型

  1. 安全性,避免用户自己编写的类动态替换 Java的一些核心类,防止Java核心api被篡改,比如 String。

    如果用户自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍作修改。比如equals()函数,这个函数经常使用,如果在这这个函数中,黑客加入一些病毒代码。并且通过自定义类加载器加入到 JVM 中。此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致病毒代码被执行。

    而有了双亲委派模型,黑客自定义的java.lang.String类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。

  2. 避免重复加载,即避免多分同样字节码的加载。

    试想一下,如果没有双亲委派,那么用户是不是可以自己定义一个java.lang.Object的同名类java.lang.String的同名类,并把它放到ClassPath中,那么类之间的比较结果及类的唯一性将无法保证,因此,为什么需要双亲委派模型?防止内存中出现多份同样的字节码

4.7 如何打破双亲委派模型

打破双亲委派机制则不仅**要继承 ClassLoader 类,还要重写 loadClass 和 findClass **方法。

另外如 JDBC 的 SPI 机制也是打破双亲委派模型的表现,以及 tomcat 的类加载机制等等。

猜你喜欢

转载自blog.csdn.net/weixin_43477531/article/details/123177136