面试:Java 平台与内存管理

1. 为什么说Java是平台独立性语言?

Java 具有平台独立性的机制为“中间码”和“Java虚拟机”。Java 程序被编译后不是生成能在硬件平台上可执行的代码,而是生成了一个“中间码”(字节码)。不同硬件平台有不同的 JVM,由 JVM 负责把中间码编译成硬件平台能执行的代码。因此 JVM 不具有平台独立性,是与硬件平台相关的。

解释执行的 3 个步骤:

  1. 代码装入(由类装载器完整)

  2. 代码校验(由字节码校验器检查)

  3. 代码执行

Java 字节码执行也分为 2 种方式:即时编译与解释执行。编译是指的是解释器先将字节码编译为机器码,再执行机器码。解释执行是解释器通过每次执行一小段代码来完成 Java 字节码程序的所有操作。通常采用的是解释执行方式。

2. Java 平台与其他语言平台的区别

Java 平台包括两个模块:JVMJava API

JVM 是一个虚拟机,把 Java 编译生成的中间代码转换机器码执行,它有自己完善的硬件架构,例如处理器、堆栈、寄存器等,还有相应的指令系统,屏蔽了与具体操作系统相关的信息。每一个 Java 程序运行时,都会有一个对应的 JVM 实例,程序运行结束后,JVM 才会退出。

3. JVM 加载 class 文件的原理机制

Java语言是一种具有动态性的解释型语言,类( class)只有被加载到 JVM 中后才能运行。当运行指定程序时,JVM会将编译生成的.class文件按照需求和一定的规则加载到内存中并组织成为一个完整的 Java 应用程序。这个加载过程是由类加载器来完成的,具体来说,就是由 ClassLoader 和它的子类来实现的。类加载器本身也是一个类,其实质是把类文件从硬盘读取到内存中。

类的加载方式分为隐式加载显式加载两种。

  1. 隐式加载指的是程序在使用new等方式创建对象时,会隐式地调用类的加载器把对应的类加载到 JVM 中。

  2. 显式加载指的是通过直接调用 Class.forName() 方法来把所需的类加载到 JVM 中。

任何一个工程项目都是由许多个类组成的,当程序启动时,只把需要的类加载到JVM中,其他类只有被使用到的时候才会被加载,采用这种方法,一方面可以加快加载速度,另外一方面可以节约程序运行过程中对内存的开销。

此外,在Java语言中,每个类或接口都对应一个 .class 文件,这些文件可以被看成一个个可以被动态加载的单元,因此当只有部分类被修改时,只需要重新编译变化的类即可,而不需要重新编译所有文件,因此加快了编译速度。

在Java语言中,类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(例如基类)完全加载到JVM中,至于其他类,则在需要时才加载。

