史上最完整的JVM深入解析

2022-07-31,,,

前言

学过Java程序员对JVM应该并不陌生,如果你没有听过,没关系今天我带你走进JVM的世界。程序员为什么要学习JVM呢,其实不懂JVM也可以照样写出优质的代码,但是不懂JVM有可能别被面试官虐得体无完肤。

概念

JJVM它是Java Virtual Machine 的缩写,主要是通过在实际计算机模仿各种计算机功能来实现的,组成部分包括堆、方法区、栈、本地方法栈、程序计算器等部分组成的,其中方法回收堆和方法区是共享区,也就是谁都可以使用,而栈和程序计算器、本地方法栈区是归JVM的。Java能够被称为“一次编译,到处运行”的原因就是Java屏蔽了很多的操作系统平台相关信息,使得Java只需要生成在JVM虚拟机运行的目标代码也就是所说的字节码,就可以在多种平台运行。

运行过程

我们都知道 Java 源文件,通过编译器,能够生产相应的.Class 文件,也就是字节码文件,而字节码文件又通过 Java 虚拟机中的解释器,编译成特定机器上的机器码 。

也就是如下:

① Java 源文件—->编译器—->字节码文件

② 字节码文件—->JVM—->机器码

每一种平台的解释器是不同的,但是实现的虚拟机是相同的,这也就是 Java 为什么能够跨平台的原因了 ,当一个程序从开始运行,这时虚拟机就开始实例化了,多个程序启动就会存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不能共享。

JVM 内存区域

JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法栈】、线程共享区域【JAVA 堆、方法区】、直接内存。

线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在 HotspotVM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死对应)

线程共享区域随虚拟机的启动/关闭而创建/销毁。

一、程序计数器

一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。

正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是 Native 方法,则为空。

这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。

二、Java虚拟机栈

虚拟机栈是Java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧,把栈帧压入栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈。

栈帧:栈帧存储方法的相关信息,包含局部变量数表、返回值、操作数栈、动态链接

a、局部变量表:包含了方法执行过程中的所有变量。局部变量数组所需要的空间在编译期间完成分配,在方法运行期间不会改变局部变量数组的大小。

b、返回值:如果有返回值的话,压入调用者栈帧中的操作数栈中,并且把PC的值指向 方法调用指令 后面的一条指令地址。

c、操作数栈:操作变量的内存模型。操作数栈的最大深度在编译的时候已经确定(写入方法区code属性的max_stacks项中)。操作数栈的的元素可以是任意Java类型,包括long和double,32位数据占用栈空间为1,64位数据占用2。方法刚开始执行的时候,栈是空的,当方法执行过程中,各种字节码指令往栈中存取数据。

d、动态链接:每个栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

三、本地方法栈

本地方法栈和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务。

四、方法区

方法区也叫永久代(Permanent Generation)。在过去(自定义类加载器还不是很常见的时候),类大多是”static”的,很少被卸载或收集,因此被称为“永久的(Permanent)”。方法区用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

五、堆

它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

(1) 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的。

(2) Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配。

(3) TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

(4) 所有新创建的Object 都将会存储在新生代Yong Generation中。如果Young Generation的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在Eden Space。

六、执行引擎

执行引擎包含即时编译器(JIT)和垃圾回收器(GC),对即时编译器我们简单介绍一下,主要重点在于垃圾回收器。

即时编译器(JIT,Just-In-Time Compiler)

JIT并不是Java虚拟机规范定义中规定必须存在的,但它又是JVM性能重要影响因素之一。

在上面的内容里,提到了HotSpot这么一个名字,它其实是虚拟机的名称。HotSpot中文意思是"热点",而HotSpot VM的特点之一也就是可以探测并优化热点代码,JIT就是它进行优化的方式。

HotSpot通过计数以及其他方式,监测到某些方法或者某些代码块执行的频率很高,就会将其编译成为平台相关的机器码,甚至于在保证结果的情况下通过优化执行顺序等方式进行优化,这种机器码的执行效率比解释执行要高出很多。而编译完成后,会通过"栈上替换"等方式进行动态的替换,比如循环执行,循环一次JIT的计数器就+1,到了阈值的时候就开始编译重复执行的代码,同时为了不影响系统的运行,原来的解释执行仍然继续,直到在第N次循环时,编译完成,会在N+1次执行前替换成编译后的机器码执行。

垃圾回收器(Garbage Collection)

1.什么是垃圾?

说到垃圾回收器,首先需要说一下什么叫垃圾。
所有的对象都存放在堆中,而有些对象用过之后就不会再被使用了,这种就叫做垃圾。概念很容易理解,但对于JVM来说,怎么确定一个对象是否是垃圾或者说怎么找到所有的垃圾对象就需要算法的支持。

2.怎么确定一个对象是垃圾?

不得不提的一种是引用计数法,实现起来最简单,一个对象被引用一次,计数器就+1,失去引用就计数器-1,等到计数器减为0了,这个对象就没有其他对象在使用了,也就可以对它进行回收了。这种算法效率很高,但这种会有一个问题在于,两个对象相互引用,但两个对象都没有被其他对象继续引用了,计数器仍然不会减为0。

