JVM原理


1. 堆和栈有什么区别?

首先我们从操作系统的角度上来看,堆和栈在操作系统中的分布:

假设我们的地址空间是0x0~0xffff,那么在这个地址空间中,堆和栈的分配逻辑是不同的

  • 堆的内存分配是从小地址到大地址,也就是从0x0向0xfff申请内存分配
  • 栈的内存分配是从大地址到小地址分配,也就是0xfff向0x0申请内存分配

然后堆和栈中间那些还没有使用的内存,就是可以被申请的

那么栈是如何工作的呢?

如上图所示,假设在执行这一段函数,那么它会先将a压栈,将b压栈,然后通过操作数寄存器将a+b的结果压栈,在这个过程中,栈顶指针总是指向下一个写入的位置,这也就是说,栈顶指针指向的位置总是可以写 入的,是还没有申请空间的,然后当要使用这些变量的时候,就会通过CPU上的寄存器,比如说rbp,esp等寄存器的值,加上一个偏移量,就能够得到栈中变量的位置了

栈还有一个很重要的功能就是执行函数调用,当执行函数调用的时候,这时候栈顶指针会自动分配出一块空间,这块空间是用于在本次函数返回后,将函数的返回值写入这个区域的,然后在这个区域申请完毕后,就会将当前程序的程序计数器的值记录到调用程序的位置,然后调用一个跳转函数,程序计数器跳转到对应的函数行,然后持续执行,当函数执行完毕后,栈帧中所有元素都弹出,然后就会将调用程序位置写回到程序计数器,执行跳转,接着就会将相关的返回值写入到之前流出来的位置

注意,栈顶在回收的时候,虽然没有显式地删除原来的值,但是弹回后的这些空间就是已经可以使用的了,不属于内存泄漏

下面再来讲讲堆,堆的话就是用来分配一些数据的存储区域的,一般来说就是存储一些对象,它并不是一种数据结构,什么样的结构都存储在堆中。

下面总结一下:

  • 栈是线程执行的活动记录,它是配合执行程序的重要工具,因为函数在执行过程中,可能产生非常多的中间量,包括有函数的返回值等,栈的结构能够非常好的支持这些操作,它提供了执行程序的必须内存空间
  • 每个线程都必须有一个栈,因为栈是线程执行的活动记录,如果一个线程是共用栈的,那么就会导致在发生线程的上下文切换调度的时候,线程A上CPU后,就会篡改栈中的内容
  • 堆通常来说是维护一个所有线程都共同可见的内存区域,这个区域通常存储的是大的数据结构。

不一定,在Java高版本中存在一种称为逃逸分析+标量替换的技术,简单来说,就是编译器会检查对象的生命周期,如果对象的声明周期仅在函数之中,那么它就会将对象拆分成标量,然后将这些变量分配到栈上,避免将对象分配到栈上导致GC压力

2. JVM的内存布局

首先这张图解释了对象的引用关系,比如说在函数中执行new Object(),那么它会在栈中存放这个对象的引用,那么这个引用就会指向堆中的对象实例数据,然后这个对象的是元信息:

类的信息:存放在堆外,通过寻址的方式来找到这个类的信息,(但是在永久代还存在的时候,这个信息实际上还是存放在堆中),但是不同的虚拟机的实现是不一样的

编译后的代码:以HotSpot为例,它是存在堆外的

常量:基于JVM的实现的不同而不同

对象和操作系统内核通过内存映射构造的缓冲区

假设现在正在进行一次网络传输,网卡中的数据需要从网卡缓冲区通过DMA技术将数据拷贝到操作系统内核空间中,然后再从操作系统内核空间拷贝到用户空间,然后进程和线程才能访问到这一部分的数据,提供是两次拷贝

然而内存映射缓冲区的这种技术,就是说减少了操作系统内核空间拷贝到用户空间的这一步,此时存在这个内存映射构造的缓冲区不是存在Heap中的,当进程/线程试图访问这些数据的时候,就会通过内存映射给出的指针,通过这个指针访问堆外内存。

Native对象:Native不是Java语言新建出来的对象,而是由底层的Native方法构造出来的C/C++系统管理的对象,因此这一部分的对象不是在JVM的堆中,而是在C/C++所管理的堆中

从线程的角度来看待这个问题

我们知道线程是程序执行的最小单位,因此无论是Java方法调用还是本地方法调用,都会涉及到线程的执行,那么线程的执行需要依赖于,栈是用来辅助线程的执行的,为了便于线程的上下文切换和调度,那么JVM也会虚拟出来自己的一个程序计数器,这个程序计数器记录的永远是下一条要执行的指令,那么运行逻辑就是:

执行引擎不断取出程序计数器中的下一条指令,在Java中,每一条指令是一个OpCode,简单来说可以看成是一个二进制

整体的架构

  • 第一步:将编写好的.java文件编译成.class文件
  • 第二步:启动一个launcher,launcher去调用ClassLoaderSystem
  • ClassLoader加载类的元数据信息,编译好的方法指令,它的作用主要就是加载方法区中的关键参数
  • 当程序运行的时候,就会将方法区中的byteCode交给执行引擎,执行引擎中有两大部分内容,第一部分是JIT(JustInTime)编译器,它是这样的,它拿到.class文件中的指令之后,第一步,先将这个指令转换为机器码,然后再去执行这个机器码,编译过的程序不再编译第二次

C/C++或者golang则是在执行之前就编译好的(Complie ahead),然后执行这些编译好的代码就可以了,但是最大的缺点就是跨平台,机器码的运行是需要依赖操作系统、CPU等环节的,那么这也就意味着机器码相同,但是在不同的平台上的运行效果是不同的,甚至有可能跑不起来,因此如果使用这种模式的时候,在跨平台的时候就会不太方便

简单来说,就是不同的CPU支持不同的指令集,如果在编译的时候盲目地去编译指令集,那么可能在CPUA上可以运行这些指令集,但是在CPUB上就找不到这些指令集了

那么JIT编译器是如何解决的呢?它是在运行时编译,也就是说,它在拿到.class文件中的指令的时候,它对你的操作系统,以及一些CPU运行时数据知根知底,它会根据你的运行时环境决定要生成什么样的机器码

GC:堆内存的实际管控者,包括有内存分配/内存回收

JNI(JavaNativeInterface):本地方法接口

实际上就是提供了一个接口,提供给Java语言去操作系统调用相关的接口,我们说Java语言它本身来说是不能够执行指令的,不能够随意调度CPU的资源,只能够将指令传递给操作系统,由操作系统代为操作

然后在运行的时候,由于要执行本地方法,因此还有一个Native Memory

Thread的运行也是需要和操作系统交互的

总结如下:

  • 运行时数据有哪些?

  • 对象有哪些数据?

  • 哪些数据是公有的?哪些数据是私有的?

  • Native数据有哪些?

Java是一门运行时编译语言,因此其内存的布局在运行的时候能够体现地淋漓尽致,在Java运行的时候,有一个称为是运行时数据区的区域,在这个区域中,可以这样描述,首先是.java文件被编译成.class文件,然后ClassLoadSystem将这个.class文件加载到运行时数据区中一个叫做元空间的区域,这个区域会包含这个对象的类信息,编译后的代码等,当这个对象在方法中需要被new出来的时候,那么就会在堆上分配空间,在栈上分配一个对象的引用,这个对象的引用会直接指向堆中的实例数据,然后当方法运行的时候,由于线程是程序执行的最小单位,因此它会在每一个线程私有的栈上完成对局部变量的分配等操作,当对象引用常量的时候,就会到方法区中寻找,这大致可以描述Java运行时,各个内存区域是如何工作的

值得说明的一点是:由于Java语言本身是不具备程序执行的能力,具体程序执行的能力实际上是操作系统读取用户程序交付的机器码然后执行的,因此Java内存中还有一块区域叫做Native memory,这块区域存储的是执行本地方法的时候,所产生的一些临时内存,比如说执行底层的本地方法的时候,会为C/C++的程序执行也分配一个堆空间和栈空间,Java语言通过本地方法库来执行这些方法

3. GC基本功

  • 为什么需要GC

在程序运行的过程中,由于需要对程序的运行进行管理,那么就需要由程序员来手动分配/管理内存了,GC是Java执行引擎中的一部分,它负责了程序的内存申请和内存分配的相关动作

  • 什么是STW

