Java初级面试题-JVM篇

运行时数据区域

程序计数器:记录正在执行的字节码指令地址。若执行本地方法,则为undefined。该区域是JVM中唯一不会发生OutOfMemoryError的区域。

Java虚拟机栈:每个Java方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用到执行完成的过程对应一个栈帧在Java虚拟机栈中入栈和出栈的过程。

这个区域可能发生两种异常:

  • 当线程请求的栈深度超过最大值,会抛出StackOverflowError异常。
  • 栈进行动态扩展时,无法申请足够内存,会抛出OutOfMemoryError异常。

本地方法栈:本地方法栈与Java虚拟机栈相似,它们之间的区别主要是本地方法栈为本地方法服务。

:几乎所有的对象都在堆中分配。是垃圾回收的主要区域。现代的垃圾收集器基本都是采用分代收集算法,其主要思想是针对不同类型的对象采用不同的垃圾回收算法。可以将堆分成两块新生代和老年代。堆不需要的连续的内存,并且可以动态扩展内存。扩展失败则抛出OutOfMemoryError异常。虚拟机参数:-Xms:堆的初始大小;-Xmx:堆的最大值。

在堆中为对象分配内存主要有两种方式:

  • 指针碰撞法

    假设Java堆中内存时完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。

  • 空闲列表法

    事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。

对象创建是一个非常频繁的行为,进行堆内存分配时还需要考虑多线程并发问题,可能出现正在给对象A分配内存,指针或记录还未更新,对象B又同时分配到原来的内存,解决这个问题有两种方案:

  1. 采用CAS保证数据更新操作的原子性;
  2. 把内存分配的行为按照线程进行划分,在不同的空间中进行,每个线程在Java堆中预先分配一个内存块,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB);

方法区:用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。和堆一样不需要连续的内存,并且可以动态扩展,扩展失败则抛出OutOfMemoryError异常。对这块区域进行垃圾回收的主要目的是对常量池的回收和卸载,但一般很难实现。HotSpot虚拟机是把它当成永久代来进行垃圾回收的。但很难确定永久代的大小。因为他受很大因素影响,并且每次FullGC之后,永久代的大小都会改变,所以容易出现OutOfMemoryError异常。为了更容易管理方法区。,从JDK1.8开始,移除永久代,并把方法区移至元空间。它位于本地内存中,而不是虚拟机内存中。

运行时常量池:运行时常量池是方法区的一部分。Class文件中的常量池(编译期生成的各种字面量和符号引用)会在类加载后被放入这个区域。除了在编译期生成的常量,还允许动态生成。例如String类的intern()方法。

直接内存:在jdk1.4时新增加入NIO类,他可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆内的DirectByteBuffer对象作为这块内存的引用进行操作。这样可以在一些场景中提高性能,因为避免了在Java堆和Native堆中来回复制数据。

垃圾回收算法

标记-清除

标记要回收的对象,然后清除。

不足:1.标记与清除效率不高。2.会产生大量内存碎片,可能导致无法给大对象分配内存。

标记-整理

让所有存活的对象移动到一段,然后清理掉端边界以外的内存。

复制

把内存划分为大小相等的两块,每次只使用其中一块,当这块内存使用完了就将还存活的对象复制到另外一块上,然后再把使用过的内存清理一遍。

不足:只使用了一半内存。

拓展:现代的商业虚拟机都采用复制算法来回收新生代,但不是把新生代划分为大小相等的两块,而是分为一块较大的Eden区和两块较小的Survivor区。每次只使用Eden区和其中一块Survivor区,在回收的时候,将Eden区和Survivor区中还存活的对象一次性复制到另一块Survivor区中,最后清理Eden和那块使用过的Survivor区。在HotSpot虚拟机中Eden和Survivor区域大小默认比例为8:1,内存使用率为百分之90.如果每次回收存活的对象大于10%,此时需要依赖老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

分代算法

现在的商用虚拟机采用分代算法算法。一般将堆分成新生代和老年代。新生代使用复制算法,老年代使用标记-清除算法或者是标记-整理算法。

如何判断一个对象是否可回收

引用计数算法

给对象添加一个引用计数器。当对象增加一个引用时,计数器加1,引用失效时,计数器减1.引用计数为0的对象可以被回收。两个对象出现循环可用的情况下,引用计数器永不为0,导致无法对它们进行回收。正因为循环引用的存中,Java虚拟机不使用引用计数算法。

可达性分析算法

通过GC Roots作为起始点进行搜索,能够到达的对象都是存活的,不可达的对象可以被回收。

GC Roots:

  1. 虚拟机栈中局部变量表中引用的对象
  2. 本地方法栈中JNI中引用的对象
  3. 方法区中静态属性引用的对象
  4. 方法区中常量引用的对象

方法区的回收

方法区的回收主要是堆常量池的回收和对类的卸载。