通过引用计数来看,node1被node2引用着,node2也被node1引用着,两个互相引用,却没有其他地方在引用,应该被清除掉,但引用计数器的值并没有减为0,无法回收。所以几乎已经被现代语言抛弃掉了,取而代之的是可达性分析标记存活对象而后使用其他算法。

可达性分析是从一个GC Root节点开始找引用的节点,找到后继续找其引用的节点,直到查找完毕,其余没有被找到过的节点就是垃圾节点,一般作为GC Root的对象有Java栈中的本地变量对象,方法区的静态变量引用的对象,方法区的常量引用的对象,本地方法栈中引用的对象等。

如上图所示,遍历所有的GC Root(黑色的对象),然后向下寻找所有的引用关系,能够找到的就标记为存活(蓝色的对象)。而无法找到的,也就无法打上标记(黄色的对象),这些没有存活标记的就是可以回收的对象。

七、类加载器子系统

顾名思义,这是用于类加载的一个子系统。

类加载的过程

类加载的过程包括了加载,验证,准备,解析和初始化这5个步骤:

1.加载:找到字节码文件,读取到内存中。类的加载方式分为隐式加载和显示加载两种。隐式加载指的是程序在使用new关键词创建对象时,会隐式的调用类的加载器把对应的类加载到jvm中。显示加载指的是通过直接调用class。forName()方法来把所需的类加载到jvm中。

2.验证:验证此字节码文件是不是真的是一个字节码文件,毕竟后缀名可以随便改,而内在的身份标识是不会变的。在确认是一个字节码文件后,还会检查一系列的是否可运行验证,元数据验证,字节码验证,符号引用验证等。Java虚拟机规范对此要求很严格,在Java 7的规范中,已经有130页的描述验证过程的内容。

3.准备:为类中static修饰的变量分配内存空间并设置其初始值为0或null。可能会有人感觉奇怪,在类中定义一个static修饰的int,并赋值了123,为什么这里还是赋值0。因为这个int的123是在初始化阶段的时候才赋值的,这里只是先把内存分配好。但如果你的static修饰还加上了final,那么就会在准备阶段就会赋值。

4.解析:解析阶段会将java代码中的符号引用替换为直接引用。比如引用的是一个类,我们在代码中只有全限定名来标识它,在这个阶段会找到这个类加载到内存中的地址。

5.初始化:如刚才准备阶段所说的,这个阶段就是对变量的赋值的阶段。

类与类加载器

每一个类,都需要和它的类加载器一起确定其在JVM中的唯一性。换句话来说,不同类加载器加载的同一个字节码文件,得到的类都不相等。我们可以通过默认加载器去加载一个类,然后new一个对象,再通过自己定义的一个类加载器,去加载同一个字节码文件,拿前面得到的对象去instanceof,会得到的结果是false。

双亲委派机制

类加载器一般有4种,其中前3种是必然存在的:

1.启动类加载器:加载<JAVA_HOME>\lib下的
2.扩展类加载器:加载<JAVA_HOME>\lib\ext下的
3.应用程序类加载器:加载Classpath下的
4.自定义类加载器

而双亲委派机制是如何运作的呢?

我们以应用程序类加载器举例,它在需要加载一个类的时候,不会直接去尝试加载,而是委托上级的扩展类加载器去加载,而扩展类加载器也是委托启动类加载器去加载。

启动类加载器在自己的搜索范围内没有找到这么一个类,表示自己无法加载,就再让扩展类加载器去加载,同样的,扩展类加载器在自己的搜索范围内找一遍,如果还是没有找到,就委托应用程序类加载器去加载.如果最终还是没找到,那就会直接抛出异常了。

而为什么要这么麻烦的从下到上,再从上到下呢?

这是为了安全着想,保证按照优先级加载.如果用户自己编写一个名为java.lang.Object的类,放到自己的Classpath中,没有这种优先级保证,应用程序类加载器就把这个当做Object加载到了内存中,从而会引发一片混乱。而凭借这种双亲委派机制,先一路向上委托,启动类加载器去找的时候,就把正确的Object加载到了内存中,后面再加载自行编写的Object的时候,是不会加载运行的。

双亲委派原则归纳总结

1.可以避免重复加载,父类已经加载了,子类就不需要再次加载。
2.提高系统的安全性,很好的解决了各个类加载器的基础类的统一问题,如果不使用该种方式,那么用户可以随意定义类加载器来加载核心api,会带来相关隐患。

JVM优化

1、一般来说,当survivor区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的eden区,survivor区及使用率,可以将年轻对象保存在年轻代,从而避免full GC,使用-Xmn设置年轻代的大小。

2、对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在eden区移动大量对象到老年代,然后这些移动的对象可能很快消亡,因此导致full GC。通过设置参数:-XX:PetenureSizeThreshold=1000000,单位为B,标明对象大小超过1M时,在老年代(tenured)分配内存空间。

3、一般情况下,年轻对象放在eden区,当第一次GC后,如果对象还存活,放到survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值,就被放到tenured老年区。这个阈值可以同构-XX:MaxTenuringThreshold设置。如果想让对象留在年轻代,可以设置比较大的阈值。