我们将程序运行抽象成两个人,一个人在不断地弄乱房间,一个人在不断地收拾房间,那个弄乱房间的人只能弄乱收拾过的地方,如果弄乱的速度远远高于收拾的速度,那么就会存在某个时刻,无法再弄乱了

那么对应到程序执行的过程中,就是程序无法再申请内存了,那么最终就会触发OOM等错误,这是我们所不希望看到的,因此当进行内存回收的时候,那么在这时候就应该要强制所有的线程暂停手上的工作,停止内存的申请,待清理好后再执行相关的操作

目前的做法是线程会有一个check()的动作,它会检查当前线程是否能够stop()以及当前引擎有没有要求stop(),在写的程序中插入这样的check()语句,也就是说当触发STW的时候,线程还有可能会执行若干条指令,直到一个safe point为止

  • 如何提高吞吐量

首先先来了解一下什么是吞吐量,在网络传输中,吞吐量指的是在单位时间内传输的数据流量,而在GC的吞吐量则是程序工作时间的占比,也就是程序占用了多少CPU时间片,-XX: GCTimeRatio = 99

说明99:1,意思讲百分之99的时间片都用于程序运行,如果是19:1,意思将百分之95的时间片都用于程序运行

  • JVM参数

标准参数:所有JVM都支持的,-cp

-X 扩展参数:部分JVM都支持,-X mx256m,设置堆内存大小

-XX 开发的时候使用,-XX: + PrintGCDetails

如何提高吞吐量?

  • 给更大的内存,当内存更大的时候,可以适当降低内存整理的频次,-X mx4G
  • 更改GC算法
  • 多线程GC能不能提高吞吐量?

假设原本一次GC需要100s,那么通过10个线程,可以使得这个任务变成10s吗?

不能,这和CPU的核数有关,如果CPU有10个核,首先也不能够得到10s,这是因为线程还有创建,分配栈空间等相关开销,而如果是低于10核的话,那么必然会大于10s,首先第一个耗时是线程的上下文切换,其次是因为任务所规定的时间片是固定的,但是目前由于各个线程是处于一个并发而非并行的状态,因此不可能达到10s

什么样的应用需要高吞吐量?

离线任务抢购任务竞技游戏音视频服务

  • Latency:指的是GC造成的卡顿(STW)时间,Pause Time(一次STW的时间)

吞吐量小,那么Latency就一定高吗?

ThroughputLetency没有必然的联系,比如说一个应用运行一天,Latency的时间是10 min,那么这10 min是非常高的延迟,但是平均到这一天的3600 min,其实对吞吐量带来的影响是很小的

多线程能不能减少Latency

从一定程度上来说可以,比如说在STW的时候,启动多个线程,使用多核CPU的优势,就可以在同一时间内处理尽可能多的内存垃圾,从而减少了处理时间

内存更大能不能减少Latency

可以,当内存更大的时候,可以降低STW的频次

  • FootPrint:最终应用对内存的需求

比如说,内存的使用速度为100M/S,内存的回收速度为80M/S,假设每10s做一次GC,那么当达到STW的时候, 此内存的真正占用量就是200M,这个200M就称作是FootPrint

在资源有限的情况下,就要时时刻刻对应用的FootPrint进行监控,达到了之后必须进行回收

打印GC详情 -XX:+PrintGCDetail

增加时间描述 -XX: +PrintGCTimeStamps

HeapSize - Xmx

4. 引用计数法和三色标记法

关于引用计数法

引用计数法的基础是Object head,在这个头部中会有一个引用计数的字段来标记当前对象被引用了多少次, 当被检查到这个字段为0的时候,那么此时就会被清除,它的问题主要是:

  • 首先是存在一个循环引用的问题,比如说A引用了B,B引用了C,C引用了A,那么在这样的情况下,永远无法解除引用,导致内存泄漏
  • 第二个问题是在并发环境下,如果对于引用计数没有做一个并发的控制,那么就会可能导致对象头中的引用字段的数字没算对,最终导致产生问题

关于根部追踪

在这个根部追踪中,它先会划定一些根节点,这些根节点可以是:

  • 方法区中的常量、静态变量的引用
  • 当前活跃的栈中的引用
  • 活动线程
  • JNI引用

如果在对象存在一条路径到达根对象的话,那么就可以不将这个对象回收掉

是否需要分阶段?

  • 第一个阶段:mark阶段,给节点上色
  • 第二个阶段:sweep阶段,当遍历的对象是需要finalize的类对象的时候,那么就会执行它的finalize(),这些对象会被加入到一个队列中,如果在执行了他们的finalize()的时候,又和root建立了可达性的连通,那么就不会被回收,其他的,没有建立可达性关系或者是没有finalize()就会被真正回收了

问题:如果在执行这个过程是非STW的,那么既有可能导致说在mark阶段和sweep阶段的时候,对象的关系拓扑图产生了变化,那么这时候就会产生问题了

比如说像上面这个图这样,假设在Mark线程的工作内存中,看到的拓扑图始终都是只有AB,但是此时有其他线程将B链上了C对象,如果要保证CDE都被标记上,那么就必须要通知Mark线程去重新标记CDE了

三色标记清除算法

它在原来的黑色和白色的基础上添加了一种灰色的状态,它代表着一种不确定性,也就是说在图中如果存在灰色的对象,那么它就要求算法要去重新扫描灰色的对象的连通关系

具体的做法是:当在执行三色标记算法的过程中,当marksweep产生了变更之后,就会产生一个屏障,这个屏障会强制将当前对象的引用写回,并且将这个对象的颜色标记为不确定,然后在执行sweep之前,它会先检查有没有灰色元素,如果还是有灰色元素,那么继续执行根追踪算法,直到整个图就只有白色和灰色为止

  • 第一步:从根节点出发,从root可以达到的白色元素标记为灰色,也就是说将元素从白色集合中删除,然后将这个元素加入到灰色集合中
  • 第二步:然后从灰色集合中弹出元素,将它自己标黑,然后将它的子节点加入到灰色集合中
  • 循环返回执行第二步,直到灰色集合为空,标记结束

如何执行颜色变更?

基于写屏障类似于一个AOP,它会在代码之上插入一段内置代码,这一段代码会变更当前颜色集合中的元素以及元素之间的引用关系

5. 复制、整理和生代算法

对象在不断创建和回收,可能导致内存分配的速度变慢,比如说在分配对象的时候,需要在堆内存中查询空闲链表,如果空闲链表中有足够的空间被利用,那么就能够分配,这个过程通常会导致内存的内碎片和内存的外碎片的问题,这将导致内存的利用率不高,基于整理算法就能够提高内存的利用率,通过整理,就可以将内存的这些碎片集中起来,然后就能够存储下更大,更多的对象了

例子:有两个4MB的内存碎片,如果要分配一个8MB的内存是无法分配的

新创建的对象的死亡率高,这意味着这些对象容易频繁地被回收,从而频繁的导致内存整理,而生存下来的对象因为不容易被回收,从而内存整理的频率会更低一些,为了避免在整理内存的过程中,去导致那些存活下来的对象因为新创建的对象的内存整理而频繁移动,因此一个好的办法就是设置一个分区,将对象的存活情况作为指标,界定这些对象应该要放在哪个区域中,从而使得内存整理频率高的分区,能够快速执行内存整理,而不需要惊动那些不需要进行频繁回收的对象

那么这种机制就是一种分代的机制,它将对象分成了若干个分区

  • 新创建的对象被分配到新生代伊甸园区,这个区域中的对象不是很长命,因此会涉及到频繁的内存整理,因为这个分区中的对象比较短命,因此通常来说他们的内存整理效率比较高
  • 而那些长命的对象所在的对象的内存整理频率不高,因此说没必要将长命对象和短命对象放在一起,短命对象被回收,移动的是整个内存区域中的对象,如果把他们放在一起,那么就会导致长命的也跟着一起移动

如图所示,Java会将新建的对象放入到Eden中,当Eden中触发MinorGC的时候,它此时并不会直接在Eden执行整理,而是将当前的对象复制到Survivor中,

-XX:SurvivorRatio=8(默认),也就说Eden:Survior = 8 :1

那么Survior的对象去哪里呢?

它会进入到一个称为是老年代的地方,那么如何才能进入到老年代呢?这设计到分代分配和分代回收的机制,详细描述一下这个流程:

首先对象新建之后,就会被分配到Eden,然后当Eden满的时候,就会将对象复制到Survivor0上,此时每次Eden满都会复制到Survior0上,然后当Survivor0满的时候,就会对Survivor0上的对象进行清除,然后将Survivor0上的存活对象复制到Survivor1上,在这个过程就会对存活的对象的头部中的age+1,最终实现了一个老年代的晋升

