文章目录:
Java内存区域
JVM的主要组成部分及作用
JVM运行时数据区域
关于HotSpot虚拟机对象问题
对象是如何创建的
创建对象时内存是如何分配的
如何处理并发安全问题
对象的内存布局
对象的访问方式有哪些
内存溢出异常问题
Java内存泄漏和内存溢出是什么,如何避免
什么情况会发生栈内存溢出
JVM垃圾回收
Java中垃圾回收是什么,为什么需要垃圾回收?
Minor GC和Full GC有什么不同?什么情况下会触发Full GC和Minor FC?
为什么要减少Full GC的发生?
JVM的内存分配与回收
Java中都有哪些引用类型
如何判断对象是否可以回收
JVM中的永久代中会发生垃圾回收吗?元空间会发生垃圾回收吗?
有什么办法主动通知虚拟机进行垃圾回收?
垃圾回收算法
垃圾收集器
关于类加载
简述类的生命周期
简述类加载过程
类加载器有哪些
什么是双亲委派机制
有哪些打破双亲委派机制的例子
JVM调优相关
虚拟机性能监控的一些命令
JVM如何调优
这里先简单解释下JVM(Java虚拟机)的作用,一般C或者C++都是直接将代码生成机器指令,CPU可以直接执行这些指令,而Java则需要先生成字节码,JVM再将字节码解释成机器码。这么做的好处就是JVM屏蔽了底层平台的差别,可以做到一次编译,再各个平台运行,比如在Windows编译,也可以在Linux运行,这么做的缺点是JVM会影响性能,这也是Java的性能一般不如C或C++的原因。
面试中的高频面试题,需要背下来
JVM主要由类装载系统、执行引擎、运行时数据区、本地接口等四部分组成,其中运行时数据区是重点掌握内容,如下图
类装载子系统(类加载器):加载类文件到内存
执行引擎:也成为解释器,负责解释指令,交由操作系统执行
本地库接口:与其他语言交互时所使用的
运行时数据区:JVM的内存区域
工作原理:首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
JVM的运行时数据区主要由方法区、堆、虚拟机栈、本地方法栈、程序计数器组成,其中方法区和堆是线程共享数据区,虚拟机栈、本地方法栈、程序计数器是线程私有数据区,结构见上图。
程序计数器:当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成,并且程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈:虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至完成的过程中,都会对应着一个栈帧在虚拟机栈中入栈到出栈的过程
本地方法栈:本地方法栈与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的,并且与虚拟机栈一样,也会抛出StackOverflowError
和OutOfMemoryError
堆:Java 虚拟机中内存最大的一块,几乎所有的对象实例都在这里分配内存
方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
HotSpot虚拟机是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。下面会以HotSpot虚拟机为背景了解下Java堆中对象的分配、布局和访问的过程
对象的创建过程主要有以下几个过程
类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存:在类加载检查后,就要为新生对象分配内存了,对象内存所需大小在类加载完成后便可以确定,内存分配方式根据Java堆中内存是否完整主要分为指针碰撞和空闲列表两种。
初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这也是为什么字段在Java代码中可以不赋值就能直接使用的原因。
设置对象头:初始化零值后,虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息都是存放在对象的对象头中。根据虚拟机当前的运行状态不同,如是否使用偏向锁等,对象头都会有不同的设置方式。
执行init方法:上述操作完成后,从虚拟机的角度看,一个新的对象已经产生了。但从Java程序的角度看,对象创建才刚刚开始,
方法还没有执行,所有的字段都还为零。所以,一般执行完new
指令后还会接着执行
方法,把对象按照程序员的意愿进行初始化(赋值),这样一个真正可用的对象才算生产出来
上文提到,创建对象的内存分配方式会根据Java内存是否完整分为指针碰撞(完整)和空闲列表(不完整)两种:
指针碰撞:假设为Java堆中内存是绝对完整的,所有用过的内存放到一边,空闲的内存放到另一边,中间放着一个指针作为分界点的指示器,所分配的内存就是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
空闲列表:假设Java堆中的内存并不是完整的,已使用的内存和空闲内存都混在一起了,这时虚拟机需要维护一个列表,用来记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
注:选择哪种分配方式由Java堆是否完整决定,Java堆是否完整由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等垃圾收集器时系统采用的是指针碰撞,在使用CMS等基于标记擦除算法的收集器时,采用的是空闲列表。
在创建对象时还需要保证线程安全,因为对象创建在虚拟机中是非常频繁的,即使仅仅修改了一个指针所指向的位置,在并发场景下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案有以下两种:
采用CAS加上失败重试的方式保证更新操作的原子性(CAS有在Java并发编程中提到,可以看看之前的文章)
每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程需要分配内存就在哪个线程的TLAB上分配,只有TLAB用完或对象大于TLAB中的剩余内存时,才需要采用CAS的方案
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充
对象头
对象头包含两部分信息,一部分用于存储自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据
实例数据部分是对象真正存储的有效信息,也是代码中所定义的各种类型的字段内容
对齐填充
HotSpot虚拟机的自动内存管理系统要求对象起止地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍,对象头部分正好是8字节的整数倍,所以,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全,对齐填充并不是必然存在的,也没有特殊的含义,只是起到了占位符的作用。
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。
句柄
Java堆中会划分出一块内存来作为句柄,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如下图(图片来源于《深入理解Java虚拟机》)
使用句柄访问对象的优势:reference中存储的是稳定的句柄地址,在对象移动时(垃圾回收时会经常移动对象)只会改变句柄中的实例数据指针,无需改变reference
直接指针
如果使用直接指针访问,reference中存储的就是对象地址,而Java堆对象的布局需要考虑如何放置访问累类型数据的相关信息,如下图(图片来源于《深入理解Java虚拟机》)
使用直接指针访问对象的优势是省了一次指针定位的时间开销,速度更快
内存泄漏:指程序中动态分配给内存一些临时对象,并且这些对象始终没有被回收,一直占用着内存,简单来说就是申请内存使用完了不进行释放
常见的内存泄漏产生原因:
静态集合类引起内存泄漏,因为静态集合的生命周期和JVM一致,所以静态集合引用的对象不能被释放
单例模式导致内存泄漏,因为单例模式的静态特性,它的生命周期和JVM的生命周期一致,如果单例对象持有外部对象的引用,这个对象也不会被回收
内部类的对象被长期持有,那么内部类对象所属的外部类对象也不能被收回
数据库连接、网络连接等各种连接没有显示释放导致内存泄漏,例如在数据库连接后不再使用时,必须调用close方法释放与数据库的连接,否则会造成大量对象无法被回收进而造成内存泄漏
改变哈希值,例如在一个对象存储到HashSet后,改变了对象中参与计算哈希值的字段,那么会导致对象的哈希值发生变化,和之前存入HashSet的哈希值不同,也就无法通过当前对象的引用在HashSet中找到这个对象,无法从HashSet中删除对象,造成内存泄漏,这也是为什么通常利用String类型的变量当作HashMap的key,因为String类型是不可变的
内存泄漏解决方案:写代码时尽量避免上述会造成内存泄漏的情况
内存溢出:指程序运行过程中无法申请到足够的内存导致的错误
常见的造成内存溢出的原因:
内存加载的数据量太大,内存不够用了
代码中存在死循环或循环产生大量对象
启动参数内存值设置过小
长期的内存泄漏也会导致内存溢出
内存溢出解决方案:
修改JVM启动参数,增加内存
使用内存查看工具动态查看内存使用情况
对代码进行排查,重点排查有没有上述提到的造成常见内存溢出情景的代码
当线程所请求的栈深度超过虚拟机所允许的最大深度后,会发生栈溢出,即StackOverflowError ,比如在方法递归调用时就可能发生栈内存溢出,可以通过 JVM参数 -Xss 来调整栈内存的大小来避免栈内存溢出,但如果是代码问题光调整栈内存大小肯定是不够的,还是要从根本解决问题。
Java和C++的一个明显区别就是Java具备内存动态分配和垃圾收集技术,而C++则需要程序员自己管理内存,这也使得一些初学者觉得C++比较难
在Java中垃圾回收的目的是回收释放不再被引用的实例对象,这样做可以减少内存泄漏、内存溢出问题的出现
Minor GC(新生代GC):指发生在新生代的垃圾收集动作,Java对象大多存活时间不长,所以Minor GC的发生会比较频繁,回收速度也比较快
Full GC/Major GC(老年代GC):指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Minor GC(不是必然的),Major GC的速度一般会比Minor GC慢10倍以上。
什么情况下会触发Minor GC:在新生代的Eedn区满了会触发
什么情况下会触发Full GC:
System.gc()
方法的调用,此方法会建议JVM进行Full GC,但JVM可能不接受这个建议,所以不一定会执行。
老年代空间不足,创建的大对象的内存大于老年代空间,导致老年代空间不足,则会发生Full GC
JDK1.7及以前的永久代空间满了,在JDK1.7以前,HotSpot虚拟机的方法区是永久代实现都得,在永久代中会存放一些Class的信息、常量、静态变量等数据,在永久代满了,并且没有配置CMS GC的情况下就会触发Full GC,在JDK1.8开始移除永久代也是为了减少Full GC的频率
空间分配担保失败,通过Minor GC后进入老年代的平均大小大于老年代的可用空间,会触发Full GC
Full GC发生过于频繁,会影响性能,因为Full GC会导致STW(Stop-The-World),STW指的是用户线程在运行至安全点(safe point)或安全区域(safe region)之后,就自行挂起,进入暂停状态,对外的表现就是卡顿。所以应尽量减少Full GC的次数。不过不论是minor gc还是major gc都会STW,区别只在于STW的时间长短。
Java的自动内存管理主要解决了给对象分配内存和回收分配给对象的内存两个问题,先来看下Java虚拟机是如何为对象分配内存的
Java对象的内存分配主要就是在堆上,Java堆的基本结构如下,大体上可以分为新生代和老年代。
新生代默认占1/3,老年代默认占2/3,新生代包含Eden区、From Survivor0区和 To Survivor1区,默认比例是8:1:1,老年代就一个Old Memory区。
一般情况下是这样分配的
对象先在Eden区分配,当Eden区没有足够的空间去分配时,虚拟机会发起一次Minor GC,将存活的对象放到From Survivor区(对象年龄为1)
当再次发生Minor GC,会将Eden区和From Survivor区一起清理,存活的对象会被移动到To Survivor区(年龄加1)
这时From Survivor区会和To Survivor区进行交换,然后重复第一步,不过这次第一步中的From Survivor区其实是上一轮中的To Survivor区。
每次移动,对象的年龄就会加1,当年龄到达15时(默认是15,对象晋升老年代的年龄阈值可以通过参数-XX: MaxTenuringThreshold
设置),会从新生代进入老年代。
下面介绍几种收集器的内存分配策略
对象优先在Eden区分配
大对象直接进入老年代(大对象指需要大量连续内存空间的Java对象)
长期存活的对象进入老年代
注:为了可以更好地适应不同程度的内存状况,虚拟机并不是必须要求对象的年龄达到MaxTenuringThreshold才进入老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold
最后说下空间分配担保机制,在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果大于,则表明Minor GC可以安全进行。如果不大于,虚拟机会查看HandlePromotionFailure设置是否允许担保失败。如果允许,则会继续检查老年代的最大可利用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则会尝试进行一次Minor GC(存在一定风险),如果小于或者HandlePromotionFailure设置不允许担保失败,则这一次会进行Full GC。
这里解释为什么会存在风险,因为在新生代使用的垃圾收集算法是复制算法,前面也提到了,只有一个Survivor空间作为轮换备份,如果这时出现大量对象在Minor GC后仍然存活,则需要老年代进行担保,Survivor无法容纳的对象会直接进入老年代,风险就是Survivor无法容纳的对象有多大很难确定,也就无法保证老年代的空间一定够用,一般是取之前每一次回收晋升到老年代对象的平均大小作为参考值。
说完了空间分配担保机制的概念,不知道大家看出来这玩意儿有什么用了吗?
其实很简单,就是怕Minor GC后需要进入到老年代的对象太多了,老年代没有那么大空间,先提前检查一下,如果检查结果显示老年代确实装不下,那么这次Minor GC就得改成Full GC,那Full GC完了老年代空间还是不够呢?那会OOM内存溢出的
在JDK1.2之后,Java对引用的概念进行了扩充,主要分为强引用、软引用、弱引用、虚引用
强引用:垃圾收集器永远不会回收掉被引用的对象
软引用:用来描述一些有用但非必需的对象,在内存发生溢出之前会被回收
弱引用:用来描述一些有用但非必需的对象,在下一次垃圾回收时被回收
虚引用:最弱的一种引用关系,无法通过虚引用来获取一个对象,虚引用的唯一目的就是能在这个对象被回收时收到一个系统通知
判断对象是否死亡的常见方法主要有引用计数法和可达性分析法两种
引用计数法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器就会加1;当引用失效时,计数器就减1,当计数器为0就是没有被使用的对象,但主流的Java虚拟机并没有选择用引用计数法来管理内存,因为无法解决对象之间相互循环引用的问题,就是两个对象相互引用,除此之外,两个对象并没有其他引用,这两个对象已经不可能被访问了,但他们的引用计数都不为0,所以无法被垃圾收集器回收。
可达性分析法
可达性分析法就是通过一系列被称为”GC Roots“的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明该对象是不可用的,也就是可回收的,如下图,对象object 5、object 6、object 7虽然有关联,但他们到GC Roots是不可达的,所以也会判定是可以回收的,这样解决了对象之间相互引用导致不能回收的问题。
注:在Java语言中,可以作为GC Roots的对象主要有以下几种:
虚拟机栈中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的属性
本地方法栈中Native中引用的对象
首先,永久代这个概念是HotSpot虚拟机中独有的,其他Java虚拟机中并没有永久代的概念
在JDK1.8之前JVM存在永久代,在JDK1.8被元空间替代。那什么是永久代呢?永久代和元空间都是方法区的具体实现,方法区只是一种规范
在永久代中主要是存放类的信息(成员方法、构造器、类加载器等)及运行时常量池,所以当永久代满了也会进行回收。
在永久代发生的内存回收主要是常量池的回收和类型的卸载。常量池的回收相对容易,只要常量池中的常量没有被任何地方引用,就可以被回收。判断一个类型是否可以回收比较麻烦,主要看以下几个方面:
该类型所有实例都被回收
加载该类的类加载器已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
为什么在JDK1.8会使用元空间取代永久代?
永久代使用的是设定好的虚拟机内存,无法动态扩展内存空间,当加载的类过多就可能发生OOM,并且永久代的内存大小设置也是难以确定的,所以对永久代调优也是比较困难的。
元空间的出现就解决了永久代的问题,因为元空间不再使用虚拟机的内存了,而是使用了本地内存,本地内存可以自动扩展调节,内存不足也不会触发Full GC。
可以通过调用system.gc()
方法通知虚拟机进行垃圾回收,但Java虚拟机规范并不能保证一定会执行。
垃圾收集算法主要有标记-清除算法、标记-复制算法、标记-整理算法、分代收集算法
标记-清除算法
标记-清除算法主要包含标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后同一回收所有被标记的对象。
标记-清除算法有两个明显的缺点:第一就是效率低,标记和清除两个过程的效率都不高;第二是空间问题,标记清除后会产生大量不连续内存碎片,空间碎片太多会导致以后在程序运作过程中需要分配大对象时,无法找到足够的连续内存进而提前触发另一次垃圾收集动作。
标记-复制算法
为了解决标记-清除算法的效率问题,标记-复制算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这块的内存用完了,就将活着的对象复制到另一块上面,再将已使用过的内存一次清理掉,如下图
标记-复制算法的好处显而易见,每次都是对半个区进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效,缺点也是显而易见的,每次可以使用的内存只有原来的一半。
标记-整理算法
如果对象存活率比较高时使用标记-复制算法就要进行比较多的复制操作,效率会变低,针对这种场景,提出了一种标记-整理算法,和标记-清除算法不同的是,标记完后不直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉其他地方的内存,如下图
分代收集算法
按照前面讲的,将Java堆分为新生代和老年代,根据每个年代的特点采用合适的收集算法。例如,在新生代每次垃圾回收时会有大量对象死亡,只有少量存活,所以在新生代选择用标记-复制算法,在老年代每次垃圾回收会有大量对象存活,考虑使用标记-清除或标记-整理算法。在商业虚拟机中一般都是采用分代收集算法。
垃圾回收算法是内存回收的方法论,垃圾收集器则是内存回收的具体实现。Java规范中并没有对垃圾收集器的实现有任何规范,所以不用的厂商、不同的版本的虚拟机提供的垃圾收集器是不同的,这里主要讨论的是HotSpot虚拟机所包含的虚拟机,按照年代划分如下:
其中新生代收集器有Serial、ParNew、Parallel,老年代收集器有CMS、Serial Old、Parallel Ol,G1则既可以在新生代收集,又能在老年代收集。两个垃圾收集器之间如果存在连线,则说明它们可以搭配使用。
那哪个收集器的性能最好呢,其实这里并不存在最好的收集器,只有在对应场景中最合适的垃圾收集器
Serial收集器
Serial收集器是最基本的收集器,并且是单线程的收集器,这里的单线程不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,在它进行垃圾收集时,必须暂停其他所有的线程工作,直到它收集结束。不难想象,这对很多应用来说都是难以接受的,如下图
除了上面写到的缺点,Serial收集器也有着优于其他收集器的地方,简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾回收自然可以获得最高的单线程的收集效率。
ParNew收集器
ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾回收外,其他地方与Serial一样,从下图中也可以看出,除了多了几个GC线程,和Serial收集器并没有什么区别
Parallel Scavenge收集器
Parallel Scavenge 是一个使用标记-复制算法的多线程收集器,看起来和ParNew很像,Parallel Scavenge收集器的关注点和与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间(用户体验),而Parallel Scavenge收集器的关注点是达到一个可控制的吞吐量(提高CPU的效率),这里的吞吐量指的是CPU用于运行代码的时间和CPU总消耗时间的比值。
那更短的停顿时间和更高的吞吐量有什么好处呢?
停顿时间越短越适合需要与用户交互的程序,良好的响应速度可以提升用户体验。更高的吞吐量适合在后台运算而不需要太多交互的程序,高吞吐量可以提高CPU的利用率,尽快地完成程序的运算任务。
Serial Old收集器
Serial Old是Serial收集器的老年代版本,同样是单线程收集器,采用标记-整理算法,主要有两大用途:一是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,二是作为CMS收集器的后备预案。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,采用多线程和标记-整理算法。该收集器是在JDK1.6才开始提供的,因为当新生代选择了Parallel Scavenge收集器,老年代只能选择Serial Old(Parallel Scavenge无法与CMS搭配使用),这时Serial Old收集器会影响整体的吞吐量,所以提供了Parallel Old收集器和Parallel Scavenge搭配使用
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,采用标记-清除算法,其运作过程可以分为初始标记、并发标记、重新标记、并发清除四个步骤。
初始标记:暂停其他线程,标记GC Roots能直接关联的对象,速度很快
并发标记:同时开启GC线程和用户线程,跟踪记录发生引用更新的地方
重新标记:修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录
并发清除:GC线程对未标记的区域进行清除
上述四个步骤中,初始标记和重新标记两个步骤会“Stop The Word”,也就是会暂停用户线程,如下图
这里解释下在垃圾收集器的语境中,并行和并发的概念:
并行:指多条垃圾收集器线程并行工作,此时用户线程仍处于等待状态
并发:指用户线程与垃圾收集线程同时执行(也可以交替执行)
CMS的优点是并发收集,停顿时间短,缺点主要有以下三个:
CMS收集器对CPU资源非常敏感
CMS收集器无法处理浮动垃圾,浮动垃圾指在CMS并发清理阶段用户线程运行时不断产生的垃圾,CMS无法在当次集中收集处理它们,只能在下一次GC时清理
所采用的标记-清除算法会导致收集结束时产生大量的空间碎片。
G1收集器
G1收集器是面向服务端应用的垃圾收集器,回收范围包括新生代和老年代,主要有以下特点:
并行与并发:G1充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop The World停顿时间,部分其他收集器需要停顿Java线程执行的GC动作,G1收集器可以通过并发的方式让Java程序继续执行
分代收集:分代概念在G1中依然保留,G1可以不需要其他收集器配合就能独自管理整个GC堆
空间整合:G1从整体上看是基于标记-整理算法实现的,从局部上看是基于标记-复制算法实现的,这意味着G1运作期间不会产生内存碎片
可预测的停顿:G1除了追求停顿外,还能建立可预测的停顿时间模型
G1收集器的运作步骤如下:
初始标记
并发标记
最终标记
筛选回收
看起来和CMS很像,如下图
先解释下什么是类加载机制,虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。其实在看类加载之前最好了解下类文件结构,因为面试中这部分问的不多就不介绍了。
类从被加载到虚拟机内存中开始,到卸载出内存为止,生命周期包括:加载、验证、准备、解析、初始化、使用和卸载等7个部分,其中验证、准备、解析统称为连接。
类的加载过程也就是类的生命周期的前五部分,加载、验证、准备、解析、初始化
加载
在加载部分,虚拟机需要完成以下三件事:
通过一个类的全限定名来获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
验证
验证的目的是为了确保Classw文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。验证主要分为四个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证
文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本虚拟机处理,例如主、次版本号是否在当前虚拟机处理范围之内
元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,例如这个类是否有父类,这个类的父类是否继承了不允许被继承的类等
字节码验证:通过数据流的控制流分析,确定程序语义是合法的、符合逻辑的。在对元数据信息中的数据类型做完校验后,字节码验证是对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如保证跳转指令不会跳转到方法体以外的字节码指令上
符号引用验证:符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,例如符号引用中通过字符串描述的全限定名是否可以找到对应的类
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区分配。注意:这时候进行内存分配的只有类变量,不包括实例变量,其次,这里指的初始值一般是数据类型的零值,public static int x = 1;
,变量x
在准备阶段被设置的初始值是0而不是1,而程序被编译之后,x
的值才为1。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用是指以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义定位到目标即可。直接引用是指直接指向目标的指针、相对偏移量或是一个可以直接定位到目标的句柄。
看了上面符号引用和直接引用的概念,相信很多人还是一头雾水,那符号引用转为直接引用有什么用呢?
其实符号引用就是通过一组符号来描述所引用的目标,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定加载内存中,这时虚拟机并不知道对象的内存地址,所以光有符号引用是不够的。而直接引用是可以指向目标的指针,是和虚拟机实现的内存布局相关的,也就是可以确定对象在内存中的位置的。
简单来说就是在编译的时候,类会编译成一个class文件,但在编译的时候虚拟机并不直到知道所引用类的地址,这时就用符号引用来替代了,在解析时将符号引用转为直接引用就是因为直接引用可以找到类在内存中的地址。
初始化
初始化是类加载的最后一步,也是类加载的最后一步,从这开始JVM开始真正执行类中定义的Java代码
前面介绍了类的加载过程,类加载器的意义很容易理解,作用就是将类加载到虚拟机的内存中。
这里再提个挺重要的知识点,任何类都需要加载它的类和加载器和这个类本身确定其在Java虚拟机中的唯一性,也就是说要比较两个类是否相等,只有两个类由同一个类加载器加载的前提下才有意义,如果两个类不是由同一个类加载器加载,那么它一定不相等。
JVM主要提供三个类加载器:
启动类加载器(Bootstrap ClassLoader):由C++语言实现,是虚拟机自身的一部分,负责加载存放在
(比如rt.jar、resources.jar、charsets.jar和class等),或被-Xbootclasspath
参数所指定路径中的并且被虚拟机识别的类库。
扩展类加载器(Extension ClassLoader):由Java语言实现,独立于虚拟机外部,负责加载
目录中的类库。
应用程序类加载器(Application ClassLoader):由Java语言实现,独立于虚拟机外部,负责加载用户路径上所指定的类库,如果程序中没有自己定义过的类加载器,一般情况这个是程序中的默认类加载器
双亲委派机制是面试中非常高频的一个知识点,需要牢牢掌握
双亲委派机制:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是这样,所以所有的加载请求最终都应该传送到顶层的启动类加载容器,只有当父类加载器无法完成加载时,子加载器才会尝试自己去加载,如下图
这里有个问题,类加载器中的父类加载器和子类加载器是继承关系吗?
既然问了,那肯定就不是了,在双亲委派模型中,类加载器之间的父子关系一般不是以继承关系实现的,而是组合的关系来复用父加载器的代码的
介绍完双亲委派的概念,那双亲委派机制有什么好处呢?
双亲委派的保证了Java程序稳定地运行,可以避免类地重复加载(父类加载器加载过,子加载器不会再进行加载),保证Java的核心API不被篡改,例如,你自己编写了一个java.lang.Object类,也不会被加载,因为根据双亲委派机制,会由启动类加载器进行加载,会先加载位于rt.jar中的java.lang.Object类,并且其他子类加载器不会再去加载ava.lang.Object类。
那双亲委派机制的弊端是什么呢?
从上面的介绍可以看到父类加载器的优先级是大于子类加载器的,只有父类加载器无法加载,子类加载器才会去尝试加载,这在大多数情况是没有问题的,因为越上层加载的类通常是基础类(像Object类),一般情况这些基础类都是被用户代码所调用的API,但基础类要是想调用用户的代码,那就会出问题了,因为第三方的类不能被启动类加载器加载。
举个很经典的例子,JDBC服务在Java开发中非常常见(操作数据库),
Connection c = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "lurenzhang", "666");
DriverManager类是在java.sql包中,java.sql包的位置是jdk\jre\lib\rt.jar,也就是DriverManager类会先被启动类加载器加载,类在加载时其中有这样一段代码
ServiceLoader
这会尝试加载classpath下面的所有实现了Driver接口的实现类,而实现了Driver接口的第三方类库应由应用类加载器加载,这样一来启动类加载器加载的类使用了启动类加载器加载的类,违背双亲委派机制的原理。
如何破坏双亲委派机制
这个需要先去了解双亲委派是怎么实现的,看下java.lang.ClassLoader的loadClass()源码就知道了,这里就不展开写了,想破环双亲委派自定义一个类加载器,重写其中的loadClass()方法即可。
在双亲委派出现之前,双亲委派模型是在JDK1.2引入的,在此之前就有自定义类加载器了,这些自然是没遵循双亲委派机制的
JIDI服务及前面提到的JDBC服务,如何解决这个问题呢?Java设计团队引入了上下文类加载器,通过上下文类加载器可以使得父类加载器请求子类加载器去完成类的加载动作。
用户对程序动态性的追求导致的,比如代码热替换、模块热部署等,已经成为Java模块化标准的OSGi实现模块化热部署的关键是它自定义的类加载,没和程序模块都有自己的类加载器,当需要更换一个程序模块时,会把程序模块和类加载器一起替换掉实现代码的热替换
tomcat等web服务器,因为一个web容器可以部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,但不同版本的类库中的全限定名很可能是一样的,如果采取双亲委派机制,这里就无法加载多个相同的类,解决方法就是,破坏双亲委派原则,提供隔离的机制,为每个web容器提供一个单独的类加载器
jsp:JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程
jstat:JVM Statistics Monitoring Tool,用于收集HopSpot虚拟机各方面的运行数据,例如虚拟机进程中的类装载、内存、垃圾收集等数据
jinfo:Configuration Info for Java,显示虚拟机配置信息
jmap:Memory Map for Java,生成虚拟机的内存转储快照(headdump),还可以查询Java堆、永久代的详细信息等
jhat:JVM Heap Dump Browser,用于分析heapdump文件,它会建立一个HTTP/HTML服务器,让用户在浏览器上查看分析结果
jstack:Stack Trace for Java,显示虚拟机的线程快照,查看各个线程的调用堆栈,可以定位线程出现长时间停顿的原因
本来这个地方只是想列几个常用的JVM参数就完了,毕竟大多数项目并不需要JVM调优,千万别项目中出了问题就想JVM调优,这绝对是本末倒置的做法。不过虽然项目中大概率用不到,但是面试会问啊,所以还是简单介绍下。
正常的情况下项目出问题或者系统优化应该先从应用层面开始,然后往底层过度,首先查查是不是代码写的太垃圾了,逻辑上有没有可优化的地方等等,然后再看看数据库有没有可优化的地方,在然后可以考虑JVM层面的优化,最后,可以考虑底层操作系统层面的优化。前两者基本就解决了大部分问题。
JVM调优的目标
大多数时候,JVM的调优最主要的目标是停顿时间和吞吐量,停顿时间一般是由垃圾收集引起的,可以简单理解为系统对请求的响应速度,吞吐量是指用户程序运行时间占用户程序运行时间和垃圾收集总时间的比值,可以简单理解为系统在特定时间内的最大工作量。还有一个指标是内存占用,程序正常运行所需要的内存,自然是越小越好。
JVM优化得目标就是系统以较小的内存资源获得较低的停顿时间和较高的吞吐量,这当然不现实了,又想马儿跑,还想马儿不吃草,所以还是要根据具体场景具体分析。
JVM调优策略
JVM调优的具体策略基本都是围绕着两部分展开的,内存和垃圾回收器
调整内存大小,如果内存太小,那么GC会非常频繁的,停顿时间自然就长了
调整新生代和老年代的比重等,比如IO密集型的,就可以把新生代加大些,因为大多数对象在新生代就会消亡
调整晋升老年代的年龄,比如老年代频繁GC,可以考虑增大老年代的比重,也可以提高晋升老年代的年龄
大对象分配的标准,因为大对象是直接进入老年代的,如果大对象的标准不合适,也会出现问题
选择合适的垃圾回收器及垃圾回收器的各种参数,比如如果是CPU是单核的,就选Serial就可以了等等
上面这只是简单举例,类似的情况还有很多,简单来说就是围绕着内存和垃圾回收器具体情况具体分析
JVM调优参数
前面说了调优策略主要是围绕着内存和垃圾回收器,那么可调的参数大多也是围绕这两个。
内存部分参数
参数 | 含义 |
---|---|
-Xms | 初始堆大小 |
-Xmx | 最大堆大小 |
-Xmn | 新生代大小 |
-Xss | 设置每个线程的堆栈大小 |
-XX:NewRatio | 设置新生代与年老代的比值 |
-XX:SurvivorRatio | 设置新生代中Eden区与Survivor区的大小比值 |
-XX:PermSize/-XX:MetaspaceSize | 初始化持久代/元空间大小 |
-XX:MaxPermSize/-XX:MaxMetaspaceSize | 设置持久代/元空间最大值 |
-XX:MaxTenuringThreshold | 设置进入老年代的年龄 |
垃圾回收器相关参数
参数 | 含义 |
---|---|
-XX:+UseG1GC | 使用G1垃圾回收器 |
-XX:ParallelGCThreads | 并行收集器的线程数 |
-XX:GCTimeRatio | 设置垃圾回收时间占程序运行时间的百分比 |
-XX:MaxGCPauseMillis | 设置目标停顿时间 |
相关参数还有很多,这里就不一一列举了
JVM的调优步骤
JVM调优肯定不是乱调的,也应先确定瓶颈及调优目标,如下:
分析GC日志及通过虚拟机监控的命令查看系统运行情况,找出哪里出了问题
确定调优的目标
确定调优策略及调整相关参数,这是个不断对比分析和调整的过程,很难一步到位的
JVM调优案例
前面简单介绍了JVM调优的方法和策略,下面从网上找了一些调优的案例,大家可以参考一下
1.数据分析平台系统频繁 Full GC
平台主要对用户在 App 中行为进行定时分析统计,并支持报表导出,使用 CMS GC 算法。
数据分析师在使用中发现系统页面打开经常卡顿,通过 jstat 命令发现系统每次 Young GC 后大约有 10% 的存活对象进入老年代。
原来是因为 Survivor 区空间设置过小,每次 Young GC 后存活对象在 Survivor 区域放不下,提前进入老年代。
通过调大 Survivor 区,使得 Survivor 区可以容纳 Young GC 后存活对象,对象在 Survivor 区经历多次 Young GC 达到年龄阈值才进入老年代。
调整之后每次 Young GC 后进入老年代的存活对象稳定运行时仅几百 Kb,Full GC 频率大大降低。
2.业务对接网关 OOM
网关主要消费 Kafka 数据,进行数据处理计算然后转发到另外的 Kafka 队列,系统运行几个小时候出现 OOM,重启系统几个小时之后又 OOM。
通过 jmap 导出堆内存,在 eclipse MAT 工具分析才找出原因:代码中将某个业务 Kafka 的 topic 数据进行日志异步打印,该业务数据量较大,大量对象堆积在内存中等待被打印,导致 OOM。
3. 鉴权系统频繁长时间 Full GC
系统对外提供各种账号鉴权服务,使用时发现系统经常服务不可用,通过 Zabbix 的监控平台监控发现系统频繁发生长时间 Full GC,且触发时老年代的堆内存通常并没有占满,发现原来是业务代码中调用了 System.gc()。
上述三个案例来源于博客:https://juejin.cn/post/6844903953415536654#heading-61
4.网站流量浏览量暴增后,网站反应页面响很慢。
1、问题推测:在测试环境测速度比较快,但是一到生产就变慢,所以推测可能是因为垃圾收集导致的业务线程停顿。
2、定位:为了确认推测的正确性,在线上通过jstat -gc 指令 看到JVM进行GC 次数频率非常高,GC所占用的时间非常长,所以基本推断就是因为GC频率非常高,所以导致业务线程经常停顿,从而造成网页反应很慢。
3、解决方案:因为网页访问量很高,所以对象创建速度非常快,导致堆内存容易填满从而频繁GC,所以这里问题在于新生代内存太小,所以这里可以增加JVM内存就行了,所以初步从原来的2G内存增加到16G内存。
4、第二个问题:增加内存后的确平常的请求比较快了,但是又出现了另外一个问题,就是不定期的会间断性的卡顿,而且单次卡顿的时间要比之前要长很多。
5、问题推测:练习到是之前的优化加大了内存,所以推测可能是因为内存加大了,从而导致单次GC的时间变长从而导致间接性的卡顿。
6、定位:还是通过jstat -gc 指令 查看到 的确FGC次数并不是很高,但是花费在FGC上的时间是非常高的,根据GC日志 查看到单次FGC的时间有达到几十秒的。
7、解决方案:因为JVM默认使用的是PS+PO的组合,PS+PO垃圾标记和收集阶段都是STW,所以内存加大了之后,需要进行垃圾回收的时间就变长了,所以这里要想避免单次GC时间过长,所以需要更换并发类的收集器,因为当前的JDK版本为1.7,所以最后选择G1垃圾收集器,根据之前垃圾收集情况设置了一个预期的停顿的时间,上线后网站再也没有了卡顿问题。
5.后台导出数据引发的OOM
问题描述:公司的后台系统,偶发性的引发OOM异常,堆内存溢出。
1、因为是偶发性的,所以第一次简单的认为就是堆内存不足导致,所以单方面的加大了堆内存从4G调整到8G。
2、但是问题依然没有解决,只能从堆内存信息下手,通过开启了-XX:+HeapDumpOnOutOfMemoryError参数 获得堆内存的dump文件。
3、VisualVM 对 堆dump文件进行分析,通过VisualVM查看到占用内存最大的对象是String对象,本来想跟踪着String对象找到其引用的地方,但dump文件太大,跟踪进去的时候总是卡死,而String对象占用比较多也比较正常,最开始也没有认定就是这里的问题,于是就从线程信息里面找突破点。
4、通过线程进行分析,先找到了几个正在运行的业务线程,然后逐一跟进业务线程看了下代码,发现有个引起我注意的方法,导出订单信息。
5、因为订单信息导出这个方法可能会有几万的数据量,首先要从数据库里面查询出来订单信息,然后把订单信息生成excel,这个过程会产生大量的String对象。
6、为了验证自己的猜想,于是准备登录后台去测试下,结果在测试的过程中发现到处订单的按钮前端居然没有做点击后按钮置灰交互事件,结果按钮可以一直点,因为导出订单数据本来就非常慢,使用的人员可能发现点击后很久后页面都没反应,结果就一直点,结果就大量的请求进入到后台,堆内存产生了大量的订单对象和EXCEL对象,而且方法执行非常慢,导致这一段时间内这些对象都无法被回收,所以最终导致内存溢出。
7、知道了问题就容易解决了,最终没有调整任何JVM参数,只是在前端的导出订单按钮上加上了置灰状态,等后端响应之后按钮才可以进行点击,然后减少了查询订单信息的非必要字段来减少生成对象的体积,然后问题就解决了。
6.CPU经常100% 问题定位思路。
问题分析:CPU高一定是某个程序长期占用了CPU资源。
1、所以先需要找出那个进行占用CPU高。
top 列出系统各个进程的资源占用情况。
2、然后根据找到对应进行里哪个线程占用CPU高。
top -Hp 进程ID 列出对应进程里面的线程占用资源情况
3、找到对应线程ID后,再打印出对应线程的堆栈信息
- printf "%x\n" PID 把线程ID转换为16进制。
- jstack PID 打印出进程的所有线程信息,从打印出来的线程信息中找到上一步转换为16进制的线程ID对应的线程信息。
4、最后根据线程的堆栈信息定位到具体业务方法,从代码逻辑中找到问题所在。
- 查看是否有线程长时间的watting 或blocked
- 如果线程长期处于watting状态下, 关注watting on xxxxxx,说明线程在等待这把锁,然后根据锁的地址找到持有锁的线程。
7.内存飚高问题定位思路。
分析:内存飚高如果是发生在java进程上,一般是因为创建了大量对象所导致,持续飚高说明垃圾回收跟不上对象创建的速度,或者内存泄露导致对象无法回收。
1、先观察垃圾回收的情况
- jstat -gc PID 1000 查看GC次数,时间等信息,每隔一秒打印一次。
-
- jmap -histo PID | head -20 查看堆内存占用空间最大的前20个对象类型,可初步查看是哪个对象占用了内存。
如果每次GC次数频繁,而且每次回收的内存空间也正常,那说明是因为对象创建速度快导致内存一直占用很高;如果每次回收的内存非常少,那么很可能是因为内存泄露导致内存一直无法被回收。
2、导出堆内存文件快照
jmap -dump:live,format=b,file=/home/myheapdump.hprof PID dump堆内存信息到文件。
3、使用visualVM对dump文件进行离线分析,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。
如果本文对你有帮助,别忘记给我个3连 ,点赞,转发,评论,
咱们下期见!答案获取方式:已赞 已评 已关~
学习更多知识与技巧,关注与私信博主(03)