4、设置最小堆和最大堆:-Xmx和-Xms稳定的堆大小堆垃圾回收是有利的,获得一个稳定的堆大小的方法是设置-Xms和-Xmx的值一样,即最大堆和最小堆一样,如果这样子设置,系统在运行时堆大小理论上是恒定的,稳定的堆空间可以减少GC次数,因此,很多服务端都会将这两个参数设置为一样的数值。稳定的堆大小虽然减少GC次数,但是增加每次GC的时间,因为每次GC要把堆的大小维持在一个区间内。

5、一个不稳定的堆并非毫无用处。在系统不需要使用大内存的时候,压缩堆空间,使得GC每次应对一个较小的堆空间,加快单次GC次数。基于这种考虑,JVM提供两个参数,用于压缩和扩展堆空间。

(1)-XX:MinHeapFreeRatio 参数用于设置堆空间的最小空闲比率。默认值是40,当堆空间的空闲内存比率小于40,JVM便会扩展堆空间。

(2)-XX:MaxHeapFreeRatio 参数用于设置堆空间的最大空闲比率。默认值是70, 当堆空间的空闲内存比率大于70,JVM便会压缩堆空间。

(3)当-Xmx和-Xmx相等时,上面两个参数无效。

6、通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。

(1)-XX:+UseParallelGC:年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。

(2)-XX:+UseParallelOldGC:设置老年代使用并行垃圾回收收集器。

7、尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统的性能。-XX:+LargePageSizeInBytes 设置内存页的大小。

8、使用非占用的垃圾收集器。-XX:+UseConcMarkSweepGC老年代使用CMS收集器降低停顿。

9、-XXSurvivorRatio=3,表示年轻代中的分配比率:survivor:eden = 2:3

10、JVM性能调优的工具:

(1)jps(Java Process Status):输出JVM中运行的进程状态信息(现在一般使用jconsole)

(2)jstack:查看java进程内线程的堆栈信息。

(3)jmap:用于生成堆转存快照

(4)jhat:用于分析jmap生成的堆转存快照(一般不推荐使用,而是使用Ecplise Memory Analyzer)

基本垃圾回收算法

大多数人对于GC的直观感受是,飘忽不定,它执行的时间是不确定的,就算手动调用System.gc()也不见得会执行。但其实不尽然,GC作为一个守护线程,它的优先级是随着内存使用情况不断变化的,会在可用内存低到一定程度后自动调用。

基本GC算法主要是标记-清除算法,复制算法,标记-整理算法。

1.标记-清除算法 其实在JVM中没怎么露脸,但它是现代GC算法的基础。通过可达性分析,将存活的对象打上标记,然后对全部对象进行扫描,将没有标记的对象清除掉.这种算法会有一个问题,清除废弃对象后,释放的内存并不是连续的,而是一个个内存碎片。这对于后续JVM分配内存并不是很好,如果需要一块较大的连续内存就没有办法将这些碎片利用起来,并且它需要遍历所有的对象,清除没有标记的,这种性能消耗很大。

2.复制算法,一般应用于新生代,这也是为什么新生代要设计成一个Eden,两个Survivor区的原因。所有对象都在Eden创建出来,每次gc就会把Eden和其中一个正在使用的Survivor区中存活的对象复制到另外一个没有使用的Survivor区。然后清除掉原来内存区的所有对象,也就是废弃的对象。每次gc都这样操作,始终留一个Survivor区不使用。这种算法的好处在于不会残留内存碎片,方便内存管理,但是需要预留一块内存,并且性能消耗是根据存活对象多少而来的,不适用于存活对象较多的情况。

3.标记-整理算法,是标记-清除算法的升级版,一般用于老年代。它将标记存活的对象统一移到内存的某一端,然后将边界外的空间清空。这样既不会占着一块内存作为备用,也不会存在内存碎片无法有效利用。但是由于要遍历存活的对象,还有重新存活对象的引用地址,所以效率要低于复制算法。

4.分代回收算法
正如我们前面了解到的,新生代和老年代各自的情况不同,直接把某种算法套用在两个区上,可能效果并不理想。而现在商业虚拟机的GC都是采用的分代回收算法,不同的堆分区采用不同的算法进行回收。

5.Minor GC和Full GC
在说这两种回收的区别之前,我们先来说一个概念,“Stop-The-World”。

如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。

而一般来说,我们把新生代的回收称为Minor GC,Minor意思是次要的,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短。而Full GC一般是老年代的回收,病伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法,这种GC每次都比较慢,造成的暂停时间比较长,通常是Minor GC时间的10倍以上。

所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。

好了,以上内容就是我对JVM进行的原理解析和模块梳理,如果你看完之后感觉有所收益,希望可以帮忙点个赞,我会更有动力继续进行技术分享。我是云客姑苏,一个有态度懂生活的程序员!

本文地址:https://blog.csdn.net/zx6688999/article/details/107887059

《史上最完整的JVM深入解析.doc》

下载本文的Word格式文档,以方便收藏与打印。