Java的堆分成哪些部分?

从对象的角度来讲,对象的实例数据是存储在堆中的,为了提高内存分配/回收的效率,不同的虚拟机对堆存储的数据有不同的规定,比如说HotSpot中原来是将对象的元数据,比如说类信息,CodeCache都存储在堆中的永久代分区中,但是在后面使用了元空间,将这个空间从堆中抽离出来了

HotSpot为了提高内存的分配/回收效率,那么它堆的内存分成了以下这几个部分

  • Eden区:当对象被新建的时候,就会将这个对象分配到这个区域上
  • S1/S0区:当Eden发生GC的时候,就会将存活的对象复制到S1/S0上,当一个区(S0/S1)发生的时候,就会将对象来回复制,每复制一次,就会使得对象头部的分代年龄字段+1,最终达到一个阈值之后就会晋升到老年代
  • 老年代:存储的是比较稳定的对象,只有当老年代的空间不足的时候才会触发GC

6. 常见的垃圾收集器

Serial Collector(串行垃圾收集器)

它是一个单线程的垃圾收集器,具体的执行流程:

当触发GC的时候,首先执行Mark,就会触发一个STW,然后基于根追踪+双色标记法来判断对象的存活情况,假设黑色是存活的,白色的是不可达的对象,然后在STW期间就会将白色的对象给sweep

为什么串行收集器不使用三色标记法?

这是因为串行收集器的Mark&&sweep是一个原子性的动作,也就是说中间不会产生Mutation,因此这样的话就不会出现三色标记法中引入的那个不确定标记,只需要在STW期间完成Mark&&sweep即可

使用场景是:

  1. 吞吐量小,内存回收工作量不大
  2. 容忍延迟,不在意卡顿
  3. 单核,内存小:0~100M
-XX:+UseSerialIGC

并行垃圾收集器

并行垃圾收集器是串行垃圾收集器的升级版,它的升级主要体现在它支持多线程垃圾回收,它尽最大努力提供最大的吞吐量,它的执行流程:首先当遇到GC的时候,就会直接触发STW,然后在STW期间完成Mark&Sweep的操作,那么它的吞吐量是要高于串行的吞吐量的,因为在多核环境下,两个线程来处理GC任务要更快一些

它的使用场景是吞吐量的要求要高于延迟的要求的

-XX:+UseParalleIGC

并发标记收集垃圾收集器(CMS)

这个垃圾收集器优化的宗旨主要是将STW的时间降到最低,Mark的阶段并不是STW的,从而提高了吞吐量,当所有的Mark操作完成了之后,才会执行Sweep Compact Copy,具体的执行流程:

(1)初始标记过程,相当于一个初始化,仅标记一下GCRoots能够直接关联的对象,并且将这些对象加入到不确定状态的集合(灰色集合),

(2)并发标记过程,执行三色标记算法,具体的过程就是:从灰色集合中取出元素,然后将这个元素标记为黑色(原本灰色),然后将取出的元素相连的白色元素放入到灰色集合中,反复这个过程,直到所有元素都被遍历过了一次

(3)remark阶段,由于在标记和mark之间存在一个mutation,因此为了避免诸如在之前标记好的要删除的元素突然产生了关联,因此在这个过程,就要执行remark,这个过程相对来说比较短,为了保证程序的正确性,因此使用了STW,暂停其他线程,然后遍历对象,如果对象有灰色的,那么就继续扫描它的子节点。

(4)在STW的状态下,将标记过的对象删除

问题:在Mark期间发生了修改,到底是如何操作的?

条件1: 白色节点被黑色节点引用(白色节点被挂在了黑色节点下,须知黑色节点是不会重新扫描的)

条件2: 灰色节点和可达关系的白色对象之间的应用遭到了破坏(删除灰白引用)

增量更新:所谓的增量就是在并发标记过程中,把复制的这种新增的引用,用一个集合存起来,在重新标记的时候,就会找到集合中的引用,然后重新去扫描,将源头标记为灰色

写屏障:写屏障是增量更新的实现基础,写屏障具体来说就是在赋值操作的前面加一个方法,赋值的后面做一些操作

跨带引用会带来什么问题?

比如说我们要对年轻代进行GC,可以断定NSPQ都是存活的,因此可以标记,如果我们不对老年代中的对象进行分析的话,就不知道年轻代的对象V还有被老年代引用的,因此可以看出,当存在跨代引用的时候,需要对其他分区的对象也进行检索,然而为了新生代的GC而去全局遍历老年代,这种做法的效率是很低的,为了避免这种老年代的性能开销,通常的分代垃圾回收期会引入一种记忆集的技术,简单来说,记忆集就是同来记录跨代引用的表

在拥有记忆集的情况下,我们就可以知道年轻代中的哪些对象存在跨带引用了

在分代GC下的GC分类

MinorGC:当新生代无法分配更多的JVM内存了,那么就会触发MinorGC,这个GC会把新生代中的对象进行一次标记,问题:假设这个新生代的要被回收了,而且老年代中有一个对象,仅有这个新生代对象的引用?这时候要不要回收老年代中的这个对象?

MajorGC:老年代的垃圾回收

FullGC:MinorGCMajorGC同时发生

什么是浮动垃圾?

在一次周期有一些垃圾本来要被回收的,但是在本次标记失败了,只能在下一个周期中删除这些垃圾

G1垃圾收集器

它的目标是解决大内存的问题,主要的解决思路就是将大内存,也就是原本的伊甸园区/S区/老年代的这些大内存区域,将这些区域再次划分成一个个的小区域,那么就彻底了解决大内存的问题,每次执行垃圾回收,只需要使用一个线程去扫描这一个个小的区域,就能够完成垃圾回收了。

执行流程:

但是比较复杂,里面的对象还有一些引用的关系,比如说一个小区域引用了另外一个小区域中的对象,一个小区域引用了另外一个大区域中的对象

  • 解释一下常见的垃圾收集器,以及区别在哪里

  • 如果对延迟要求高,可以考虑哪种GC,优先考虑ZGC,CMS等

  • 内存空间很大的情况下推荐哪种GC?考虑G1

ZGC收集器

这个收集器的特点是对延迟的容忍度很低,它的最大延迟只能是几个ms,暂停的时间不会随着堆带下,存活的对象数目的增加而增加,通常来说内存大小的适应度约是8MB~16TB

7. JVM调试工具实操

JMeter:测量性能,一般可以用来做QPS,压力测试等

线程组:

Ramp-up:意思说在几秒内到达线程总数

Loop Count:每个用户的请求次数

采样器:可以增加参数等

监听器:Graph Result

ServerSocker serverSocket = new ServerSocker(8080);
//它本质上就是一个文件,这个文件中存储了所有客户端的连接描述符也就是Client fd
//所有连接过来的连接都被存储在了这个文件中了

jps

可以通过jps来查看当前系统的Java进程,如何实现远程监控?

jstatd 2 > &1 > log.txt &在后台运行,在后台运行的守护进程

jps 192.168.132.128

可以通过shell脚本,提前输入各个节点的IP地址,如何执行定时的监控

jstat

JVM统计监控工具,一般来说可以看GC的维度,class的维度,可以使用gcutils来查看相关的信息

jstat -gcutil PID

YGC:产生YGC的次数

YGCT:YGC所消耗的时间

FGC:产生FullGC的次数

FGCT:FullGC消耗的时间

CCS:压缩的类,这是说在64位的机器上,因为不需要使用过大的空间来存储这个类

jcmd PID GC.heap_info
# 可以看到具体的GC内容,比如说每个分区的使用情况
# committed:JVM进程真实使用的内存
# reverved:JVM进程预定的内存大小,类似于一个虚拟内存,在具体使用的时候才申请
# 堆的内存是真实使用的,而不是预定的虚拟内存空间

jmap PID

  • dump:整个Java进程的内存情况
  • clstats:打印类加载器的信息

jhat dump之后的文件

jinfo:查看和修改虚拟机的配置,可以远程操作,它可以避免Java进程的重启而直接修改,注意并不是所有的参数都能够实时更改

jstack:打印Java的Stack,远程调试就是基于这个工具实现的

jconsole:图形界面,然后选择进程,是一个实时的监控,实际上是基于其他工具的具体数据来实现的

8. 字节码实战

  • .java文件编译为.class文件,这个过程中,.class文件还是存在磁盘上的
