自己整理的一些《深入理解Java虚拟机》的知识点

寒假阅读了《深入理解Java虚拟机(第三版)》这本书,主要阅读了该书的前七章。整理了一些JVM的知识点。

JVM内存分区

JVM运行时内存

  • 程序计数器:当前线程所执行的字节码的行号指示器,是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是本地方法(native),这个计数器值应为空。它也是唯一一个没有OutOfMemoryError情况的区域。

  • 虚拟机栈:它的生命周期和线程相同,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。

  • 本地方法栈:本地方法栈是为虚拟机使用到的本地(Native) 方法服务。和虚拟机栈一样,会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

  • 堆:Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,其唯一目的就是存放对象实例。是垃圾收集器管理的内存区域。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

  • 方法区:和堆一样是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  • 运行时常量池:是方法区的一部分。用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

对象的内存布局

在HotSpot虚拟机中,对象在堆内内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
Mark Word
对象头
对象头包含两类信息:

第一类用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的所、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。被称为“Mark Word”

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针。

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用

对象的访问定位

主流的访问方式主要有使用句柄和直接指针两种。

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

  • 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

总结:使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference本身不需要被修改。使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销。

判断对象是否存活的算法

  • 引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可 能再被使用的。单纯的引用计数很难解决对象之间相互循环引用的问题。

  • 可达性分析算法:通过一系列的GC Roots的根对象作为起始节点,从这些节点开始,根据引用关系向下搜索,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再被使用的。

可固定作为GC Roots的对象包括:

  • 在虚拟机栈中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的变量,比如Java类的引用类型静态变量。

  • 在方法区中常量引用的对象,比如字符串常量池里的引用。

  • 在本地方法栈中JNI(通常所说的Native方法)引用的对象。

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如:NullPointException、OutOfMemoryError)等,还有系统类加载器。

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

引用

  • 强引用:在程序代码中普遍存在的引用赋值,类似Object obj = new Object();这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用:描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围之中进行第二次回收。JDK1.2之后提供了SoftReference类来实现软引用。
  • 弱引用:和软引用类似,强度更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生位置。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉被弱引用关联的对象。相关类:WeakReference。
  • 虚引用:最弱的引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用关联的唯一目的只是为了能在这个对象被垃圾收集器回收时受到一个系统通知,相关类:PhantomReference。

被标记为GC的对象不一定会被GC掉

真正宣告一个对象死亡至少经历两次标记的过程。如果对象进行可达性分析后没有与GC Roots相连,那么这是第一次标记,之后会在进行一次筛选,筛选的条件是是否有必要执行finalize()方法。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。

垃圾收集算法

垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类。主流JVM均为涉及引用计数式垃圾收集算法。

  • 标记-清除算法:标记出所有需要回收的对象,统一回收掉所有被标记的对象。主要缺点有两个:第一个是执行效率不稳定,标记和清除过程执行效率随对象数量增长而降低;第二个时内存空间碎片化问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后再程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
  • 标记-复制算法:将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还活着的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效。缺点是将可用的内存缩小为了原来的一半。
  • 标记-整理算法:首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。对象移动操作必须全程暂停用户应用程序才能进行

新生代布局

一般而言,新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor(From Survivor)。发生垃圾收集后,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间(To Survivor)上,然后直接清理掉Eden和已用过的那块Survivor空间,“From”和“To”会交换他们的角色。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。

经典垃圾收集器

经典垃圾收集器
新生代有Serial、ParNew、Parallel Scavenge;老年代有CMS、Serial Old、Parallel Old;还有不区分年代的G1。

  • Serial收集器:单线程、使用复制算法,垃圾回收时必须暂停所有的工作线程,适用于单CPU,它是所有收集器里额外内存消耗(Memory Footprint)最小的。

  • ParNew收集器:实质上是Serial收集器的多线程并行版本,除了Serial收集器外,只有它能和CMS收集器配合工作。

  • Parallel Scavenge收集器:标记-复制算法,特点是它的目标是达到一个可控制的吞吐量。吞吐量是处理器用于运行用户代码的时间与处理器总消耗时间的比值。

  • Serial Old收集器:是Serial收集器的老年代版本,单线程,使用标记-整理算法。

  • Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,标记-整理算法,注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器组合。

