分享

JVM基础

 景昕的花园 2023-10-10 发布于北京

JVM基础

     JVM是指Java Virtual Machine,Java虚拟机。

     JVM首先是一个Machine,可以理解为一个操作系统。它可以提供CPU、内存、硬盘、网络等组件对应的管理能力:例如CPU的时间片管理、内存分页管理、硬盘上的文件管理、网络输入输出管理等等。从这些方面来看,JVM和一台正常的操作系统没有什么两样。    

     但是JVM是一个Virtual Machine,是虚拟的操作系统,不是真实的操作系统。操作系统是用来沟通底层硬件和软件的,例如Windows,Linux,MacOs,手机上的Android和ios。但JVM是用来沟通这些真实操作系统与使用Java的软件的:所以它并不是运行在硬件上、而是运行在操作系统软件上的一个虚拟操作系统。

     为什么要搞这么一个虚拟操作系统?这会涉及到计算机组成原理和一些编译原理。简单的说,不同的操作系统有不同的“接口”,而JVM提供了一套适配器,把不同操作系统中的不同“接口”适配成同一套“接口”,从而方便了软件的编写和运行。

     JVM就是Java提供的这样一套虚拟机。Java提供了一套JRE(Java Runtime Environment)来运行虚拟机,并提供了一套JDK(Java Develop Kit)用于开发Java软件。一般情况下,JDK中包含了一套JRE。

     为什么我们要了解、深入JVM?

     个人理解,JVM是Java生态的基础,甚至是核心。JVM为Java注入了非常强大的生命力——它有多个不同厂商或社区各自独立实现的版本;JVM甚至成为了很多其它语言的运行平台,例如Scala,Kotlin,Ceylon,Xtend,Groovy,Clojure和Fantom。换句话说,即使有一天Java语言过时了、再也没有人用了,JVM也会继续在软件行业中呼风唤雨。

     另一方面,在Java开发实践中,有一种思路叫做“面向JVM编程”(也叫“面向JIT编程”、“面向GC编程”)。这种思路就是在充分了解JVM的基础上,利用JVM特性中的优点、规避存在的缺点,提高代码和系统的效率、减少潜在的问题。虽然几行代码带来的提高比较有限;但是当系统的并发量和运行时间达到一定规模时,效率优化累积得到的性能提高就很可观了。

     JVM的基本结构如下图所示:

JVM内存结构

     上图中的“方法区”、“堆”、“Java栈”、“PC寄存器”、“本地方法栈”一起组成的“运行时数据区,就是JVM的内存区域。

     其中,方法区和堆是直接提供给应用软件使用的内存,也叫非堆内存(non-heap)和堆内存(heap);考虑到堆内存一般会分为年轻代(Young Gen)和老年代(Old Gen),非堆内存又被称作永久代(Perm Gen),这些细节在后面讲;Java栈、PC寄存器、本地方法栈是提供给JVM使用的,也叫栈内存。

     非堆内存和堆内存空间不够用了,一般会抛出OutOfMemoryError;栈内存空间不够了,一般会抛出StackOverflowError。

问题:为什么是Error而不是Exception?

     从线程的角度来看,非堆内存和堆内存是线程共享的,因此需要代码来处理其线程安全性。栈内存都是线程私有的,天然就是线程安全的。

方法区

     方法区用来存储JVM加载的类(结构、方法等)信息、常量、静态变量、即时编译器(JIT = Just-In-Time,在运行中记录程序运行特征并据此进行优化,例如分支预测。这个东西以后再细说)的编译信息等等。

     类结构都存储在方法区,所以在反射的时候,我们获取到的Class、Method、Filed等定义都是从方法区拿到的。

     有些情况下,GC也会对方法区做垃圾回收。