javac A.java
  • 反汇编指令查看
javap -c A.class >> A.txt

指令一般来说按照功能可以分为

  • invoke(调用函数)
    • invokespecial:调用构造函数,如super(),init(),private
    • invokedynamic,临时生成的程序,duck typing
    • invokestatic
    • invokevirtual:虚函数,可以重写的函数,除了static、private、final修饰之外的方法
    • invokeinterface
  • load(将参数压栈),将某个对象压入栈中
    • load variable => stack
    • getXXX
  • store(存储)=>ref => local variable,比如说将已知的值写入到其他地方上
  • 计算 => add/mul/div/sub
  • 跳转指令 => jsr/jsr_w

9. Java对象在内存中的结构

Object是如何初始化的(生命周期)

(1)类加载:初始化的过程,首先会先将.java文件通过javac指令编译成.class文件,注意此时.class文件还是在磁盘上的,然后经过ClassLoader就会将.class中的内容转换为bytecode,存储到磁盘中。此时类因为是首次加载的,因此就会执行static静态代码块,那么为什么会触发初始化呢?这是因为有线程new/访问静态成员/loader加载这些类了,就会导致初始化,但是这些类在被初始化过一次之后,就不会再执行初始化了,经过这个状态之后就是一个loaded

这个过程是线程安全的吗?是线程安全的,这个过程是基于单线程来完成的

(2)对象的创建过程,第一步,读取类信息,通过类信息确定要给对象分配多大的内存空间,也就是申请内存空间,第二步,执行构造函数,也就是一个<init>,注意,在Java中是允许部分初始化的这种情况出现的,经过这个过程就是一个create

这个过程是线程安全的吗?不是线程安全的,它允许部分初始化,也就是说它允许一种情况,就是提前将引用返回了,但是初始化工作没有完成

(3)对象使用中,在生命周期中是这样的被使用=>不可见=>不可达

  • 被使用:就是通过规定的RootSet就可以找到这个对象,而且这个对象是真真切切的被使用了的
  • 泄漏:就是通过RootSet可以找到,但是没有被使用
  • 不可达:通过RootSet不可以找到

(4)对象被回收,标记=>执行finalize=>回收内存空间,这一步的具体过程与垃圾回收算法有关

Object在内存中是如何存储的

在HotSpot的Object的格式如下:

Mark Word(ObjectHeader)
klassOop(ObjectHeader)普通对象指针
arraylength(ObjecterHeader),数组特有的字段
实例数据(Instance data)
padding

MarkWord

  • hashcode:对象的hashcode
  • age:分代年龄
  • 标志位(3bit)
    • unlocked(无锁)
    • light-weight-locked(轻量级锁)
    • heavy-weight-locked(重量级锁)
    • marked for gc(标记了GC)
    • biased(偏向锁)
  • Lock Record Address
    • 轻量级锁(指向栈中的锁记录)
    • 检查MarkWord是否指向当前线程的栈
  • Monitor Address
  • Forwarding Address:gc自己使用,在gc遍历的时候可以用这个

kclass:类信息指针

padding:将对象补齐为8个字节的倍数

总结:

  • 它有严格的定义
  • 不同的虚拟机可能会不同
  • 分成头部和数据,可能还有padding

空Object有多大?

16个字节。在64位下,MarkWord有8个Byte,kclassPointer有8个Byte,因此一共是16

10. ClassLoader是什么

ClassLoader是一个运行时组件,它能够将.class文件(磁盘)转成bytecode(内存),然后在运行时将bytecode交给执行引擎进行存储

那么在这个过程中,ClassLoader充当一个加载器的角色,也就是将.class文件转换为在执行过程需要用到的bytecode

只有一个ClassLoader够不够用?

首先要了解这个问题,.class的来源是多样的,也就是说可以来源于本地文件,可以来源于内存,来源于网络,来源于其他jar包,正是因为这些.class文件的来源多样,那么就意味着处理这些文件可能具有不同的策略

  • 不一样的缓存策略:比如说要将.class文件存储在哪里?如果在磁盘上怎么处理,如果在网卡的缓冲区中,怎么处理,如果想要从本地文件中读取.class,那么直接打开IO流即可,如果想要从远程读取.class,那么可能还要建立一个TCP/Socket来进行文件的传输
  • 不一样的安全策略:比如说在第三方的jar包中,开发者不希望使用者看到里面的实现细节,那么如何来实现呢?
  • 不一样的统计策略:这个范围比较广泛,主要说的是在加载不同来源的.class的统计逻辑可能是不一样的
  • 不一样的代理策略:就是说类中的特定字段信息,可以被classLoader进行修改,比如说,在某段字节码之前,夹带私货,在那之前添加一段代码

软件设计到遇到多样化的需求的时候,怎么办?

考虑继承、封装、组合,比如说I/O Stream,I/O本质上就是从流中读取字节,但是为了适应不同的需求,比如说从文件中读取,从远程网络读取,读取字符等这些需求,JDK基于I/O Stream实现了不同的流,然后通过这些不同的流来执行相关的代码

版本问题

问题:比如说有一个类A1.0版本依赖了JDK1.7的HashMap,然后类1.1依赖了JDK1.8的HashMap,那么在使用的时候,它的解决思路可以是这样的:就是类A1.0使用一个Loader,由这个Loader去完成类A1.0所需要所有类的加载,然后类1.1也使用一个Loader,然后由这个Loader去完成类1.1所有的类的加载

这样做的好处是边界会很清晰,也就是说能够确保一个Loader中加载类不会和第二个Loaer加载的类冲突掉

但是有个缺点就是相同的类会导致多次加载

在这种情况下,提出了一种树状的Loader,也就是设计一个顶级Loader,然后其他的Loader都基于这个Loader实现基础类的加载

如下图所示,在这个基础上实现不同的ClassLoader之间的依赖传递,这个设计让Foo和Bar之间是不可见的,但是它可以让ClassLoader中的东西可见,Bar也可以让这个ClassLoader中的东西可见

简单来说,就是对于一些通用的类,交给它的父级Loader去加载,设置为可见的

加载之后,会在ClassLoader内部中设置一个缓存,代表这个类已经被加载过了

Java类加载模型?

树状关系

  • Root Class Loader
  • Left Class Loader(边缘加载器,比如说加载第三方的jar包等)

委托模型

  • 子Class Loader委托父Class Loader完成工作
  • 缓存设置在父节点

ClassLoader

  • BootStrap Class Loader:加载Java的核心类,比如说JDK中的类
  • Extensions Class Loader:例如JRE目录下的lib/ext
  • Application Class Loader:classpth
  • Custom Class Loader:用户自定义

简单来说,双亲委派模型可以用下面的实例来进行理解

假设用户指定了一个类加载器,然后试图加载一个类,那么首先用户类加载器会检查自己是否已经加载过了这个类,如果已经加载过了,那么就直接返回加载成功。

如果没有加载过,那么就会价交给系统类加载器,这个加载器会检查自己是否已经加载过了这个类,如果没有加载,那么继续传递依赖上去,直到到达根加载器

然后根加载器就会试图加载这个类,如果根加载失败了,那么就会向下传递任务,直到加载成功或者抛出异常

11. 如何来打破双亲委派模型

class BinLoader extends ClassLoader{
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        if(name == "hello.go"){
            try{
                //自己生成一个类
                return defineClass("hello",new byte[],0,100);
            }catch (Exception e){
                e.printStackTrace();
            }
        }
        super.findClass(name);
    }
}

自定义Loader,并且重写findClass()

JVM

1. 对运行时数据区的理解

对运行时数据区的理解,重点是要放在运行时这个字眼进行理解,由于各个虚拟机之间的实现不同,这里我以最熟悉的HotSpot虚拟机作为一个切入点进行理解

