Java/面试题:Java 虚拟机

Java/面试题:Java 虚拟机

  • 平台无关性是通过 字节码 + 虚拟机来实现的。Java 源码首先被编译成字节码,再由不同平台的 JVM 进行解析,在不同而平台上运行时不需要重新编译。Java 虚拟机在执行字节码的时候,把字节码转换成具体平台上的机器指令。
  • 为什么 JVM 不直接将源代码编译成机器指令去执行?
    • 如果 JVM 直接将源代码编译成机器指令去执行,那么每次编译都需要进行各种检查
    • 从兼容性方面考虑,也可以将别的语言编译成字节码,增加了 JVM 这个平台的兼容扩展能力
  • Java 虚拟机包括四个部分
    • Class loader:根据特定格式,加载 Class 文件到内存
      • BootStrapClassLoader:C++ 编写,加载 Java 核心库 java.*
      • ExtClassLoader:Java 编写,加载扩展库 javax.*
      • AppClassLoader:Java 编写,加载程序所在目录
      • 自定义 ClassLoader:Java 编写,定制化加载,可以通过网络远程加载类,可以对字节码进行加解密,可以使用 ASM 字节码增强来修改字节码
      • 为什么使用双亲委派机制去加载类:避免多份同样字节码的加载
      • 类加载过程
        1. 加载:通过 ClassLoader 加载 class 字节码,生成 Class 对象
        2. 链接:包括校验(检查加载的 Class 的正确性和安全性)、准备(为类变量分配存储空间并设置类变量初始值)、解析(JVM 将常量池内的符号引用转换为直接引用)
        3. 初始化:执行类变量赋值和静态代码块
      • 类的加载方式有隐式加载(new 关键字)和显式加载(loadClass, forName)
      • loadClass 和 forName 的区别:Class.forName() 得到的 class 是已经完成初始化了的(加载 MySQL 驱动时,代码中有静态代码块,如果使用 loadClass 方法加载则不会运行静态代码块,必须使用此方法加载);Classloader.loadClass() 得到的 class 是还没有链接的(大量用于 Spring IOC 的延迟加载中,不需要执行类加载的链接和初始化步骤,可以加快加载速度,等到实际需要使用类的时候才去初始化)。
    • Execution Engine:对命令进行解析
    • Native Interface:融合不同开发语言的原生库为 Java 所用
    • Rumtime Data Area:JVM 内存空间结构模型
  • 类从编译到执行的过程(假设类名为 Robot)
    • 编译器将 Robot.java 源码编译为 Robot.class 字节码文件
    • ClassLoader 将字节码转换为 JVM 中的 Class 对象
    • JVM 利用 Class 对象 实例化 Robot 对象
  • 内存模型:
    • 从操作系统角度来看:内存可以划分为内核空间和用户空间,其中用户空间是 Java 进行运行时的内存空间
    • 从线程方面来看:线程私有的区域有:程序计数器、虚拟机栈、本地方法栈;所有线程共享的区域有 MetaSpace 和堆
    • 程序计数器(Program Counter Register)是对 Java 代码计数,对 Native 方法则计数器值为 Undefined;是当前线程所执行的线程代码逻辑上(非物理)的行号指示器,不会发生内存泄露。
    • 其中虚拟机栈包含多个栈帧,每个栈帧包括局部变量表、操作数栈、动态链接、返回地址。
    • 递归过深会导致栈帧数量超过虚拟机栈的深度,引发 StackOverFlow
    • 当虚拟机栈可以动态扩展时,如果无法申请到足够的内存,会引起 OutOfMemory
    • 当方法调用结束后,栈帧的内存就会被回收,不用通过 GC 来回收
    • MetaSpace 和 PermGen 都是方法区的一种实现。在 Java8 之后使用 MetaSpace 替代了 PermGen。
      • 元空间使用本地内存,而 永久代使用的是 JVM 的内存。因此不再会出现 OutOfMemory 异常
      • 字符串常量池存放在永久代中,容易出现性能问题和内存溢出
      • 类和方法的信息大小难以确定给永久代大小的指定带来困难;如果指定太小会可能会溢出,如果太大,则老年代可能会溢出
      • 永久代会为 GC 带来不必要的复杂性,元数据可能会随着 FULL GC 发生移动。使用 MetaSpace 可以更好支持并发隔离元数据
      • 永久代是 HotSpot 特有的处理,而其他虚拟机并不支持永久代。所以使用 MetaSpace 可以方便 HotSpot 与其他 JVM 比如 Jrockit 的集成
      • 字符串常量池在 JDK7 之前是放在永久代中的,在 JDK7 之后被移动到了堆中

