《今天面试了吗》-jvm

690 阅读8分钟

前言

随着cpu运行速度的提高和内存的增大,我们的应用程序的用户响应时间和系统吞吐量也发生了质的提高。但是只有硬件设备的提高是不行的,软件的性能和运行在硬件上的虚拟机的各项参数都影响着系统的质量。在越来越多的大厂面试中,jvm逐渐成为面试官青睐的考点。

运行时数据区域

java虚拟机运行时有哪些数据区域,他们都有什么用途?

有程序计数器、java虚拟机栈、本地方法栈、堆和方法区五大模块。请看下图:

程序计数器

程序计数器是一块较小的内存空间,他可以看做是当期线程所执行的字节码的行号指令器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。 在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间互不影响,独立存储,所以程序计数器是“线程私有的”。另外,程序计数器是唯一一个在java虚拟机规范中没有规定OOM的区域。

Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同,虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。(程序员经常会把Java内存划分为堆内存和栈内存,这种说法比较粗糙,其中的栈内存就是指虚拟机栈,或者说是虚拟机栈中的局部变量表的部分)
在Java虚拟机规范中,对这个区域规定了两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常。如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OOM异常。

本地方法栈

本地方法栈与虚拟机栈作用类似,他们之间的区别不过是虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈也会抛出StackOverFlowError异常和OOM异常。

Java堆是Java虚拟机管理的最大的一块内存,是所有线程共享的区域,在虚拟机启动时就创建。堆用来存放对象实例,几乎所有的对象实例都在这里分配内存(注意是几乎所有)。 这一点在Java虚拟机规范中描述为:所有的对象实例以及数组都要在堆上分配,但随着JIT编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换技术将会导致一些微妙的变化发生,所有对象都分配在堆上也不是那么绝对了。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OOM异常。

方法区

方法区与Java堆一样,是各个线程共享的区域,它用来存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。根据Java虚拟机规范,当方法区无法满足内存分配的 需求时,将抛出OOM异常。
运行时常量池是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。运行时常量池相对于Class文件常量池的另外一个重要特征就是具备动态性。也就是说运行期间也可能将新的常量 放入池中,这种特性被利用的比较多的就是String类的intern()方法。

直接内存

直接内存并不是运行时数据区的一部分,也不是Java虚拟机定义的内存区域。本机直接内存的分配不受Java堆大小的限制,但是受本机总内存大小以及处理器寻址空间的限制。

内存溢出

堆内存溢出

Java堆用于存储对象实例,只要不断创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆容量限制后就会OOM。 (轻易不要运行)

public class HeapOOMTest {
    
    static class OOMObject {
        
    }
    