假设JVM进程在运行过程中,执行了一条语句new A(),假设A从来没有被加载过:

  • 首先是将A.java文件编译为A.class文件,此时.class文件存储在磁盘上
  • 接着就是.class文件ClassLoadSystem转换为byteCode,然后存储到内存中,注意这个bytecode其实就是机器能够执行运行的机器码,又称为opCode,这个byteCode会被存储到JVM进程中的一个称为元空间的区域
  • 接着完成对象的初始化,此时一般来说会完成两件事情,完成对象A()的初始化①根据对象A的类信息为对象A分配内存②执行构造函数,注意,此时有可能有一种情况,就是说有可能提前返回这个对象的内存引用地址,而此时的构造函数并没有完成,在调用new A()的栈上分配一个引用,这个引用就是实际对象在堆上的地址
  • 上面这个过程涉及到两个区域,第一个区域是调用函数所使用的虚拟机栈,第二个区域就是分配对象内存的堆区域,虚拟机栈区域是私有的,每个线程都有一个自己的栈,这样做的意义是在上下文切换调度的时候,防止各个线程之间互相篡改栈中的数据
  • 接着构造完毕对象后,之前的虚拟机栈就会继续执行,执行过程需要依赖程序计数器,这个程序计数器记录的是下一条指令的地址
  • 注意,Java语言它本身并没有执行程序的权限,也就是说它无法直接使用CPU的资源,而是通过操作系统给出的系统调用函数,通过系统调用函数来执行相关的程序,那么底层的程序在运行也是需要一定的内存区域的,这各区域就称之为本地方法区,也就叫做Nativa Memory,在这个区域中存储了有C/C++语言的堆或者栈
  • 直接内存区域:这个区域比较特殊,通常用来高速传输数据,减少拷贝次数,比如说在Java通过Socket完成数据的传输,那么在这个过程中,网卡缓冲区中的数据通常会通过DMA设备,不需要CPU的干预,通过窃取总线周期,将数据传送到操作系统内核缓冲区中,然后当JVM进程需要使用这个内存的时候,它会有一个映射指针,通过这个指针就能够直接访问到这个内存了

堆和栈的本质区别

是辅助程序执行不可或缺的工具,它的功能主要有:

(1)作为函数执行的备忘录(局部变量表),在函数执行过程中,通常会有大量的变量,当需要使用这些变量的时候,就会将这些变量压栈,然后通过rbpesp等CPU寄存器的值来存储栈顶指针,然后通过这个指针来获取相关的值,比如说有

int a = 2;
int b = 3;
int c = a+b;

这时候就可以看到栈中就有5 3 2 这样顺序压下来的栈了

(2)作为函数执行的活动记录,栈中有一个很重要的概念是栈帧,通过栈帧,记录一次函数执行的过程,比如说在即将调用一次函数的时候,首先会将函数返回地址压栈,接着申请一块空间为返回值留空间,在函数执行完毕后就会将返回地址弹出,作为jmp的操作数了

从上面可以看出,栈是用来辅助函数执行用的,没有栈,函数就无法运行,而函数脱离了堆实际上也是可以运行的,但是通常我们会有需求说要让线程可以共享一块区域,那么这一块区域就是堆内存了

2. 方法区和永久代的关系

首先要说的是什么是方法区,在一个.class文件ClassLoader加载之后,这个.class中的信息,例如类名,方法名,字段信息,静态变量、以及JIT编译器编译后产生的OpCode等信息都需要被存储到一个全局可见的位置,这个位置就是方法区

那么在虚拟机规范中,方法区它是一个概念,具体的实现是交给了具体的虚拟机,以HotSpot为例,它的实现有方法区和元空间,它会将解析后的信息存储到元空间/永久代中

问题:为什么要将永久代替换为元空间?

这和堆内存通过年龄分代进行划分的道理是一样的,在过去,如果触发了老年代的垃圾回收,那么也就会触发永久代的垃圾回收,由于永久代中的对象的生命周期远远长于老年代的生命周期,如果每次触发老年代的GC都回去遍历永久代的情况,那么就会极大的降低效率,从这个角度来说:

(1)为了提高GC的效率,根据对象数据的生命周期,更能够能体现分代机制的优势

(2)对于一些常量,这些常量通常来说不会被GC,没有必要将其纳入堆中,受堆的管控

(3)对于一些例如字符串这样的对象,由于会被大量使用,因此最好定期做GC

如何调整元空间的大小?

-XX : MaxMetaspacesize:设置最大的元空间的大小

3. JVM常量池

常量池 == Class常量池,.java文件被编译成.class文件,Class文件除了包含类的版本,字段,方法,接口等描述信息之外,还有一项就是常量池,常量池是当Class文件被Java虚拟机加载进来后放在方法区各种字面量符号引用

  • 字面量:Java语言层面常量的概念,可以理解为魔法值,比如说1(基本数据类型的值),”haha”(文本字符串),声明为final的常量值
  • 符号引用:类的结构和完全限定名,类符号引用,字段符号引用,方法符号引用,接口方法的引用

运行时常量池是什么?

运行时常量池是方法区中的一部分,运行时常量池是当.class文件被加载到内存后,Java虚拟机会将Class文件常量池中的内容转移到运行时常量池中(运行时常量也是每个类都有一个),在程序的执行过程中,如果产生了常量,那么就有可能将文件常量池中的值放入到运行时常量池

什么是字符串常量池?

字符串常量池又被称为是字符串池,String Pool,JVM为了提升性能和减少内存的开销,避免字符串的重复创建,维护了一块特殊的内存空间,这个空间由String类来进行维护

说说原理吧,由于Java底层实际上是C++,因此它的底层是一个叫做stringTable.cpp的东西,这个东西其实就是一个HashSet,这个stringTable保存的是这个字符串对象的引用,这个引用是一个指针,指向的是堆中的字符串对象,JDK1.8版本的字符串常量池中存储的是字符串对象以及字符串常量值

在JDK1.7之前,字符串常量放在永久代中,在1.7之后,字符串常量和静态变量被移动到了堆中

为什么?这是因为永久代(方法区)的GC回收效率太低了,只有在整堆收集的才会被执行GC,而Java的字符串通常来说也是朝生夕灭的,将这些对象放在堆中,才能被GC执行引擎管辖,否则的话就会在内存中堆积大量没有使用的字符串

String s1 = "abc";
String s2 = "abc";
s1 == s2?

结论:它输出的是true,采用字面值创建一个字符串的时候,JVM首先去字符串池中查找是否有abc这个对象,如果没有这个对象,那么就会在字符串池中缓存这个abc对象,然后将这个abc的引用对象返回给s1

接着s2的赋值也是如此,它首先回去字符串串池中查找是否有abc这个对象,如果有,那么就直接返回这个引用

由于引用地址相同,因此直接返回true,那么创建了几个对象呢?创建了一个对象

String s1 = new String("abc");
String s2 = new String("abc");
s1 == s2?

结论:首先我们记住,使用new,会在堆上产生一个新的字符串对象

首先第一句,JVM首先会去字符串常量池中看有没有abc这个对象,如果没有则生成并且缓存,否则啥也不干,然后在堆上生成一个值为abc的对象,然后执行第二句,也是在堆上生成一个值为abc的对象

总之,生成了三个对象

关于intern()方法

一个初始化为空的字符串池,它由String类独自维护,当调用intern()的时候,如果池中已经包含了一个等于此String对象的字符串的时候,那么就直接返回池中的字符串,否则就将这个String添加到池中,然后返回这个池中的对象的引用

4. Java对象的创建过程

A a = new A();
A aa = new A();

执行这两条语句,会发生什么?

执行第一条语句

这是类A被第一次加载到JVM中,要执行下面的流程

(1)类加载流程:当虚拟机遇到一条new指令后,首先会先去检查这个指令的参数是否能够在常量池中定位这个符号的引用,如果缺失了,那么就会触发类的加载流程

(2)对象的内存分配,对象的内存分配通常来说会有两种风格,指针碰撞风格和空闲列表风格

  • 指针碰撞风格:常见于堆内存被划分为了规整的区域,这种分配方式会保存一个边界指针,这个边界指针指向了还没有分配的区域和已经分配的区域的边界,每次只要指针向后边移动相关的内存偏移即可,通常用于标记-整理算法
  • 空闲列表风格:常见于堆内存不规整的情况,这种分配方式会保存一个空闲分区表,它会将一个空闲分配分区保存到一个链表中,然后当需要分配内存的时候,就将遍历这个链表,然后基于一定的算法来选择出最佳的空闲分区

内存分配是线程安全的吗?

不是,内存分配是多个线程可以同时操作的,并且操作的是每个线程都可见的堆内存区域,JVM在执行这个的时候并没有加锁,而是基于预分配+CAS来实现的

  • 预分配TLAB:通过这个TLAB,每个线程都会有自己的一块空间,其他线程无法访问这个TLAB,这样的话在内存大小许可的情况下,就可以一直在这里面分配内存了,只有当内存不足的时候,才会申请其他空间
  • CAS+重试:通过CAS轮询内存区域是否被分配,如果被分配了就重试

