Java 程序员面试笔试宝典 第 4 章 Java 基础知识:4.8 Java 平台与内存管理

4.8.1 为什么说 Java 是平台独立性语言

平台独立性是指可以在一个平台上编写和编译程序,而在其他平台上运行。保证 Java 具有平台独立性的机制为「中间码」和「Java 虚拟机(Java Virtual Machine,JVM)」。Java 程序被编译后不是生成能在硬件平台上可执行的代码,而是生成了一个「中间码」。不同的硬件平台上会安装有不同的 JVM,由 JVM 来负责把「中间码」翻译成硬件平台能执行的代码。由此可以看出 JVM 不具有平台独立性,而是与硬件平台相关的。

解释执行过程分三步进行:代码的装入、代码的校验和代码的执行。装入代码的工作由「类装载器」完成。被装入的代码由字节码校验器进行检查。

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

而在 C/C++ 语言中,编译后的代码只能在特定的硬件上执行,换个硬件平台这些代码就无法执行了,从而也导致了 C/C++ 没有跨平台的特性。但 C/C++ 有更高的执行效率。

常见笔试题:

1.一个 Java 程序运行从上到下的环境次序是( )。

A.操作系统、Java 程序、JRE/JVM、硬件 B.JRE/JVM、Java 程序、硬件、操作系统

C.Java 程序、JRE/JVM、操作系统、硬件 D.Java 程序、操作系统、JRE/JVM、硬件

答案:C。见上面讲解。

2.下列说法中,正确的是( )。

A.Java 程序经编译后会产生机器码  B.Java 程序经编译后会产生字节码

C.Java 程序经编译后会产生 DLL     D.以上都不正确

答案:B。.java 文件被 javac 指令编译为.class 后缀的字节码文件,再由 JVM 执行。


4.8.2 Java 平台与其他语言平台有哪些区别

Java 平台是一个纯软件的平台,这个平台可以运行在一些基于硬件的平台(例如 Linux、Windows 等)之上。Java 平台主要包含两个模块:JVM 与 Java API(Application Program Inter-face,应用程序接口)。

JVM 是一个虚构出来的计算机,用来把 Java 编译生成的中间代码转换为机器可以识别的编码并运行。它有自己完善的硬件架构,例如处理器、堆栈、寄存器等,还具有相应的指令系统,它屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 JVM 上运行的目标代码(即字节码),就可以在多种平台上不加修改地顺利运行。每当一个 Java 程序运行时,都会有一个对应的 JVM 实例,只有当程序运行结束后,这个 JVM 才会退出。JVM 实例通过调用类的 main()方法来启动一个 Java 程序,而这个 main()方法必须是公有的、静态的且返回值为 void 的方法,该方法接受一个字符串数组的参数,只有同时满足这些条件才可以作为程序的入口方法。

Java API 是 Java 为了方便开发人员进行开发而设计的,它提供了许多非常有用的接口,这些接口也是用 Java 语言编写的,并且运行在 JVM 上。


4.8.3 JVM 加载 class 文件的原理机制是什么

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

类的加载方式分为隐式加载与显式加载两种。隐式加载指的是程序在使用 new 等方式创建对象时,会隐式地调用类的加载器把对应的类加载到 JVM 中。显式加载指的是通过直接调用 class.forName()方法来把所需的类加载到 JVM 中。

任何一个工程项目都是由许多个类组成的,当程序启动时,只把需要的类加载到 JVM 中,其他类只有被使用到的时候才会被加载,采用这种方法,一方面可以加快加载速度,另外一方面可以节约程序运行过程中对内存的开销。此外,在 Java 语言中,每个类或接口都对应一个.class 文件,这些文件可以被看成一个个可以被动态加载的单元,因此当只有部分类被修改时,只需要重新编译变化的类即可,而不需要重新编译所有文件,因此加快了编译速度。

在 Java 语言中,类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(例如基类)完全加载到 JVM 中,至于其他类,则在需要时才加载。在 Java 语言中,可以把类分为 3 类:系统类、扩展类和自定义类。Java 针对这 3 种不同的类提供了 3 种类型的加载器,这 3 种加载器的关系如下:

类加载的主要步骤分为以下 3 步:

1)装载。根据查找路径找到相对应的 class 文件,然后导入。

2)链接。链接又可以分为 3 个小的步骤,具体如下。

① 检查。检查待加载的 class 文件的正确性。

② 准备。给类中的静态变量分配存储空间。

③ 解析。将符号引用转换成直接引用(这一步是可选的)。

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


4.8.4 什么是 GC

在 Java 语言中,垃圾回收(Garbage Collection,GC)是一个非常重要的概念,它的主要作用是回收程序中不再使用的内存。为了减轻开发人员的工作,同时增加系统的安全性与稳定性,Java 语言提供了垃圾回收器来自动检测对象的作用域,可自动地把不再被使用的存储空间释放掉。具体而言,垃圾回收器要负责完成 3 项任务:分配内存、确保被引用对象的内存不被错误地回收以及回收不再被引用的对象的内存空间

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

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

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

垃圾回收都是依据一定的算法进行的,下面介绍其中几种常用的垃圾回收算法。

(1)引用计数算法(Reference Counting Collector)

引用计数作为一种简单但是效率较低的方法,其主要原理如下:在堆中对每个对象都有一个引用计数器;当对象被引用时,引用计数器加 1;当引用被置为空或离开作用域的时,引用计数减 1,由于这种方法无法解决相互引用的问题,因此 JVM 没有采用这个算法。

(2)追踪回收算法(Tracing Collector)

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

(3)压缩回收算法(Compacting Collector)

压缩回收算法的主要思路如下:把堆中活动的对象移动到堆中一端,这样就会在堆中另外一端留出很大的一块空闲区域,相当于对堆中的碎片进行了处理。虽然这种方法可以大大简化消除堆碎片的工作,但是每次处理都会带来性能的损失。