    public static void main(String [] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

Java堆内存的OOM异常是实际应用中最常见的内存溢出,当出现了咋办?一般的手段是先通过内存映像分析工具对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是确认是内存泄露还是内存溢出。如果是内存泄露,可进一步通过工具查看泄露对象到GCRoots的引用链,找到为什么垃圾收集器无法回收它们。如果不存在泄露,就是内存中的对象必须都存活,那就要检查虚拟机的堆内存是否可以调大,从代码上检查是否某些对象生命周期过长,减少内存消耗,优化代码。

虚拟机栈溢出

关于虚拟机栈,在java虚拟机规范中描述了两种异常:
(1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverFlowError异常。
(2)如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOdMemoryError异常。
这里描述的两种情况实际上有些重叠:当栈空间无法继续分配的时候,到底是内存太小导致的,还是已使用的栈空间太大,本质上是一样的。

public class StackSOFTest {

    private int stackLength = -1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String [] args) throws Throwable {
        StackSOFTest oom = new StackSOFTest();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:"+oom.stackLength);
            throw e;
        }
    }
}

运行结果:

stack length:13980
Exception in thread "main" java.lang.StackOverflowError
	at oom.StackSOFTest.stackLeak(StackSOFTest.java:14)
	at oom.StackSOFTest.stackLeak(StackSOFTest.java:14)
	at oom.StackSOFTest.stackLeak(StackSOFTest.java:14)
	...后续省略

实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,虚拟机抛出的都是StackOverflowError异常。

对象是“生”是“死”

对象的四种引用

引用分为强引用,软引用,弱引用和虚引用四种,这四种引用强度依次逐渐减弱。
1、强引用就是指在程序代码中普遍存在的,是指创建一个对象并把这个对象赋给一个引用变量,类似Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器就永远不会回收被引用的对象。如果想中断强引用和某个对象之间的关联,可以显示的将引用赋值为null,这样jvm在合适的时间就会回收该对象。
2、软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将会发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。SoftReference的特点是它的一个实例保存对一个Java对象的软引用,该软引用的存在不妨碍垃圾收集器线程对该Java对象的回收。

3、弱引用也是用来描述非必需对象的。当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。

4、虚引用和前面的软引用和弱引用不同,它并不影响对象的生命周期。在java中使用PhantomReference类来表示。如果一个对象与虚引用关联,跟没有引用与之关联一样,任何时候都可能被回收。 要注意的是,虚引用必须和引用队列关联使用。当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。为对象设置虚引用的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。

引用计数法的缺陷

引用计数法就是给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1.当引用失效时,计数器的值就减一。任何时刻计数器值为0的对象就是不可能再被使用的。
优缺点:实现简单,判断效率高,大部分情况下是个很不错的算法。但是致命问题是没办法解决对象之间相互循环引用的问题。

public class ReferenceCountingGC {

    private Object instance = null;

    private static final int _1MB = 1024*1024;

    private byte[] bigSize = new byte[2*_1MB];