(3)初始化零值:这个过程就是将对象所得到的内存空间都初始化为0,这一步保证了对象的实例字段在Java代码中不赋值就可以直接使用,程序能够访问到这些字段的数据类型所对应的零值

(4)设置对象头:就是设置Object head,包括有Mark wordhashcodearray_lengthage

(5)执行方法:执行构造方法

执行第二条语句

(1)发现类已经被加载过了,于是跳过.class的加载

同(2)-(4)

5. 对象访问定位的两种方式?有什么优缺点?

句柄模式:是这样描述的,引用指向的是句柄池中的一个句柄,这个句柄包含有一个实际对象在堆中的指针,还有一个对象的类型的指针

直接指针模式:是这样描述的,引用指向的就是堆中的实际对象,然后再通过这个实际对象的头部的类信息字段去找到具体的类信息

这两种对象的访问方式各有优势,由于在发生GC的时候,有可能会发生对象的移动,在这个过程中,那么直接指针模式,就需要修改每一个栈上引用的引用地址,这将会带来性能的下降

句柄模式的最大优点就是句柄的地址是稳定的,当对象被移动的时候,只需要修改句柄的引用就可以了

但是句柄模式在查找对象的时候是二次寻址,而直接指针是一次寻址。

6. 分代分配回收算法是什么?什么情况下会进入老年代?

分代年龄机制的理论是这样描述的:

  • 那些新创建出来的对象,往往是朝生夕死的,也就是很快就不会被使用,需要GC,GC大概率会收集掉这些对象
  • 那些经历过多次GC的对象,往往很难被GC掉,因此GC的一般不会收集掉这些对象

那么在设计的时候,通常来说就需要做一个分区,也就是将那些容易被GC的放一个区,那些不容易被GC的放一个区。这就是分代分配回收堆内存模型的一个基本思想,在分代分配回收机制下,通常会分为这样的区域

  • 伊甸园区(Eden区):新创建的对象都会放在这里
  • S0/S1区:在伊甸园区经历了GC之后,会基于标记/复制算法,将对象在这来回复制
  • 老年代:当S0/S1的对象经历了足够多次的GC之后,就会晋升为老年代中的对象

具体的流程是:

  • 当new出来一个对象,就会放入Eden,然后Eden发生MinorGC的时候,就会将那些存活的对象复制到S0
  • S0满了,就会将S0中存活的对象全部拷贝复制到S1中,循环往复
  • 如果S0/S1中存在有age达到能够晋升到老年代的对象,那么就将这个对象晋升上去
  • 如果老年代满了,那么就会触发一个MajorGC,这个GC会删除那些老年代中死亡的独享
  • 当永久代满了,就会触发FullGC

7. 如何判断对象是否死亡?

一般来说,判断对象是否死亡可以通过引用计数法/可达性分析法

首先新来讲讲引用计数法,这个方法可以基于对象的头部来做,就是在对象的头部埋下一个字段,称为说是被引用的次数,当有对象引用它了,那么count++,当取消引用了就count–,当count == 0,就可以标记这个对象为可以删除了,但是它有一个致命的问题,就是说这个不能出现循环引用,比如说A B C在程序中没有被使用了,但是A B C相互引用,最终就会导致对象无法被删除掉

可达性分析法:是一种比较好的方法,它的具体思路是从JVM进程中注册的那些GC Roots出发,向下BFS或者DFS去查询这GCRoots及其子节点,通常来说有双色标记法和三色标记法来实现这个算法,当节点为黑色的,那么就代表不用删除节点,当节点为白色的,那么就需要删除节点

哪些对象可以作为GC Roots?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如说有一个线程
private void func(){
    A a = new A();
}
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

对象可以被回收,就一定会被回收吗?

不一定,及时在可达性分析法中不可达的对象,也并非是非死不可的,这时候他们暂时处于缓刑的阶段,要真正宣告一个对象死亡,至少要经历两次标记的过程,可达性分析法中不可达的对象被第一次标记并且进行一次过滤,过滤的条件是说这个对象是否有必要执行finalize方法,当对象没有覆盖finalize方法,或者finalize方法已经被虚拟机调用过的时候,虚拟机将这两种情况视为没有必要执行

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收

还有的一个就是在引用类型不同的时候会有不同的情况发生

引用类型总结

  • 强引用:如果一个对象具有强引用,那么垃圾回收器就绝对不会回收它的,当内存空间不足,Java虚拟机宁愿抛出OOM错误,也不会回收这些拥有强引用的对象
  • 软引用:如果一个对象只有软引用,那么就类似于可有可无的生活用品,如果内存足够,那么垃圾回收器就不会回收它,如果内存空间不足了,那么就会回收这些对象的内存,软引用可以用来实现一些内存大小敏感的高速缓存
  • 弱引用:如果一个对象只有弱引用,那么也是可有可无的,但是它比软引用的生命周期要更短,一旦遇到垃圾回收,那么无论空间是否充足,都会回收这个对象
  • 虚引用:形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期,如果一个对象只有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收

虚引用主要用来跟踪对象被垃圾回收的活动

虚引用和弱引用的一个区别在于:虚引用和引用队列必须联合使用,当垃圾回收期准备回收一个对象的时候,如果发现它还有虚引用,那么就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中,程序可以通过判断引用队列中是否加入了虚引用,通过检查队列中是否存在这个对象,来了解这个对象是否被GC了。

软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止OOM等问题的发生

如何判断一个常量是废弃的

  • JDK1.7之前的运行时常量池逻辑包含字符串常量池存放在方法区,此时hotspot虚拟机对方法区的实现为永久代
  • JDK1.7字符串常量被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是hotspot中的永久代
  • JDK1.8 hotspot移除了永久代用元空间取代,这时候字串常量池还在堆中,运行时常量池还在方法区,只不过方法区的实现从永久代转换成了元空间

假如在字符串常量池中的存在字符串abc,如果当前没有任何String对象引用这个常量的时候,那么就证明废弃了,如果此时发生了内存回收的话而且有必要的话,abc就会被系统清理出常量池了

如何判断一个类是无用的?

方法区主要回收的是无用的类,那么如何判断一个类是无用的类?

  • 该类所有的实例都被回收了,Java堆中不存在该类得到任何实例
  • 加载这个类的ClassLoader已经被回收了,这也就意味着ClassLoader中没有这个类的缓存了
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法通过任何地方通过反射访问这个类的方法

8. 可达性分析的流程?哪些对象可以作为GC Roots?

可达性分析的流程,首先要从GC Roots的选取说起,它大约是从进程所必须要使用的对象开始向下搜索,这个搜索一般来说来自于:

(1)虚拟机栈中引用的对象,它表示说在函数执行过程中引用了这些对象,不能够轻易解除这些对象

(2)本地方法栈中引用的对象,同理,就是在执行本地方法的时候,如果需要涉及Java堆中的对象的时候,就会将这些对象给保留

(3)方法区中对象,方法区中一般存储的是常量,方法区中类静态属性引用的对象,方法区中常量引用的对象

(4)所有被同步锁持有的对象

那么可达性分析的流程大概是这样的,一般来说有三个过程,Mark=>Mutation=>Sweep

那么可达性分析的过程:

(1)首先是初始化标记,它会将GC Roots下的第一个对象给标记起来,然后标记成灰色

(2)并发标记,就是将灰色的对象弹出集合,然后将灰色对象下的子节点全部标记成灰色之后,将父灰色对象标记成黑色,然后不断循环这个过程

(3)由于并发标记的过程中,可能存在一个并发修改的问题,也就是说原本黑色的对象下增加了一个引用,如果不加以修正,那么就会导致对象被误删除,解决这个问题的思路是增量更新,所谓增量更新就是将新增引用,比如说A=>B,将A这个对象加入到一个集合中,然后在remark阶段中,取出这个集合中的元素,然后再次标记子节点

(4)并发标记的过程中,还会存在一个问题,就是一个对象从一个灰色对象的引用被取消了,这种情况下,有导致两种情况的发生这个白色对象接到了一个黑色对象的下面,这样的话就会导致白色对象被删除了,还有一种情况是这个白色对象接到一个灰色对象的下面,这样的话不会出现问题,首先第一种情况非常严重,因此为了避免这种情况,提出了原始快照的方案,这种方案是讲,当从灰色对象下解除一个对象的引用的时候,会记录这一条引用,然后在remark标记的过程中,还是会去扫描这一条引用

这种方式,由于在并发的过程中,看到的对象引用关系和一开始的是一样的,因此称之为原始快照

