带你全面了解高级 Java 面试中需要掌握的 JVM 知识点

2019-05-18  本文已影响0人  当年明月_3025

带你全面了解高级 Java 面试中需要掌握的 JVM 知识点。 ——当年明月

JVM 内存划分与内存溢出异常

概述

如果在大学里学过或者在工作中使用过 C 或者 C++ 的读者一定会发现这两门语言的内存管理机制与 Java 的不同。在使用 C 或者 C++ 编程时,程序员需要手动的去管理和维护内存,就是说需要手动的清除那些不需要的对象,否则就会出现内存泄漏与内存溢出的问题。

如果你使用 Java 语言去开发,你就会发现大多数情况下你不用去关心无用对象的回收与内存的管理,因为这一切 JVM 虚拟机已经帮我们做好了。了解 JVM 内存的各个区域将有助于我们深入了解它的管理机制,避免出现内存相关的问题和高效的解决问题。

引出问题

在 Java 编程时我们会用到许多不同类型的数据,比如临时变量、静态变量、对象、方法、类等等。 那么他们的存储方式有什么不同吗?或者说他们存在哪?

运行时数据区域

Java 虚拟机在执行 Java 程序过程中会把它所管理的内存分为若干个不同的数据区域,各自有各自的用途。

enter image description here

(图片来源于网络)

补充

虽然上面的图里没有运行时常量池和直接内存,但是这两部分也是我们开发时经常接触的。所以给大家补充出来。

JAVA8 的改变

对于方法区,它是线程共享的,主要用于存储类的信息,常量池,方法数据,方法代码等。我们称这个区域为永久代

大部分程序员应该都见过 java.lang.OutOfMemoryError:PermGen space 异常,这里的 PermGen space 其实指的就是方法区。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出,典型的场景是在 JSP 页面比较多的情况,容易出现永久代内存溢出。

在JDK 1.8中,HotSpot 虚拟机已经没有 PermGen space 这个区域了,取而代之的是一个叫做Metaspace (元空间)的东西。

enter image description here

(图片来源于网络)

变化就是移除了方法区,增加了元空间,与方法区最大的区别是:元空间不再虚拟机中,而是使用本地内存。默认情况下,元空间的大小仅受本地内存限制。

这样更改的好处:

内存溢出

虽然有 JVM 帮我们管理内存,但是在实际开发过程中一定还会遇到内存溢出的问题。堆,栈,方法区都有可能出现内存溢出问题。下面我们就结合几个实际的小例子来给大家展示一下,方便大家以后根据不同的情况对内存溢出问题进行快速准确的定位。

思考

看完上面的讲解后,相信大家对于 Java 中各种变量、类、方法、实例的存储位置都已经了解了。下面结合一些简单的面试题来加深一下大家的理解。

String a = new String("xyz");

问:这段代码创建了几个对象,都存在 JVM 中的哪个位置?

答:答案是两个对象,第一个是通过 NEW 关键字创建出来的 a 对象,它的存储位置当然是在堆中。第二个是 xyz 这个对象,它存在常量池中(String 在 Java 中被定义为不可变的对象,类的定义和方法都是 final 的,所以会被当作常量看待)。

问:a 对象的引用存在哪里?

答:对象的引用全部存在栈中。

问:Java 中各个对象、变量、类的存储位置?

答:如果你已经掌握了上面的内容,这个问题应该是不难的。NEW 出来的对象存储在堆中,局部变量和方法的引用存在栈中,类的相关信息、常量和静态变量存在方法区中,1.8以后使用元空间存储类相关信息。

问:Java 中会有内存溢出问题吗?发生在哪些情况下?

答:JVM 的堆、栈、方法区、本地方法栈、直接内存都会发生内存溢出问题。典型的堆溢出的例子:集合持有大量对象并且长期不释放。典型的栈溢出例子:无法快速收敛的递归。典型的方法区溢出例子:加载了大量的类或者 JSP 的程序。

垃圾回收算法与收集器

概述

上一篇文章我们已经了解了 Java 的这几块内存区域。对于垃圾回收来说,针对或者关注的是 Java 堆这块区域。因为对于程序计数器、栈、本地方法栈来说,他们随线程而生,随线程而灭,所以这个区域的内存分配和回收可以看作具备确定性。对于方法区来说,分配完类相关信息后内存大小也基本确定了,加上在 JAVA8 中引入的元空间,所以这个部分也不用关注。

方法区回收

很多人认为方法区是没有垃圾收集的,Java 虚拟机规范也确实说过可以不要求在虚拟机方法区实现垃圾收集,而且在这个地方收集性价比比较低。在堆中,一次可以回收70%~95%的空间,而方法区也就是永久代的回收效率远低于此。方法区垃圾收集主要回收两部分内容:废弃常量和无用的类。