(4)复制回收算法(Coping Collector)

复制回收算法的主要思路如下:把堆分成两个大小相同的区域,在任何时刻,只有其中的一个区域被使用,直到这个区域的被消耗完为止,此时垃圾回收器会中断程序的执行,通过遍历的方式把所有活动的对象复制到另外一个区域中,在复制的过程中它们是紧挨着布置的,从而可以消除内存碎片。当复制过程结束后程序会接着运行,直到这块区域被使用完,然后再采用上面的方法继续进行垃圾回收。

这个算法的优点是在进行垃圾回收的同时对对象的布置也进行了安排,从而消除了内存碎片。但是这也付出了很高的代价:对于指定大小的堆来说,需要两倍大小的内存空间;同时由于在内存调整的过程中要中断当前执行的程序,从而降低了程序的执行效率。

(5)按代回收算法(Generational Collector)

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

常见笔试题:

当 Float 对象在第 2 行被创建后,什么时候能够被垃圾回收?( )

A.4 行以后 B.5 行以后 C.6 行以后 D.7 行以后

答案:C。在第 6 行后不再有对象引用 Float 对象了,因此能够被垃圾回收。

2.下列关于垃圾回收的说法中,正确的是( )。

A.一旦一个对象成为垃圾,就立刻被回收掉

B.对象空间被回收掉之后,会执行该对象的 finalize 方法

C.finalize 方法和 C++ 的析构函数完全是一回事情

D.一个对象成为垃圾是因为不再有引用指着它,但是线程并非如此

答案:D。成为垃圾的对象,只有在下次垃圾回收器运行时才会被回收,而不是马上被清理,因此选项 A 错误。finalize 方法是在对象空间被回收前调用的,因此选项 B 错误。在 C++ 语言中,调用了析构函数后,对象一定会被销毁,而 Java 语言调用了 finalize 方法,垃圾却不一定会被回收,因此 finalize 方法与 C++ 的析构函数是不同的,所以选项 C 也不正确。对于 D,当一个对象不再被引用后就成为垃圾可以被回收,但是线程就算没有被引用也可以独立运行的,因此与对象不同。所以正确答案为 D。

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

答案:由于垃圾回收器的存在,Java 语言本身没有给开发人员提供显式释放已分配内存的方法,也就是说,开发人员不能实时地调用垃圾回收器对某个对象或所有对象进行垃圾回收。但开发人员却可以通过调用 System.gc()方法来「通知」垃圾回收器运行,当然,JVM 也并不会保证垃圾回收器马上就会运行。由于 System.gc()方法的执行会停止所有响应,去检查内存中是否有可回收的对象,这会对程序的正常运行以及性能造成极大的威胁,因此实际编程时,不推荐频繁使用这一方法。


4.8.5 Java 是否存在内存泄露问题

内存泄露是指一个不再被程序使用的对象或变量还在内存中占有存储空间。在 Java 语言中引进了垃圾回收机制,由垃圾回收器负责回收不再使用的对象,既然有垃圾回收器来负责回收垃圾,那么是否还会存在内存泄露的问题呢?

其实,在 Java 语言中,判断一个内存空间是否符合垃圾回收的标准有两个:第一,给对象赋予了空值 null,以后再没有被使用过;第二,给对象赋予了新值,重新分配了内存空间。一般来讲,内存泄露主要有两种情况:一是在堆中申请的空间没有被释放;二是对象已不再被使用,但还仍然在内存中保留着。垃圾回收机制的引入可以有效地解决第一种情况;而对于第二种情况,垃圾回收机制则无法保证不再使用的对象会被释放。因此,Java 语言中的内存泄露主要指的是第二种情况。

在 Java 语言中,容易引起内存泄露的原因很多,主要有以下几个方面的内容:

1)静态集合类,例如 Vector。如果这些容器为静态的,由于它们的生命周期与程序一致,那么容器中的对象在程序结束之前将不能被释放,从而造成内存泄露,如上例所示。

2)各种连接,例如数据库连接、网络联接以及 IO 连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用 close 方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对 Connection、Statement 或 ResultSet 不显式地关闭,将会造成大量的对象无法被回收,从而引起内存泄露。

3)监听器。在 Java 语言中,往往会使用到监听器。通常一个应用中会用到多个监听器,但在释放对象的同时往往没有相应地删除监听器,这也可能导致内存泄露。

4)变量不合理的作用域。一般而言,如果一个变量定义的作用范围大于其使用范围,很有可能会造成内存泄露,另一方面如果没有及时地把对象设置为 null,很有可能会导致内存泄露的发生,示例如下:

5)单例模式可能会造成内存泄露。单例模式的实现方法有很多种,下例中所使用的单例模式就可能会造成内存泄露:

 

在上述实现的单例模式中,Singleton 存在一个对对象 BigClass 的引用,由于单例对象以静态变量的方式存储,因此它在 JVM 的整个生命周期中都存在,同时由于它有一个对对象 Big-Class 的引用,这样会导致 BigClass 类的对象不能够被回收。


4.8.6 Java 中的堆和栈有什么区别

在 Java 语言中,堆与栈都是内存中存放数据的地方。变量分为基本数据类型和引用类型,基本数据类型的变量(例如 int、short、long、byte、float、double、boolean 以及 char 等)以及对象的引用变量,其内存都分配在栈上,变量出了作用域就会自动释放,而引用类型的变量,其内存分配在堆上或者常量池(例如字符串常量和基本数据类型常量)中,需要通过 new 等方式进行创建。

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

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

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

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

 

发布了132 篇原创文章 · 获赞 21 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/qq_40993412/article/details/104339077