深入理解java虚拟机读书笔记

2.Java内存区域与内存溢出异常

2.2运行时数据区域

深入理解java虚拟机读书笔记

2.2.1程序计数器

  • 一小块内存空间,相当于字节码行号指示器,字节码解释器通过这个计数器来执行下一步需要执行字节码指令
  • jvm多线程是通过线程轮流切换分配处理器时间的,每个线程都有独立的计数器,独立存储,互不影响,这类内存区域称为“线程私有”内存
  • 如果线程执行的是native方法,则计数器为空。

2.2.2Java虚拟机栈

  • 同程序计数器一样,虚拟机栈也是线程私有,且生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,每个方法执行时都会创建栈帧,用于存储局部变量,操作数栈,动态链接,方法出口等。每一个方法从调用至执行完成的过程,就是一个栈帧从入栈到出栈的过程。
  • 如果线程请求的栈深度大于虚拟机说允许的深度,将抛出栈溢出异常;如果虚拟机栈可动态扩展,但无法申请到足够内存时,会抛出内存不足异常。

2.2.3本地方法栈

本地方法栈和虚拟机栈作用类似,只不过本地方法栈为虚拟机使用到的native方法服务,同虚拟机栈一样,本地方法栈也会抛出Stack Overflow ErrorOut of Memory Error异常

2.2.4Java堆

堆是被所有线程共享的内存区域,在虚拟机启动时自动创建,此区域的唯一目的就是存放对象实例。几乎所有对象实例都在这分配内存,但随着JIT编译器发展,对象分配在堆也不是那么绝对了。

Java堆是垃圾收集器管理的主要区域。

从内存回收角度看,由于现在收集器基本都采用分代收集算法,所有堆中还可以细分为新生代,老年代;再细致一点有Eden区,Form Survivor区,To Survivor区。

从内存分配角度看,线程共享的堆中可能划分出多个线程私有的分配缓冲区,不管如何划分都与存放内容无关。进一步划分只是为了更好的回收,分配内存。

Java虚拟机规范规定,堆可以处于不连续的物理内存中,只要逻辑上连续即可。

2.2.5方法区

方法区同堆一样,线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量和静态变量,即时编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名Non-Heap

除了和堆一样不要连续的内存空间和可固定或扩展大小外,还可以选择不实现垃圾回收。

2.2.6运行常量池

运行常量池是方法区的一部分,编译期生成的字面常量和符号引用,在类加载后进入运行常量池。

Java虚拟机堆Class文件的每一部分的格式都有严格规定,每一个字节用于存储哪种数据必须符合规范上的要求才会被虚拟机认可,装载和执行,对于运行时常量池,虚拟机没有细节要求,不同提供商实现的虚拟机可以按照的自己的需求来实现这个内存区域。不过一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用存储在运行时常量池中。

Java不要求常量只有在编译期才能产生,运行期间也可以将新的常量放入运行常量池中,如Stringintern()方法获取的常量可能为运行时放入常量池的。

2.2.7直接内存

深入理解java虚拟机读书笔记

深入理解java虚拟机读书笔记

直接内存不是虚拟机运行时的内存区域。Java1.4之后引入了一种基于通道和缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,通过一个在堆中的DirectBuffer对象作为这块内存的引用,进行操作。服务器管理员会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,从而导致内存不够异常。

2.3 HotSpot虚拟机

2.3.1对象创建

语言层面上new关键字

虚拟机:定位该类的符号引用;检查这个符号引用的类是否已被加载,解析和初始化过;如果没有先执行类加载。

类加载通过后,为新对象分配内存(所需的大小在加载完类可确定);假设堆中的内存时绝对规整的,所有用过的内存在一边,没有的在另一边,中间放着一个指针作为分界,那分配一块内存仅需要移动一段对象大小的距离,这种分配方式称为指针碰撞。如果堆内存并不是规整的,虚拟机就需要维护一个列表,记录哪些内存空闲,使用状态,并分配大小足够的内存块给新对象,这种方式称为空闲列表。选择分配方式由Java堆是否规整决定,Java堆是否规整由垃圾收集器是否有压缩整理功能决定。

对象创建是很频繁的,对于指针碰撞,可以通过限制同步来确保内存分配的原子性。对于空闲列表,让每个线程在堆中都留一块本地线程分配缓存(TLAB),当某个线程需要分配内存时先分配缓冲区,缓冲区满了之后才需要同步锁定。虚拟机是否需要TLAB,可通过-XX-UseTLAB参数设置。