跨代引用问题了解吗?怎么解决的?

首先跨代引用问题说的是:在遍历一个分区的时候,由于涉及到了另外一个分区的引用的情况,从而导致不得不去扫描另外一个分区的现象称为跨代引用问题

假设一下,有一个年轻代对象A引用了老年代对象B,假设我们要回收老年代的对象,扫描的对象都都是基于老年代为根的,由于这个对象无法通过老年代的对象扫描到,因此这个老年代对象B就好像没有被引用到了导致误删。那么怎么解决的呢?

它是通过一张表,我们通常称为是记忆集,这个记忆集记录了所有跨代的引用关系,那么在遍历其中一个代的时候,还会扫描这张表,如果这张表存在老年代中的对象,就会标记,从而避免了又要去遍历另外一个代中的对象。

9. 垃圾回收的算法有哪些?有什么特点?

一般来说,垃圾回收算法有四种模型,这四种模型其实都是基于操作系统中的内存管理来实现的:

标记-清除算法:这种算法通常来说,还需要配合空闲链表进行实现,因为无法内存区域是不规整的,那么在这样的情况下,无法使用指针碰撞的规则,它的主要思路是,在标记结束后,就会直接删除这个对象,然后将可用的内存空间描述加入到空闲链表中,它的效率很高,因为不需要做额外的操作,但是它会使得内存空间产生大量的不连续碎片

标记-整理算法:这种算法是基于了第一种标记-清除算法,这种算法致力于使得内存空间变得规整,在这种模式下,不需要空闲链表了,因为在这种算法下,会使得内存空间被严格分成了没有分配的空间和有分配的空间,只要将分配指针向后移动就可以了,这种模式叫做指针碰撞

标记-复制算法:这种算法也是基于了第一种标记-清除算法,这种算法也是致力于使得内存空间变得规整,可以使用指针碰撞来分配内存

为什么标记整理算法的效率是比较低下的?

因为复制算法只需要把活的对象拷贝到S区,这个过程中只需要考虑移动堆顶指针,然后按照顺序分配内存即可,实现起来十分简单,运行高效,而标记整理算法,还需要考虑每个对象的大小以及位置,其中涉及的指针计算是比较复杂的,同时在移动对象的时候还需要移动指针的同时,复制内存数据,因此是十分低效的

但是它的内存利用效率是比较低的,因为每次只能使用一部分的内存空间

分代回收算法:当前虚拟机垃圾收集都是基于分代回收算法实现的,根据分代年龄假说,新生的对象都是朝生夕灭的,因此我们可以为新生的对象设置一个分区,每次回收这个分区,都大概率会有大量的对象被GC,那么选择什么算法呢?可以使用标记-复制算法,这是因为每次存活下来的对象都是很少的,因此每次的复制量都会比较低,因此使用这种算法能够取得更高的收益,而老年代的对象因为对象较大,而且每次GC只能回收很少的空间,如果采用复制的话,那么就意味着内存利用率会很低,此时使用标记-清除/标记-整理

为什么分为新生代和老年代?

新生代/老年代的出现是为了针对不同的年龄的对象执行不同的GC算法,比如说对新生代的GC每次存活下来的对象很少,因此它对内存空间的敏感程度是低于指针计算复杂度的敏感程度的,因此适合使用标记-复制算法

老年代由于对象较大,因此它对内存空间的敏感程度是高于指针计算复杂度的敏感程度的,因此适合使用标记-清除/标记-整理算法

10. 说说常见的垃圾收集器

串行收集器

主要的执行流程:在需要GC的时候,此时会触发STW,然后使用一个单线程的垃圾收集线程去执行Mark Sweep直到收集执行,因此在这个过程中,标记算法可以使用双色标记法,因为中间没有线程在做修改

新生代采用的是标记复制算法,老年代采用的是标记整理算法

  1. 吞吐量小,内存回收工作量不大
  2. 容忍延迟,不在意卡顿
  3. 单核,内存小:0~100M

ParNew收集器

ParNew的执行工作量实际上是要大于单线程的执行的,但是由于多核的优势,它可以提高吞吐量(指的是降低GC的时间占比),它特点就是在触发GC的时候,使用STW,然后启动多线程,用多线程执行GC

它在新生代会使用标记复制算法,在老年代会执行标记整理算法

Parallel Scavenge收集器

这个收集器主要是对于老年代有不同的收集策略

-XX:+UseParallelGC #老年代串行执行
-XX:+UseParallelOldGC #老年代并行执行

Parallel Scavenge收集器关注的是吞吐量(高效率的利用CPU),CMS等垃圾收集器的关注点更多的是用户线程的停顿时间,使用了这个垃圾收集器选项,就可以将内存管理优化的工作交给虚拟机去完成

Serial Old收集器
它是串行收集器的老年代版本,意思是在老年代执行STW+Mark+Sweep,其中老年代执行的是标记整理算法

Parallel Old

Parallel Scavenge的老年代版本,使用多线程和标记整理算法,在注重吞吐量和CPU资源的场合

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合在注重用户体验的应用上的使用

标记-清除算法,它的运作过程实现了让垃圾收集线程与用户线程基本上同时工作,分为四个步骤:

初始标记:暂停所有的线程(STW),并且记录下与root相连的对象,速度是很快的

并发标记:同时开启GC和用户线程,用一个闭包结构去记录那些可达的对象,但是在这个阶段结束之后,这个闭包结构并不能保证当前的标记是正确的,这是因为并发用户线程可能还会删除各自的引用,导致问题的发生

  • 增量更新:就是当添加一个白色对象到黑色对象的时候,这时候会将这个对象通过写屏障的方式(代理),将这个对象添加到一个集合中,然后在重新标记过程中处理
  • 原始快照:就是当删除一个灰色对象下的白色对象的时候,会记录下这个引用关系,在重新标记过程中处理

重新标记:STW,重新标记阶段就是为了修正并发标记期间因为用户程序运行而导致标记变动的那一部分的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长

并发清除:STW,回收垃圾对象

  • 对CPU资源是敏感的
  • 无法处理浮动垃圾
  • 它使用的回收算法,标记清除,将导致大量的外碎片

G1收集器

空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。在堆的结构设计时,G1打破了以往将收集范围固定在新生代或老年代的模式,G1将堆分成许多相同大小的区域单元,每个单元称为Region。Region是一块地址连续的内存空间

G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。Region的大小是一致的,数值是在1M到32M字节之间的一个2的幂值数,JVM会尽量划分2048个左右、同等大小的Region

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

G1收集的运作过程大致如下:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短
  • 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行
  • 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

全局变量和栈中引用的对象是可以列入根集合的,这样在寻找垃圾时,就可以从根集合出发扫描堆空间。在G1中,引入了一种新的能加入根集合的类型,就是记忆集(Remembered Set)。Remembered Sets(也叫RSets)用来跟踪对象引用。G1的很多开源都是源自Remembered Set,例如,它通常约占Heap大小的20%或更高。并且,我们进行对象复制的时候,因为需要扫描和更改Card Table的信息,这个速度影响了复制的速度,进而影响暂停时间。

有个场景,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为GC Roots。那不是得又做全堆扫描?成本太高了吧。

HotSpot给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。

想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

11. 什么是字节码?类的文件结构的组成是什么?

在Java中,JVM可以理解的代码就叫做字节码(扩展名为.class文件),它不面向任何特定的处理器,只面向虚拟机,Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型可移植的特点,所以Java程序在运行的时候是比较高效的,字节码转换为bytecode的过程实际上是JIT编译器完成的,JIT编译器会根据当前操作系统的底层决定将字节码编译为什么样的指令集

  • 魔数(每个Class文件的头4个节点称为是魔数,唯一的作用是确定这个文件是否为一个能被虚拟机接收的Class文件)
  • Class文件版本号
  • 常量池(Constant Pool),它主要存放的是两大常量,字面量和符号引用
  • 访问标志
  • 当前类
  • 字段表集合
  • 方法表集合
  • 属性表集合

12. 类的生命周期?类的加载过程?

类的生命周期是从.class文件被加载到虚拟机内存中开始到被卸载出内存为止,因此它的整个生命周期可以概括为:

(1)加载:通过全类名获取定义此类的二进制字节流,将字节流所代表的静态存储结构转换为方法区的运行时数据结构,在内存中生成bytecode,以及将解析出来的数据存储到方法区中。

每一个Java类都有一个引用指向加载它的ClassLoader,例如是数组类,数组类的ClassLoader是它的基元类型,这个过程可以控制到底是哪一个类加载器来执行类构造的方法,通过loadClass()方法来执行相关方法

