目录
Parallel Scavenge 收集器(多线程复制算法、高效):
Java类从编译到执行的全过程
Java类从编译到操作系统的执行,大致经过几个部分的处理,分别如下:
Java类->编译器编译为class文件->类加载子系统->运行时数据区->执行引擎->操作系统。
乍一看上面描述的可能有点懵,那么让我们来讲一讲,这几部分到底是怎么协同工作的:
1. 编码:我们通过编程工具对java语言进行编写,此时编写出来的是后缀为.java的java类文件,这个文件是为了让我们编程时更加便捷,是我们能看得懂的文件,但是虚拟机看不懂。
2. 编译:运行代码时,通过计算机中的javac编译器,将java文件转化为class文件,这个文件是jvm所能看得懂的文件。
3. 类加载子系统:通过这一步,对class文件进行加载、链接、初始化操作,最终将class文件的信息加载到jvm的内存中。
4. 运行时数据区:在这一步,class文件被大卸八块,内部对象被分配到堆、方法被分配到栈、变量将被赋值、执行步骤将被记录。
5. 执行引擎:字节码文件会被分配到执行引擎执行,执行引擎以指令为单位读取java字节码,一条条的执行指令,执行引擎在执行时会调用GC来进行垃圾回收。
6. 操作系统:执行引擎在执行时通过JVM指令,指示JVM应该做什么工作,而JVM指令则是将操作系统的指令做出封装后的产物,换句话说,JVM此时告诉操作系统,应该做什么。
看了以上的介绍,大家是不是有所了解了呢,下面让我们来一起了解一下类加载子系统、运行时数据区、执行引擎、垃圾回收算法这几部分的相关知识吧。
类加载子系统
类加载子系统的具体结构如下图所示:
类加载器:
BootstrapClassLoader:加载目录:lib/rt.jar等核心类。
ExtentionClassLoade:加载扩展jar包,jre/lib/ext/*.jar
AppClassLoader:加载classpath内容
CustomClassLoader:加载自定义的class类
这一部分需要提的就是,类加载器采用双亲委派的原则,对class类进行加载,关于双亲委派,具体解释如下:
双亲委派:
当一个类加载器收到需要加载的请求时,它自己不会先去加载,而是将这个请求交给父类的加载器去加载,如果父类的加载器还有父类,那就一直向上请求直到请求到顶级的加载器。如果父类可以成功加载,那就直接成功返回;如果父类不能成功加载,那就由子加载器尝试自己加载,实际上就是收到请求后自己先不处理给父类处理。
用大白话说就是,儿子在加载时不直接进行加载,而是一层一层的向上询问,父亲加载了吗,父亲没加载的话再问它的父亲,直至顶层,最后都没加载的话,顶层的加载器再逐层向下进行委派,直至委派到最底层的加载器。
为什么要双亲委派呢?
为了安全和避免类的重复加载,例如:如果直接加载,不委派给父类加载器,那我们可以重写java核心类库,覆盖java的类库,在代码加入自己的逻辑,把代码移交给客户,在客户运行程序时通过自定义的类去窃取客户信息。
运行时数据区
运行时数据区的结构如下:
运行时数据区可谓是jvm面试的重点,下面我们来介绍一下运行时数据区的组成,以及相关的问题:
堆:
是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行 垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以 细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
新生代:用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区,默认分配内存比例:8:1:1.
Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老 年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行 一次垃圾回收。
ServivorFrom:上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
ServivorTo:保留了一次 MinorGC 过程中的幸存者。
堆内存中的垃圾收集规则:
新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC仍然存活的对象移动到老年代。
老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。
Minor GC : 清理年轻代
Major GC : 清理老年代
Full GC : 清理整个堆空间,包括年轻代和永久代
所有GC都会停止应用所有线程。
新生代对象在经历过几次垃圾回收之后存活,才会被转到老年代?
当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升),主要存放应用程序中生命周期长的内存对象,MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没 有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减 少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的 时候,就会抛出 OOM(Out of Memory)异常。
为什么是15次?:
一个Java对象在JVM内存中的布局由三个部分组成,分别是对象头、实例数据、对齐填充,而一个对象的GC年龄,是存储在对象头里面的,而对象头里面有4个bit位来存储GC年龄,而4个bit位能够存储的最大数值是15,所以从这个角度来说,JVM分代年龄之所以设置成15次是因为它最大能够存储的数值就是15,虽然JVM提供了参数来设置分代年龄的大小,但是这个大小不能超过15,而实际当中也不一定非得到15次后才挪到老年代,在ServivorTo的内存占用率达到50%时,会将ServivorTo中最大的对象挪到老年代,新创建的对象在栈和线程内部以及eden区无法进行分配时,也会直接被放进老年代。
栈:
是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成 的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
比如:
在main方法中定义 int i= 8; i=i++;
将8压入栈,将8弹出,放入局部变量表的i中,此时i等于8
i=i++,将i的值拿出来压栈,此时局部变量表的值进行了++是9,但是此时弹出的栈值是8.
++i正是做的相反的操作
方法区:
用于存储被 JVM 加载的类信息、常量、静 态变量、即时编译器编译后的代码等数据,运行时常量池(Runtime Constant Pool)也是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加 载后存放到方法区的运行时常量池中。
Java8的元数据区:
Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间 的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用 本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
本地方法栈:
本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个 C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。
程序计数器:
记录当前代码执行的行号信息,就是执行到class文件的哪一行了。
执行引擎
字节码文件会被分配到执行引擎执行,执行引擎以指令为单位读取java字节码,一条条的执行指令,执行引擎结构如下:
垃圾回收算法
如何定位垃圾?
引用计数法:
对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。
可达性分析:
如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
标记清除算法(Mark-Sweep):
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间,最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
复制算法(copying):
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
标记整理算法(Mark-Compact):
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
分代收集算法:
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(YoungGeneration)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
新生代与复制算法:
大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。
老年代与标记复制算法:
老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。 1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。 2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。 3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,EdenSpace 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 FromSpace 进行清理。 4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。 5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。 6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。
分区收集算法:
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。
垃圾回收器介绍以及组合
Serial 垃圾收集器(单线程、复制算法):
Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
ParNew 垃圾收集器(Serial+多线程):
ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。【Parallel:平行的】ParNew虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。只有ParNew它能与CMS收集器配合工作。
Parallel Scavenge 收集器(多线程复制算法、高效):
Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。
Serial Old 收集器(单线程标记整理算法 ):
Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在 Client 默认的 java 虚拟机默认的年老代垃圾收集器。在 Server 模式下,主要有两个用途: 1. 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。 2. 作为年老代中使用 CMS 收集器的后备垃圾收集方案。
Parallel Old 收集器(多线程标记整理算法):
Parallel Old 收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在 JDK1.6才开始提供。在 JDK1.6 之前,新生代使用 ParallelScavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。
CMS 收集器(多线程标记清除算法):
Concurrent mark sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:
初始标记:只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
并发标记:进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程
重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记 记录,仍然需要暂停所有的工作线程。
并发清除:清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。
G1 收集器:
Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是: 1. 基于标记-整理算法,不产生内存碎片。 2. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。 G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。追求低停顿,建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经实现Java(RTSJ)的来及收集器的特征
垃圾收集器之间的配合使用:
连线的都可以配合使用:
JVM常见面试问题解答
JVM调优场景举例
所谓调优,不仅仅是针对线上出现了JVM问题的解决与跟踪,我理解调优包含以下几方面:
- 根据需求进行JVM规划和预调优。
- 优化JVM运行环境。
- 解决JVM运行中遇到的问题。
以下罗列几种调优的场景供大家参考,也可将场景带入自身项目中:
案例1,如何确认服务器配置?:
案例2,如何选择合适的垃圾回收器?:
案例3,系统cpu占用100,如何调优?:
案例4,系统内存过高,如何解决?:
案例5,如何面对cpu占用率居高不下?:
实际工作中遇到的案例
以下是小编在实际工作中遇到的JVM问题的举例:
场景1 CPU占用率过高:
试运行时,发现周期性的在某个月的月底的一天中的两个小时,会有cpu居高不下的情况,每次过了这两个小时就没事了,起初以为是访问量增大没太在意,但是后来再次出现,查询了登录日志表,发现访问量并不大,开始排查原因:
- 使用top命令查看cpu占用情况,找到java占用的线程号。
- 使用top-Hp 线程号的命令查看线程的情况。
- 找到占用率最高的线程的线程号,使用jstack工具查看线程执行情况。
- 可以定位到哪个类的哪一行了,进行验证。
最后发现,在一个查询类中循环和判断的次数很多(每到月底会对新增的政策做出查询和导出,要求先查询万人助企的政策,然后按照万人助企下面的分类进行复杂排序,再查询其他类型的政策,再按照复杂规则排序),代码逻辑很是复杂,督促整改,最后将数据重新依据业务排序要求重新进行清洗和打标(修改了模型的入库顺序,同时要求模型的同事按照新的逻辑进行了分类),以简单的代码逻辑实现了业务需求。
场景2:CPU占用过高的问题:
1. 怀疑是不是GC导致的CPU突然变高。
2.使用top命令查看占用cpu较高的线程,但是发现找不到重点的线程了。
3.然后使用jstat -gcutil 线程号 端口号 打印出了gc日志。
4.发现Fullgc占用时间较长,stop the world 时间长达21分钟,查看内存分配怀疑分配给应用的内存小,但是运维反馈分配多少吃多少,所以确定是代码的问题了。
5.使用jmap命令下载gc日志,使用eclipse进行分析后发现,对象什么的都是正常的,使用命令,jstat -gccause 16671 1000 后发现,有代码中出现了System.gc()。
6. 代码场景,统计中国移动每个分公司参加培训的人员信息,全国各地有好多个分公司,把这个统计写在了程序里面,而不是写在了sql里面,并且写完了之后怕对象占用的过高,所以又调用了System.gc()。
场景3:
突然间有一天客户经理反馈系统整体变慢,且运维也反馈了有OOM出现并备份了GC的相关日志,申请相关的GC日志后,通过JVM自带的工具进行分析,发现一个类里面的一个对象占用过多,客户开通产品的程序中,客户可以开通最多好几千个产品,在持久化时可传入list对象,进行批量的持久化修改操作,只需要传入一次即可,有个程序写持久化的时候循环向list中放入对象,第一次放一个第二次放两个第三次放三个…….导致本该一次性完成的动作,循环了好几万次,造成了内存溢出。
JVM常用的命令和参数
常用命令:
1、jps:查看进程及其相关去信息
2、jmap:用来生成dump文件和查看堆相关的各类信息的命令。
3、jstat:查看jvm运行时的状态信息
4、jstack:查看jvm线程快照的命令
5、jinfo:查看jvm参数和动态修改部分jvm参数
常用参数:
1、-Xms:初始化堆大小
2、-Xmx:最大堆大小
3、-Xmn:新生代的内存空间大小
4、-XX:SurvivorRatio
5、-Xss:每个线程的堆栈大小
6、-XX:PermSize:设置永久代初始值
7、- XX:MaxPermSize:设置永久代最大值
对象的创建过程
- 加载:通过classLoader加载对象。
- 链接:验证、准备、解析
- 初始化:为类中的符号引用赋值,将内存中的地址指向变量。
- 申请对象内存
- 成员变量赋默认值
- 调用构造方法
- 成员变量顺序赋初始值
- 执行构造方法语句
对象在内存中的存储布局
普通对象:
数组对象:
对象头包括什么
对象头没有固定的内容,根据对象的状态去划分,下面的内容说出来即可:
对象怎么定位
由于reference类型在Java虚拟机规范里只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。
通过句柄池:
如果使用句柄访问方式,Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。使用句柄方式最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
通过直接指针:
如果使用该方式,Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。使用直接指针方式最大的好处就是速度更快,他节省了一次指针定位的时间开销。
HotSpot而言,他使用的是直接指针访问方式进行对象访问,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
对象的分配过程
- 先尝试在栈上分配,对象过大的话。
- 直接放在老年代。
- 否则,放在堆的edan区,之后经过S1和S2的洗礼,到达老年代。
内存泄露和内存溢出
内存泄漏是指程序在申请内存后,无法释放已申请的内存空间。内存溢出是指程序申请内存时,没有足够的内存供申请者使用。
最大堆和最小堆的设置
假设如果在生产环境中,初始堆大小-Xms与最大堆大小-Xmx是不等的,那么JVM就会根据堆内存的使用情况,动态的向操作系统申请内存,扩大或者是缩小,以-Xmx和-Xms的值为上下界,这里的每一次调整都会产生一定的系统开销,虽然做到了动态申请堆大小的能力,不过生产环境中,很少说一台机器跑好多个JAVA程序,一般情况下都是一对一,那么动态申请调整堆大小就没有意义了,因为不管内存申请的多还是少,都只是这个JAVA程序在用,不需要给其他的程序腾出空间,相反的,如果把初始堆大小-Xms与最大堆大小-Xmx设置成不相等,那么反而画蛇添足,因为如果初始堆大小-Xms与最大堆大小-Xmx不相等,那么就会需要申请空间时,而每次申请空间,就会产生相应的系统开销,同时如果一开始堆大小是-Xms,会增加程序运行时进行垃圾回收的次数,降低程序的性能。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/116511.html