面试总结(三)-Java底层知识

平台无关性

平台无关性就是一种语言在计算机上的运行不受平台的约束,一次编译,到处运行。也就是说,用 Java 创建的可执行二进制程序,能够不加改变的运行于多个平台。
对于不通的平台,不同的硬件和操作系统,最主要的区别就是指令不同。因此,想要做到跨平台,最重要的就是可以根据对应的硬件和操作系统生成对应的二进制指令。而这一工作,主要由 Java 虚拟机完成。虽然 Java 语言是平台无关的,但是JVM却是平台有关的,不同的操作系统上面要安装对应的JVM。

  1. Java源文件被编译器编译成字节码文件。
  2. JVM将字节码文件编译成相应的操作系统机器码。
  3. 机器码调用相应操作系统的本地方法库执行相应的方法。

Q1: 为什么要先编译成字节码再解析成机器码,而JVM不直接将字节码解析成机器码呢?

  1. 准备工作:每次执行都需要各种检查,都要重新编译重新分析,所以引入了中间字节码,多次执行程序不需要反复编译。
  2. 兼容性:也可以将别的语言解析成字节码。也可以被jvm执行,增加兼容扩展能力。

JVM(Java 虚拟机)

他是虚拟的计算机,通过软件仿真模拟计算机功能。拥有完善的硬件架构,如处理器,堆栈等,他屏蔽了底层操作系统原理。这是一个内存中的虚拟机,写的所有类,变量,方法都在内存中,JVM本质上就是一个进程。
Java 虚拟机主要分为五大模块:类装载器子系统、运行时数据区、执行引擎、本地方法接口和垃圾收集模块。其中垃圾收集模块在Java虚拟机规范中并没有要求Java虚拟机垃圾收集,但是在没有发明无限的内存之前,大多数JVM实现都是有垃圾收集的。

Java类的加载机制

什么是类的加载

类的加载指的是将类的 .class 文件中的二进制数据读取到内存中,将其放在运行时数据区的方法区内,然后再堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。

类的生命周期

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。

  • 加载
  1. 加载:查找并加载类的二进制数据,加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成三件事情。1)通过一个类的全限定名来获取其定义的二进制字节流;2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;3)在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区这些数据访问的入口。
  • 连接
  1. 验证:验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作。1)文件格式验证;2)元数据验证;3)字节码验证;4)符号引用验证。
  2. 准备:准备阶段是正式为类的静态变量分配内存并将其初始化为默认值的阶段。这些内存都将在方法区分配,这里所设置的初始值通常情况下是数据类型默认的零值(如:0,null,false 等),而不是被在 Java 代码中被显示地赋予的值。如果同时被 final 和 static 修饰,那么准备阶段变量 value 就会被初始化为属性所指的值。
  3. 解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符合引用进行。1)符号引用就是一组符合来描述目标,可以是任何字面量。2)直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
  • 初始化
  1. 初始化:初始化阶段为类的静态变量赋予正确的初始值,JVM 负责对类初始化,主要对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:1)声明类变量是指定初始值;2)使用静态代码块为类变量指定初始值。
  2. 类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括六种:1)创建类的实例,即 new 的方式;2)访问某个类或接口的静态变量,或者对该静态变量赋值;3)调用类的静态方法;4)反射,如 Class.forName("com.cbhis.Test");5)初始化某个类的子类,则其父类也会被初始化;6)Java 虚拟机启动时被标明为启动类的类(Java Test),直接使用 java.exe 命令来运行某个主类。
  • 结束生命周期
    Java 虚拟机结束生命周期有如下几种情况:1)执行了 System.exit() 方法;2)程序正常执行结束;3)程序在执行过程中遇到了异常或错误而异常终止;4)由于操作系统出现错误而导致 Java 虚拟机进程终止。
类加载器

从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:1)启动类加载器:它使用 C++ 实现(仅限于 Hotspot,即 JDK1.5 之后默认的虚拟机,有很多其他的虚拟机是用 Java 语言实现的),是虚拟机自身的一部分;2)所有其他的类加载器:这些类加载器都由 Java 语言实现,独立于虚拟机之外,并且全部继承自抽象类 java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。