内存分配完成,虚拟机需要将分配到的内存初始化零 (不包括对象头),如果使用TLAB这一工作也可提前至TLAB分配时,这一步保证对象实例在没有初值时可以使用,为零。

接下来对对象的必要信息进行设置(所属类的信息,如何找到对象元数据信息,对象哈希码,对象GC分代信息),这些在对象头设置,

2.3.2对象的内存布局

对象在内存中存储布局:对象头,实例数据,对其填充。

  • 对象头:包含两部分信息,第一部分为运行时数据(哈希码,GC分代年龄,锁状态,线程持有的锁等),这部分数据在32位和64位虚拟机中的长度为32 bit和64 bit,官方称为MarkWord。但运行是的数据早已超过了MarkWord所能容下的空间。考虑虚拟机空间效率,MarkWord被设计成非固定的存储空间。例如对象未被锁定的状态下32bit就不存锁状态。而其他状态下图
    深入理解java虚拟机读书笔记

    另外一部分是类型指针,虚拟机通过这个指针确定对象是哪个类的实例。并不是所有虚拟机都通过这个类型指针查找类元数据。另外如果对象是Java数组,对象头中还需要一个记录数组长度的位置,因为数组无法像普通的Java对象那样通过元数据信息确定大小。

  • 实例数据:包括定义的变量字段,从父类继承的,子类定义的

  • 对其填充:这部分不是必然存在,它仅仅起着占位符的作用,由于HostSpot VM自动内存管理系统要求对象的起始地址必须是8字节的整数倍,即对象大小必须是8字节的整数倍,但对象不够时补全。

2.3.3 对象的访问定位

  • 句柄:Java堆中会划分出一块内存作为句柄池,引用存储的就是对象的句柄地址,而句柄中包含了与实例对象相关的数据信息。
    深入理解java虚拟机读书笔记

  • 指针直接访问
    深入理解java虚拟机读书笔记

两种方式各有优势,句柄优势在稳定的对象地址,在对象被移动(GC)时只会改变句柄中存储的实例数据指针。直接指针的优势在于快

2.4内存不足异常

2.4.1Java堆溢出

对象创建时保证GC Roots与对象之间有可达路径,不停创建对象,就会发生OutOfMemoryError异常

2.4.2 虚拟机栈和本地方法栈溢出