JAVA8 引入的元空间很好的解决了方法区回收效率低下的问题。

引出问题

Java 堆中存储的是 NEW 出来的对象,那么什么样的对象是需要被垃圾回收器回收掉的那?可能你会回答不用的对象或者死掉的对象。那如何判断对象已经不用了或者死掉了那?怎么回收这些死掉了的对象那?

如何判断对象已死

  1. 方法里引用的对象。
  2. 方法区中的类静态属性引用的对象。
  3. 方法区中的常量引用的对象。
  4. 本地方法中引用的对象。

HotSpot 虚拟机采用的是可达性分析算法。

如何回收

当前的商业虚拟机的垃圾收集都采用分代垃圾回收的算法,这种算法并没有什么新的思想。只是根据对象的存活周期将不同的内存划分为几块。一般是把 Java 堆分为新生代老年代,根据新生代和老年代存活时间的不同采取不同的算法,使虚拟机的 GC 效率提高了很多。新生代采用复制算法,老年代采用标记-清除或者标记-整理算法。

回收算法

内存分配与回收策略

首先需要了解两个名词:

Minor GC:新生代 GC,指的是发生在新生代的垃圾回收动作,因为 Java 对象大多都具有朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。

Full GC:老年代 GC,出现了 Full GC,一般也会伴随至少一次的 Minor GC(非绝对的)。

对象的内存分配,往大了讲就是在堆中分配。下面是更细粒度的分配策略。

垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。下面介绍基于 HotSpot 虚拟机中的垃圾收集器。对于垃圾收集器,大家有个概念就可以了,没有必要去深究垃圾收集器的底层原理,当然如果有余力,了解底层原理当然是最好的。

enter image description here

(图片来源于网络)

  1. 初始标记(Stop The World),标记 GC Roots 能关联到的对象。
  2. 并发标记
  3. 重新标记(Stop The World)
  4. 并发清除

思考

问:JVM 中使用了什么算法进行垃圾回收?

答:根据对象的存活时间采取了分代垃圾回收算法。新生代采取了复制算法(面试时可以就对象的分配以及 Eden、Survivor、Survivor 继续说一些),老年代采取了标记-清除或标记-整理算法。

问:如何判断对象已死?

答:引用计数器和可达性分析算法,HotSpot 虚拟机采取了可达性分析算法。

问:你了解哪些垃圾收集器?他们有什么区别?

答:新生代的有 Serial(单线程),ParNew(Serial 的多线程版本),PS(比较注重吞吐量)。老年代有 Serial Old(单线程),Parallel Old(ParNew 的老年代版本),CMS(两次Stop The World,实现了并发清除)。G1(基本不牺牲吞吐量的前提下完成低停顿的内存回收,新生代和老年代都可以回收)。

虚拟机中的类加载机制

概述

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中验证、准备和解析三个部分统称为连接。

enter image description here

(图片来源于网络)

什么时候加载

对于什么时候进行类的加载,虚拟机规范中并没有进行强制约束。但是以下几种情况时,必须对类进行初始化(加载、验证、准备则肯定要在此之前完成)。

这四种场景称为对一个类进行主动引用,除此之外所有引用类的方式都不会出发初始化。

下面演示两个被动使用类字段的例子,通过子类引用父类的静态字段,不会导致子类初始化:

class SuperClass{
    static{
        System.out.println("super init");
    }
    public static int value = 123;
}

class SubClass extends SuperClass{
    static{
        System.out.println("sub init");
    }
}
public class Show{
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}

//输出结果
super init
123

常量在编译阶段会存入调用类的常量池,本质上没有直接应用到定义常量的类,因此不会使定义常量的类的初始化,这段代码运行后不会输出 ConstClass init ,因为虽然在 Java 源码中引用了 ConstClass 类中的常量 HELLOWORLD,但是在编译阶段这个值就已经被存到了常量池中,对 ConstClass.HELLOWORLD 的引用实际都转化为了 Show 类对自身常量池的引用了。这两个类在编译成 Class 之后就不存在任何联系了。

