【Java面试八股文】JVM
参考资料:
- JVM虚拟机入门教程-陈树义
- JavaGuide
- 《深入理解Java虚拟机》
面经:
1. 讲一下JVM内存模型(运行时数据区)
JVM内存模型分为两部分:线程共享和线程私有
JDK1.8之后方法区被元空间Metaspace替代。
-
程序计数器PC:代码流程的控制和多线程上下文切换恢复现场
-
虚拟机栈:也就是我们常说的栈内存。Java中线程执行代码其实都是在执行一个个方法,每执行一个方法,该线程的虚拟 栈空间就会被压入一个栈帧,因此虚拟机栈是由一个个栈帧组成的。每个栈帧中都拥有该方法执行过程中产生的局部变量表、操作数栈、动态链接以及方法出口信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈内存随线程的创建而创建,死亡而死亡
-
本地方法栈:虚拟机栈提供Java方法服务,本地方法提供Native方法服务,也是由一个个栈帧组成。
-
堆:虚拟机所管理的内存中最大的一块,所有线程共享,存放几乎所有的对象实例(new出来的东西都放在,但是引用是局部变量,放在栈内存)和数组。
Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存
堆也叫做GC堆(Garbage Collected Heap)
-
方法区:又叫非堆,用于存储类信息,常量、静态变量以及即时编译器编译后的代码缓存等数据。运行时常量池是方法区的一部分,保存的信息有类的版本、字段、方法、接口等描述信息以及常量池表(Final,String等)。
2. 对象创建的过程
-
类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
-
为新生对象分配内存
对象所需要的内存大小在类加载完成后便可确定,分配内存即把堆中的一块等同大小的内存划分出来给对象。但是因为Java堆中空闲内存和已被分配的内存有两种不同的情况:
- 规整:已分配的内存在一边,空闲的内存在一边,中间放着一个指针作为指示器。分配内存的时候仅仅需要指针向空闲方向移动对象大小相同的距离。这种分配方式叫做“指针碰撞”
- 不规整:已分配的内存和空闲的内存相互交错在一起。这种情况下虚拟机必须维护一个列表来记录哪些内存是可用的,分配的时候从列表中找出一块足够大的内存分配给对象。这种分配方式叫做“空闲列表”。
堆是否规整取决于垃圾回收器是否带有空间压缩整理的能力。当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
分配内存如何解决线程安全的问题?
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
- CAS乐观锁+失败重试,保证更新操作的原子性
- TLAB(本地线程分配缓冲):为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
-
初始化零值
虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
-
设置对象头
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
-
执行init方法(有点类似于依赖注入,由程序员控制注入什么)
在上面工作都完成之后,从虚拟机的视⻆来看,一个新的对象已经产生了,但从 Java 程序的视⻆来看,对象创建才刚开始,
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
3. 对象的内存布局
-
对象头
- 运行时数据:hashcode、GC分代年龄、锁状态、持有的锁
- 类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
-
实例数据
字段内存、继承下来的字段内存
-
对齐填充
4. 栈内存中的reference如何访问堆内存中的变量
- 句柄访问
- 直接指针
垃圾回收算法
5. JVM垃圾回收概述
哪些内存要进行垃圾回收
线程私有的内存空间是不需要进行垃圾回收的,因为当方法结束或者线程终止,内存自然会跟随着回收。垃圾回收的主战场是堆,主目标就是堆中分配的对象。这部分的内存分配和回收是动态的。
什么对象需要被回收?
死亡的对象需要被回收,判定对象是否存活都和“引用”离不开关系。也就是说,没有被引用,没有指针指向的对象将被回收。
Java中的引用详解
以前我对引用的认识是:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义没有错,但是不足以应付我们复杂的业务逻辑。比如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。这个时候就引入扩充引用这个概念了。
JDK1.2之后,Java对引用进行了扩充,将引用分为:(强度注解降低)
- 强引用
- 软引用
- 弱引用
- 虚引用
强引用:即类似“Objectobj=newObject()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(肯定不回收)
软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。(满了就先回收你)
弱引用:也是描述一些还有用,但非必须的对象,但强度更低,只能生存到下一次垃圾收集为止。(不用等满,下一次就回收你)
虚引用:相当于没有引用,完全不会对其生存时间构成影响。唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
JVM如何判断对象已死(没有引用)
-
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
缺点:无法解决相互循环引用的问题
-
可达性分析算法
通过一系列称为“GCRoots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(ReferenceChain),如果某个对象到GCRoots间没有任何引用链相连则证明此对象是不可能再被使用的。简单来说,GC Root 就是经过精心挑选的一组活跃引用,这些引用是肯定存活的。那么通过这些引用延伸到的对象,自然也是存活的。
引用,GC Root是引用的集合。这个引用集合由以下组成:
- 当前所有正在被调用的方法的引用类型的参数/局部变量/临时值
- JVM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
6. 方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类信息。
如何判断一个常量为废弃常量
假如在常量池中存在字符串 "abc",如果当前没有任何String对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池。
如何判断一个类为无用的类
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
7. 垃圾回收算法
三个假说以及堆为什么要分代
根据大多数程序运行实际情况的经验准则,我们发现堆中的对象有以下特点:
- 绝大多数对象撑不过第一轮垃圾回收
- 越是熬过多次垃圾回收过程的对象越是难以消亡
- 跨代引用相对于同代引用来说仅占极少数,也就是说同一个方法中引用的对象一般都是同代的
根据这三条假说,收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。不同代的堆采用不同的回收算法获得最大效率,以此来提高垃圾回收的效率。
在新生代中,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。简单来说就是新生代都死得很快,我们只需要关注那些没死的。
在老生代中,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。简单来说就是老生代就死不了,因此很久才回收一次。
这就是堆为什么要分代的原因:选择最合适的GC算法。
标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有不需要回收的对象,在标记完成后,统一回收掉未被标记的对象。也可以反过来。标记过程就是对象是否属于垃圾的判定过程。
最基础的收集算法,后续收集算法都是基于其改进的。
缺点:
- 执行效率不稳定;
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把所有已使用过的内存空间一次清理掉。
这个算法基于假说1:绝大部分的对象都撑不过第一轮垃圾收集。因此复制只是少量操作,回收就完事。
缺点:
- 对象存活率高时效率较低
- 将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
标记-整理算法
标记-整理算法就是一种“移动式”的标记-清除算法,先把存活的对象移动到内存的一侧,再清空端边界以外的内存。
其实这个算法也有缺点,对于老生代区域来说,对象存活率较高,因此移动的代码也很高。但是不移动就会造成内存碎片的问题。不难两全其美。
还有一种“和稀泥”的方式,先用标记-清除算法回收垃圾,等内存碎片真的很多的时候再使用标记-整理算法处理内存碎片的问题。
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。这些我们上面都说过了。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
8. 常见的垃圾回收器
垃圾回收器一般分为四大类:
-
串行-Serial-单个垃圾回收线程
-
并行-Parallel-多个垃圾回收线程
-
并发-CMS
-
G1(JDK8后)
-
ZGC(JDK11后才有)
JVM中并行和并发的概念
- 并行:并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
- 并发:并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。
HotSpot实现了很多垃圾回收器,可以自行搭配使用,一般为新生代选一个回收器,老生代选一个回收器。连线表示这两个回收器可以适配。
如何查看默认垃圾回收器
java -XX:+PrintCommandLineFlags -version
默认使用Parallel Scavnge + Parallel Old
Minor GC、Major CG和Full GC
- Minor GC:针对整个新生代
- Major GC:针对整个老年代
- Full GC:针对堆
Serial收集器
- 单线程串行
- 新生代-标记复制算法
Serial Old
- 单线程串行
- 老年代-标记整理算法
ParNew(Parallel New)
- 多线程并行
- 新生代-标记复制算法
Parallel Scavenge
-
多线程并行
-
相比ParNew提供了参数设置和自适应调节策略以提高吞吐量
-
新生代-标记复制算法
Parallel Old
- 多线程并行
- 老年代-标记整理
- 同样提供了参数设置和自适应调节策略
CMS
-
多线程并发(第一款并发老年代收集器)
-
老年代-改进的标记-清除算法
CMS的垃圾回收算法(基于标记-清除,标记可达的对象,清除所有不可达的对象):
-
初始标记(Stop the world)
🍕标记一下GC Roots能直接关联到的对象,虽然需要Stop the world,但是速度很快
-
并发标记
🍕从GCRoots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
-
重新标记(Stop the world)
🍕修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,不会标记新产生的对象,只会改变此前的标记状态。时间较短
-
并发清除
🍕清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
可以看到回收算法总共被分为四个阶段,其中1,3这两个时间段的需要停止用户线程,2,4这两个时间长的不需要停止用户线程。总体看起来就是并发的。
因此CMS也被叫做“并发低停顿收集器”
-
-
如果CMS回收失败,虚拟机会使用Serial Old垃圾收集器对老年代进行回收(Full gc),此时所有的工作进程都要停止,会产生一段长时间的停止。那么问题来了,什么时候CMS回收会失败呢?
在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(ConcurrentModeFailure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用SerialOld收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
我的总结:并发标记和并发清除过程中用户线程还在持续运行,这个过程中产生的对象此次CMS垃圾回收是无法清除的,因此必须预留出一部分空间。但是如何预留的空间不够用,在CMS垃圾回收过程中内存爆了,此次CMS回收并发失败,虚拟机将使用Full GC对整个堆进行垃圾回收。
-
缺点:
- 对处理器资源敏感
- 无法处理“浮动垃圾”
- 标记清除算法的通病--内存碎片
G1
微观上还是分代思想,内存宏观上不再是分区了,化整为零
在G1收集器出现之前的所有其他收集器,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老 年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。回收衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。JVM会在在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。
我的总结:其实G1收集器本质上还是分代回收算法,但是回收的单位不是整个新生代或者老年代了,而是一个个Region。具体先回收哪个Region由该Region的权重决定,优先回收所获得的空间大以及回收所需时间少的Region。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
-
回收是以Region为单位的,一个Region可以存储多个对象,但是大对象是不存放在Region中的,使用Humongous专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的HumongousRegion之中,G1的大多数行为都把HumongousRegion作为老年代的一部分来进行看待,也就是说大对象会直接进入老年区。
-
一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?
并不!每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。
9. JVM的内存分配策略
-
对象优先在新生代的Eden区分配。
- 当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
- 当新生代没有多余的空间时,对象会通过分配担保机制将新生代内存转移到老年代
-
大对象直接进入老年代
-
因为新生代使用标记-复制算法,如果大对象没死的话,就要在 Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
为什么JVM要有两个Survicor(from to)?
假设只有一个survicor区:
首次分配对象到Eden区,垃圾回收时把存活的对象复制到survivor区,清空eden。第二次垃圾回收的时候,eden区和survivor区都有死亡的对象,那怎么办?总不能把survivor区死了的对象移动到eden区吧。所以这时候再需要一个survivor区了,eden区和from区存活的对象复制到to区,清空eden区和from区,交换from和to的指针。
这样看起来就很完美了,对象都是从eden,from -> to区,to区里面永远是死不了的对象。
eden区和survivor区的内存空间默认是八二开,为了提交内存利用率。
-
容易出发垃圾回收机制,尽管新生代还有不少的空间
-
HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配
-
-
长期存活的对象将进入老年代
- 对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。
- 对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置。
-
动态年龄判定
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄。
10. 空间分配担保
发生Minor GC之前,虚拟机首先会检查“老年代中最大连续区域的大小 是否大于 新生代中所有对象的总大小”,
也就是说老年代目前能否一定能将新生代中的所有对象全部装下?
- 若能装下,此时进行Minor GC没有任何风险,然后进行Minor GC;
- 若不一定能装下,此时进行Minor GC是有风险的,检查HandlePromotionFailure设置值是否允许担保失败,
- 若允许,那么检查老年代最大可用的连续空间 是否大于 历次晋升到老年代对象的平均大小;
- 若大于,尝试进行Minor GC,尽管这次Minor GC是有风险的;
- 若小于,改为进行一次Full GC。
- 不允许,改为进行一次Full GC,清除老年代的废弃数据来扩大老年代的空闲空间,以便给新生代作分配担保。
- 若允许,那么检查老年代最大可用的连续空间 是否大于 历次晋升到老年代对象的平均大小;
“风险”是指什么?
- 新生代使用“复制”算法,需要分配担保;而老年代使用的是“标记-整理”算法,不需要。
- 在Minor GC后,当Survivor内存不够时,老年代要进行担保,必须要有足够空间存放存活对象;
- 但是有多少对象活下来在回收之前时不知道的,所以取 “历次晋升到老年代对象的平均大小”作为经验值,决定是否用Full GC来让老年代腾出更多空间;
- 若最终还是担保失败了,那么失败之后重新发起一次Full GC。为了防止Full GC过于频繁,HandlePromotionFailure一般设置为打开。
11. 类加载机制
魔数
每个class文件的头四个字节被称为魔数,它的作用是用来确定这个文件是否为一个能被虚拟机接受的class文件,值为0xCAFEBABE(咖啡宝贝)
Class文件的结构
- 魔数
- 版本号: 45-57
- 常量池:字面量、符号引用
- 访问标志:识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等等
- 类索引、父类索引(确定父类索引的全限定类名)和接口索引
- 字段表(变量名、变量值以及类型等)
- 方法表
- 属性表
类加载过程详解
一般来说,我们把Java的类加载过程分为三个主要步骤:加载、链接、初始化
-
加载:是Java将字节码数据从不同的数据源读取到JVM中,并映射为JVM认可的数据结构(Class对象)这里的数据源可能是各种各样的形态,如jar文件、class文件,甚至是网络数据源等;如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。
-
连接:是把原始的类定义信息平滑地转化入JVM运行的过程中。这里可进一步细分为三个步骤
-
验证(Verifcation):这是虚拟机安全的重要保障,JVM需要核验字节信息是符合Java虚拟机规范的,否则就被认为是VerifyError,这样就防止了恶意信息或者不合规的信息危害JVM的运行,验证阶段有可能触发更多class的加载
验证点有以下几个:
- 文件格式验证:魔数开头,版本号,常量类型等
- 元数据验证:是否有父类,是否继承了不能被继承的final修饰的类,是否实现了父类或接口的全部方法
- 字节码验证:判断程序语义是否合法
- 符号引用验证:确保解析性能能正常执行
-
准备(Preparation):创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间, 分配初始值(int:0,boolean:false等),而不会去执行更进一步的JVM指令,比如赋值。
-
解析(Resolution):在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。
-
-
初始化:这一步真正去执行Java程序代码,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。JVM规定有且只有六种情况必须立即对类进行初始化(此时加载和连接操作需要提前准备好)
- 使用new关键字实例化对象的时候 | 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候 | 调用一个类型的静态方法的时候
- 反射调用时
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 使用JDK7新加入的动态语言支持时
- 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时
类初始化的关键是执行类的clinit方法,clinit是编译器生成的,编译器会自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生clinit方法,编译器收集的顺序是由语句在源文件中出现的顺序决定的。执行子类的clinit方法会优先调用父类的clinit方法。如果有多个线程同时初始化一个类的话,只有一个线程能进入初始化,其他线程都会被阻塞等待,初始化完成唤醒这些线程后不会再进入clinit方法,因为一个类只会被初始化一次
12. 类加载器
第10点我们讲了类的加载过程:加载-连接-初始化,初始化完成表示类信息已被成功加载入JVM,使用即使用new创建类对象,销毁即清除对象内存。类加载器的作用就是完成类的加载过程。
从JVM的角度看,我们有三种类加载器,类加载器之间是双亲委派架构。
- 启动类加载器:这个类加载器使用C/C++语言实现的,嵌套在JVM内部,java程序无法直接操作这个类。它用来加载Java核心类库,如:
JAVA_HOME/jre/lib/rt.jar``resources.jar
、sun.boot.class.path
路径下的包,用于提供jvm运行所需的包 - 扩展类加载器:Java语言编写,由
sun.misc.Launcher$ExtClassLoader
实现,我们可以用Java程序操作这个加载器。派生继承自java.lang.ClassLoader,父类加载器为启动类加载器
从系统属性:java.ext.dirs
目录中加载类库,或者从JDK安装目录:jre/lib/ext
目录下加载类库。我们就可以将我们自己的包放在以上目录下,就会自动加载进来了 - 应用程序类加载器:Java语言编写,由
sun.misc.Launcher$AppClassLoader
实现。派生继承自java.lang.ClassLoader,父类加载器为启动类加载器
。它负责加载环境变量classpath
或者系统属性java.class.path
指定路径下的类库。它是程序中默认的类加载器,我们Java程序中的类,都是由它加载完成的。我们可以通过ClassLoader#getSystemClassLoader()
获取并操作这个加载器
13. 双亲委派机制
各加载器之间的层次关系被叫做“双亲委派模型”,除了启动类加载器,其他加载器都有父加载器。双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
这样做的好处有:
- Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,同一个类只会被加载一次,JVM中也只有一个类信息
- 避免重复加载Java类型
那如果打破双亲委派模式呢?方法有二
- 自定义ClassLoader类加载器,重写loadClass方法,因为虚拟机在进行类加载时会调用类加载器的loadClass方法,这个方法的逻辑就是双亲委派模式的实现
- 父类加载器请求子类加载器完成类加载动作,具体实现是线程上下文类加载器Thread Context ClassLoader
14. 常用JVM调优参数汇总
https://cloud.tencent.com/developer/article/1198524?utm_source=ld246.com
15. JVM启动流程
总结:Java 命名启动JVM,根据路径找到jvm.cfg配置JVM实例,根据配置找到jvm.dll,初始化JVM并找到class文件,找到main方法运行
16. GC优化流程
为什么要要进行GC优化,因为JVM的GC会对系统性能产生影响,所以我们要优化GC,提高性能。GC优化一般有三步:
- 确定目标:这个最重要,明确应用程序的系统需求是性能优化的基础,确定系统是要高可用、低延迟或是高吞吐
- 优化参数:通过收集GC信息,结合系统需求,确定优化方案,例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等。进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上GC的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。
- 验收优化结果:将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。
案例一:Minor GC和Major GC频繁
- Minor GC频繁可能的原因是新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。
- 通过查看GC日志发现晋升年龄为2,太小了,象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC,可以通过调高晋升年龄,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。
案例二:使用CMS回收器时请求高峰期发生GC,导致服务可用性下降
- GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
【目前就复习就这里,后续更新~】