线程请求栈深度过大(Stack Overflow),虚拟机栈无法申请到内存空间(Out Of Memory

2.4.3 方法区和运行常量池溢出

String intern()方法,查找常量池中是否含有值相同的字符串,若有返回其引用。

3.垃圾收集器与分配策略

3.2对象已死吗

3.2.1引用计数

给对象添加一个引用计数器,有指针引用它,计数加一,引用失效时,减一。当为零时,这个对象就不可能再被使用,需要回收。但这种方法在循环引用的情况下失效。

3.2.2可达性分析法

  • 通过一些列称为GC Root的对象作为起点,从这些节点向下搜索,经过的路径称为引用链。当对象到gc root之间没有引用链时,这个对象就判定为可回收对象。这样即使出现循环引用,只要与gc root没有引用链,就会判断为回收对象。
  • 可作为gc root的对象:栈帧中的变量引用的对象;方法区中静态属性引用的对象;方法区中常量引用的对象;本地方法栈中JNI(即一般说的native方法)引用的对象
  • 可达分析算法中不可达的对象也不是立即被回收了,而是进行一次筛选是否有必要执行finalize方法,若对象的finalize()方法被执行过或没有覆盖finalize方法,就判断没有必要执行,有必要执行的对象会进入一个F-Queue的队列中,进行并稍后有一个虚拟机建立的优先级低的Finalizer线程去执行它,但并不会一直等待它,避免阻塞。finalize方法是对象避免死亡的最后一次机会(finalize方法中添加引用)。但不推荐这么做

3.2.3引用

  • 强引用,即Object o=new Object();这类引用,只要引用还在,垃圾收集器永远不会回收
  • 软引用,描述一些有用但非必须的对象,可用SoftReference类实现。但系统内存溢出异常之前,垃圾收集器会把这些对象进行回收,如果回收之后还没有足够的内存,才内存溢出异常。
  • 弱引用,也描述非必须对象,可用WeakReference类实现。这类引用只能存活到下一次内存回收之前,下次垃圾收集无论内存是否足够,都会被回收。
  • 虚引用,对象是否有虚引用与其生存时间没有关系,也不能通过虚引用获得对象。它存在的目的只是能在垃圾回收该对象时收到一个通知,可用PhantomReference类实现

3.2.4回收方法区

​ 虚拟机规范中不要求虚拟机在方法区(HotSpot虚拟机中的永久代)实现垃圾收集,而且在方法区收集垃圾效率极低,相较于新生代中一次回收70%到-90%的空间。

​ 永久代垃圾主要回收两部分,废弃常量,无用的类。废弃常量即没有对象引用这个常量,在内存回收时就会清理这个常量。无用类需要满足:该类所有对象都被回收(即堆中没有类的实例);加载该类的ClassLoader已被回收;该类的java.lang.Class 对象没有被引用(没有被反射调用)。

但是满足这些条件的常量,类只能说是可以回收,不是必须回收,虚拟机提供了更详细的参数类加载卸载的设置。

3.3垃圾收集算法

3.3.1标记清除法

首先标记所有要清除的对象,在标记完成后统一回收所有被标记的对象

缺陷:效率不高,产生大量不连续内存碎片

3.3.2复制算法

它把每块内存划分为大小相等的两块,每次只用其中的一半,当对象需要被回收时,把还存活的对象复制到另一半,再把使用过的内存空间一次清理掉。这样避免了内存碎片的问题

缺陷:只使用一半内存,代价过高

3.3.3标记整理法

应用于老年代,让所有存活的对象向一端移动,然后哦直接清理掉端边界以外的内存。

3.3.4分代收集法

结合上面几种方法,新生代采用复制,老年代采用标记清除或者标记整理

3.4HotSpot算法实现

OopMap:存储对象的某些信息,方便完成GC root枚举

3.5垃圾收集器

3.5.1Serial收集器

它是最早的收集器,是一个单线程收集器,仅使用一个cpu或一个线程去完成收集工作,且它在收集垃圾时必须暂停其他所有工作的线程,直到收集结束。即使它缺点不少,但目前仍是虚拟机运行在Client模式下的默认新生代收集器,因为它相较于其他收集器的单线程简单而高效。

3.5.2ParNew收集器

Serial的多线程版本,只有它和Serial能和CMS收集器一同工作,常作为运行在Server模式下的虚拟机首选的新生代收集器。

3.5.3Parallel Scavenge收集器

新生代,使用复制算法,并行的多线程收集器。

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间),该收集器控制适合的吞吐量

3.5.4Serial Old

Serial的老年代版本,使用标记整理算法

3.5.5CMS(ConcurrentMarkSweep)收集器

它是以最短回收停顿为目标的收集器,使用的时标记清除法。

3.5.6G1(GarbageFirs)收集器

面向服务端应用收集器

特点:并行(这里指多个收集器线程同时运行,用户线程还需等待)与并发(用户线程与收集器线程同时执行,可能交替运行),分代收集,空间整合,可预测的停顿。

3.6内存分配与回收策略

3.6.1对象首先在Eden区分配

大多数情况下,对象在新生代分配,新生代空间不够时,虚拟机启动一次GC

Tips

新生代GC(MinerGC)新生代垃圾回收,较频繁,速度也比较快

老年代GC(FullGC)老年代收集,一半新生代收集会伴随一次老年代收集,一半比新生代GC慢十倍以上

3.6.2大对象直接进入老年代

大对象指需要连续分配大量存储空间的对象,如长字符串或者数组,应尽量避免出现大对象,因为容易导致内存空间不够而提前触发垃圾回收。

3.6.3长期存活的对象进入老年代

为了区分对象该放入哪个分代,虚拟机给每个对象定义了一个对象年龄(age)计数器。如果Eden区一个对象经历一次GC后仍存活,且到了Survivor区,age加一;当对象年龄到一定程度(默认为15)时,就会晋升到老年代中。对于晋升老年代的年龄阈值,可以通过-XX:MaxTenuringThreshold设置

3.6.4动态对象的年龄绑定

虚拟机并不是永远的要求对象年龄达到阈值时才晋升老年代,如果survivor区中的相同年龄对象总和已大于了区域的一半,那么大于等于该年龄的对象会自动进入老年代。

3.6.5空间分配担保

MinerGC之前,虚拟机会检查一次老年代最大连续可用空间是否大于新生代对象总空间,如果这个条件成立,那么MinerGC时安全的,如果不成立,则会检查HandlePromotionFailure值是否允许担保失败,如果允许,会继续检查老年代的最大连续可用空间是否大于以往晋升到老年代对象的平均大小,如果大于,可进行一次MinerGC,如果小于,或者HandlePromotionFailure值不允许,则进行一次FullGC。

