jvm八股

文章目录

运行时数据区域

  • 程序计数器(线程私有):唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(U ndefined)
  • Java虚拟机栈(线程私有) :线程私有的,栈描述的是Java方法执行的线程内存模型。每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。无论是正常return或是抛出异常都会导致方法结束,栈帧弹出。如果栈不可扩展,最终会StackOverFlowError,如果栈可扩展,则会OOM
    • 局部变量表:存储局部变量,编译时确定大小
    • 操作数栈:用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
    • 动态连接:当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接
  • 本地方法栈(线程私有):线程私有的,类似Java虚拟机栈,本地方法栈则是为虚拟机使用到的本地(Native)方法服务,也会有StackOverFlowError或OOM
  • Java堆:线程共享的,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(之所以是"几乎",比如经过逃逸分析变量不会逃逸的时候会分配在栈上而不是堆上)
  • 方法区 :线程共享的,存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据 。JDK1.8之前实现为永久代,把收集器的分代设计扩展至方法区,收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作,但是这种设计导致了Java应用更容易遇到内存溢出的问题(受MaxPermSize参数的限制)。JDK1.8之后实现为元空间,使用的是本地内存,只受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  • 运行时常量池 :是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。不过一般来说,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。运行时常量池还具备动态性,Java语言并不要求常量一定只有编译期才能产生,比如String.intern()
  • 字符串常量池:JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。JDK1.7之前字符串常量池放在方法区中,JDK1.7后为了提高回收率放在了Java堆中。
  • 直接内存:直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。JDK1.4加入的NIO,可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了在 Java 堆和 Native 堆之间来回复制数据。直接内存大小只受本机内存大小限制。

Java堆

对象创建

  1. 类加载:遇到new指令,首先检查能否在常量池定位到该类的符号引用,检查类是否已经加载、解析、初始化,没有的话则先加载类
  2. 分配内存 :类加载完成后,为对象分配内存,对象所需内存的大小在类加载完成后便可完全确定
    • 确定内存分配机制,常见算法有:指针碰撞、空闲列表
    • 保证内存分配是线程安全的:CAS、TLAB(Thread Local Allocation Buffer)
  3. 初始化:内存分配完成后,将内存空间初始化为零值
  4. 设置对象头(Object Header)
  5. 构造函数调用

对象的内存布局

内存布局划分为三个部份:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头包含两类信息:

  1. 对象自身的运行时数据(Mark Word):占据32或64bit大小,hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向时间戳等。
  2. 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针 来确定该对象是哪个类的实例

对象的访问定位

主流访问方式有:句柄、直接指针

句柄

虚拟机划分出一块句柄池,reference保存句柄的地址,句柄保存对象实例数据和对象类型数据的指针。

好处在于当对象被移动的时候(GC的时候可能会发生),只会改变句柄中的实例数据指针,reference存放的句柄地址不用变更。

直接指针

直接存储对象的地址,好处是只需要一次寻址就能访问到对象实例数据。

GC

判断对象是否已死

引用计数算法

引用数+1则计数+1。简单的引用计数难以解决循环引用问题。

可达性分析算法

首先有一系列的GC Roots,从这些根出发,不可达的对象为已死。

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

  • 虚拟机栈中的对象
  • 本地方法栈中的对象
  • 方法区中的静态变量
  • 方法区中的常量
  • 虚拟机内部的引用,
  • 如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
  • 所有被synchronized持有的对象
  • 反映Java虚拟机内部情况的JM XBean、JVM TI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象"临时性"地加入,共同构成完整GC Roots集合。

引用的类别

  • 强引用:被强引用的对象永远不会被回收
  • 软引用:只被软引用的对象在OOM即将发生前会被回收
  • 弱引用:只被弱引用的对象在下次GC一定被回收
  • 虚引用:不影响对象的回收,但也不能从虚引用获取被引用的对象。只是用来当发生GC并且对象内存被回收的时候收到通知(通过从引用队列获取虚引用)。

关于虚引用,这里详细介绍一下。如上所述虚引用用来当发生GC并且对象内存被回收的时候收到通知,另一种在GC时收到通知的方法是重载对象的Object.finalize方法,这个方法在对象被确认可以GC的时候会被调用,用户可以重载这个方法做点事情,比如释放资源,但是finalize有很多问题:

  • 性能问题:GC是单线程执行,所有对象的finalize相当于串行执行,无法自定义执行finalize的线程,甚至可能造成死锁等问题
  • 灵活性问题:用户无法自定义对象的finalize是否被执行、各对象间finalize的顺序等
  • 对象复活问题:finalize中可以将对象重新强引用,阻止其被GC。这个问题会在下个小节更加详细介绍