class ConstClass{
    static{
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}
public class Show{
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}

//定义常量的类并没有初始化
hello world

接口有一点不同,当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正使用到了父接口才会初始化。

类加载器

虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到 Java 虚拟机外部去实现,以便让程序自己去决定如何获取所需要的类,这个动作的代码模块称为类加载器

对于一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,比较两个类是否相等需要在这两个类是由同一个类加载器加载的前提下才有意义。

双亲委派模型

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。只有当父类加载器反馈自己无法完成这个加载请求(他的搜索范围中没有找到所需的类)时,子类加载器才会尝试去加载。

好处:使用双亲委派模型的好处是,Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,比如 java.lang.Object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最后都是委派给启动类加载器进行加载。

如果不使用双亲委派模型,用户自己写一个 Object 类放入 ClassPath,那么系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无从保证。

现在你可以尝试自己写一个名为 Object 的类,可以被编译,但永远无法运行。因为最后加载时都会先委派给父类去加载,在 rt.jar 搜寻自身目录时就会找到系统定义的 Object 类,所以你定义的 Object 类永远无法被加载和运行。

Java 虚拟机的类加载器可以分为以下几种:

enter image description here

(图片来源于网络)

Java 内存模型与线程

概述

了解 JVM 的 Java 内存模型以及结构对于我们在多线程开发时有很大帮助。了解线程安全的虚拟机底层运作原理以及虚拟机实现高效并发所采取的一些列锁优化措施是我们开发高效和安全代码的基础。

通过硬件类比 Java 内存模型

通过对比发现,二者的变量更改、数据共享、内存刷新以及架构都非常相似。

volatile 与特殊规则

volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,定义成 volatile 的字段能保证此变量对所有线程的可见性,修改后立刻刷新到主存,其他线程读取这个变量也要在主存中读取。volatile 可以禁止指令重排序优化。

通过上面的 Java 内存模型和 volatile 立即刷新和避免指令重排序的特性可以发现 volatile 可以保证数据的可见性。但是它不能保证原子性。

对于 64 位的数据类型,在模型中规定,它允许将没有被 volatile 修饰的 64 位数据的读写划分为两次的 32 位来操作,即不保证他的原子性。不过目前各种平台的商用虚拟机机会都选择把 64 位的数据读写作为原子操作来对待,因为不需要专门为 long 和 double 声明 volatile。

Java 与线程

并发不一定要依赖多线程(PHP 常见的多进程并发),但是在 Java 里面谈论并发,大多数都与线程脱不开关系。

线程是比进程更轻量级的调度单位,线程可以把一个进程的资源分配和调度执行分开,各个线程既可以共享进程资源,又可以独立调度。

Java 中写一个线程有三种方式,继承 Thread 类,实现 Runnable 接口,实现 Callable 接口。对于 Sun JDK 来说,它的 Windows 与 Linux 版都是使用一对一的线程模型来实现的,一条 Java 线程就映射到一条轻量级进程之中。

状态转换

Java 定义了 5 种线程状态,一个线程有且仅有其中一种状态。

enter image description here

(图片来源于网络)

线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方法进行任何其他协同操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。 按照线程安全由强至弱来排序,我们可以将 Java 语言中各种操作共享的数据分为以下五类:

线程安全的实现方法

锁优化

synchronized 锁是一个重量级锁,在 JDK 1.5 时对它进行了优化。

虚拟机性能监控与故障处理工具

概述

对一个系统问题定位时,数据是依据,工具是运用知识处理数据的手段。这里的数据包括:运行日志,异常堆栈,GC 日志,线程快照,堆转储快照等等。通过这些数据,我们可以快速定位 JVM 发生问题的位置,快速的解决它。

JDK 命令行工具

在 JDK 的 bin 目录,除了 Java 和 Javac,还有一些比较好用的 JDK 工具帮我们去定位系统问题。实际上他们都是对 tools.jar 类库里面的接口的简单封装,这些命令可以让你在应用程序中实现功能强大的监控分析功能。

通过这些封装好的命令,完全可以自己实现一个虚拟机运行监控的小系统。大部分公司都有自己的 JVM 内存监控系统,实现原理也是调用这几个命令。Github 上有许多比较好的实现,大家可以参考参考。

由于 markdown 编辑器注脚语法的 Bug 总也调不好,所以就把他们当作名词解释放在最后了。

HotSpot: 遵循 Java 虚拟机规范的商用虚拟机有很多,HotSpot 虚拟机是 Open JDK 中所使用的虚拟机,也是目前使用最广泛的。

Java Native Method: Java Natvie Method是 Java 调用一些本地方法的接口,所谓本地方法指的是用其他语言实现的方法,比如 C 或者 C++。因为某些操作系统底层的操作 Java 确实不如 C 或者 C++ 做的好。

StopTheWorld:顾名思义就是停止所有操作,垃圾收集器在进行垃圾回收时,需要停止其他所有工作线程,让垃圾收集器回收死掉的对象。

SPI:SPI 全称 Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。

参考

《深入理解Java虚拟机:JVM高级特性与最佳实践》

最后的话

程序员这个职业需要我们不断进步,需要我们不断学习新的知识。

程序员具备了许多非常优秀的素质,爱学习,有责任感,能抗压,花钱少。希望大家的这条路越走越宽,也走越顺利。

如果通过上面的文章学到了一些东西的话,请帮忙点个赞,想获取更多的学习资料或者视频教程,可以加入 QQ群 725758660,免费分享学习资料。

上一篇 下一篇

猜你喜欢

热点阅读