《Java架构师的第一性原理》25Java基础之Java虚拟机第3篇常用面试题

发布时间 2023-12-21 14:03:30作者: 沙漏哟

jre、jdk、jvm的关系:

jdk是最小的开发环境,由jre++java工具组成。

jre是java运行的最小环境,由jvm+核心类库组成。

jvm是虚拟机,是java字节码运行的容器,如果只有jvm是无法运行java的,因为缺少了核心类库。

JVM内存模型

(1):堆<对象,静态变量,共享

(2):方法区<存放类信息,常量池,共享>(java8移除了永久代(PermGen),替换为元空间(Metaspace))

(3):虚拟机栈<线程执行方法的时候内部存局部变量会存堆中对象的地址等等数据>

(4):本地方法栈<存放各种native方法的局部变量表之类的信息>

(5):程序计数器<记录当前线程执行到哪一条字节码指令位置>

对象4种引用

(1):强(内存泄露主因)

(2):软(只有软引用的话,空间不足将被回收),适合缓存用

(3):弱(只,GC会回收)

(4):虚引用(用于跟踪GC状态)用于管理堆外内存

对象的构成:

一个对象分为3个区域:对象头、实例数据、对齐填充

对象头:主要是包括两部分,1.存储自身的运行时数据比如hash码,分代年龄,锁标记等(但是不是绝对哦,锁状态如果是偏向锁,轻量级锁,是没有hash码的。。。是不固定的)2.指向类的元数据指针。还有可能存在第三部分,那就是数组类型,会多一块记录数组的长度(因为数组的长度是jvm判断不出来的,jvm只有元数据信息)

实例数据:会根据虚拟机分配策略来定,分配策略中,会把相同大小的类型放在一起,并按照定义顺序排列(父类的变量也会在哦)

对齐填充:这个意义不是很大,主要在虚拟机规范中对象必须是8字节的整数,所以当对象不满足这个情况时,就会用占位符填充

如果判断一个对象是否存活:

一般判断对象是否存活有两种算法,一种是引用计数,另外一种是可达性分析。在java中主要是第二种

java是根据什么来执行可达性分析的:

根据GC ROOTS。GC ROOTS可以的对象有:虚拟机栈中的引用对象,方法区的类变量的引用,方法区中的常量引用,本地方法栈中的对象引用。

JVM 类加载顺序

(1):加载 获取类的二进制字节流,将其静态存储结构转化为方法区的运行时数据结构

(2):校验 文件格式验证,元数据验证,字节码验证,符号引用验证

(3):准备 在方法区中对类的static变量分配内存并设置类变量数据类型默认的初始值,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中

(4):解析 将常量池内的符号引用替换为直接引用的过程

(5):初始化 为类的静态变量赋予正确的初始值(Java代码中被显式地赋予的值)

JVM三种类加载器

(1):启动类加载器(home) 加载jvm核心类库,如java.lang.*等

(2):扩展类加载器(ext), 父加载器为启动类加载器,从jre/lib/ext下加载类库

(3):应用程序类加载器(用户classpath路径) 父加载器为扩展类加载器,从环境变量中加载类

双亲委派机制

(1):类加载器收到类加载的请求

(2):把这个请求委托给父加载器去完成,一直向上委托,直到启动类加载器

(3):启动器加载器检查能不能加载,能就加载(结束);否则,抛出异常,通知子加载器进行加载

(4):保障类的唯一性和安全性以及保证JDK核心类的优先加载

双亲委派模型有啥作用:

保证java基础类在不同的环境还是同一个Class对象,避免出现了自定义类覆盖基础类的情况,导致出现安全问题。还可以避免类的重复加载。

如何打破双亲委派模型?

(1):自定义类加载器,继承ClassLoader类重写loadClass方法;

(2):SPI

tomcat是如何打破双亲委派模型:

tomcat有着特殊性,它需要容纳多个应用,需要做到应用级别的隔离,而且需要减少重复性加载,所以划分为:/common 容器和应用共享的类信息,/server容器本身的类信息,/share应用通用的类信息,/WEB-INF/lib应用级别的类信息。整体可以分为:boostrapClassLoader->ExtensionClassLoader->ApplicationClassLoader->CommonClassLoader->CatalinaClassLoader(容器本身的加载器)/ShareClassLoader(共享的)->WebAppClassLoader。虽然第一眼是满足双亲委派模型的,但是不是的,因为双亲委派模型是要先提交给父类装载,而tomcat是优先判断是否是自己负责的文件位置,进行加载的。