因此官方推荐使用PhatomReference或者Cleaner来实现对象被GC后的工作。并且第三点「对象复活问题」是「为什么不用弱引用通知对象被GC,而是使用虚引用」的原因:结合弱引用的定义,经过测试发现,当对象确定只被弱引用,并且发生GC时,referent会从弱引用中删除,且弱引用进入引用队列。而finalize如果将对象复活,但此时弱引用已经进入引用队列,因此不能用弱引用来实现对象GC的通知,因为对象被复活了,没有被GC掉。

用一句话概括就是,虚引用用于通知对象的内存真正被回收,换句话来说,我认为如果对象不会被复活的话,弱引用可以替代虚引用。

关于Object.finalize方法,深入理解Java虚拟机一文中:

finalize()能做的所有工作,使用try-finally 或者其他方式都可以做得更好、 更及时,所以笔者建议大家完全可以忘掉Java语言里面的这个方法

垃圾收集算法

分代收集理论

  • 弱分代假说:绝大多数对象朝生夕灭,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。对这部分的GC称为Young GC / Minor GC
  • 强分代假说:熬过越多次GC的对象越难消亡,虚拟机便可以使用较低的频率来回收这个区域,对这部分对象的GC称为Old GC / Major GC
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

跨代最直接的解决方法是扫描整个老年代,但是效率低下。依据跨代引用假说,只需要在新生代建立一个"记忆集",将老年代划分为若干小块,标识出哪一块内存存在跨代引用,当发生minor GC的时候只需要将这些存在跨代引用的内存块加入GC Roots。

标记清除算法

标记所有需要回收的对象,然后统一回收这些对象。有两个缺点:标记清除的效率随着待回收对象的增加而降低、空间碎片化

标记复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后把原来那块内存一次清理掉。缺点是当存活的对象占大多数时,效率较低,并且能用的空间只有一半。

还有一种非针对新生代内存的标记复制,不需要根据1:1比例划分内存的Appel式回收,空间分为一块大的eden,两块小的survivor,平时只有eden和其中一块survivor工作,另一块survivor用于minor GC的时候存放存活的对象,然后一次性把eden和另一块survivor清空。如果survivor空间太小的话导致放不下存活对象,那么会触发分配担保机制,把这些存不下的对象直接进入老年代。

标记整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

标记整理算法直接将存活对象往内存空间一端移动:

如果在老年代中使用这样的策略,那么每次GC都会移动大量存活对象,而且必须暂停用户应用程序,即"Stop The World"。但是如果不移动对象的话,势必要增加内存管理的复杂度(比如使用空闲链表),降低了分配内存的效率,但不移动对象的话,STW的时间会更短。

实现细节

并发的可达性分析

三色标记法:对象图中的对象标记成三种颜色

  • 白色:初始状态,还没被扫描到的对象
  • 灰色:已经被扫描过,但是至少还有一个引用(这个对象的引用属性)没扫描
  • 黑色:已经被扫描过,并且这个对象的所有引用都已经扫描过。黑色对象是安全存活的,如果有其他对象指向了它,无需重新扫描。黑色对象不可能直接(不经过灰色对象)指向白色对象,除非用户程序并发修改引用,下面会说明这个问题

以上情况1为正常标记完成的情况,最终白色对象将会被回收。情况2,3为在收集器标记的过程中,用户程序并发地将灰色对象对白色对象的引用取消了,并增加黑色对象对该白色对象的引用,由于黑色对象及其所有引用不会再被扫描,因此该白色对象无法被扫描,最终被错误地回收。

因此错误地回收对象,需要同时满足以下两个条件:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

只需要破坏其中任意一个条件,就不会发生对象被错误回收的情况,以下是两种解决方案:

  • 增量更新(破坏第一个条件):标记过程中新增黑色对象对白色对象的引用时,将其记录下来,等待并发扫描结束后,再以这些黑色对象为根重新扫描一次
  • 原始快照(破坏第二个条件):当灰色对象删除了指向白色对象的引用关系时,就将这个引用记录下来,等待并发扫描结束后,再以这些灰色对象为根重新扫描一次

增量更新和原始快照都通过写屏障实现。CMS使用增量更新,G1、Shenandoah使用原始快照。

垃圾收集器

serial收集器

基于标记-复制的新生代收集器,serial不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,即STW。它是所有收集器里额外内存消耗最小的,而且收集几十兆甚至一两百兆的新生代,STW完全可以控制在十几、几十毫秒,最多一 百多毫秒以内。

ParNew收集器