问题:下面哪些东西是存在方法区里的?

     由于类定义是放在方法区里的,我们可以简单的理解:class文件大小与方法区内存使用情况是息息相关的。class文件越大,方法区占用越多;class文件越小,方法区占用越少。

     所以,定义类(比如枚举)时,不要把代码中不需要使用的信息(例如“desc”之类的中文描述)定义为字段,而应该把它们写到注释中。以下面这两个枚举为例,用JavaDoc方式写的枚举,编译完成后的class文件大小为59KB;而用字段方式写的枚举,编译完成后的class文件大小为87KB。

     堆内存就是我们常说、常用的JVM内存。它用来存储运行时的实例。也就是说我们new一个对象时,这个对象就会存储在堆上。一般来说,GC(Garbage Collection)机制也是运行在堆内存上的:当堆内存中某个对象“不可达”时,GC会释放它的空间。

     堆内存上分配空间的性能较差;也就是说,new一个对象的性能较差。所以很多代码规范会建议不要在循环体内new对象、只在最必要的时候去new对象等;并且设计模式中也有FlyWeight、对象池等模式来减少new对象的性能消耗。

     例如,我们建议只在最必要的时候去new对象:

     堆内存还会被分为青年代、老年代等等,这些在介绍GC时再说。

Java栈

     也叫JVM栈。这是专门用来存储线程私有数据的空间。可以理解为:每一个线程都有一个栈;线程每调用一个方法,就会向这个栈里push一个“栈帧”;方法退出,则pop该栈帧。方法的嵌套调用、上下文信息的保存和恢复,就是通过这个栈来实现的。栈帧里存储存储局部变量(Local Variables),操作栈(Operand Stack),方法出口(Return Value),动态连接(Current Class Constant Pool Reference)等信息。

     代码中,我们可以从异常(Throwable)类中获取到当前线程的Java栈信息:

     不过,这个操作的性能非常差。所以,建议尽量不要用异常来控制代码流程;日志中也可以酌情关闭这些Location信息的输出。

     局部变量就是在方法体内声明和初始化的变量。如果是基本类型,就直接存在Java栈上;如果是对象类型,就在堆内存上存储,Java栈中存一个引用。一般来说栈内存的分配效率要比堆内存更好,这也是为什么一般建议使用基本类型的原因之一。

     动态连接是指在运行时,把变量、方法等链接到实际的堆/非堆内存中的地址上。这是Java语言多态能力的来源。最常见的就是这种:

     有动态链接自然就有静态链接。静态链接则是在编译期就决定好变量、方法等指向的地址了。静态链接比链接的性能要好,因为它把在运行期间做的事情放到了编译期间来处理。所以,有些规范会建议我们尽量让JVM对代码做静态链接。例如,静态方法、私有方法、实例构造器、父类方法这四种方法都可以在编译器就确定到底要使用哪个方法,因此JVM会对它们做静态链接。这也是为什么我们应当尽可能缩小方法的可见性,以及应当把一些方法设定为静态方法的原因。例如:

     操作栈、方法出口跟代码没太直接的关系,有兴趣自己查吧。

     然后,考虑这样一个问题:我们每次调用一个方法,都会创建一个栈帧并将其入栈;方法退出时再将其出栈并销毁。这里的创建、入栈、出栈、销毁操作都会消耗一些性能。那么,是不是说我们应当尽可能减少方法之间的嵌套调用?或者说写一个长方法是不是比写多个短方法更好呢?

     当然不是。一方面,Java代码是写给程序员看的。一个长方法的复杂度太高,在后续维护和扩展方面都有很大问题。另一方面,JVM提供了一种机制,用来在编译和运行期间把小方法组合成大方法,从而减少前面所说的入栈/出栈和创建/销毁带来的开销。这种机制就是方法内联。虽然我们没法明确要求JVM一定对某个方法做内联处理,但是可以通过一些手段让JVM“更倾向于”对我们的方法做内联处理。例如,把方法声明为private、final、static;或者在JVM参数中指定一些JIT优化参数;或者就是前面讨论的:减少方法行数。

问题:为什么private/final/static的方法更容易被内联?

     一般情况下,Java栈深度超出允许范围(例如递归嵌套太深)时,会抛出StackOverFlowError。如果内存扩展时无法为Java栈申请到足够内存,也会抛OutOfMemoryError。

PC寄存器

     PC寄存器又叫程序计数器,类似于原生CPU中的程序计数器。每个线程有一个独立的PC寄存器,用来存储当前线程执行到字节码的哪一行(实际应该是字节码在方法区内的内存地址)。分支、循环、跳转、异常处理、线程上下文恢复等功能都要依赖它来完成 。

     如果某个方法是native方法,PC寄存器中对应的值为空(Undefined)。

     按JVM规范,PC寄存器上不抛任何Error;各厂商自己实现了的除外。所以,一般来说,即使PC寄存器空间不够用了,也不会抛出StackOverFlowError或者OutOfMemoryError。