(2)验证:验证是连接的第一步,它主要是将存储在内存中的bytecode以及class中的字节流中的信息是一个合法的类文件,否则会危害虚拟机甚至是操作系统的安全,主要是要验证.class的文件格式,元数据验证(比如说这个类是否有父类,这个类是否继承了不允许继承的类final)字节码的验证(程序语义是否正确)符号引用的验证(类的正确性验证)

(3)准备:准备阶段就是说这个类通过了验证,是一个合法的.class文件,准备阶段是正式为类变量分配内存并且设置类变量初始化的节点,这些内存都在方法区中分配

  • 此时完成内存分配的变量仅有类变量
  • 在JDK1.7之后,HotSpot就将原本放在永久代中的常量放到堆上了

(4)解析,解析阶段就是虚拟机将常量池内的符号引用替换为直接引用的过程,解析的动作针对类、接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用执行

在程序执行方法的时候,系统明确知道这个方法所在的位置,Java虚拟机为每个类都准备了一张方法表来存放类中的所有方法,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用这个方法了,通过解析操作符号引用就就可以直接转变为目标方法在类中方法表的位置,从而使得方法就可以被调用

(5)初始化,执行初始化方法<clinit>(),注意,这个是说类的初始化,这一步才开始真正地执行代码,这个方法是线程安全的,所以在多线程环境下进行类的初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现

当遇到 newgetstaticputstaticinvokestatic 这 4 条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。

  • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
  • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
  • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
  • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。

使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。

初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。

当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。

MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。

(6)使用

(7)卸载,该类的所有的实例对象都已经被GC了,堆中没有这个类的对象,该类没有在其他任何地方被引用,该类的类加载器已经被GC

因此由我们自定义的类加载器的类是可以被卸载的

13. 双亲委派模型是什么?不想用双亲委派模型要怎么破坏?

类加载的作用是什么?

类加载的作用是获取类的基本信息,比如说这个类的类名,实现了什么接口,父类是什么,有哪些字段,有哪些方法,是否是公有的,当我们新建一个对象的时候,因为要为这个对象分配内存,那么就需要要提前知道这个对象需要多大的空间,如何来调用这个对象中的方法,如何来执行这个对象的方法,因此加载类的作用就是为了创建对象,一般来说,类的基本信息会被加载到虚拟机中的方法区中,比如说在HotSpot中,就会被加载到元空间

有哪些类加载器?

BootstrapClassLoader:它是最底层的加载类,主要是用来加载Java的核心类库的,它是JVM的一部分,通常是用NULL的表示的,比如说你对String去getClassLoader,得到的是null

ExtentsionClassLoader:扩展加载器,主要负责加载%JRE_HOME%/lib/ext目录下的jar包

AppClassLoader:应用程序加载类,面向用户的加载类,负责加载当前应用classpath下的所有jar包和类

除此之外,还可以使用自定义的类加载器,通过自定义的类加载器,就可以实现自定义的类加载逻辑,比如说可以通过对字节码文件加密,只有我们自定义的类加载器才能进行解密

什么是双亲委派模型?

双亲委派模型描述的是JDK中推荐的一种的加载类的方式,它的核心思想是自底向上查找类是否被加载过,如果被加载过了,那么就无序加载,然后自顶向下去尝试加载类。执行流程可以详细描述为:

在类加载的过程中,系统会先判断这个类是否被加载过了,已经被加载的类会直接返回,否则才会尝试嘉爱这个类,类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是将这个请求向上传递到父级请求,这样的话,基于双亲委派模型的类加载器,最终都会收集到所有的请求,只有当父加载器无法实现这个加载的时候,子加载器才会尝试去自己加载

如何判定两个Java类是否相同?

JVM不但要判断类的全路径类名是否相同,还要判断此类的加载器是否相同,只有两者都相同的情况下,才能说这两个类相同

自定义类加载器,如果不打破双亲委派模型的话,那么可以:

  • 首先继承ClassLoader
  • 重写findClass()
  • 无法被父类加载的类最终会通过这个方法被加载

如果想要打破双亲委派模型,那么就需要重写findClass()方法了

这是因为findClass()定义的是从哪里去加载这个类,如果是默认的话就会向上递归,直到父加载器完成加载

例如说Tomcat服务器为了能够优先加载Web应用目录下的类,然后再通过加载其他目录下的类,就自定义了WebAppClassLoader

14. 双亲委派模型有什么好处?

双亲委派模型保证了Java程序运行的稳定性,可以避免类的重复加载,JVM区分不同类的方式不仅仅根据类名,想用的类文件被不同的类加载器加载也算作是不同的两个Class,同时,如果保证了双亲委派模型,那么如果有恶意第三方库伪造了一个JDK中的核心类,那么就会导致安全的问题,使用双亲委派模型能够保证使用的核心类是安全的。

15. 默认类加载器?

默认的类加载器有三种:

  • BootStrapClassLoader:这个加载器是JVM中的一个组件,它是虚拟机的一部分,本身不是使用Java语言实现的,因此在ClassLoadergetParent的时候就会得到null
  • ExtentsionClassLoader:这个加载器用来加载特定的包,比如说%JRE_HOME%/lib下的jar包,通过这个可以实现第三方库的加载
  • ApplicationClassLoader:用户类加载器

16. JVM性能监控工具?

可以使用jps进行Java进程监控,包括其PID

jstat:收集HotSpot虚拟机各方面的数据

jinfo:显示虚拟机的配置信息

jmap:查看Java的堆栈信息

如何排查死锁?
第一步,通过Linuxps -aux | grep java指令,查看运行中的Java进程,查看运行时间长的进程

第二步,通过jstack PID,就能够打印出哪些线程引发了死锁

17. 重要的参数?

如何指定堆内存:指定最大堆和最小堆

-Xms<heapSize>[unit]
-Xmx<heapSize>[unit]

如何设定新生代内存

-XX:NewSize=<youngSize>[unit]
-XX:MaxNewSize=<youngSize>[unit]
#配置新生代的最小内存256m,最大内存1024m
-XX:NewSize=256m
-XX:MaxNewSize=1024m
#设置最小和最大一致
-Xmn256m

如何调节老年代和新生代的比例?(重要)

-XX:NewRatio=1

指定元空间的大小

-XX:PermSize=N
-XX:MaxPermSize=N
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParNewGC
-XX:+UseG1GC

18. 遇到的GC问题如何解决?

YoungGC频繁怎么办?

假设有关任务会频繁地调用一个接口,YoungGC的次数在某一个时间点飙升,同时伴随着Old区域内存的快速升高,然后最终会导致一次Full GC,这样的情况一般可以将问题归结于对新生代的管理不当,一般来说有这样的情况:

  • 新生代的Eden区的配置太小,导致Eden的频繁GC,服务器的内存配置满足不了现有的业务量,优先对内存进行扩容,可以通过jmap -histo并且结合dump堆文件作进一步分析,查看是哪个对象占用了大量内存不释放。

YoungGC和OldGC都很频繁怎么办?

思路:

  • GC样本采集

如果是因为FullGC导致的系统卡顿,首先需要对GC情况进行一些数据的采集,下面的指令能够知道发生GC的次数和耗时

jstat gc -pid 2000 10000
  • 结合GC样本和JVM参数配置,分析堆内存中的对象流转情况

结合之前的GC次数以及JVM参数中的新生代和老年代中的空间,分析FootPrint

  • 结合对象挪动到老年代的规则,验证并且调优

定位内存突然飙升导致的OOM异常

# 执行堆栈分析,生成当前的堆栈快照,此时会生成两个文件,分别是.dump和.log文件
jmap -dump:format=b,file=A.log PID
# 通过查看导出的dump文件(可以使用jvisualvm进行解析,就可以看到某个实例在内存中的占比,然后根据代码定位即可)

CPU飙升怎么办?

CPU飙升是CPU密集型的操作过多,正常操作的时候基本上很少出现,此时就需要快速定位CPU过高的原因并且排除,一般来说有两种情况:程序中出现了死循环线程数无限增大(比如使用了CachedThreadPool)

一般步骤是

通过top指令查看当前哪个Java进程的CPU占用最高

通过top -H查看这个进程中的线程运行情况,就可以看到哪个线程的占用最高

然后将TID=>十六进制,然后通过jstack PID |grep -A TID 就可以从堆栈信息查询到相关的代码了


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录