类的加载机制

类从加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载-验证-准备-解析-初始化-使用-卸载。其中验证-准备-解析称为链接(Linking)。加载、验证、准备、初始化、卸载这五个阶段顺序是确定的,解析在某些情况下会在初始化后进行。

加载(Loading)

在加载阶段,虚拟机需要完成下面3件事:

  1. 通过一个类的全限定名获取定义此类的二进制字节流,实现这个动作的代码就是"类加载器",获取的途径不仅仅是Class文件,也可能来源网络、数据库等。
  2. 将这个字节流所表示的静态存储结构转化为方法区运行时数据结构。
  3. 在内存中生成一个代表这个类的class对象,作为方法区的各种数据的访问入口。

加载阶段和链接阶段的部分内容是交叉进行的(如部分字节码格式验证)。加载阶段还没结束,链接阶段可能已经开始了,夹杂在加载阶段操作依然属于链接阶段,并且加载阶段和链接阶段的开始时间是仍然保持固定的先后顺序的。加载阶段可以是用户参与的阶段,开发人员可以自定义类加载器,去实现自己的类加载过程。

拓展:

JVM是按需加载类的

JVM如果加载一个jar包,只会在加载那些明确使用到的类到内存中。要查看JVM到底加载哪些类可以在启动参数上加-verbose:class

JDK内建的类加载器
  • 启动类加载器(Bootstrap Class-Loader),负责加载jre/lib下面的jar文件。
  • 扩展类加载器(Extension or Ext Class-Loader),负责加载jre/lib/ext下面的jar文件。
  • 应用类加载器(Application or App Class-Loader),加载我classpath的的内容。
ClassLoader

ClassLoader主要有三个作用,加载Class到JVM中;审查每个类该由谁加载,它是一种父优先的加载机制;将Class字节码重新解析成JVM统一要求的对象格式。

验证(Verification)

验证的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。验证阶段大致会完成下面4个阶段的验证动作:

  1. 文件格式验证:验证字节流是否符合class文件的规范,如是否以魔数0xCAFEBABE开头。
  2. 元数据验证:堆字节码描述的信息进行语义分析。以保证其描述的信息符合Java语言规范。
  3. 字节码验证:字节码验证将对类的方法进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的事,一个类方法体的字节码没有通过字节码验证,那一定有问题,但若一个方法通过了验证,也不能说明它一定安全。
  4. 符号引用验证:确保解析动作能够正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备(Preparation)

准备阶段是正式为类的静态变量分配内存并设置变量的初始值的阶段,这些变量所使用的内存都将在方法区中分配。

public static int a=123;准备阶段后 a 的值为 0,而不是 123,要在初始化之后才变为 123,但若被 final 修饰,public static final int a=123;在准备阶段后就变为了 123。

解析(Resolution)

解析阶段是虚拟机将常量池中的符号引用变为直接引用的过程。

符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。

直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。

初始化(Initialization)

类初始化是类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。

在遇到下列情况时,若没有初始化,则需要触发其初始化(加载-验证-准备自然需 要在此之前):

  1. 1.使用 new 关键字实例化对象 2.读取或设置一个类的静态字段 3.调用一个类的静态方法。
  2. 使用 java.lang.reflect包的方法对类进行反射调用时,若类没有进行初始化,则需要触发其初始化
  3. 当初始化一个类时,若发现其父类还没有进行初始化,则要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要制定一个要执行的主类(有 main 方法的那个类),虚拟机会先初始化这个类。
  5. 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

类的卸载

类的卸载条件必须满足以下条件,并且满足了也不一定会卸载。

  1. 该类的所有实例被回收
  2. 加载该类的Class Loader已经被回收
  3. 该类对应的Class对象没有被引用

需要注意的是,JVM所创建的三个默认类加载器Bootstrap ClassLoader、ExtClassLoader和AppClassLoader都不可能满足这些条件,因此,任何系统类(例如java.lang.String)或者通过应用程序类加载器加载的任何应用程序类都不能在运行时释放。

类加载机制的三个基本特征

  1. 双亲委派模型。但不是所有类加载器都遵守这个模型。有时候,启动类加载器所加载的类型,可能是要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。例如,Java中JNDI、JDBC、文件系统、Cipher等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
  2. 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
  3. 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会再子加载器中重复加载。但是,在类加载器”邻居”之间,同一类型仍然可以被加载多次,因为互相之间并不可见。

双亲委派模型

双亲委派模型,就是当类加载器试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用双亲委派模型的目的是避免加载Java类型。

参考

  1. 《深入分析Java Web技术内幕》 8.3.3 类和类加载器
  2. 极客时间 杨晓峰的Java专栏
  3. CyC2018/CS-Notes

猜你喜欢

转载自blog.csdn.net/wantaceveryday/article/details/84929672
今日推荐