基于标记-复制的新生代收集器,ParNew收集器实质上是Serial收集器的多线程并行版本,并与作为老年代收集器的CMS配合使用。ParNew收集器是激活CMS后的默认新生代收集器。

Parallel Scavenge收集器

也是基于标记-复制的新生代收集器。Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,主要适合在后台运算而不需要太多交互的分析任务。可以通过指定参数开启自适应的调节策略,以提供最合适的停顿时间或者最大的吞吐量。

Serial Old收集器

serial的老年版本,基于标记-整理算法的老年代收集器

Parallel Old收集器

Parallel Scavenge的老年版本,基于标记整理的老年代收集器。在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合

CMS(Concurrent mark sweep)收集器

以最短STW时间为目标 ,基于标记-清除、增量更新的收集器,适合交互频繁型应用。运作过程包括四个步骤:

  1. 初始标记:需要STW,标记GC Roots能直接关联到的对象,速度很快
  2. 并发标记:从GC Roots的直接关联对象遍历整个对象图,耗时长,可以与用户程序并发运行
  3. 重新标记:需要STW,修正并发标记中因用户程序发生变动的标记,耗时比初始标记稍长
  4. 并发清除:清除回收对象,由于不需要移动对象因此可以与用户程序并发运行

缺点:

  1. 在并发回收阶段会因为CMS占用了一部分线程而导致应用程序变慢,降低总吞吐量
  2. 并发标记和并发清理过程中 CMS无法处理"浮动垃圾" ,只能由下一次GC处理。重要的是这个过程中如果预留的内存无法满足程序分配新对象的需要,就会出现一次"并发失败", 只能STW并临时启用Serial Old收集器来重新进行老年代的垃圾收集
  3. CMS由于使用标记-清除,会出现内存碎片,导致老年代还有很多剩余空间,但就是无法找,到足够大的连续空间来分配当前对象,最终触发Full GC。解决办法是Full GC的时候整理内存碎片

G1收集器

目的是实现停顿时间模型:支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

在G1之前的其他收集器,收集目标要么是全体新生代(Minor GC)、要么是全体老年代(Major GC)、要么是整个Java堆(Full GC)。而G1是Mixed GC模式,面向整个堆,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大

将连续的Java堆划分成多个大小相等的独立区域(region),region是最小回收单元,每一个Region都可以根据需要动态地扮演着新生代的Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象(大小超过一半region大小的对象),G1的大多数行为都把Humongous Region作为老年代。

G1停顿时间模型的实现就建立在:它将Region作为单次回收的最小单元,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

具体地说,G1收集器去跟踪各个Region里面的垃圾堆积的"价值"大小,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1收集器的运行过程大致为四个步骤:

  1. 初始标记:STW,标记GC Roots,修改TAMS
  2. 并发标记:与用户程序并发执行,扫描整个对象图
  3. 最终标记:STW,处理原始快照
  4. 筛选回收:STW,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,将回收的Region中存活的对象移动到空Region并清空回收的Region

只有并发标记没有STW,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。

G1 vs CMS

  • G1将整块年轻代/老年代划分为一个个region,并根据回收价值回收,这样G1能够更加灵活地控制 GC 停顿时间
  • G1使用标记整理,避免了内存碎片。另外CMS不使用标记整理是因为耗时太长,G1通过回收性价比高的region,灵活控制停顿时间。
  • G1需要使用记忆集解决跨代引用,占整个堆内存大,而且 G1 中维护记忆集的成本较高,带来了更高的执行负载

类加载机制

类加载时机

加载、链接、初始化、使用、卸载

必须进行类初始化的情况有:

  • 使用new、读写类的静态字段(非final)、调用类的静态方法
  • 对类进行反射调用
  • 初始化类的时候,父类也要初始化
  • 执行main方法的主类
  • MethodHandle
  • 含有default方法的接口的实现类初始化的时候,先初始化接口

类加载过程

加载

加载三步骤:

  1. 通过类的全限定名获取类的二进制字节流
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成Class对象,作为方法区数据结构的访问入口

验证

包括文件格式验证、元数据验证、字节码验证、符号引用验证。

如果代码事先经过验证,可以用参数关闭类加载过程中的验证,以缩短加载时间。

准备

为类静态变量分配内存,并初始化为零值,在初始化阶段才会putstatic设置为用户指定的初始值。而对于final的静态变量,则会在准备阶段就会初始化成用户指定的初始值。

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

初始化

Java虚拟机才真正开始执行类中编写的Java程序代码,初始化阶段就是执行类构造器<clinit>()方法的过程,这个类构造器方它是Javac编译器的自动生成物,由自动收集类中的所有类变量的赋值动作和static块的语句合并产生的。其中static块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的static块可以赋值,但是不能访问:

java 复制代码
public class Test { 
  static {
    i = 0; // ok
    System.out.print(i); // not ok
    static int i = 1; 
  }
}

虚拟机保证了clinit在多线程环境下的同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的clinit方法,如果clinit有耗时操作,那么可能会造成多个线程阻塞。clinit最好耗时短或者提前加载。即同一个类加载器下,一个类型只会被初始化一次。

类加载器

类加载过程中,「通过一个类的全限定名来获取描述该类的二进制字节流」这个动作放到虚拟机外部去实现,让应用程序自己决定如何获取所需的类,实现的代码称为类加载器,可以用于实现类层次划分、OSGi、程序热部署、代码加密等技术。

首先在Java虚拟机中任意一个类的唯一性由类加载器和类本身共同确定,比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,只要加载它们的类加载器不同,那这两个类就必定不相等。

双亲委派模型

在虚拟机的视角,只有两种类加载器:

  • 启动类加载器:是虚拟机自身的一部份
  • 其他类加载器:由Java语言实现,独立于虚拟机,并且全部继承自ClassLoader

自JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。

JDK8及以前的版本绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载:

  • 启动类加载器:加载JAVA_HOME\lib目录和-Xbootclasspath指定路径的类库
  • 扩展类加载器:加载JAVA_HOME\lib\ext目录的扩展类库(Java9后被天然具有扩展能力的模块化机制取代)
  • 应用程序类加载器:加载用户类路径上的所有类库,如果用户没有定义自己的类加载器,一般情况下这个就是程序中默认的类加载器

这里类加载器之间的关系通常不是继承,而是使用组合来复用父加载器的代码。

双亲委派模型的工作过程:类加载器将收到的类加载请求先交给父加载器完成,最终传送到启动类加载器。如果父加载器无法完成这个请求(它的搜索范围内没有找到所需的类),子加载器才会尝试自己去完成加载。

双亲委派模型这种工作模式的好处是,具备了一种带有优先级的层次关系,比如java.lang.Object是由启动类加载器加载的,无论哪个类加载器来加载这个类,最终都由启动类加载器加载,那么即使用户自己也编写了一个名为java.lang.Object的类,运行时加载的也是rt.jar里面的那个java.lang.Object,保证了Java类型体系中最基础的行为,避免造成混乱。

破坏双亲委派模型

如果不想破坏双亲委派模型,那么自定义加载器的时候,重写ClassLoaderfindClass方法即可。如果想打破的话,那么需要重写loadClass方法,目的是重写加载类的流程。

比如 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理:

  • CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离
  • CatalinaClassLoader用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类
  • SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、Mybatis
  • 每个 Web 应用都会创建一个单独的 WebAppClassLoader,各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间类的隔离

但是单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类。

比如应用程序中的业务类实现了Spring中的接口,这些接口属于Web 应用之间共享的,由SharedClassLoader加载,并且Spring内部需要访问这些业务实现类,但是SharedClassLoader无法找到业务类,因此就需要用到线程上下文类加载器(ThreadContextClassLoader),当 Spring 需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器。线程线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的,一般的格式如下:

java 复制代码
try {
  // 保存原始ClassLoader
  ClassLoader curCl = Thread.currentThread().getContextClassLoader();
  // 获取并设置所需的ClassLoader
  ClassLoader cl = 比如WebAppClassLoader;
  Thread.currentThread().setContextClassLoader(cl);
  // 使用设置的ClassLoader加载一些类,处理一些框架内的逻辑等
  loadClass();
} finally {
  // 还原
  Thread.currentThread().setContextClassLoader(curCl);
}
相关推荐
yuanbenshidiaos6 小时前
c++---------数据类型
java·jvm·c++
java1234_小锋9 小时前
JVM对象分配内存如何保证线程安全?
jvm
40岁的系统架构师12 小时前
1 JVM JDK JRE之间的区别以及使用字节码的好处
java·jvm·python
寻找沙漠的人13 小时前
理解JVM
java·jvm·java-ee
我叫啥都行13 小时前
计算机基础复习12.22
java·jvm·redis·后端·mysql
bufanjun00116 小时前
JUC并发工具---ThreadLocal
java·jvm·面试·并发·并发基础
东阳马生架构1 天前
JVM简介—1.Java内存区域
jvm
工程师老罗1 天前
Android笔试面试题AI答之SQLite(2)
android·jvm·sqlite
Qzer_4071 天前
jvm字节码中方法的结构
jvm
奇偶变不变2 天前
RTOS之事件集
java·linux·jvm·单片机·算法