在Java语言中,可以把类分为 3 类:系统类、扩展类和自定义类。Java针对这3种不同的类提供了3种类型的加载器:Bootstrap LoaderExtClass LoaderAppClassLoader,这 3 种加载器的关系如下:

  1. Bootstrap Loader 负责加载系统类jre/lib/rt.jar的类

  2. ExtClassLoader 负责加载扩展类jar/lib/ext/* .jar的类

  3. AppClassLoader 负责加载应用类classpath 指定的目录或 jar中的类)

以上这 3 个类是如何协调工作来完成类的加载呢?

它们是通过委托的方式实现的。就是当有类需要被加载时,类加载器会请求父类来完成这个载入工作,父类会使用其自己的搜索路径来搜索需要被载入的类,如果搜索不到,才会由子类按照其搜索路径来搜索待加载的类。下例可以充分说明类加载器的工作原理:


public class TestLoader{

public static void main (String[] args) {

// 调用 Class 加载器

ClassLoader clApp = TestLoader.class.getClassLoader();

System.out.println(clApp);

// 调用上一层 Class 加载器

ClassLoader clExt = clApp.getParent();

System.out.println(clExt);

// 调用根部 Class 加载器

ClassLoader clBoot = clExt.getParent();

System.out.println(clBoot);

}

}



// 输出:

// jdk.internal.loader.ClassLoaders$AppClassLoader@1de0aca6

// jdk.internal.loader.ClassLoaders$PlatformClassLoader@2437c6dc

// null

TestLoader 类是通过 AppClassLoader来加载的,另外要说明的一点:由于 Bootstrap Loader是由 C++ 实现的,因此在 Java 中看不到它(null)。

类加载步骤:

  1. 装载:根据查找路径找到对应的 class 文件,然后倒入

  2. 链接:链接分为 3 个小步骤

    1. 检查:检查加载的 class 文件的正确性

    2. 准备:给类中的静态变量分配存储空间

    3. 解析:将符号引用转换成直接引用(这一步可选)

  3. 初始化:对静态变量和静态代码块执行初始化工作

4. 什么是 GC(垃圾回收)?

为了减轻开发人员的工作,同时增加系统的安全性和稳定性,Java 语言提供了垃圾回收器来自动检测对象的作用于,把不再被使用的存储空间释放。具体而言,垃圾回收期负责完成 3 项任务:

  1. 分配内存

  2. 确保被引用的内存不被错误地回收

  3. 回收不再被引用的对象的内存空间

垃圾回收器的存在一方面把开发人员从释放内存的复杂工作中解脱出来,提高了开发人员的生产效率;另一方面,对开发人员屏蔽了释放内存的方法,可以避免因开发人员错误地操作内存而导致应用程序的崩溃,保证了程序的稳定性。但是,垃圾回收也带来了问题,为了实现垃圾回收,垃圾回收器必须跟踪内存的使用情况,释放没用的对象,在完成内存的释放后还需要处理堆中的碎片,这些操作必定会增加JVM的负担,从而降低程序的执行效率。

对对象而言,如果没有任何变量去引用它,那么该对象将不可能被程序访问,因此可以认为它是垃圾信息,可以被回收。只要有一个以上的变量引用该对象,该对象就不会被垃圾回收。

对于垃圾回收器来说,它使用有向图来记录和管理堆内存中的所有对象,通过这个有向图就可以识别哪些对象是“可达的”(有引用变量引用它就是“可达的”),哪些对象是“不可达的”(没有引用变量引用它就是不可达的),所有“不可达”对象都是可被垃圾回收的,示例如下:


public class Test{

public static void main(String[] args){

Integer i1 = new Integer(1);

Integer i2 = new Integer(2);

i2 = i1;

// some other code

}

}

在执行完 i2=i1之后,资源 2 所占的内存是不可达的,GC 认为此内存不会被使用了,就会回收这块内存空间。

常用的垃圾回收算法:

  1. 引用计数(Reference Counting Collector):是一种简单但效率较低的算法,在堆中每个对象都有个引用计数器;当对象被引用时,引用计数器加 1;当引用被置空或离开作用域时候,引用计数减 1;由于这种方法 无法解决相互引用问题,JVM 没有采用这个算法。

  2. 追踪回收(Tracing Collector):利用 JVM 维护的对象引用图,从根节点开始遍历对象的应用图,同时标记遍历到的对象。当遍历结束后,未被标记的对象就是目前不被使用的对象,可以被回收了

  3. 压缩回收(Compacting Collector):把堆中活动的对象移动到堆中一端,这样就会在堆另外一段端留出和那一块空闲区域,相当于对堆中的碎片进行了处理。虽然这种算法可以大大简化消除碎片工作,但每次处理都会带来性能上的损失。

  4. 复制回收(Coping Collector):把堆分成两个大小相同的区域,任何时刻,只有其中一个区域被使用,直到这个区域的被消耗完为止,此时垃圾回收器会中断程序的执行,通过遍历的方式把所有活动的对象复制到另外一个区域中,在复制的过程中它们是紧挨着布置的,从而可以消除内存碎片。当复制过程结束后程序会接着运行,直到这块区域被使用完,然后再采用上面的方法继续进行垃圾回收。这个算法的优点是在进行垃圾回收的同时对对象的布置也进行了安排,从而消除了内存碎片。但是这也付出了很高的代价:对于指定大小的堆来说,需要两倍大小的内存空间;同时由于在内存调整的过程中要中断当前执行的程序,从而降低了程序的执行效率。

  5. 按代回收(Generational Collector):复制回收算法主要的缺点如下:每次算法执行时,所有处于活动状态的对象都要被复制,这样效率很低。由于程序有“程序创建的大部分对象的生命周期都很短,只有一部分对象有较长的生命周期”的特点,因此可以根据这个特点对算法进行优化。按代回收算法的主要思路如下:把堆分成两个或者多个子堆,每一个子堆被视为一代。算法在运行的过程中优先收集那些“年幼”的对象,如果一个对象经过多次收集仍然“存活”,那么就可以把这个对象转移到高一级的堆里,减少对其的扫描次数。

是否可以主动通知 JVM 进行垃圾回收?

Java 没有提供显式释放已分配内存的方法,但是开发人员可以通过 System.gc() 显式通垃圾回收器运行,当然 JVM 也并不保证 gc 会马上运行。System.gc() 的执行会停止所有响应,去检查内存中是否有可回收的对象,这回对程序的运行以及性能造成极大威胁,因此实际编程不建议频繁使用。

5. Java 是否存在内存泄露问题?

内存泄露是指一个不再被程序使用的对象或变量还在内存中占有存储空间。

其实,在Java语言中,判断一个内存空间是否符合垃圾回收的标准有两个

  1. 给对象赋予了空值 null,以后再没有被使用过

  2. 给对象赋予了新值,重新分配了内存空间

一般来讲,内存泄露主要有两种情况

  1. 在堆中申请的空间没有被释放
  1. 对象已不再被使用,但还仍然在内存中保留着。垃圾回收机制的引入可以有效地解决第一种情况;而垃圾回收机制则无法保证不再使用的对象会被释放。因此,Java语言中的内存泄露主要指的是第二种情况。

Vector v = new Vector(10);

for(int i = 1; i < 10; i++){

Object o = new Object();

v.add(o);

}

上面的例子中,不断创建新对象到 Vector 中,循环退出后,o 的作用于将会结束,但是由于 v 在使用这些对象,垃圾回收器无法将其回收,此时就造成了内存泄露。只有将这些对象从 Vector 中删除才能释放已创建的这些对象。


class Server{

private String msg;

public void recieveMsg(){

readFromNet(); // 把网络接受数据保存到 msg 中

saveDB(); // 把 msg 保存到数据库中

}

}

上面的 saveDB()msg 保存到数据中,那么 msg 字符串已经没用了,但是 msg 的生命周期和对象的生命周期相同,此时还不能被回收,因此造成了内存泄露。解决方法:

  1. msg 作用返回限制在 recieveMsg() 方法内部,变成局部变量,方法结束后就被回收

  2. 使用完 msg 之后,设置 msg=null,那么久没有对象再引用它了,gc 会自动回收其所占空间

6. Java 中的堆和栈有什么区别?

在Java语言中,堆与栈都是内存中存放数据的地方。变量分为基本数据类型和引用类型。

基本数据类型的变量(例如 int、shot、long、byte、foat、 double、 boolean 以及 char 等)以及对象的引用变量,其内存都分配在栈上,变量出了作用域就会自动释放。

而引用类型的变量,其内存分配在堆上或者常量池(例如字符串常量和基本数据类型常量)中,需要通过new等方式进行创建。

6.1 栈

栈用来存放基本类型和引用变量。栈内存管理是通过压栈和弹栈操作完成的,以栈帧为基本单位来管理程序的调用关系。每当有函数调用时,都会通过压栈方式创建新的栈帧,每当函数调用结束后都会通过弹栈的方式释放栈帧。

6.2 堆

堆内存用来存放运行时创建的对象。一般来讲,通过new关键字创建出来的对象都存放在堆内存中。由于JVM是基于堆栈的虚拟机,而每个Java 程序都运行在一个单独的JVM实例上,每一个实例唯一对应一个堆一个Java程序内的多个线程也就运行在同一个JVM实例上,因此这些线程之间会共享堆内存鉴于此,多线程在访问堆中的数据时需要对数据进行同步。

6.3 引用

在堆中产生了一个数组或对象后,还可以在栈中定义一个特殊的变量,让栈中这个变量的取值等于数组或对象在堆内存中的首地址,栈中的这个变量就成了数组或对象的引用变量。引用变量就相当于是为数组或对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或对象。这就是Java中引用的用法。

从堆和栈的功能以及作用来比较,堆主要用来存放对象的,栈主要是用来执行程序的。相较于堆,栈的存取速度更快,但栈的大小和生存期必须是确定的,因此缺乏一定的灵活性。而堆却可以在运行时动态地分配内存,生存期不用提前告诉编译器,但这也导致了其存取速度的缓慢。