因为新生代使用复制收集算法,只能使用一个survivor区用来轮换备份,当出现大量对象在内存回收后都存活的情况时(极端情况下所有都存活),就需要老年代做担保,把survivor区无法容纳的对象直接进入老年代,但前提老年代有足够的空间去存放对象,一共有多少对象存活下来在内存回收之前是不知道的,所以与以往的大小均值作为比较。为了避免频繁FullGC,一般HandlePromotionFailure设置允许。

7.虚拟机类加载机制

7.1概述

虚拟机把描述类的数据从Class文件中加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机执行的Java类型。这就是虚拟机类加载机制。

Java中类的加载,连接和初始化都是在运行期间完成的,虽然会增加一些性能开销,但为程序提供了高度的灵活性,Java动态扩展的语言特性就是依靠这个特点实现。例如面向接口的应用程序会在运行时指定实际类。用户可以通过Java预定义和自定义的类加载器,在应用程序运行时加载网络上或者其他地方的二进制流作为程序代码的一部分执行。

Tips:
Class文件并非特指磁盘上的文件,而是一串二进制字节流,无论何种形式都可以;实际情况中每个Class文件都有可能是接口或者类,以下提及到“类”的描述中都包含了接口和类的可能性,需要区分的会单独指出;

7.2类加载时机

类从加载到虚拟机开始至卸载出内存的生命周期:加载,验证,准备,解析,初始化,使用,卸载。

深入理解java虚拟机读书笔记

加载,验证准备初始化这五个阶段顺序是固定的。解析阶段有可能再初始化之后开始,为了支持运行时动态绑定。

加载的时间没有规范约束,交给虚拟机的具体实现*把握。对于初始化虚拟机规定了五种情况必须初始化。

1.遇到new,getstatic,putstatic或invokestatic这四条字节码指令时,如果类没有初始化,则需先触发其初始化,这四种情况的场景:new实例化对象,读取或设置类的静态字段(final修饰,在编译期放入常量池的静态字段除外),调用类的静态方法时。

2.使用java.lang.reflect包的方法对类进行反射调用时,若类没有初始化需要先初始化

3.初始化一个类发现该类的父类没有初始化,需要先初始化其父类

4.虚拟机启动时,回先初始化main方法所在的类

5.java.lang.invoke.MethodHandle实例最后解析的结果REF_getStatic…四个句柄,且这个方法句柄所对应的类没有初始化,需要先初始化。

虚拟机称这五种场景为主动引用,其他情况均为被动引用,不会涉及初始化。

子类通过继承获得的静态字段在调用时不会初始化子类,只会初始化其父类。

通过数组定义来引用类,不会触发类初始化。

深入理解java虚拟机读书笔记

类中的常量会在编译时放入常量池,ConstClass.HELLOWORLD这种引用会转化成NotInitialization类对自身常量池的引用了,所以不会涉及ConstClass类初始化。

接口与类有所区别的地方在于初始化时并不要求其父接口全部初始化,只有在真正用到父接口(如引用接口中定义的常量)才会初始化。

7.3类加载过程

7.3.1加载

  • 获取加载类的二进制字节流

  • 将字节流代表的静态存储转为方法区运行时的数据结构

  • 在内存中生成一个代表该类的Class对象,作为方法区这个类的各种数据访问入口

对于非数组类的加载可控,即自定义类加载器。对于数组类,不通过类加载器创建,而是有直接创建。

对于数组类的元素类型,若是引用类型就走上面类加载的流程,若不是引用类型(如int[]数组),虚拟机会标记数组类标记为与引导加载器关联。

7.3.2验证

验证的目的是确保Class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。

因为Class文件并不一定要求由java源码编译而来,可以从网络,甚至16进制编辑器直接产生Class文件。虚拟机如果不检查输入的字节流,可能会因为载入有害字节流导致系统崩溃。

验证大概分四个校验动作

  • 文件格式验证
    是否符合Class文件规范
  • 元数据验证
    语义是否符合java语言规范
  • 字节码验证
    语义是否合法,符合逻辑
  • 符号引用验证
    类自身外的(常量池各种符号引用)信息匹配性校验,确保解析动作正常执行

7.3.3准备

正式为类变量(static修饰的)分配内存(在方法区)并设置初始值。这里的初始值“通常情况”是零值,假设一个变量定义为public static int value=123;,经过准备阶段后的初始值是0,因为这个时候未执行任何java方法,value赋值为123的方法存放于类构造器()方法中。
深入理解java虚拟机读书笔记

