Java 面试题超详细整理《JVM 篇》
当程序主动使用某个类时,如果该类还未被加载到内存中,则 JVM 会通过加载、连接、初始化 3 个步骤来对该类进行初始化。如果没有意外,JVM 将会连续完成 3 个步骤,所以有时也把这个 3 个步骤统称为类加载或类初始化。
加载:通过一个类的全限定名获取定义此类的二进制字节流,并将这个字节流所代表的静态存储结构转换成方法区中的运行时数据结构,并在堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区类数据的访问入口,这个过程需要类加载器参与。
连接过程:验证-》准备-》解析
① 验证(Verify):目的在于确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全,主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
②准备(Prepare):为类变量分配内存并且设置该类变量的默认初始值,即零值
③解析:将常量池内的符号引用转换为直接引用的过程
初始化:对静态变量和静态代码块执行初始化工作
加载 class 文件的方式:
①从本地系统中直接加载
②通过网络获取,典型场景:Web Applet
③从 zip 压缩包中读取,成为日后 jar、war 格式的基础
④运行时计算生成,使用最多的是:动态代理技术
**初始化阶段就是执行类构造器方法
<clinit>()
的过程
此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来(当代码中包含 static 变量时,
<clinit>()
自动生成,如果没有静态代码快则不会生成)。
<clinit>()
方法中的指令按语句在源文件中出现的顺序执行
<clinit>()
不同于类的构造器。(关联:构造器是虚拟机视角下的<init>()
)
若该类具有父类,JVM 会保证子类的
<clinit>()
执行前,父类的<clinit>()
已经执行完毕
虚拟机必须保证一个类的
<clinit>()
方法在多线程下被同步加锁**
类加载器:通过类的权限定名获取该类的二进制字节流的代码块。
JVM 支持两种类型的类加载器 ,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器,所以 ExtClassLoader 和 AppClassLoader 都属于自定义加载器。
四者之间是包含关系,不是上层和下层,也不是子父类的继承关系:
启动类加载器(Bootstrap ClassLoader):用来加载 java 核心类库,无法被 java 程序直接引用。
扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。
用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。
如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。
总结就是: 当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。
作用:
双亲机制避免了类的重复加载
保护程序安全,防止核心 API 被随意篡改
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:
线程私有的:程序计数器、虚拟机栈、本地方法栈
线程共享的:堆、方法区
程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;
Java 虚拟机栈(Java Virtual Machine Stacks):每个方法在执行的同时都会在 Java 虚拟机栈中创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息;栈帧就是 Java 虚拟机栈中的下一个单位。
本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;Native 关键字修饰的方法是看不到的,Native 方法的源码大部分都是 C 和 C++ 的代码
Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;java 堆是垃圾收集器管理的主要区域,因此也被成为“GC 堆”。
方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然 Java 虚拟机规范把?法区描述为堆的?个逻辑部分,但是它却有?个别名叫做 Non-Heap(?堆),?的应该是与 Java 堆区分开来。
运行时常量池:
运?时常量池是?法区的?部分。Class ?件中除了有类的版本、字段、?法、接?等描述信息外,还有常量池表(?于存放编译期?成的各种字?量和符号引?)既然运?时常量池是?法区的?部分,?然受到?法区内存的限制,当常量池?法再申请到内存时会抛出 OutOfMemoryError 错误。
JDK1.8 运?时常量池在元空间,字符串常量池在堆中。
异常相关:
程序计数器: 内存区域中唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈与本地方法栈: 在 Java 虚拟机栈和本地方法栈中,规定了两个异常状况:如果线程请求的栈深度大于栈所允许的深度,将抛出 StackOverflowError 异常;如果栈可以动态扩展,并且扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
堆: 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时(内存大小超过“-xmx"所指定的最大内存时),将会抛出 OutOfMemoryError 异常。
方法区: 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类(比如说加载大量第三方 jar 包),导致方法区溢出,虚拟机会抛出 OutOfMemoryError 异常。
Java 虚拟机是线程私有的,它的生命周期和线程相同。 虚拟机栈描述的是 Java 方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(StackFrame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
解析栈帧:
局部变量表:是用来存储我们临时 8 个基本数据类型、对象引用地址、returnAddress 类型。(returnAddress 中保存的是 return 后要执行的字节码的指令地址。)
操作数栈:操作数栈就是用来操作的,例如代码中有个 i = 6*6,他在一开始的时候就会进行操作,读取我们的代码,进行计算后再放入局部变量表中去
动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
方法返回地址:在方法退出后都返回到该方法被调用的位置,正常的话就是 return 调用者的 pc 计数器的值,不正常的话返回异常表中的对应信息
对于虚拟机栈来说不存在垃圾回收问题
Java 虚拟机栈会出现两种错误: StackOverFlowError 和 OutOfMemoryError 。
StackOverFlowError : 若 Java 虚拟机栈的内存??不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最?深度的时候,就抛出 StackOverFlowError 错误。
OutOfMemoryError : 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也?法提供更多内存的话。就会抛出 OutOfMemoryError 错误。
扩展:那么?法/函数如何调??
Java 栈可?类?数据结构中栈,Java 栈中保存的主要内容是栈帧,每?次函数调?都会有?个对应的栈帧被压? Java 栈,每?个函数调?结束后,都会有?个栈帧被弹出。
Java ?法有两种返回?式: return 语句、抛出异常。不管哪种返回?式都会导致栈帧被弹出。
一个方法调用另一个方法,会创建很多栈帧吗?
会创建。如果一个栈中有动态链接调用别的方法,就会去创建新的栈帧,栈中是由顺序的,一个栈帧调用另一个栈帧,另一个栈帧就会排在调用者下面
递归的调用自己会创建很多栈帧吗?
答:递归的话也会创建多个栈帧,就是在栈中一直从上往下排下去
java 堆(Java Heap)是 java 虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,?乎所有的对象实例以及数组都在这?分配内存。
java 堆是垃圾收集器管理的主要区域,因此也被成为“GC 堆”。从垃圾回收的?度,由于现在收集器基本都采?分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老生代,再细致?点可分为:Eden 空间、From Survivor、To Survivor 空间等
根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。当前主流的虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制)。如果堆中没有内存可以完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
JDK1.8 以前使用永久代(方法区),JDK1.8 以后使用元空间
整个永久代有?个 JVM 本身设置固定大小上限,?法进?调整,而 JVM 加载的 class 的总数,方法的大小等都很难确定,因此对永久代大小的指定难以确定。太小的永久代容易导致永久代内存溢出,太大的永久代则容易导致虚拟机内存紧张,空间浪费。?元空间使?的是直接内存,受本机可?内存的限制,虽然元空间仍旧可能溢出,但是?原来出现的?率会更?。
元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
你可以使? -XX MaxMetaspaceSize 标志设置最?元空间??,默认值为 unlimited,这意味着它只受系统内存的限制。 -XX MetaspaceSize 调整标志定义元空间的初始??如果未指定此标志,则 Metaspace 将根据运?时的应?程序需求动态地重新调整??。
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
直接内存可以看成是物理内存和 Java 虚拟机内存的中间内存,他可以直接使? Native 函数库直接分配堆外内存,然后通过?个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引?进?操作。这样就能在?些场景中显著提?性能,
因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存??以及处理器寻址空间的限制。
| 对比 | JVM 堆 | JVM 栈 |
| --- | --- | --- |
| 物理地址方面 | 堆的物理地址分配对对象是不连续的。因此性能慢些。在 GC 的时候也要考虑到不连续的分配,所以使用了各种垃圾回收算法 | 栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。 |
| 内存分配方面 | 堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。 | 栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。 |
| 存放的内容方面 | 堆存放的是对象的实例和数组。因此该区更关注的是数据的存储 | 栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。 |
| 程序的可见度 | 堆对于整个应用程序都是共享、可见的。 | 栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。 |
堆:主要用来存储对象和数组,物理地址分配不连续、内存大小不确定、线程共享
栈:用来存放操作数栈,物理地址分配连续、内存在编译期确定、线程私有
加载类元信息,判断类元信息(加载、链接、初始化)是否存在
为对象分配内存
处理并发问题
初始化分配到的空间,属性的默认初始化(零值初始化)
设置对象头信息
执行 init 方法初始化(属性显示初始化、代码块中的初始化、构造器初始化)
为对象分配内存:
类加载完成后,接着会在 Java 堆中划分一块内存分配给对象。内存分配根据 Java 堆是否规整,有两种方式:
指针碰撞:如果 Java 堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
空闲列表:如果 Java 堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些
处理并发问题:
采用 CAS 配上失败重试保证更新的原子性
在 Eden 区给每个线程分配一块区域 TLAB - 通过设置 -XX:+UseTLAB 参数来设置(区域加锁机制),
对象的访问定位
**句柄访问: 栈的局部变量表中,记录的对象的引用,然后在堆空间中开辟了一块空间,也就是句柄池。
特点: reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改**
![在这里插入图片描述](https://static001.geekbang.org/infoq/86/8645d690a50e7dbbc6ec9a21e7c85706.png)
直接指针(HotSpot 采用):
**直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据。
特点: 节省了指针定位的开销,但是在对象被移动时reference本身需要被修改。**
![在这里插入图片描述](https://static001.geekbang.org/infoq/50/5039367ebfd67b76464b36421efaf579.png)
在 java 中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
优点:JVM 的垃圾回收器都不需要我们手动处理无引用的对象了,这个就是最大的优点
缺点:程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收
垃圾收集 GC(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。
对于 GC 来说,当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当 GC 确定一些对象为"不可达"时,GC 就有责任回收这些内存空间。
有什么办法手动进行垃圾回收?
程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行
垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是存活的,是不可以被回收的;哪些对象已经死掉了,需要被回收。一般有两种方法来判断:
引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数-1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;(python 中使用)
可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。(Java 中使用)
可以作为 GC Root 的对象:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 Native 方法引用的对象。
标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。
标记-清除算法
标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:
标记阶段:标记出可以回收的对象。
\* **清除阶段:回收被标记的对象所占用的空间。**
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。
优点:实现简单,不需要对象进行移动。
缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。
复制算法:
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。
优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制
标记-整理算法
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。
优点:解决了标记-清理算法存在的内存碎片问题。
缺点:仍需要进行局部对象移动,一定程度上降低了效率。
分代收集算法:
当前商业虚拟机都采用 分代收集 的垃圾收集算法。分代收集算法,顾名思义是根据对象的 存活周 期 将内存划分为几块。一般包括 年轻代 、 老年代 和 永久代 ,如图所示: (后面有重点讲解)
分区算法:
一般来说,在相同条件下,堆空间越大,一次 GC 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间
评论