CMS(Concurrent Mark Sweep)收集器:是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法实现。整个运作过程包括四个步骤:

  1. 初始标记(CMS initial mark):初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要STW
  2. 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  3. 重新标记(CMS remark):修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长,但比并发标记阶段时间短。需要STW
  4. 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断已经死亡的对象,由于不需要移动对象,所以这个阶段可以与用户线程同时并发。

优点:并发收集、低停顿。

缺点:

  1. CMS收集器对处理器资源非常敏感。并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量。
  2. CMS收集器无法处理“浮动垃圾”,有可能会出现“并发失败”(Concurrent Mode Failure)进到导致有STW的Full GC产生。在CMS的并发标记和并发清理阶段,用户线程继续运行,伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。
  3. 由于标记-清除算法,收集结束时会有大量空间碎片产生,可能也会触发Full GC。

G1收集器

它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1把连续的Java堆划分为多个大小相等的独立区域(Region)。G1垃圾回收器抛弃了分代的概念,将堆内存划分为大小固定的几个独立区域,并维护一个优先级列表,在垃圾回收过程中根据系统允许的最长垃圾回收时间,优先回收垃圾最多的区域。G1收集器的运作过程大致可划分为四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,修改TAMS指针的值。这个阶段需要停顿线程,但耗时很短。

  • 并发标记(Concurrent Marking):从GC Roots开始对堆中对象进行可达性分析,找出要回收的对象。这个阶段耗时较长,但可与用户程序并发执行。

  • 最终标记(Final Marking):对用户线程做一个短暂的暂停。

  • 筛选回收(Live Data Counting and Evacuation):必须暂停用户线程,由多条收集器线程并行 完成的。

G1从整体上看是基于标记-整理算法实现的收集器,但从局部上(两个Region)看是基于标记-复制算法实现的,意味着G1运作期间不会产生内存空间碎片。并且可以精确的控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。

Jdk默认使用的垃圾收集器

Jdk1.7默认为Parallel Scavenge+Parallel Old

Jdk1.8默认为Parallel Scavenge+Parallel Old

Jdk1.9默认为G1

内存分配策略

对象优先在Eden分配,如果说Eden内存空间不足,就会发生Minor GC/Young GC。

大对象直接进入老年代,大对象:需要大量连续内存空间的Java对象,比如很长的字符串和大型数组,1、导致内存有空间,还是需要提前进行垃圾回收获取连续空间来放他们,2、避免Eden区以及两个Survivor区之间来回复制,产生大量的内存复制操作。

HotSpot虚拟机供了-XX:PretenureSizeThreshold参数,大于这个数量直接在老年代分配,缺省为0 ,表示绝不会直接分配在老年代。

长期存活的对象将进入老年代,默认15岁,-XX:MaxTenuringThreshold调整

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

空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保,如果另外一块 Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象直接进入老年代。只要老年代的连续空间大于新生代对象的总大小或者历次晋升的平均大小,就进行Minor GC,否则Full GC。

类编译加载过程

类加载过程即加载、验证、准备、解析和初始化这五个阶段。

在类加载之前需要先将编写好的.java文件编译成.class文件,才能在虚拟机上正常运行代码。文件编译主要通过javac命令生成.class文件。(可以通过javap命令反编译.class文件)

类加载

  • 加载

在加载阶段,Java虚拟机需要完成三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存(堆区)中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 连接

类加载之后,会进行连接,初始化最后才会被使用。连接又包括验证、准备、解析三个部分。

  1. 验证:验证类符合Java和JVM规范,避免危害虚拟机的安全

  2. 准备:为类的静态变量(被static修饰的变量)分配内存设置初始值。例如:public static int value = 123;,变量value在准备阶段初始值为0而不是123。而public static final int value = 123;,变量会被初始化为123。

  3. 解析:JVM将常量池中的符号引用替换为直接引用(符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量)

  • 初始化:执行类构造器<clinit>()方法为类进行初始化。

类加载器

JVM提供了三种类加载器,分别启动类加载器(Bootstrap ClassLoader)、扩展类加

载器(Extention ClassLoader)和应用类加载器(Application ClassLoader)。应用类加载器也称为系统类加载器,程序中默认的加载器。
类加载器

双亲委派模型

双亲委派模型
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载,直到被加载成功,若最后一个子类也不能加载,就抛出ClassNotFoundException异常。

实现代码:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

如何打破双亲委派模型:继承ClassLoader类,重写loadClass方法。

本文参考自:

《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》
《阿里巴巴java性能调优实战(2021华山版)》

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值