从 Java 开发人员的角度来看,类加载器可以大致划分为以下三类:

  • 启动类加载器:Bootstrap ClassLoader,负责加载存放在 JDK/jre/lib(JDK 代表 JDK 的安装目录)下,或被 -Xbootclasspath 参数指定的路径中的,并且能被虚拟机识别的类库(如:rt.jar,所有的 java. 开头的类均被 Bootstrap ClassLoader 加载)。启动类加载器是无法被 Java 程序直接引用的。
  • 扩展类加载器:Extension ClassLoader,该加载器由 sun.simc.Launcher$ExtClassLoader 实现,它负责加载 JDK/jre/lib/ext 目录中,或者 java.ext.dirs 系统变量指定的路径中的所有类库(如:javax. 开头的类),开发者可以直接使用扩展类加载器。
  • 应用类加载器:Application ClassLoader,该类加载器由 sun.simc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath) 所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是默认的类加载器。

应用程序都是由这三种类加载器配合进行加载的,如果有必要,还可以加入自定义类加载器。因为 JVM 自带的 ClassLoader 只是从本地文件系统加载标准的 java class 文件,因此如果编写了自己的 ClassLoader,可以做到如下几点:

  1. 在执行非置信代码之前,自动验证数字签名。
  2. 动态的创建符合用户特定需要的定制化构建类。
  3. 从特定的场所取得 java class,例如数据库中和网络中。

这几种类加载器层次关系如下图所示:

类的加载
JVM 类加载机制
  1. 全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  2. 父类委托:先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中记载该类。
  3. 缓存机制:缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效。
类加载的三种方式
  1. 命令行启动应用时候由 JVM 初始化加载。
  2. 通过 Class.forName() 方法动态加载。
  3. 通过 ClassLoader.loadClass()方法动态加载

Q1: Class.forName() 和 ClassLoader.loadClass() 区别

  1. Class.forName():将类的 .class 文件加载到 jvm 中之外,还会对类进行解释,执行类中的 static 块;
  2. ClassLoader.loadClass():将 .class 文件加载到 jvm 中,不会执行 static 中的内容,只有在 newInstance() 才会去执行 static 块;
  3. Class.forName(name, initialize, loader)带参函数也可控制是否加载 static 块,并且只有调用了 newInstance() 方法才会调用构造函数,创建类对象。
双亲委派模型

双亲委派模型的工作流程是:如果一个类加载器收到了类加载器的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载器请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

意义:1)系统类防止内存中出现多份同样的字节码;2)保证 Java 程序安全稳定运行。

参考链接:jvm系列(一):java类的加载机制

JVM 内存结构

JVM 内存结构包括了程序计数器、虚拟机栈、本地方法栈、堆和方法区。程序计数器、虚拟机栈和本地方法栈属于线程私有的内存区域;而堆和方法区是所有线程共享的内存区域。

程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,线程私有,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 native 方法,这个计数器值则为空(Undefined)。

虚拟机栈(JVM Stacks)

Java 虚拟机也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种八种基本数据类型、对象引用和returnAddress类型。

本地方法栈(Native Method Stacks)

本地方法栈与虚拟机栈所发挥的作用非常相似,其区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则是为虚拟机使用到的 native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。

堆(Heap)

Java 堆是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,如果从内存回收的角度看,Java 堆中还可以细分为:新生代和老年代,新生代又分为 Eden 区、From Survivor 区、To Survivor 区,默认情况下新生代按照 8:1:1 的比例来分配。

方法区(Method Area)

方法区又叫非堆(Non-Heap),也是属于所有线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

参考链接:jvm系列(二):JVM内存结构

GC 算法与垃圾收集器

GC 即垃圾收集(Garbage Collection)。JVM 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈的操作,实现了自动的内存清理,因此,内存的垃圾回收主要集中于 java 堆和方法区中,在程序的运行期间,这部分内存的分配和使用都是动态的。

对象存活判断
  1. 引用计数法:每个对象有一个引用计数属性,新增一个引用时计数加 1,引用释放时计数减 1,计数为 0 时可以回收。此方法虽然简单,但无法解决对象相互循环引用的问题。
  2. 可达性分析:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。在 Java 语言中,GC Roots 包括:1)虚拟机栈中引用的对象;2)方法区中类静态属性实体引用的对象;3)方法区中常量引用的对象;4)本地方法栈中 JNI 引用的对象。
