前 言
作者简介:半旧518,长跑型选手,立志坚持写10年博客,专注于java后端
☕专栏简介:纯手打总结面试题,自用备用
文章简介:JVM最基础、重要的13道面试题
脚本慢:程序流程图,加log计时
发现特别慢的时间结点,看代码逻辑,从逻辑上不可能有特别耗时的操作。
考虑是垃圾回收操作。
使用JVisaulVM进行排查,发现是在做垃圾回收。
堆做dump,定位到代码。
有几个地方。
第一个地方是,生成报告的结点是循环依赖的。A、B互相持有,由于JVM的回收机制是根可达算法,会堆积到老年代回收。设置clear()予以解决。
第二个是将缓存变量由类的实例改称了类。
第三个是将concurrentHashMap改为synchornized和WeakHashMap
程序计数器、
堆(解决的是存储数据问题,只有一个,类的实例对象以及数组,线程共享,垃圾回收器会自动回收不再使用的对象)
栈(解决的是程序运行时的问题,是线程所私有的,描述的是java方法所执行的内存信息,每个方法都会创建一个栈帧用于存储局部变量、程序的运行状态、方法返回值等信息,每个方法从调用到结束,就对应着一个栈帧从入栈到出栈的过程。栈中数据直接存放在寄存器中,存取速度比堆要快)、
方法区(类锁共享,常量池、接口定义等)
本地方法区、类加载器(加载类到内存)
强引用就是不会被回收,当虚拟机内存空间不足时,宁愿抛出OutOfMemoryError也不清除它。一般我们使用的都是强引用。
软引用用来存放一些有用但不是必须的对象,只有当内存不足的时候才会回收。
弱(WeakReference)引用用来存放一些不重要的对象,只要进行回收就会被清理回收(如缓存,可以由这个降到jvm调优的实例)
虚引用并不影响对象的生命周期,随时可能被回收,只是一个标记,主要用来跟踪对象的垃圾回收活动。
Serial收集器是单线程收集器,在他进行垃圾回收时,其它工作线程都必须等待,直到它收集结束。其cpu利用率最高,但用户的等待时间最长。比较适合小型的应用
Parrallel收集器,是多线程收集器,其停顿时间短,回收效率高,吞吐量很大,比较适合于科学计算,大型应用等。
CMS收集器,采用多线程进行垃圾回收,其算法是标记-清除算法。它以响应时间优先,可以减少垃圾回收的停顿时间,适合于电信领域、服务器等。
G1收集器,吸收CMS收集器的特点,支持高吞吐量、支持很大的堆。
标记清除算法:
当JVM堆内存空间满以后,会做两件事情:标记和清除。
第一是收集器从根节点开始标记所有被根节点引用的对象。
第二是收集器对堆内存从头到尾进行线性扫描,发现没有被标记的对象进行清除。
值得注意的是,清除并不是物理意义上的清除,只是将其记录在空闲地址列表里,当需为了新对象申请存储空间时,判断这个存储区域的空间是不是够用,如果够用则覆盖这个存储区域。
标记清除算法具有下列缺点:可能产生许多内存碎片。进行GC时,需要stw,用户体验感较差。其效率不是很高。
复制算法。复制算法相对于标记清除效率更高一点,但不适用于活跃对象太多的场合,比如老年代。该算法把内存区域分成两半,每次只使用其中一块,但进行垃圾回收时,将活跃的对象复制到另一块内存。将之前使用的内存全部清除。最后将两块内存的角色互换。
复制算法有一个显著的缺点,就是需要浪费掉一半内存。可以配合一个担保空间(老年代)来完善。将大对象存放到担保空间中,这样就可以节省很多空间。
标记整理算法。在标记清除算法的基础上,增加了整理。在回收内存空间后,会将所有存活的对象往左方空闲空间移动。这样解决了内存碎片问题,但是也需要更多的时间成本。
分代回收算法。实际上,JVM虚拟机采用分代回收算法,结合了上述几种算法的优势。将堆内存空间分为新生代、老年代。对于新生代的对象,因为会有大量的对象死去,少量的对象存活,使用复制算法。对于老年代对象,其生命周期较长,没有额外的担保空间,因此采用标记整理或者标记清理算法对其进行回收。
JVM代表java虚拟机,它的责任是运行java应用。Java之所以能够实现跨平台,就是因为不同的平台有对应平台的JVM虚拟机。在java源代码被javac编译为class文件以后,由对应平台的jvm虚拟机将class文件转换为机器码后执行。
JRE是java运行时环境,是Java程序运行所必须的,它包含了JVM和java的核心类库。
JDK代表java开发工具包,它包含jdk,java的程序编译器等。
JIT是即时编译器,JVM在将字节码转换为机器码时,就需要利用JIT或者转译器。JIT相比转译器,编译后的字节码会成为优化后的原生指令码,更高效。但是也因此需要一定的成本。因此,对于极少执行到的代码,使用转译器更划算。对于重复或多次执行的代码,使用JIT更加高效。
JVM的常量池包括运行时常量池、基本类型包装类常量池、Class文件常量池、全局字符串常量池。
一个java程序被javac编译成class文件,而java中的字节码需要数据支持,这些数据可能较大,不宜直接存在class文件中,因此可以放在Class文件常量池中。 class文件常量池主要存放两大常量:字面量和符号引用(类名、方法名等等)。
基本数据类型的包装类大部分实现了常量池技术,这些类时Byte、Short、Integer、Character、Long、Boolean,即除了浮点类型以外。另外,五种整形包装类只有当值小于127时,才会使用对象池技术。
字符串常量池,字符串的分配,耗费高昂的时间、空间成本,而且其使用十分高频,因此极其影响性能。JVM为了提高性能,使用字符串池进行优化提升。具体做法是,开辟一块空间作为字符串常量池,创建字符串时,先判断串池中是否存在该字符串,存在则返回引用实例,不存在则创建并存到串池中。它其实就是虚拟机所维护的一个字符串引用表。在HotSpot VM中,它就是一个String table,其底层实现就是一个hashtable。
运行时常量池,运行时常量池,它就是class文件的常量池来构建的,它的作用是存储java class文件常量池中的符号信息,运行时常量池相对于class常量池一大特征就是具有动态性,当我们使用API,string.intern()就会把在运行时通过代码生成常量放入运行时常量池。
推荐阅读:这一次,彻底弄懂java中的常量池 - 掘金 (juejin.cn)
判断一个对象是否存活,分为两种方法:引用计数法和根可达计数法。
引用计数法的原理就是:对象每被引用一次,就计数加1,引用失效,则计数减1。当引用计数为0时,就说明该对象可以被回收。当时这种算法无法解决循环依赖问题。
根可达计数法是指,从GCROOT开始,从上往下进行搜索,当一个对象没有与GCROOT连接的路径时,说明该对象不可用。
可以作为GCROOT的对象有:本地方法区中类的静态属性、方法区中常量池常量所引用的对象、本地方法栈JNI所引用的对象,虚拟机栈中所引用的对象
但当一个对象满足了上述条件,并不会马上就被回收,还需要执行两次标记。第一次标记是确定其是否有finalize()方法,并且没有被执行过,将其标记为垃圾对象,等待回收。
第二次标记是创建一个F-Queue,并生成一个finalize线程去执行finalize方法。但是虚拟机并不保证上述过程可以顺利进行,因为可能出现线程死锁或者执行缓慢,导致回收线程崩溃。
如果执行完finalize()方法,该对象依旧没有与GCROOT有直接或者间接的连接,才会执行垃圾回收。
CMS(Concurrent Mark Sweep)垃圾回收器使用的是标记清除算法,他的目标是最快的对用户进行响应。在进行垃圾回收时使用户线程和GC线程并发执行。
CMS的垃圾回收过程是:
(1)初始标记:标记和GCRoot相关联的下级,这个过程会stw,但是时间很短,因为跟GCRoot直接相连的下级很少。
(2)并发标记:在初始标记的基础下,进一步顺着GCRoot这个链路一路向下标记,这个过程耗时很长,但是不会有stw,因为是并发执行的。
(3)重新标记:因为在并发标记过程中,并没有阻塞其他工作线程,因此还可能产生新的垃圾,因此再进行一次标记。
(4)并发清除:清除删掉已经死亡的对象,这个过程也是并发进行的。
CMS垃圾回收器存在的问题是:
(1)内存碎片问题。可能导致大对象没有空间存储,提前进行FullGC。可以通过设置参数,使FullGC之前先进行内存碎片的整理。但是这个整理工作是无法并发执行的。
(2)并发回收导致cpu资源紧张。由于占用了工作线程,会使应用程序变慢,降低程序的总吞吐量。
(3)无法清理浮动垃圾。并发标记、并发清理阶段用户线程依旧工作,还会产生新的垃圾。只能等待下一次CMS垃圾回收时进行回收。
(4)并发失败。由于再垃圾回收阶段,用户线程还会继续工作,需要为其空出一定内存,如果留的空间不够其工作,就会并发失败。虚拟机不得不启动备用方案:退化成Serial OLd,这样会有stw出现,停顿很长一段时间。
G1垃圾回收器的全程是Garbage First,它的思想是面向局部收集。在整体上,他采用的算法是标记-整理算法,在局部上,他采用的算法是标记-复制算法。他被设计来替代CMS垃圾回收器,在jdk9以后,他成为了JVM的默认垃圾回收器。
它的回收阶段分为:
(1)初始标记(会STW):在MinorGC时,并发进行初始标记。仅仅标记GCRoot能够直接访问到的对象。并且修改TAMS(Top at Mark Start)指针,使下一阶段用户线程进行并发标记时,能够正确的在可用的region分配对象。
(2)并发标记:从GCRoot开始扫描整个堆中的对象图,找出需要回收的对象。耗时较长。
(3)最终标记(会STW):对用户线程进行短暂的停顿,处理并发标记过程中引用发生变化的对象。
(4)清理阶段(会STW):更新整个region的统计数据,进行回收价值与成本的估计进行排序,根据用户的期望时间进行region回收。由于设计到存活对象的移动,必须进行stw。
推荐阅读:24-一步一图带你理清G1垃圾回收流程 - 知乎 (zhihu.com)
分为新生代和老年代,新生代默认占空间的1/3,老年代默认占内存空间的2/3。新生代分为Eden区、To survival、From survival,其默认占比是8:1:1。当Eden区内存不足时,会出发Minor GC。Minor GC的发生非常频繁,其速度也较快。
对应的,发生在老年代的GC被称为Major GC。一次Major GC通常伴随着至少一次Minor GC。
下面说说Minor GC的过程。
(1)在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区;
(2)Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。
(3)Survivor 区相同年龄所有对象大小的总和 > (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。
(4)Survivor 区内存不足会发生担保分配,超过指定大小的对象可以直接进入老年代。
(5)大对象将直接进入老年代。为了避免为大对象分配内存时由于分配担保机制(就是当在新生代无法分配内存的时候,把新生代的对象转移到老生代,然后把新对象放入腾空的新生代。)带来的复制而降低效率。
老年代回收(FullGC)触发条件:
(1)无法放入Survivor区直接进入老年代。YoungGC时,如果eden区+ from survivor 区存活的对象无法放到 to survivor 区了,这个时候会直接将部分对象放入到老年代。
(2)调用System.gc时,系统建议执行Full GC,但是不必然执行。
(3)老年代空间不足。
(4)方法区空间不足。
在java中,每一个类或者接口都会被编译成为一个.class文件。类加载就是把这些.class文件中的二进制数据读到内存中,进行校验、解析和初始化。
类的加载分为几个阶段:加载、连接(验证、准备、解析)、初始化
类加载的第一步是加载,这个过程主要做三件事:
(1)根据类的全限定名找到对应的.class文件。
(2)把.class文件中的二进制数据读取出来,转成方法区的运行时数据结构
(3)在堆中生成一个对应的java.Lang.Class对象,作为方法区数据的访问入口。
验证阶段主要是做一些文件格式、字节码验证、符号引用验证等,保证文件中的内容符合虚拟机的规范,防止数据危害虚拟机自身的安全。
准备阶段对类的静态信息(static 修饰过的变量)分配内存空间,并赋初始值。
解析阶段会把当前加载的类和它引用的类进行正式的连接。
初始化的过程,就是执行类构造器 ()方法的过程
虚拟机设计团队把类加载的过程放到了JVM外部去实现,以便让应用程序决定如何获取所需的类。JVM提供了三种类加载器,启动类加载器(BootStrap ClassLoader,负责加载
双亲委派模式其实一句话就可以说清楚:任何一个类加载器在接到一个类的加载请求时,都会先让其父类进行加载,只有父类无法加载(或者没有父类)的情况下,才尝试自己加载。
双亲委派模式优势:避免重复加载 + 避免核心类篡改
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载。如果不是采用双亲委派机制,不同的类加载器加载一个类,这个类会被加载两次。而使用双亲委派机制,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。
其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。