本地方法栈

     本地方法栈与Java栈很相似,不过本地方法栈是为Native方法服务的。

直接内存

     直接内存不是JVM内存的一部分。它是Java通过Native函数直接从底层操作系统获取的一块内存。直接内存最早出现在NIO中,用于提高IO性能。

     直接内存大小不受JVM配置的限制,而受到底层操作系统的限制。但是,直接内存不足时,也会抛出OutOfMemoryError。

堆和栈的区别

     这个留作问题,大家来回答吧。

Java内存模型

     JVM内存结构是一套静态的架构,它可以解释JVM如何在单线程环境下管理内存。但是在多线程环境下,各线程之间是如何通信与同步的?这就涉及到Java内存模型了。

     Java并发采用的是共享内存模型。也就是说,多个线程之间通过对一段共享内存做读写操作来交换信息和控制顺序。

问题:除了共享内存模型之外,并发编程还有什么方式来处理线程通信和线程同步?

     在Java的这套内存模型中,JVM的内存被划分成了两部分:线程栈和堆内存。每一个线程都有自己的线程栈,并且只能访问自己的线程栈而不能访问别人的。堆内存则保存了Java程序中创建的几乎所有对象,无论是哪个线程创建的。

问题:线程栈和堆内存分别对应JVM内存结构中的哪些部分?

     对JMM来说,我们除了要关注局部变量/成员变量、基本类型/复杂对象之外,需要特别注意一点:虽然复杂对象都是存在堆内存中的,但是一般来说,当线程使用它时,也会在线程栈上创建一个私有的副本。如下图所示:

     这个副本只有本线程可见,而且,出于性能考虑,一个线程会优先去读/写这个本地副本。这就像缓存一样,会出现脏数据的问题:无论是主内存变了而本地副本没来得及变、还是本地副本变了而主内存和其它线程的副本没来得及变,都有可能出现并发问题:例如重排序问题、竞态条件(Race Conditions)问题等。上次飞霄已经讲过,不再赘述。

垃圾回收

     程序中的每一个对象都有其作用域,超出这个作用域之后,这个对象就不再可用。“不再可用”不仅是指在代码中我们不能再次使用这个对象,还包括了JVM可以释放这个对象所占用的内存空间。这个释放内存空间的操作/算法/线程,就是GC(Garbage Collection)。

     GC的基本思路其实就是标记-删除。首先标记好哪些对象可用、哪些对象不可用,然后在某个时间点上把标记为不可用的对象从内存中“删除”掉——也就是释放它的内存空间。在做完这一步之后,GC可能还会做一些内存压缩、整理等操作,以减少分页碎片。

     因为Java只会回收不可用的对象,所以,为了尽快释放内存,我们可以有两种做法。第一种是在适当的位置把代码中不再使用的对象赋值为null。但这种做法不太“优雅”,而且一不留神还可能引发NPE。另一种做法是减小变量的作用域。例如,把一个大方法拆分成几个小方法,从而把大方法中的变量变成小方法中的变量。例如下面这段代码:

     垃圾回收的基本思路是标记-删除。

标记算法

     标记算法的核心是找出不可用的对象。

     Java中的实例都是对象,一般情况下,一个对象是否还有用的标志就是它是否被别的对象引用到了:如果还被引用,说明这个对象还有用;否则就是没有用了。

问题:哪些情况下,即使一个对象被引用了,它也可以被GC回收?

     但是,还有一个特殊情况:循环引用。对象A引用对象B,对象B引用对象A。除此之外没有任何其它对象会引用他们俩。这种对象实际上也是不可用的,也会被GC回收掉。

     考虑以上两种情况,GC一般有两种方式来检查对象是否可用。第一种是计数器方式,即在记录并更新每个对象被引用的次数。一旦被引用次数变成0了,那么这个对象就可以被回收了。这种方式执行效率很高,但是它很难处理循环引用的问题。另一种是有向图方式,即在内存中维护一个根节点,用有向图的结构串联和更新引用者和被引用者。在这个有向图中,从根节点出发不可到达的对象就可以被GC回收了。这种方式效率比较低,但是处理精度很高。

     目前大部分JVM都会用有向图来检查内存对象是否可用。这也是为什么在堆内存上new一个对象的效率比较低的原因之一。