SPI:(Service Provider interface)

(1):服务提供接口(服务发现机制):

(2):通过加载ClassPath下META_INF/services,自动加载文件里所定义的类

(3):通过ServiceLoader.load/Service.providers方法通过反射拿到实现类的实例

SPI应用?

(1):应用于JDBC获取数据库驱动连接过程就是应用这一机制

(2):apache最早提供的common-logging只有接口.没有实现..发现日志的提供商通过SPI来具体找到日志提供商实现类

双亲委派机制缺陷?

(1):双亲委派核心是越基础的类由越上层的加载器进行加载, 基础的类总是作为被调用代码调用的API,无法实现基础类调用用户的代码….

(2):JNDI服务它的代码由启动类加载器去加载,但是他需要调独立厂商实现的应用程序,如何解决? 线程上下文件类加载器(Thread Context ClassLoader), JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC

导致fullGC的原因

(1):老年代空间不足

(2):永久代(方法区)空间不足

(3):显式调用system.gc()

堆外内存的优缺点

Ehcache中的一些版本,各种 NIO 框架,Dubbo,Memcache 等中会用到,NIO包下ByteBuffer来创建堆外内存 堆外内存,其实就是不受JVM控制的内存。

相比于堆内内存有几个优势:

减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了复制这项工作。可以扩展至更大的内存空间。比如超过 1TB 甚至比主存还大的空间。

缺点总结如下:

堆外内存难以控制,如果内存泄漏,那么很难排查,通过-XX:MaxDirectMemerySize来指定,当达到阈值的时候,调用system.gc来进行一次full gc 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合 jstat查看内存回收概况,实时查看各个分区的分配回收情况, jmap查看内存栈,查看内存中对象占用大小, jstack查看线程栈,死锁,性能瓶颈

JVM七种垃圾收集器

(1):Serial 收集器 复制算法,单线程,新生代)

(2):ParNew 收集器(复制算法,多线程,新生代)

(3):Parallel Scavenge 收集器(多线程,复制算法,新生代,高吞吐量)

(4):Serial Old 收集器(标记-整理算法,老年代)

(5):Parallel Old 收集器(标记-整理算法,老年代,注重吞吐量的场景下,jdk8默认采用 Parallel Scavenge + Parallel Old 的组合)

(6):CMS 收集器(标记-清除算法,老年代,垃圾回收线程几乎能做到与用户线程同时工作,吞吐量低,内存碎片)以牺牲吞吐量为代价来获得最短回收停顿时间-XX:+UseConcMarkSweepGC jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代) jdk1.9 默认垃圾收集器G1

使用场景:

(1):应用程序对停顿比较敏感

(2):在JVM中,有相对较多存活时间较长的对象(老年代比较大)会更适合使用CMS

cms垃圾回收过程:

(1):初始标识<找到gcroot(stw)>

GC Roots有以下几种:

1:系统类加载器加载的对象

2:处于激活状态的线程

3:JNI栈中的对象

4:正在被用于同步的各种锁对象

5:JVM自身持有的对象,比如系统类加载器等。

(2):并发标记(三色标记算法) 三色标记算法处理并发标记出现对象引用变化情况:黑:自己+子对象标记完成 灰:自己完成,子对象未完成 白:未标记;并发标记 黑->灰->白 重新标记 灰->白引用消失,黑引用指向->白,导致白漏标 cms处理办法是incremental update方案 (增量更新)把黑色变成灰色 多线程下并发标记依旧会产生漏标问题,所以cms必须remark一遍(jdk1.9以后不用cms了)

G1 处理方案:

SATB(snapshot at the begining)把白放入栈中,标记过程是和应用程序并发运行的(不需要Stop-The-World) 这种方式会造成某些是垃圾的对象也被当做是存活的,所以G1会使得占用的内存被实际需要的内存大。不过下一次就回收了 ZGC 处理方案:颜色指针(color pointers) 2*42方=4T

(3):重新标记(stw)

(4)并发清理

备注:重新标记是防止标记成垃圾之后,对象被引用

(5):G1 收集器(新生代 + 老年代,在多 CPU 和大内存的场景下有很好的性能) G1在java9 便是默认的垃圾收集器,是cms 的替代者 逻辑分代,用分区(region)的思想(默认分2048份) 还是有stw 为解决CMS算法产生空间碎片HotSpot提供垃圾收集器,通过-XX:+UseG1GC来启用

G1中提供了三种模式垃圾回收模式