常问面试题:

  • JVM 三大性能调优参数 -Xms -Xmx -Xss 的含义

    • -Xss:规定了每个线程虚拟机栈的大小,决定了进程中的并发线程数
    • -Xms:堆的初始值
    • -Xmx:堆的最大值
    • 一般把 -Xms 和 -Xmx 设置为一样,因为堆不够用而进行扩容时会发生内存抖动,影响稳定性
  • Java 内存模型中堆和栈的区别--内存分配策略

    • 静态存储:编译时确定每个数据目标在运行时的存储空间要求,要求不能有嵌套递归和可变数据结构的存在
    • 栈式存储:数据区需求在编译时未知,在运行时模块入口前确定其大小并分配内存
    • 堆式存储:编译时或者运行时模块入口都无法确定,动态分配(比如可变长度串和对象实例),可以按照任意方式释放和分配
    • 管理方式:栈内存自动释放,堆释放内存需要 GC
    • 空间大小:栈比堆小
    • 碎片相关:栈产生而碎片远小于堆
    • 分配方式:栈支持静态和动态分配,堆仅支持动态分配
    • 效率:栈的效率比堆高,因为栈是先进后出的,只有入栈和出栈两个操作,管理简单,但是灵活程度不够。
    • 从一个例子来看 元空间、堆、栈的区别
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      public class HelloWorld {
      private String name;

      public void sayHello() {
      System.out.println("Hello " + name);
      }

      public void setName(String name) {
      this.name = name;
      }

      public static void main(String[] args) {
      int a = 1;
      HelloWorld hw = new HelloWorld();
      hw.setName("test");
      hw.sayHello();
      }
      }
      上述代码对应的内存分配如下:
    内存区域 存储内容
    元空间 Class:HelloWorld-Method:sayHello-Field:name
    Class:system
    Object:String("test")
    HelloWorld
    指向对中字符串对象(Parameter referene:test)和本地变量对象(Variable referene:hw)的引用,局部变量表(a),行号
    • 不同 JDK 版本之间 intern() 方法的区别:
      • JDK6 以及之前:调用 intern 方法时,如果字符串常量池已经包含有该字符串对象,则从字符串常量池中返回该字符串对象的引用;否则将此字符串对象添加到字符串常量池中,再返回该字符串对象的引用。
      • JDK6 之后:调用 intern 方法时,如果字符串常量池已经包含有该字符串对象,则从字符串常量池中返回该字符串对象的引用;否则如果该字符串对象已经存在于堆中,则将堆中此字符串对象的引用添加到字符串常量池中,并且返回该引用;如果堆中不存在,则在字符串常量池中创建该字符串并返回其引用。
      • 总结:在 JDK6 之前,只能把堆中字符串的副本复制到字符串常量池中,而在 JDK6 之后,可以把堆中字符串对象的引用放到字符串常量池中
    • 在 Java7 之后,常量池(包括字面量和符号引用量)已经从永久代移动到堆中。因为之前字符串常量池在永久代中,如果频繁使用 intern 方法创建字符串,会导致 OOM(可以使用 -XX:PermSize 和 -XX:MaxPermSize 来指定永久代的大小)。
  • 关于 intern 方法在 JDK6 前后的区别,我们通过一个例子来看:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class InternDifference {
    public static void main(String[] args) {
    String s1=new String("a");
    s1.intern();
    String s2="a";
    System.out.println(s1==s2);

    String s3=new String("a")+new String("a");
    s3.intern();
    String s4="aa";
    System.out.println(s3==s4);
    }
    上述代码在 JDK6 的输出是:
    1
    2
    false
    false
    在 JDK7 的输出是:
    1
    2
    false
    true
    解释:

  • String s1 = new String("1"); 第一句代码,生成了2个对象。常量池中的“1” 和堆中的字符串对象。s.intern(); 这一句是 s1 对象去常量池中寻找后发现 “1” 已经在常量池里了,因此 s1 指向的地址还是堆中的字符串对象。

  • 接下来String s2 = "1"; 这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s1 和 s2 的引用地址明显不同

  • String s3 = new String("1") + new String("1");,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String("1")我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。

  • 接下来s3.intern();这一句代码

    • 在JDK6以前是直接把字符串对象的值存储在字符串常量池中,所以把 s3 中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串`,所以可以在常量池中生成一个 “11” 的对象。而 s3 指向的地址还是堆中字符串对象“11”的地址
    • 而 JDK6 之后常量池中不需要再存储一份对象了,可以直接存储堆中的引用,所以将堆中字符串对象的地址放在字符串常量池中,所以 s3 指向的地址就是字符串常量池中的地址。
  • 最后String s4 = "11"; 这句代码中”11”是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了

    • 在 JDK6 中,字符串常量池中存储的是一个”11”字符串对象,而 s3 指向的是堆中存放的字符串对象,与字符串常量池中的字符串没有关系,因此最后的比较 s3 == s4 是 false。
    • 在 JDK 7 中,字符串常量池中存储的是是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较 s3 == s4 是 true。
  • 下面是上述代码在内存区域中的图示:

    JDK6 内存示意图


    JDK7 内存示意图


    参考链接

  • 美团技术团队:深入解析String#intern

评论