删除算法

     GC的删除操作有几种不同的算法。

清除算法

     最简单的算法。直接把不可用对象的内存空间回收回来。效率不高,并且可能产生大量内存碎片。

复制算法

     将内存划分为大小相等的两块,每次只使用其中一块。使用中的这块用完了的时候,把可用对象复制到另一块上面,然后把当前这块全部清理掉。

     这个算法简单、高效,而且不会产生内存碎片,但是会导致JVM实际只能使用一半内存。

整理算法

     将所有可用对象都向内存地址的一端移动,然后直接清理掉空出来的内存。

分代收集算法

     要理解分代收集算法,首先要理解它对JVM内存的划分:

     分代收集算法会把JVM堆内存分为两大部分:年轻代(Young Gen)和老年代(Old Gen)。与之对应的,非堆内存被称作永久代(Perm Gen)。其中,年轻代又被划分为三个部分:Eden(伊甸园)、S0和S1(两个存活区)。S0和S1也会被称作From和To,不过谁是From、谁是To不是固定的,而是会随着GC而变化:有可能这次GC时S0是From、S1是To,那么下次GC时S0就是To、而S1变成了From。如前面提到的,GC主要运行在堆内存上,也就是年轻代和老年代上。

     对非堆内存也会做GC,不过条件比较苛刻。如前所说,非堆内存存储的主要是类结构、常量、静态变量等。这些信息都与类直接相关,因此,只有当某个类彻底不再使用了的时候,它存储在非堆内存中的信息才会被回收。而一个类不再使用必须同时满足以下三点:类的所有实例都已经被回收、加载类的ClassLoader已经被回收、类对象的Class对象没有被引用(即没有通过反射引用该类的地方)。这是非常苛刻的三个条件,通常只有自定义ClassLoader等情况下会出现,所以我们很少关注对非堆内存的GC。

     然后我们来看看一个对象从new出来、到被GC回收并彻底释放内存的过程。

     绝大多数情况下,一个对象被new出来以后,会被分配到JVM内存的Eden区域中。只有一些超大对象会直接分配到Old Gen上去。当Eden中的内存空间不足时,就会触发GC(对Eden的GC叫做Minor GC)。这次GC会把Eden和From中的可达对象全部复制到To中,然后释放Eden和From中的空间;最后把这次的From标记为To、把这次的To标记为From,以方便下一次GC时的复制处理。每一次Minor GC都会把仍然存活在To中的对象的“年龄”加一。当一个对象的年龄达到阈值时(默认是8)、或者To空间不足时,它就会在下一次GC时被转移到Old Gen中。当Old Gen空间不足时,JVM就会启动一次Major GC(它有一个更令人闻风丧胆的名字叫Full GC),来释放Old Gen的空间。如果Major GC后Old Gen空间仍然不足,JVM就会尝试向底层操作系统申请更多内存;如果无法扩容了,就会抛出OutOfMemoryError。

     显然,对Young Gen的GC应用到了复制算法。但是这里没有把Young Gen平分成两个部分,而是分成了Eden、From、To这三部分,默认情况下它们的大小比例是8:1:1。之所以会这样设置,是因为在Young Gen中,80%以上的对象都是朝生暮死的,一次Minor GC后能存活下来的对象通常都很少。所以Eden和From/To的比例没有必要设计为1:1。当然,也有超过10%的情况,这时就需要把一部分对象存入Old Gen了。

     同时,也因为Young Gen中大部分对象的生命周期都很短,所以每次Minor GC需要复制的对象都很少,因此Minor GC的用时非常短,即使它要“Stop The World”,对应用软件来说影响也非常非常小。但是,Major GC的时间就非常久了,甚至可以说Major GC是Java应用的一个噩梦。

问题:一次Minor GC的时间大约是多久?一次Major GC的时间大约是多久?

     这也是为什么我们要缩短变量的生命周期、要写短方法的又一个原因。仍然可以用前面的代码来做例子:

内存泄露

     GC只回收“不可达”对象,也造成了一个隐患:如果一个对象始终可达,那么GC就永远不会回收它;如果程序已经不需要使用这个对象了,那么我们就可以说这个对象所占用的内存发生了泄露;如果这个对象占用的内存空间不断增加,那么JVM内存迟早会被它“吃光”。这时,内存泄漏就演变成了问题。

     比如说,用以下代码实现一个简单的缓存,就可能产生内存泄露:

     Java自身也出过一些导致内存泄漏的bug,例如String#subString()方法。不过很多后来都修复了。

     内存泄露可以有很多方法和工具来检测,但是最好还是在写代码的时候就多加注意,避免出现这样的问题。

垃圾收集器

Serial GC 

     串行收集器。单线程处理,一般就是简单的标记-删除-压缩。性能较差。

Parallel GC

     并行收集器。可以简单的理解为Serial GC的并行版本。但是它只会并行处理Minor GC,而仍然会单线程的处理Major GC。

     在多核平台上可以提高CPU利用率。

Parallel Old GC

     Parallel GC的加强版,在Major GC时也使用多线程来处理。具体做法是把Old Gen划分为若干个独立单元,线程以独立单元为单位进行并行处理。

     能够提高一点Old Gen的回收效率,但是据称实践中的性能提高并不明显。

Concurrent Mark Sweep (CMS) Collector

     使用Parallel GC来处理Young Gen。对Old Gen则采用三次标记法来处理:初始标记、并发标记、再次标记、并发清除。

     停顿时间短;但是很吃CPU,并且CMS不做内存压缩,因此容易产生内存碎片。

G1 Garbage Collector

     G1的全称是Garbage First,垃圾优先。

     前面几种GC都是基于分代收集算法的。但是G1用了一种不同的算法——或者说是一种改进的分代收集算法。从Java9开始,G1成为了默认垃圾收集器。JDK10又对G1做了较大幅度的优化。不过下面的内容主要是基于JDK8的。

     在内存分配策略上,G1不会简单的把JVM内存划分为固定的、逻辑连续的Young/Old Gen,而是把它划分为若干个大小相同、内部逻辑连续但相互之间不一定连续的内存块(Region)。每一个内存块会有一个固定的分代角色——Eden,Survivor,Old。但是每一代所占用的内存总大小(也就是内存块的个数)不固定。除了这三个角色之外,G1还有一个新的内存角色:Thread Local Allocation Buffer(本地线程缓冲区)。它是对Eden的一个细分,用于在多线程环境下存放一些线程本地的变量。

     大体上G1在管理对象时也会按Eden、Survivor、Old等分代进行内存分配、晋升、回收。分配时也是优先分配在Eden中(会优先分配在TLAB中,非线程私有的再在非TLAB的Eden中找个地方),只有超过Region一半的巨型对象会直接分配到Old Gen上去。Minor GC也会将Eden和From中的可达对象复制、压缩到To中,并对它们的年龄加1;超龄的对象会放到Old Gen中。Old Gen空间不足时触发Major GC。

     G1算法的改进是这样的:首先并发地对各内存块做一次标记,由此可以得知哪些内存块中的垃圾最多、可以释放的空间最多。然后优先对这些内存块做垃圾回收——所以这个算法叫做Garbage First,也就是“垃圾优先”算法。并且,G1算法会根据用户定义的暂停时间来计算一次处理多少个内存块、处理哪些内存块。决定好了之后,它会把这些内存块中的存活对象统一拷贝到另一个内存块中,并同时做一些内存压缩和碎片整理等工作。

     G1垃圾收集器提供了两种GC模式:YoungGC和MixedGC。YoungGC只对Eden和Survivor做垃圾回收,MixedGC会同时对三代内存都做垃圾回收,并且对Old Gen的回收操作中会复用一部分对Young Gen的结果,所以它是Mixed。如果MixedGC都无法释放足够的Old Gen空间了,最终会触发Serial GC。

     G1的优点主要有:利用多CPU来缩短Stop The World时间,这一点与CMS类似。但是G1能够压缩内存空间,减少内存碎片;而CMS由于不压缩内存,因此容易产生内存碎片。另外,G1会根据用户定义的停顿时间来控制一次垃圾回收的工作范围,因此GC停顿更加可控——虽然并非严格“可控”;等等。一般大型服务器上推荐使用G1收集器。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多