垃圾收集算法
  1. 标记-清除算法
    标记-清除(Mark-Sweep)算法,是最基础的收集算法。算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

    缺点:1)效率问题,标记和清除过程的效率都不高;2)空间问题,标记-清除算法之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序中后面的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

  2. 复制算法
    复制(Copying)算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

    这样使用每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。

  3. 标记-整理算法
    标记-整理(Mark-Compact)算法,针对复制算法的缺点,提出了标记-整理算法,标记过程仍和标记-清除算法一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

  4. 分代收集算法
    分代收集(Generational Collection)算法,基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。Java 堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

  1. Serial 收集器
    串行收集器是最古老,最稳定以及效率高的垃圾回收器,只使用一个线程去回收,可能会产生较长的停顿。新生代、老年代都使用串行回收;新生代采用复制算法、老年代采用标记-整理算法;垃圾收集的过程会 Stop The World。

参数控制:
-XX:+UseSerialGC 串行收集器

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本。新生代并行回收,老年代串行回收;新生代采用复制算法、老年代采用标记-整理算法。

参数控制:
-XX:+UseParNewGC ParNew 收集器
-XX:ParallelGCThreads 限制线程数量

Parallel 收集器

Parallel 收集器类似 ParNew 收集器,Parallel 收集器更关注系统的吞吐量。可以通过参数打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大吞吐量;也可以通过参数控制 GC 的时间不大于多少毫秒或者比例;新生代采用复制算法、老年代采用标记-整理算法。

参数控制:
-XX:+UseParallelGC Parallel 收集器 + 老年代串行

Parallel Old 收集器

Parallel Old 收集器是 Parallel 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供。

参数控制:
-XX:+UseParallelOldGC Parallel 收集器 + 老年代并行

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字上就可以看出 CMS 收集器是基于“标记-清除”算法实现的,它的运作相对于前面几种收集器来说要更复杂一些,整个过程分为 4 个步骤,包括:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

其中初始标记和重新标记这两步骤仍然需要“Stop The World“。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的是并发标记和并发清除过程,收集器线程都可以和用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。属于老年代收集器(新生代采用 ParNew)。

优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量

参数控制:
-XX:+UseConcMarkSweepGC 使用 CMS 收集器
-XX:+UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间较长
-XX:+CMSFullGCsBeforeCompaction 设置进行几次 Full GC 后,进行一次碎片整理
-XX:ParallelCMSThreads 设定 CMS 的线程数量(一般情况约等于可用 CPU 数量)

G1 收集器

G1(Garbage First)收集器,G1 收集器采用“标记-整理”算法,不会产生内存空间碎片,主要应用在多 CPU 大内存的服务中,在满足吞吐量的同时,竟可能的满足垃圾回收时的暂停时间。

在之前的垃圾回收算法,使用的堆内存结构如下:

这些 space 必须是地址连续的空间。

在 G1 算法中,采用了完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块(Region),每个 Region 是逻辑连续的一段内存,结构如下:

每个 Region 被标记了 E、S、O 和 H,说明每个 Region 在运行时都充当了一种角色,其中 H 是以往算法中没有的,它代表 Humongous,这表示这些 Region 存储的都是超大对象(Humongous Object),当新建对象大小超过 Region 大小一半时,直接在新的一个或多个连续 Region 中分配,并标记为 H。

G1 中提供了三种垃圾回收模式,young gc、mixed gc 和 full gc,在不同的条件下被触发。

  1. young gc
    发生在年轻代的GC算法,一般对象(除了巨型对象)都是在 eden region 中分配内存,当所有eden region 被耗尽无法申请内存时,就会触发一次 young gc,这种触发机制和之前的 young gc 差不多,执行完一次young gc,活跃对象会被拷贝到 survivor region 或者晋升到 old region 中,空闲的 region 会被放入空闲列表中,等待下次被使用。
  2. mixed gc
    当越来越多的对象晋升到老年代 old region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 mixed gc,该算法并不是一个 old gc,除了回收整个 young region,还会回收一部分的 old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 old region 进行收集,从而可以对垃圾回收的耗时时间进行控制。mixed gc 中也有一个阈值参数-XX:InitiatingHeapOccupancyPercent ,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次 mixed gc。
  3. full gc
    如果对象内存分配速度过快,mixed gc 来不及回收,导致老年代被填满,就会触发一次 full gc,G1 的 full gc 算法就是单线程执行的 serial old gc ,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 full gc。

参考链接:jvm系列(三):GC算法 垃圾收集器