    public static void main(String [] args) {

        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        //假设在这行发生GC,ObjA和ObjB是否能被回收
        System.gc();
    }
}

观察GC日志可以看出GC发生了内存回收,意味着虚拟机并没有因为这两个对象相互引用就不回收它们,这也从侧面说明虚拟机并没有采用引用计数法来判断对象是否存活。

可达性分析

这个算法的基本思想是通过一系列被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径叫做引用链,当一个对象到GC Roots没有任何引用链相连 (用图论的话来说就是从GC Roots到这个对象不可达),则证明此对象是不可用的。
在 Java语言中,可作为GC Roots的对象包括以下几种
(1)虚拟机栈(栈帧中的本地变量表)中引用的对象。
(2)方法区中类静态属性引用的对象。
(3)方法区中常量引用的对象。
(4)本地方法栈中JNI(即一般说的Native方法)引用的对象。

对象是生存还是死亡?

即使在可达性分析法中不可达的对象,也并非“非死不可”,他们还有拯救自己的机会。要宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后没有与GC Roots的引用链,那么它将会被第一次标记,并且此时需要判断是否有必要执行finalize()方法。没有必要的话,那么这个对象就宣告死亡,可以回收了。如果有必要执行,那么这个对象会被放置在一个叫做F-Queue的队列中,并在稍后由虚拟机自动建立的低优先级的Finalizer线程去执行它。finalize()是对象拯救自己的最后一次机会-只要重新与引用链上的 任何一个对象建立关联即可(譬如把自己赋值给某个类变量或者对象的成员变量),那么在第二次标记时它将被移除“可回收”的集合,如果对象还没有逃脱,基本上就真的被回收了。
具体的过程见下图:

垃圾收集算法

标记清除算法

标记-清除算法是最基础的算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收。
它的主要不足有两个:一是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清楚之后会产生大量不连续的内存碎片。
标记-清除算法的执行过程见下图:

复制算法

为了解决效率问题,“复制”算法出现了。它将内存空间划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上,然后再把已使用的的内存空间一次性清理掉。这样每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但是这种算法的代码是将内存空间缩小为原来的一半。
复制算法的执行过程见下图:

标记-整理算法

标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
标记-整理算法的执行过程见下图:

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”的算法,这种算法只是根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,那就选用“复制算法“,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用”标记-清理“算法或者”标记-整理“算法。

垃圾收集器

堆内存是垃圾收集器主要回收垃圾对象的地方,堆内存可以根据对象生命周期的不同分为新生代和老年代,分代收集,新生代使用复制算法,老年代使用标记清除或者标记整理算法。
HotSpot虚拟机提供了7中垃圾收集器,其中新生代三种:Serial/ParNew/Parallel Scavenge收集器,老年代三种:Serial Old/Parallel Old/CMS,都适用的是G1收集器。所有垃圾收集器组合 情况如下图:

Serial收集器

最基本也是发展历史最长的垃圾收集器,在进行垃圾收集时,必须Stop The World(暂停其他工作线程),直到收集结束。只使用一条线程完成垃圾收集,但是效率高,因为没有线程交互的开销,拥有更高的单线程收集效率。发生在新生代区域,使用复制算法。

ParNew收集器

Serial收集器的多线程版本。在进行垃圾收集时同样需要Stop The World(暂停其他工作线程),直到收集结束。使用多条线程进行垃圾收集(由于存在线程交互的开销,所以在单CPU的环境下,性能差于Serial收集器)。目前,只有Parnew收集器能与CMS收集器配合工作。发生在新生代区域,使用复制算法。

Parallel Scavenge收集器

ParNew收集器的升级版,具备ParNew收集器并发多线程收集的特点,以达到可控制吞吐量为目标。(吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间(运行用户代码时间+垃圾收集时间)的比值)。该垃圾收集器能根据当前系统运行情况,动态调整自身参数,从而达到最大吞吐量的目标。(该特性成为GC自适应的调节策略)。发生在新生代,使用复制算法。

Serial Old收集器

Serial 收集器应用在老年代的版本。并发、单线程、效率高。使用标记整理算法。

Parallel Old收集器

是Parallel Scavenge应用在老年代的版本,以达到可控制吞吐量、自适应调节和多线程收集为目标,使用标记整理算法。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。使用这类收集器的应用重视服务的响应速度,希望系统停顿时间最短,以带来更好的用户体验。
使用标记清除算法,一共四个步骤:初始标记、并发标记、重新标记和并发清除。详情见下表:

步骤作用特点
1、初始标记标记GC Roots能直接关联到的对象速度快,单线程:标记时用户线程必须停止
2、并发标记从GC Roots对象开始对堆中对象进行可达性分析,找出存活对象耗时长;并行:垃圾收集器和用户线程一起进行;单线程标记:只开一条线程进行标记
3、重新标记修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录速度慢:比初始标记时间长,比并发标记时间短
4、并发清除清除上述步骤标记的对象耗时长;并行:垃圾收集线程和用户线程一起进行;单线程:只开一条线程进行垃圾收集

下面说下CMS的优缺点:
优点:
(1)并行:用户线程和垃圾收集线程同时进行。
(2)单线程收集:只使用一条线程完成垃圾收集。
(3)垃圾收集停顿时间短:获取最短的回收停顿时间,即希望系统停顿的时间最短,提高响应速度。
缺点:
(1)总吞吐量会降低:因为该收集器对CPU资源非常敏感,在并发阶段不会导致停顿用户线程,但会因为占用部分线程(CPU资源)导致应用程序变慢,总吞吐量会降低。
(2)无法处理浮动垃圾:由于并发清理时用户线程还在运行,所以会有新的垃圾不断产生,只能等到下一次GC时再清理。(因为这一部分垃圾出现在标记过程之后,所以CMS 无法在当次GC中处理他们,因此CMS无法等到老年代填满再进行Full GC,CMS需要预留一部分空间)。
(3)垃圾收集后会产生大量的内存碎片:因为CMS收集器是使用标记-清除算法的。
下面一张图了解下CMS的工作过程:

G1收集器

G1收集器是最新最前沿的垃圾收集器。特点如下:
(1)并行:用户线程和垃圾收集线程同时进行。
(2)多线程:即使用多条垃圾收集线程进行垃圾回收。(并发和并行充分利用多CPU和多核环境的硬件优势来缩短垃圾收集的停顿时间)
(3)垃圾收集效率高:G1收集器是针对性对Java堆内存区域进行垃圾收集,而非每次都对整个区域进行收集。即G1除了将Java堆内存分为新生代和老年代之外,还会细分为许多个 大小相等的独立区域(Region),然后G1收集器会跟踪每个Region里的垃圾代价值大小,并在后台维护一个列表。每次回收时,会根据允许的垃圾收集时间优先回收价值最大的 Region,从而避免了对整个Java堆内存区域的回收,提高了效率。因为上述机制,G1收集器还能建立可预测的时间模型:即让使用者明确执行一个长度为M毫秒的时间片段,消耗在 垃圾收集上的时间不得超出N毫秒。即具备实时性。
(4)不会产生内存碎片。从整理上看,G1收集器是基于标记-整理算法的,从局部看是基于复制算法的。在新生代使用复制算法,在老年代使用标记-整理算法。

下面了解下工作流程,跟CMS有点像。

步骤作用特点
1、初始标记标记GC Roots能直接关联到的对象速度快,单线程:标记时用户线程必须停止
2、并发标记从GC Roots对象开始对堆中对象进行可达性分析,找出存活对象耗时长;并行:垃圾收集器和用户线程一起进行;单线程标记:只开一条线程进行标记
3、最终标记修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录(记录在对应Region的Remembered Set中)速度慢:比初始标记时间长,比并发标记时间短;多线程:多条GC线程一起工作,但用户线程需要停止
4、筛选回收对每个Region的垃圾价值进行排序,优先回收价值高的Region耗时长;多线程:多条GC线程一起工作,但用户线程需要停止;回收效率高

下面是G1的工作过程:

类加载过程

在java中编译并不进行链接工作,类型的加载、链接和初始化工作都是在jvm执行过程中进行的。在Java程序启动时,jvm通过加载指定的类,然后调用该类的main方法而启动。在JVM启动过程中, 外部class字节码文件会经过一系列过程转化为JVM中执行的数据,这一系列过程我们称为类加载过程。

类加载整体流程

从类被JVM加载到内存开始到卸载出内存为止,整个生命周期包括:加载、链接、初始化、使用和卸载五个过程。其中链接又包括验证、准备和解析三个过程。如下图所示:

类加载时机

java虚拟机规范通过对初始化阶段进行严格规定,来保证初始化的完成,而作为其之前的必须启动的过程,加载、验证、准备也需要在此之前开始。
Java虚拟机规定,以下五种情况必须对类进行初始化:
1、虚拟机在用户指定包含main方法的主类后启动时,必须先对主类进行初始化。
2、当使用new关键字对类进行实例化时、读取或者写入类的静态字段时、调用类的静态方法时,必须先触发对该类的实例化。
3、使用反射对类进行反射调用时,如果该类没有初始化先对其进行初始化。
4、初始化一个类,而该类的父类还未初始化,需要先对其父类进行初始化。
5、在JDK1.7之后的版本中使用动态语言支持,java.lang.invoke.MethodHandle实例解析的结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,而该句柄对应的类 还未初始化,必须先触发其实例化。

加载

在加载阶段,虚拟机需要完成三件事:
1、通过一个类的全限定名来获取此类的class字节码二进制流。
2.将这个字节码二进制流中的静态存储结构转化为方法区中的运行时数据结构。
3、在内存中生成一个代表该类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。
对于Class对象,Java虚拟机规范并没有规定要存储在堆中,HotSpot虚拟机将其存放在方法区中。

验证

验证作为链接的第一步,大致会完成四个阶段的检验:
1、文件格式验证:该阶段主要在字节流转化为方法区中的运行时数据时,负责检查字节流是否符合Class文件规范,保证其可以正确的被解析并存储在方法区中。后面的检查都是基于方法区的 存储结构进行检验,不会再直接操作字节流。
2、元数据验证:该阶段负责分析存储于方法区的结构是否符合Java语言规范。此阶段进行数据类型的校验,保证符合不存在非法的元数据信息。
3、字节码验证:元数据验证保证了字节码中的数据符合语言的规范,该阶段则负责分析数据流和控制流,确定方法体的合法性,保证被校验的方法在运行时不会危害虚拟机的运行。
4、符号引用验证:在解析阶段会将虚拟机中的符号引用转化为直接引用,该阶段则负责对各种符号引用进行匹配性校验,保证外部依赖真实存在,并且符合外部依赖类、字段、方法的访问性。

准备

准备阶段正式为类的字段变量(被static修饰的类变量)分配内存并设置初始值。这些变量存储在方法区中。当类字段为常量类型(即被static final修饰),由于字段的值已经确定,并不会在后面修改,此时会直接赋值为指定的值。

解析

解析阶段将常量池中的符号引用替换为直接引用。在字节码文件中,类、接口、字段、方法等类型都是由一组符号来表示。其形式由java虚拟机规范中的Class文件格式定义。在虚拟机执行 指定指令之前,需要将符号引用转化为目标的指针、相对偏移量或者句柄,这样可以通过此类直接引用在内存中定位调用的具体位置。

初始化

在类的class文件中。包含两个特殊的方法:clinit和init,这两方法由编译器自动生成,分别代表类构造器和构造函数,其中构造函数编程实现,初始化阶段就是负责调用类构造器,来初始化 变量和资源。
clinit方法由编译器自动收集类的赋值动作和静态语句块(static)中的语句合并生成的,有以下特点:
1、编译器收集顺序又代码顺序决定,静态语句块只能访问它之前定义的变量,在它之后定义的变量只能进行赋值不能访问。
2、虚拟机保证在子类的clinit方法执行前,父类的clinit已经执行完毕。
3、clinit不是必须的,如果一个类或接口没有变量赋值和静态代码块,则编译器可以不生成clinit。
4、虚拟机会保证clinit方法在多线程中被正确的加锁和同步。如果多个线程同时初始化一个类,那么只有一个线程执行clinit,其他线程会被阻塞。

双亲委派模型

类加载器

1、定义:实现类加载阶段的“通过一个里的全限定名来获取描述此类的二进制字节流”的动作的代码模块成为“类加载器”。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否“相等”,只有在这两个类是同一个类加载器加载的前提下才有意义。

2、类加载器种类
从Java虚拟机的角度只有两种类加载器:
(1)启动类加载器(BootStrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分。
(2)另一种就是所有其他类的加载器,这些类加载器都是由Java语言实现,独立于虚拟机外部,并且都继承自抽象类java.lang.ClassLoader。
从Java开发人员的角度,类加载器还可分为3种系统提供的类加载器和用户自定义的类加载器。
(1)启动类加载器(BootStrap ClassLoader):负责加载存放java_home\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的类。
(2)扩展类加载器(Extension ClassLoader):这个加载器sun.misc.LauncherExtClassLoader实现,它负责加载javahome\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。(3)应用程序类加载器(ApplicationClassLoader):这个类加载器由sun.misc.LauncherExtClassLoader实现,它负责加载java_home\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 (3)应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.LauncherAppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义的类加载器,一般情况下 这个就是程序中默认的类加载器。
(4)自定义类加载器(User ClassLoader):用户自定义的类加载器。用户在编写自己定义的类加载器时,如果需要把请求委派给引导类加载器,那直接使用numm代替即可。要创建用户自己 的类加载器,只需要继承java.lang.ClassLoader,然后覆盖它的findClass(String name)方法即可。如果要符合双亲委派模型,则重写findClass()方法。如果要破坏的话,则重写 loadClass()方法。

双亲委派模型

上图展示的类加载器之间的这种层次关系称为类加载器的双亲委派模型。
1、双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当有自己的父类加载器。
2、类加载器的双亲委派模型在jdk1.2被引入,但它不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器的实现方式。

双亲委派模型的工作过程如下:
1、如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
2、每一层的类加载器都重复第一步,因此所有的类加载请求最终都传送到了顶层的类加载器中。
3、只有父类加载器返回自己无法完成这个加载请求,子加载器才会尝试自己去加载。

对象的创建、存储和访问

对象的创建

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

2、分配内存:对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。但是不同垃圾回收器的算法会导致堆内存存在两种情况:绝对规整和相互交错。(比如标记清楚算法和标记整理算法)
(1)指针碰撞:假设Java堆内存是绝对规整的,所有用过的内存都存放在一起,空闲的内存存放在另一边,中间放着一个指示器作为分界点的指示器,所分配的内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式成为”指针碰撞“。
(2)空闲列表:如果是相互交错的,那么虚拟机会维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划给对象实例,并更新列表上的记录。这种分配方式成为”空闲列表“。

3、分配内存的并发问题:即使是仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的 指针来分配内存的情况。针对这个问题有两种解决方案:
(1)失败重试:对分配内存空间的动作进行同步处理,虚拟机采用CAS和失败重试机制保证更新操作的原子性。
(2)本地线程分配缓存:哪个线程要分配内存,就在哪个线程的TLAB(Thread Local Allocation Buffer)上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

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

5、对象设置:接下来虚拟机会对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希吗、对象的GC分代年龄等信息。这些信息存放在对象头中。至此一个新的对象产生了。

6、实例构造器的init方法:虽然对象产生了,但是init方法并没有执行,所欲字段还需要赋值(包括成员变量赋值,普通语句块执行,构造函数执行等。)

Clinit和init

Clinit

类构造器的方法,与类的初始化有关。例如静态变量(类变量)和静态对象赋值,静态语句块的执行。如果一个类中没有静态语句块,也没有静态变量或静态对象的赋值, 那么编译器可以不为这个类生成方法。

init

实例构造器(即成员变量,成员对象等),例如成员变量和成员对象的赋值,普通语句块的执行,构造函数的执行。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为三个区域:对象头、实例数据和对齐填充。

对象头

对象头包括两部分信息:运行时数据和类型指针。

运行时数据

第一部分用于存储对象自身的运行时数据,如哈希吗(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

存储内容标志位状态
对象哈希吗、分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10膨胀(重量级锁定)
空(不需要记录信息)11GC标记
偏向线程ID、偏向时间戳、对象分代年龄01可偏向

下面是HotSpot虚拟机对象头Mark Word:

类型指针

对象头的另一部分是类型指针,即对象指向他元数据的指针,虚拟机可以通过这个指针确定这个对象是哪个类的实例。但是如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数据长度的数据。

对象的实例数据

接着数据头的是对象的实例数据,这部分是真正存储的有效信息。无论是从父类中继承下来的还是在子类中定义的,都需要记录下来。

对齐填充

最后一部分对齐填充并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是 对象的大小必须是8字节的整数倍。而对象头部分是8字节的倍数,当实例数据没有对齐的时候,需要对齐填充凑够8字节的整数倍。

对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的引用数据来操作堆上的具体对象。对象的访问方式取决于虚拟机的实现,目前主流的访问方式有使用句柄和直接指针两种。
句柄引用和直接引用不同在于:使用句柄引用的话,那么Java对堆中将会划分出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,但是直接引用引用中存储的直接就是对象地址。 Java使用的是直接指针访问对象的方式,因为它最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项 非常可观的执行成本。

下面是通过直接指针访问对象

总结

本文讲解了运行时数据区域,内存溢出,如何判断对象是否存活,垃圾回收算法和垃圾收集器,类加载机制和双亲委派模型以及对象的创建存储和访问几个方面,涵盖jvm的核心考点,希望你有所收获。