上述提到的通常情况是零值,特殊情况:public static final int value=123;编译时javac会将value生成ConstantValue属性,准备阶段时,虚拟机会根据ConstantValue的设置为变量赋值。

7.3.4解析

将常量池中的符号引用(Class文件中以CONSTANT_Class_Info,CONSTANT_Fieldref_Info,CONSTANT_Methodref_Info等类型常量出现)转换为直接引用的过程。

  • 符号引用:能无歧义定义到目标的一组符号。目标不一定已加载到内存中
  • 直接引用:可以是一个指向目标地址的指针,相对偏移量或者能间接定位到目标的句柄。同一个符号引用在不同虚拟机上翻译出来的直接引用一般不相同。如果有了直接引用,目标对象必然在内存中已存在

7.3.5初始化

初始化为类加载阶段的最后一步,在前面的过程中,除了加载阶段用户应用程序可以通过自定义类加载器参与之外,其余过程全有虚拟机主导控制。初始化阶段才真正开始执行类定义的java程序代码。

初始化阶段是执行类构造器()方法的过程。

  • <clinit>()方法是有编译器自动收集类所有变量的赋值动作和static块中的语句合并产生的,编译器收集的顺序是有源文件代码的顺序决定的,所以静态语句块只能访问到定义在static块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
  • <clinit>()不同于<init>()(即实例构造器),它不需要显示调用父类构造器,虚拟机会保证在子类<clinit>()执行之前,父类的已经之前完。
  • 由于父类<clinit>()先执行,也意味着父类静态语句块要先于子类变量的赋值操作。
  • <clinit>()对于类和接口不是必须的,如果一个类中没有static块,也没有静态变量赋值操作,虚拟机可以不生成该类的<clinit>()方法
  • 接口中没有static块,但仍有变量的初始化赋值操作,因此接口也执行<clinit>()方法。不同于类的是,接口的<clinit>()执行不要求父接口的执行,只有当使用父接口变量时才执行。接口的实现类初始化时也不执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁,同步。栗子:若多个线程同时去初始化一个类,那么只会有一个线程执行类的<clinit>()方法,其他线程都会阻塞并等待,直到活动线程执行完毕。但活动线程退出<clinit>()方法后,其他线程不会再进入该类的<clinit>()方法。

7.4类加载器

“通过类全路径名获取类二进制字节流”这个动作在虚拟机外部实现,便于应用程序自己决定获取如何获取所需要的类,这个动作的代码模块称为类加载器。

7.4.1类与类加载器

每一个类加载器都有单独的类名称空间:比较两个类相等(包括equals(),isAssignableFrom(),isInstance()方法及instaceof关键字判定),只有在同一个类加载器加载的前提下才有意义,否则即使两个类来源于同一个Class文件,被不同的类加载器加载也不相等。

7.4.2双亲委派模型

从虚拟机(HotSpot)的角度讲,只有两种不同的类加载器,一种为启动类加载器(c++实现,虚拟机的一部分)另一种为其他类加载器,这些都有java实现,独立于虚拟机外部,且全继承自java.lang.ClassLoader

  • 启动类加载器
    负责把<JAVA_HOME>/lib目录中,或者-Xbootclasspath参数指定目录中的类库加载到虚拟机中。不可被用户应用程序直接引用。
  • 扩展类加载器
    加载<JAVA_HOME>/lib/ext目录中,或者被java.ext.dirs系统变量所指定目录中的所有类库,开发者可直接使用扩展类加载器。
  • 应用程序类加载器
    加载用户ClassPath所制定的类库,开发者可直接使用这个加载器,如果应用程序没有自定义自己的类加载器,一般情况应用程序就是默认类加载器。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zu8k9JLu-1588297869428)(Java虚拟机.assets/image-20200430211627495.png)]

我们的应用程序都是有上面三个类加载器相互配合进行加载的,也可自己定义类加载器。双亲委派模型要求处理启动类加载器,其余类加载器都应当有自己父类加载器,这里类加载器之间不以继承关系实现,而用组合实现父类加载器的复用。

双亲委派模型的过程

如果一个类加载器收到了类加载请求,他首先不会加载这个类,他会传给自己的父类加载器,每一层的类加载器都如此,直到传到启动类加载器,但启动类加载器不能完成这个类加载请求时,子类加载器才会加载。

双亲委派的目的是确保应用程序正确执行,如果用户自己定义了一个Object类,如果没有双亲委派,则会出现很多不同的Object类,java类型体系中最基础的行为也就无法保证,应用程序一片混乱。