(1):young gc(eden region被耗尽无法申请内存时,就会触发)

(2):mixed gc(当老年代大小占整个堆大小百分比达到该阈值时,会触发)

(3):full gc(对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发)

(8):ZGC和shenandoah (oracle产收费) no stw

arthas 监控工具

(1):dashboard命令查看总体jvm运行情况

(2):jvm显示jvm详细信息

(3):thread 显示jvm里面所有线程信息(类似于jstack)  查看死锁线程命令thread -b

(4):sc * 显示所有类(search class)

(5):trace 跟踪方法

定位频繁full GC,堆内存满 oom

第一步:jps获取进程号 第二步:jmap -histo pid | head -20 得知有个对象在不断创建 备注:jmap如果线上服务器堆内存特别大,,会卡死需堆转存(一般会说在测试环境压测,导出转存) -XX:+HeapDumpOnOutOfMemoryError或jmap -dumpLformat=b,file=xxx pid 转出文件进行分析 (arthas没有实现jmap命令)heapdump --live /xxx/xx.hprof导出文件

G1垃圾回收器(重点)

回收过程 (1):young gc(年轻代回收)--当年轻代的Eden区用尽时--stw 第一阶段,扫描根。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等 第二阶段,更新RS(Remembered Sets)。处理dirty card queue中的card,更新RS。此阶段完成后,RS可以准确的反映老年代对所在的内存分段中对象的引用 第三阶段,处理RS。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。第四阶段,复制对象。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段 第五阶段,处理引用。处理Soft,Weak,Phantom,Final,JNI Weak 等引用。

(2):concrruent marking(老年代并发标记) 当堆内存使用达到一定值(默认45%)时,不需要Stop-The-World,在并发标记前先进行一次young gc

(3):混合回收(mixed gc) 并发标记过程结束以后,紧跟着就会开始混合回收过程。混合回收的意思是年轻代和老年代会同时被回收

(4):Full GC? Full GC是指上述方式不能正常工作,G1会停止应用程序的执行,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免Full GC的发生,一旦发生需要进行调整。

什么时候发生Full GC呢?

比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决

尽管G1堆内存仍然是分代的,但是同一个代的内存不再采用连续的内存结构

年轻代分为Eden和Survivor两个区,老年代分为Old和Humongous两个区

新分配的对象会被分配到Eden区的内存分段上

Humongous区用于保存大对象,如果一个对象占用的空间超过内存分段Region的一半;

如果对象的大小超过一个甚至几个分段的大小,则对象会分配在物理连续的多个Humongous分段上。

Humongous对象因为占用内存较大并且连续会被优先回收

为了在回收单个内存分段的时候不必对整个堆内存的对象进行扫描(单个内存分段中的对象可能被其他内存分段中的对象引用)引入了RS数据结构。RS使得G1可以在年轻代回收的时候不必去扫描老年代的对象,从而提高了性能。每一个内存分段都对应一个RS,RS保存了来自其他分段内的对象对于此分段的引用

JVM会对应用程序的每一个引用赋值语句object.field=object进行记录和处理,把引用关系更新到RS中。但是这个RS的更新并不是实时的。G1维护了一个Dirty Card Queue

那为什么不在引用赋值语句处直接更新RS呢?

这是为了性能的需要,使用队列性能会好很多。

线程本地分配缓冲区(TLAB:Thread Local Allocation Buffer)?

栈上分配->tlab->堆上分配 由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步。为了避免加锁,提高性能每一个应用程序的线程会被分配一个TLAB。TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁

PLAB:Promotion Thread Local Allocation Buffer

G1会在年轻代回收过程中把Eden区中的对象复制(“提升”)到Survivor区中,Survivor区中的对象复制到Old区中。G1的回收过程是多线程执行的,为了避免多个线程往同一个内存分段进行复制,那么复制的过程也需要加锁。为了避免加锁,G1的每个线程都关联了一个PLAB,这样就不需要进行加锁了

OOM问题定位方法

(1):jmap -heap 10765如上图,可以查看新生代,老生代堆内存的分配大小以及使用情况;

(2):jstat 查看GC收集情况

(3):jmap -dump:live,format=b,file=到本地

(4):通过MAT工具打开分析

99 直接读这些牛人的原文

JVM 内存管理基础知识

类文件的结构、JVM 的类加载过程、类加载机制、类加载器、双亲委派模型

深度揭秘垃圾回收底层,这次让你彻底弄懂她

虚拟机系列 | JVM运行时数据区