java面试题

目录

(必看)科目重点

重要先后顺序:
先:JVM,多线程并发,计算机网络,MySQL数据库。
后:其他所有

3)数据结构:数组、链表、栈、队列、树。

1)Java基础:JDK 常用类的原理、源码、List Set Map以及底层实现方式、ConcurrentHashMap以及HashMap底层实现、HashMap原理以及扩容、序列化等。

4)网络:五层结构、TCP、HTTP、 HTTPS、三次握手及四次挥手、拥塞控制策略算法。

  1. JVM:运行时数据区、新生代与年老代、垃圾回收算法、垃圾回收器(CMS、G1)常用配置参数、线上问题定位及解决。

10)多线程并发:线程池的原理以及参数、synchronize、volatile、lock、CAS以及悲观锁、锁升级、wait notify await signal sleep等信号量机制、创建线程的方式、ThreadLocal变量的原理、AQS、线程安全的HashMap等。

5)数据库(MySQL):索引原理、B+与B树、隔离级别、锁机制、分库分表、慢SQL定位及优化、数据库的事务、线上问题解决。

2)设计模式:常用几种的原理、使用场景,单例、动态代理、模板等。

6)操作系统:进程和线程及通信方式、系统调用、线程同步方式、调度算法、内存管理机制、快表、分页和分段、逻辑and物理地址、虚拟内存、置换算法。

8)框架:Spring loC 原理 Spring AOP原理和使用、Spring常用的扩展点、MyBatis的核心流程、bean生命周期。

9)Linux:基本命令的使用、快速定位和排查问题。

  1. 缓存:Redis单线程、缓存穿透 击穿 雪崩以及应对策略、一致性、分布式锁。

第一章 Java语言

1.Java基础(前面18个是重点)

1.8为啥要有包装类?

Java语言是面向对象的语言,其设计理念是“一切皆对象”。但8种基本数据类型却出现了例外,它们不具
备对象的特性。正是为了解决这个问题,Java为每个基本数据类型都定义了一个对应的引用类型,这就
是包装类。

扩展阅读
Java之所以提供8种基本数据类型,主要是为了照顾程序员的传统习惯。这8种基本数据类型的确带来了
一定的方便性,但在某些时候也会受到一些制约。比如,所有的引用类型的变量都继承于Object类,都
可以当做Object类型的变量使用,但基本数据类型却不可以。如果某个方法需要Object类型的参数,但
实际传入的值却是数字的话,就需要做特殊的处理了。有了包装类,这种问题就可以得以简化。

1.9 说一说自动装箱、自动拆箱的应用场景

自动装箱:可以把一个基本类型的数据直接赋值给对应的包装类型;

自动拆箱:可以把一个包装类型的对象直接赋值给对应的基本类型;

通过自动装箱、自动拆箱功能,可以大大简化基本类型变量和包装类对象之间的转换过程。比如,某个方法的参数类型为包装类型,调用时我们所持有的数据却是基本类型的值,则可以不做任何特殊的处理,直接将这个基本类型的值传入给方法即可。

1.12 说一说你对面向对象的理解

面向对象思想是一种更符合我们思考习惯的思想,并将我们从执行者变成了指挥者。面向对象是一种更优秀的程序设计方法,它的基本思想是使用类、对象、继承、封装、消息等基本概念进行程序设计。它从现实世界中客观存在的事物出发来构造软件系统,并在系统构造中尽可能运用人类的自然思维方式,强调直接以现实世界中的事物为中心来思考,认识问题,并根据这些事物的本质特点,把它们抽象地表示为系统中的类,作为系统的基本构成单元,这使得软件系统的组件可以直接映像到客观世界,并保持客观世界中事物及其相互关系的本来面貌。

扩展阅读
结构化程序设计方法主张按功能来分析系统需求,其主要原则可概括为自顶向下、逐步求精、模块化
等。结构化程序设计首先采用结构化分析方法对系统进行需求分析,然后使用结构化设计方法对系统进
行概要设计、详细设计,最后采用结构化编程方法来实现系统。

因为结构化程序设计方法主张按功能把软件系统逐步细分,因此这种方法也被称为面向功能的程序设计
方法;结构化程序设计的每个功能都负责对数据进行一次处理,每个功能都接受一些数据,处理完后输
出一些数据,这种处理方式也被称为面向数据流的处理方式。

结构化程序设计里最小的程序单元是函数,每个函数都负责完成一个功能,用以接收一些输入数据,函
数对这些输入数据进行处理,处理结束后输出一些数据。整个软件系统由一个个函数组成,其中作为程
序入口的函数被称为主函数,主函数依次调用其他普通函数,普通函数之间依次调用,从而完成整个软
件系统的功能。

每个函数都是具有输入、输出的子系统,函数的输入数据包括函数形参、全局变量和常量等,函数的输
出数据包括函数返回值以及传出参数等。结构化程序设计方式有如下两个局限性:

①设计不够直观,与人类习惯思维不一致。采用结构化程序分析、设计时,开发者需要将客观世界模
型分解成一个个功能,每个功能用以完成一定的数据处理。

②适应性差,可扩展性不强。由于结构化设计采用自顶向下的设计方式,所以当用户的需求发生改变,或需要修改现有的实现方式时,都需要自顶向下地修改模块结构,这种方式的维护成本相当高。

1.13面向对象的三大特征是什么?

封装、继承、多态。

①封装:将对象的实现细节 隐藏起来,然后通过一些公用方法来暴露该对象的功能;
②继承:面向对象实现软件复用的重要手段,当 子类继承父类后,子类作为一种特殊的父类,将直接获得父类的属性和方法;
③多态:子类对象可以 直接赋给父类变量,但运行时依然表现出子类的行为特征,这意味着同一个类型的对象在执行同一个方法时,可能表现出多种行为特征。

扩展阅读
抽象也是面向对象的重要部分,抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注 意与当前目标有关的方面。抽象并不打算了解全部问题,而只
是考虑部分问题。例如,需要考察Person 对象时,不能在程序中把Person的所有细节都定义出来,通常只能定义Person的部分数据、部分行为特征,而这些数据、行为特征是软件系统所关心的部分。

1.14封装的目的是什么,为什么要有封装?

封装是面向对象编程语言对客观世界的模拟,在客观世界里,对象的状态信息都被隐藏在对象内部,外 界无法直接操作和修改。对一个类或对象实现良好的封装,可以实现以下目的:
①隐藏类的实现细节;
②让使用者只能通过事先预定的方法来访问数据,从而可以在该方法里加入控制逻辑,限制对成员变 量的不合理访问;
③可进行数据检查,从而有利于保证对象信息的完整性;
④便于修改,提高代码的可维护性。

扩展阅读
为了实现良好的封装,需要从两个方面考虑:
①将对象的成员变量和实现细节隐藏起来,不允许外部直接访问;
②把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作。

封装实际上有两个方面的含义:把该隐藏的隐藏起来,把该暴露的暴露出来。这两个方面都需要通过使 用Java提供的访问控制符来实现。

1.15说一说你对多态的理解

因为子类其实是一种特殊的父类,因此Java允许把一个子类对象直接赋给一个父类引用变量,无须任何 类型转换,或者被称为向上转型,向上转型由系统自动完成。当把一个子类对象直接赋给父类引用变量时,例如

BaseClass obj = new SubClass(); ,

这个obj引用变量的编译时类型是BaseClass,而运行时类型是SubClass,当运行时调用该引用变量的方法时,其 方法行为总是表现出子类方法的行为特征,而不是父
类方法的行为特征,这就可能出现:相同类型的变 量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。

扩展阅读
多态可以提高程序的可扩展性,在设计程序时让代码更加简洁而优雅。例如我要设计一个司机类,他可以开轿车、巴士、卡车等等,示例代码如下:
在这里插入图片描述

在设计上述代码时,我已采用了重载机制,将方法名进行了统一。这样在进行调用时,无论要开什么交通工具,都是通过 这样的方式来调用,对调用者足够的友好。

但对于程序的开发者来说,这显得繁琐,因为实际上这个司机可以驾驶更多的交通工具。当系统需要为 这个司机增加车型时,开发者就需要相应的增加driver方法,类似的代码会堆积的越来越多,显得臃 肿。

采用多态的方式来设计上述程序,就会变得简洁很多。我们可以为所有的交通工具定义一个父类Vehicle,然后按照如下的方式设计drive方法。调用时,我们可以传入Vehicle类型的实例,也可以传入 任意的Vechile子类型的实例,对于调用者来说一样的方便,但对于开发者来说,代码却变得十分的简 洁了。
在这里插入图片描述

1.16 Java中的多态是怎么实现的?

多态的实现离不开继承。
在设计程序时,我们可以将参数的类型定义为父类型。在调用程序时,则可以 根据实际情况,传入该父类型的某个子类型的实例,这样就实现了多态。对于父类型,可以有三种形 式,即普通的类、抽象类、接口。对于子类型,则要根据它自身的特征,重写父类的某些方法,或实现 抽象类/接口的某些抽象方法。

1.20 介绍一下Object类中的方法

Object类提供了如下几个常用方法:
①Class<?> getClass():返回该对象的运行时类。
②boolean equals(Object obj):判断指定对象与该对象是否相等。
③int hashCode():返回该对象的hashCode值。在默认情况下,Object类的hashCode()方法根据该对象的地址来计算。但很多类都重写了Object类的hashCode()方法,不再根据地址来计算其hashCode()方法值。
④String toString():返回该对象的字符串表示,当程序使用System.out.println()方法输出一个对象,或者把某个对象和字符串进行连接运算时,系统会自动调用该对象的toString()方法返回该对 象的字符串表示。Object类的toString()方法返回 运行时类名@十六进制hashCode值 格式的字符串,但很多类都重写了Object类的toString()方法,用于返回可以表述该对象信息的字符串。

另外,Object类还提供了wait()、notify()、notifyAll()这几个方法,通过这几个方法可以控制线程的暂停和运行。Object类还提供了一个clone()方法,该方法用于帮助其他对象来实现“自我克隆”,所谓“自我克隆”就是得到一个当前对象的副本,而且二者之间完全隔离。由于该方法使用了protected修饰,因此它 只能被子类重写或调用。

1.21 说一说hashCode()和equals()的关系

hashCode()用于获取哈希码(散列码)。
eauqls()用于比较两个对象是否相等。

它们应遵守如下规定: 如果两个对象相等,则它们必须有相同的哈希码。如果两个对象有相同的哈希码,则它们未必相等。

1.22为什么要重写hashCode()和equals()?

Object类提供的equals()方法默认是用 == 来进行比较的,也就是说只有两个对象是 同一个对象时,才能返回相等的结果。

而实际中需求的是,若两个不同的对象它们的内容是相同的,就认为它们相等。

Object类中equals()方法的默认实现是没有实用价值的,所以通常都要重 写。由于hashCode()与equals()具有联动关系(参考“说一说hashCode()和equals()的关系”一题),所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足相关的约定。

1.23 ==和equals()有什么区别?

==运算符:
①作用于基本数据类型时,是比较两个数值是否相等;
②作用于引用数据类型时,是比较两个对象的内存地址是否相同,即判断它们是否为同一个对象;

equals()方法:
①没有重写时,Object默认以 来实现,即比较两个对象的内存地址是否相同;
②进行重写后,一般会按照对象的内容来进行比较,若两个对象内容相同则认为对象相等,否则认为 对象不等。

1.26说一说String和StringBuffer有什么区别

String类是不可变类,即一旦一个String对象被创建以后,包含在这个对象中的字符序列是不可改变 的,直至这个对象被销毁。

StringBuffer对象则代表一个字符序列可变的字符串,当一个StringBuffer被创建以后,通过StringBuffer提供的append()、insert()、reverse()、setCharAt()、
setLength()等方法可以改变这个字符串对象的字符序列。一旦通过StringBuffer生成了最终想要的字符串,就可以调用它的toString()方法 将其转换为一个String对
象。

1.27说一说StringBuffer和StringBuilder有什么区别

StringBuffer、StringBuilder都代表可变的字符串对象,它们有共同的父类AbstractStringBuilder ,并且两个类的构造方法和成员方法也基本相同。

不同的是StringBuffer 是线程安全的,而StringBuilder是非线程安全的,所以StringBuilder性能略高。一般情况下,要创建一 个内容可变的字符串,建议优先考虑StringBuilder类。

1.33接口和抽象类有什么区别?

1.从设计目的上来说,二者有如下的区别:

接口体现的是一种规范。对于接口的实现者而言,接口规定了实现者必须向外提供哪些服务;对于接口 的调用者而言,接口规定了调用者可以调用哪些服务,以及如
何调用这些服务。当在一个程序中使用接 口时,接口是多个模块间的耦合标准;当在多个应用程序之间使用接口时,接口是多个程序之间的通信 标准。

抽象类体现的是一种模板式设计。抽象类作为多个子类的抽象父类,可以被当成系统实现过程中的中间 产品,这个中间产品已经实现了系统的部分功能,但这个产品依然不能当成最终产品,必须有更进一步 的完善,这种完善可能有几种不同方式。

2.从使用方式上来说,二者有如下的区别:
①接口里只能包含抽象方法、静态方法、默认方法和私有方法,不能为普通方法提供方法实现;抽象 类则完全可以包含普通方法。
②接口里只能定义静态常量,不能定义普通成员变量;抽象类里则既可以定义普通成员变量,也可以 定义静态常量。
③接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让 其子类调用这些构造器来完成属于抽象类的初始化操作。
④接口里不能包含初始化块;但抽象类则完全可以包含初始化块。
⑤一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接 口可以弥补Java单继承的不足。

扩展阅读
接口和抽象类很像,它们都具有如下共同的特征:
①接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。
②接口和抽象类都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实
现这些抽象方法。

1.41说一说你对static关键字的理解

在Java类里只能包含成员变量、方法、构造器、初始化块、内部类(包括接口、枚举)5种成员,而 static可以修饰成员变量、方法、初始化块、内部类(包括接口、
枚举),以static修饰的成员就是类成 员。类成员属于整个类,而不属于单个对象。

对static关键字而言,有一条非常重要的规则:类成员(包括成员变量、方法、初始化块、内部类和内 部枚举)不能访问实例成员(包括成员变量、方法、初始化块、内部类和内部枚举)。因为类成员是属 于类的,类成员的作用域比实例成员的 作用域更大,完全可能出现类成员已经初始化完成,但实例成员 还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错误。

1.43 static和final有什么区别?

static关键字可以修饰成员变量、成员方法、初始化块、内部类,被static修饰的成员是类的成员,它属 于类、不属于单个对象。以下是static修饰这4种成员时表现出
的特征:
①类变量:被static修饰的成员变量叫类变量(静态变量)。类变量属于类,它随类的信息存储在方 法区,并不随对象存储在堆中,类变量可以通过类名来访
问,也可以通过对象名来访问,但建议通 过类名访问它。
②类方法:被static修饰的成员方法叫类方法(静态方法)。类方法属于类,可以
通过类名访问,也 可以通过对象名访问,建议通过类名访问它。
③静态块:被static修饰的初始化块叫静态初始化块。静态块属于类,它在类加载的时候被隐式调用 一次,之后便不会被调用了。
④静态内部类:被static修饰的内部类叫静态内部类。静态内部类可以包含静态成员,也可以包含非 静态成员。静态内部类不能访问外部类的实例成员,只能访问外部类的静态成员。外部类的所有方 法、初始化块都能访问其内部定义的静态内部类。

final关键字可以修饰类、方法、变量,以下是final修饰这3种目标时
①表现出的特征: final类:final关键字修饰的类不可以被继承。
②final方法:final关键字修饰的方法不可以被重写。
③final变量:final关键字修饰的变量,一旦获得了初始值,就不可以被修改。

1.47 说一说你对Java反射机制的理解

Java程序中的对象在运行时可以表现为两种类型,即编译时类型和运行时类型。例如。

person p=new student();

有时,程序在运行时接收到外部传入的一个对象,该对象的编译时类型是Object,
但程序又需要调用该 对象的运行时类型的方法。这就要求程序需要在运行时发现对
象和类的真实信息,而解决这个问题有以 下两种做法:

①第一种做法是假设在编译时和运行时都完全知道类型的具体信息,在这种情况
下,可以先使用instanceof运算符进行判断,再利用强制类型转换将其转换成其运行时类型的变量即可。
②第二种做法是编译时根本无法预知该对象和类可能属于哪些类,程序只依靠运行时信息来发现该对 象和类的真实信息,这就必须使用反射。

具体来说,通过反射机制,我们可以实现如下的操作:程序运行时,可以通过反射获得任意一个类的Class对象,并通过这个对象查看这个类的信息; 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员;

程序运行时,可以通过反射机制生成一个类的动态代理类或动态代理对象。

1.49 说一说Java的四种引用方式

Java对象的四种引用方式分别是强引用、软引用、弱引用、虚引用,具体含义如下:

强引用:这是Java程序中最常见的引用方式,即程序创建一个对象,并把这个
对象赋给一个引用变 量,程序通过该引用变量来操作实际的对象。当一个对象
被一个或一个以上的引用变量所引用时, 它处于可达状态,不可能被系统垃
圾回收机制回收。

软引用:当一个对象只有软引用时,它有可能被垃圾回收机制回收。对于只有
软引用的对象而言, 当系统内存空间足够时,它不会被系统回收,程序也可使
用该对象。当系统内存空间不足时,系统 可能会回收它。软引用通常用于对
内存敏感的程序中。

弱引用:弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的
对象而言,当系统垃 圾回收机制运行时,不管系统内存是否足够,总会回收该
对象所占用的内存。当然,并不是说当一 个对象只有弱引用时,它就会立即被
回收,正如那些失去引用的对象一样,必须等到系统垃圾回收 机制运行时才会
被回收。

虚引用:虚引用完全类似于没有引用。虚引用对对象本身没有太大影响,对象
甚至感觉不到虚引用 的存在。如果一个对象只有一个虚引用时,那么它和没有
引用的效果大致相同。虚引用主要用于跟 踪对象被垃圾回收的状态,虚引用
不能单独使用,虚引用必须和引用队列联合使用。

2.JVM(前10 是重点)

2.1 JVM包含哪几部分?

JVM 主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口),下图可以大致描述 JVM 的结构。
在这里插入图片描述
JVM 是执行 Java 程序的虚拟计算机系统,那我们来看看执行过程:首先需要准备好编译好的 Java 字节码文件(即class文件),计算机要运行程序需要先通过一定方式(类加载器)将 class 文件加载到内存中(运行时数据区),但是字节码文件
是JVM定义的一套指令集规范,并不能直接交给底层操作系统去 执行,因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集交给 CPU 去执行,这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地
图制作等),这就用到了本地 Native 接口(本地库接口)。

①ClassLoader:负责加载字节码文件即 class 文件,class 文件在文件开头有特定的文件标示,并且ClassLoader 只负责class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。

②Runtime Data Area:是存放数据的,分为五部分:Stack(虚拟机栈),Heap(堆),Method Area(方法区),PCRegister(程序计数器),Native Method Stack(本地方法栈)。几乎所有
的关于 Java 内存方面的问题,都是集中在这块。

③Execution Engine:执行引擎,也叫 Interpreter。Class 文件被加载后,会把指令和数据信息放入内存中,Execution Engine 则负责把这些命令解释给操作系统,即将 JVM 指令集翻译为操作系统指令集。

④Native Interface:负责调用本地接口的。他的作用是调用不同语言的接口给JAVA 用,他会在Native Method Stack 中记录对应的本地方法,然后调用该方法时就通过 Execution Engine 加载对应的本地 lib。原本多用于一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。

2.2 JVM是如何运行的?

JVM的启动过程分为如下四个步骤:

  1. JVM的装入环境和配置
    java.exe负责查找JRE,并且它会按照如下的顺序来选择JRE: 自己目录下的JRE;父级目录下的JRE;查注册中注册的JRE。

  2. 装载JVM
    通过第一步找到JVM的路径后,Java.exe通过LoadJavaVM来装入JVM文件。LoadLibrary 装 载 JVM 动 态 连 接 库 , 然 后 把 JVM 中 的 到 处 函 数JNI_CreateJavaVM 和 JNI_GetDefaultJavaVMIntArgs 挂接到InvocationFunction 变量的CreateJavaVM和GetDafaultJavaVMInitArgs 函数指针变量上。JVM的装载工作完成。

  3. 初始化JVM
    获得本地调用接口调用InvocationFunction -> CreateJavaVM,也就是JVM中JNI_CreateJavaVM方法获得JNIEnv结构的实例。

  4. 运行Java程序
    JVM运行Java程序的方式有两种:jar包 与 class。运行jar 的时候,java.exe调用GetMainClassName函数,该函数先获得JNIEnv实例然后调用JarFileJNIEnv类中getManifest(),从其返回的Manifest对象中取getAttrebutes(“Main-Class”)的 值,即jar 包中文件:META-INF/MANIFEST.MF指定的Main-Class的主类名作为运行的主类。之后main函数会调用Java.c中LoadClass方法装载该主类(使用JNIEnv实例的FindClass)。运行Class的时候,main函数直接调用Java.c中的LoadClass方法装载该类。

2.3 说一说Java的内存分布情况

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据
区域。这些区域有各 自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程
的启动而一直存在,有些区域则是依赖用 户线程的启动和结束而建立和销毁。根据
《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包 括以下几个运行时数
据区域。
在这里插入图片描述

  1. 程序计数器
    程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计 数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异 常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个 确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因 此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线 程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯 一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

  2. Java虚拟机栈
    与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚 拟机都会同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈 到出栈的过程。在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟 机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩 展时无法申请到足够的内存会抛出OutOfMemoryError异常。

  3. 本地方法栈
    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定, 因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接 就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展 失败时分别抛出StackOverflowError和OutOfMemoryError异常。

  4. Java堆
    对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例, Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看, 随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现 在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已 经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该 被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于 大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连 续的内存空间。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无 法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

  5. 方法区
    方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中 把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与 Java堆区分开来。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

  6. 运行时常量池
    运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时 会抛OutOfMemoryError异常。

  7. 直接内存
    直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本 机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务 器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存, 使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展 时出现OutOfMemoryError异常。

2.4 介绍一下类加载的过程

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统 称为连接(Linking)。这七个阶段的发生顺序如下图所示。
在这里插入图片描述
在上述七个阶段中,包括了类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段。

一、加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
    加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方 法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结 构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对 象将作为程序访问方法区中的类型数据的外部接口。

二、验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规 范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会 完成下面四个阶段的检验动作:文件格
式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式验证:
    第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
  2. 元数据验证:
    第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。
  3. 字节码验证:
    第三阶段是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:
    符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验, 通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

三、准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值 的阶段。从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是 一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概 念的。而在JDK8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

四、解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出 现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目 标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们 能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实 例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中 存在。

五、初始化
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户 应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到 初始化阶段,Java虚拟机才真正开始执行
类中编写的Java程序代码,将主导权移交给应用程序。进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序 编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化 阶段就是执行类构造器() 方法的过程。 () 并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。

2.5 介绍一下Java的垃圾回收机制

一、哪些内存需要回收
在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多 个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有 处于运行期间,我们才能知道程序究竟会
创建哪些对象,创建多少个对象,这部分内存的分配和回收是 动态的。垃圾收集器所关注的正是这部分内存该如何管理,我们平时所说的内存分配与回收也仅仅特指这一部分内存。

二、怎么定义

①垃圾引用计数算法:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值 就减一;任何时刻计数器为零的对象就是不可能再被使用的。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内
存,主要原因是,这 个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的 引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问, 但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

②可达性分析算法:
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain), 如果某个对象到GCRoots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GCRoots是不可达的,因此它们将会被判定为可回收的对象。
在这里插入图片描述
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参 数、局部变量、临时变量等。
2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
3.在方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
4.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
5.所有被同步锁(synchronized关键字)持有的对象。
6.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

回收方法区:

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中 的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当 前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字 段的符号引用也与此类似。判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。

三、怎么回收

①垃圾分代收集理论:
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(GenerationalCollection)的理论进行设 计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说 之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
    这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同 的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存 储。显而易见,如果一个区域中大多数对
    象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集 中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一
    块,虚拟机便可以使 用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

②标记-清除算法:
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为 “标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要 被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增 长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片 太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如下图所示。
在这里插入图片描述
③标记-复制算法:
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半 区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使 用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对 整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序 分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存 缩小为了原来的一半,空间浪费未免太多了一点。标记-复制算法的执行过程如下图所示。
在这里插入图片描述
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和
其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中 仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor 空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用
内存空间为整个 新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代 是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分 百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的 安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

④标记-整理算法:
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想 浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的 极端情况,所以在老年代一般不能直接选用这种算法。针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark- Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行 清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算 法的示意图如下图所示。
在这里插入图片描述

2.6 请介绍一下分代回收机制

当 前 商 业 虚 拟 机 的 垃 圾 收 集 器 , 大 多 数 都 遵 循 了 “ 分代收集 ”(GenerationalCollection)[插图]的理论进 行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代 假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同 的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存 储。把分代收集理论具体放到现在的商用
Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对 象之间会存在跨代引用。假如要现在进行一次只局限于新生代区域内的收集,但新生代中的对象是完全 有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收 集理论添加第三条经验法则:

  1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个 对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(称为“记忆集”, RememberedSet),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引 用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性, 会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

2.7 JVM中一次完整的GC流程是怎样的?

新创建的对象一般会被分配在新生代中,常用的新生代的垃圾回收器是 ParNew垃圾回收器,它按照8:1:1 将新生代分成 Eden 区,以及两个 Survivor 区。某一时刻,我们创建的对象将 Eden 区全部挤 满,这个对象就是挤满新生代的最后一个对象。此时,Minor GC 就触发了。在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。为什么要做这样的检查呢?原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:

  1. 老年代剩余空间大于新生代中的对象大小,那就直接Minor GC,GC完survivor不够放,老年代也
    绝对够放;

  2. 老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了“老年代空间分配担保规则”,具体来说就是看-XX:-HandlePromotionFailure 参数是否设置了。老年代空间分配担保规则是这样的,如果老年代中剩余空间大小,大于历次
    Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况:老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 MinorGC;老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查。开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:

  3. Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束;

  4. Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC结束;

  5. Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC。

前面都是成功 GC 的例子,还有 3 中情况,会导致 GC 失败,报 OOM:

  1. 紧接上一节 Full GC 之后,老年代任然放不下剩余对象,就只能 OOM;
  2. 未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,也只能 OOM;
  3. 开启老年代分配担保机制,但是担保不通过,一次 Full GC 后,老年代任然放不下剩余对象,也是能 OOM。GC完整流程,参考下图:

在这里插入图片描述

2.8 如何确定对象是可回收的?

①引用计数算法:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值 就减一;任何时刻计数器为零的对象就是不可能再被使用的。 但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内
存,主要原因是,这 个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的 引用计数就很难解决对象之间相互循环引用的问题。举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问, 但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

②可达性分析算法:
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain), 如果某个对象到GCRoots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GCRoots是不可达的,因此它们将会被判定为可回收的对象。
在这里插入图片描述
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参 数、局部变量、临时变量等。
2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
3.在方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
4.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
5.所有被同步锁(synchronized关键字)持有的对象。
6.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

2.9 什么是内存泄漏,怎么解决?

内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命
周期的对象已经不再 需要,但由于长生命周期对象持有它的引用而导致不能被回收。
以发生的方式来分类,内存泄漏可以分 为4类:

  1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
  2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
  3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。
  4. 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

避免内存泄漏的几点建议:
①尽早释放无用对象的引用。
②避免在循环中创建对象。
③使用字符串处理时避免使用String,应使用StringBuffer。
④尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。

2.10 什么是内存溢出,怎么解决?

内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内
存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢
出。引起内存溢出的原因有很多种,常见的有以下几种:

  1. 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
  2. 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
  3. 代码中存在死循环或循环产生过多重复的对象实体;
  4. 使用的第三方软件中的BUG;
  5. 启动参数内存值设定的过小。

内存溢出的解决方案:
第一步,修改JVM启动参数,直接增加内存。
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
第四步,使用内存查看工具动态查看内存使用情况。

3. 集合类(前15 是重点)

3.1 Java中有哪些容器(集合类)?

Java中的集合类主要由Collection和Map这两个接口派生而出,其中Collection接口
又派生出三个子接 口,分别是Set、List、Queue。所有的Java集合类,都是Set、
List、Queue、Map这四个接口的实现 类,这四个接口将集合分成了四大类,其

Set代表无序的,元素不可重复的集合;
List代表有序的,元素可以重复的集合;
Queue代表先进先出(FIFO)的队列;
Map代表具有映射关系(key-value)的集合。
这些接口拥有众多的实现类,其中最常用的实现类有HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap等。

3.2 Java中的容器,线程安全和线程不安全的分别有哪些?

java.util包下的集合类大部分都是线程不安全的,例如我们常用的HashSet、
TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap,这些都
是线程不安全的集合类,但是它们的优点是性能好。如果需要使用线程安全的集合
类,则可以使用Collections工具类提供的synchronizedXxx()方法,将这些集合类
包装成线程安全的集合类。

java.util包下也有线程安全的集合类,例如Vector、Hashtable。这些集合类都是
比较古老的API,虽然实现了线程安全,但是性能很差。所以即便是需要使用线程安
全的集合类,也建议将线程不安全的集合 类包装成线程安全集合类的方式,而不是
直接使用这些古老的API。

从Java5开始,Java在java.util.concurrent包下提供了大量支持高效并发访问的集
合类,它们既能包装良好的访问性能,有能包装线程安全。这些集合类可以分为两
部分,它们的特征如下:

①以Concurrent开头的集合类:
以Concurrent开头的集合类代表了支持并发访问的集合,它们可以支持多个
线程并发写入访问, 这些写入线程的所有操作都是线程安全的,但读取操作不
必锁定。以Concurrent开头的集合类采用了更复杂的算法来保证永远不会锁
住整个集合,因此在并发写入时有较好的性能。

②以CopyOnWrite开头的集合类:
以CopyOnWrite开头的集合类采用复制底层数组的方式来实现写操作。当线程
对此类集合执行读 取操作时,线程将会直接读取集合本身,无须加锁与阻塞。
当线程对此类集合执行写入操作时,集 合会在底层复制一份新的数组,接下来
对新的数组执行写入操作。由于对集合的写入操作都是对数 组的副本执行操
作,因此它是线程安全的。
在这里插入图片描述

3.3 如何得到一个线程安全的Map?

  1. 使用Collections工具类,将线程不安全的Map包装成线程安全的Map;
  2. 使用java.util.concurrent包下的Map,如ConcurrentHashMap;
  3. 不建议使用Hashtable,虽然Hashtable是线程安全的,但是性能较差

3.4 介绍一下HashMap底层的实现原理

它基于hash算法,通过put方法和get方法存储和获取对象。
存储对象时,我们将K/V传给put方法时,它调用K的hashCode计算hash从而得到
bucket位置,进一步 存储,HashMap会根据当前bucket的占用情况自动调整容量
(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调
用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键
值对。

如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来。在
Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使
用红黑树来替换链表,从而提高速度。

3.5 HashMap为什么线程不安全?

HashMap在并发执行put操作时,可能会导致形成循环链表,从而引起死循环。

3.6 HashMap如何实现线程安全?

  1. 直接使用Hashtable类;
  2. 直接使用ConcurrentHashMap;
  3. 使用Collections将HashMap包装成线程安全的Map。

3.7 说一说HashMap和HashTable的区别

  1. Hashtable是一个线程安全的Map实现,但HashMap是线程不安全的实现,所以HashMap比Hashtable的性能高一点。
  2. Hashtable不允许使用null作为key和value,如果试图把null值放进Hashtable中,将会引发空指针 异常,但HashMap可以使用null作为key或value。

扩展阅读
从Hashtable的类名上就可以看出它是一个古老的类,它的命名甚至没有遵守Java
的命名规范:每个单 词的首字母都应该大写。也许当初开发Hashtable的工程师也
没有注意到这一点,后来大量Java程序中 使用了Hashtable类,所以这个类名也
就不能改为HashTable了,否则将导致大量程序需要改写。与Vector类似的是,尽量少用Hashtable实现类,即使需要创建线程安全的Map实现类,也无须使用Hashtable实现类,可以通过Collections工具类把HashMap变成线程安全的Map。

3.8 HashMap与ConcurrentHashMap有什么区别?

HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操
作,否则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环,
这样在查找时就会发生死循环,影响到整个 应用程序。

Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包
装类,然后把所有功 能都委托给传入的Map,而包装类是基于synchronized关键
字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的
是互斥锁,性能与吞吐量比较低。

ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有
使用一个全局锁来 锁住自己,而是采用了减少锁粒度的方法,尽量减少因
为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需
要锁的。

3.9 介绍一下ConcurrentHashMap是怎么实现的?

JDK 1.7中的实现:
在 jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组
结构构成,采取分段锁来保证安全性。Segment 是 ReentrantLock 重入锁,在
ConcurrentHashMap 中扮演锁的角色, HashEntry 则用于存储键值对数据。一
个 ConcurrentHashMap 里包含一个 Segment 数组,一个Segment 里包含一个
HashEntry 数组,Segment 的结构和 HashMap 类似,是一个数组和链表结构。
在这里插入图片描述
JDK 1.8中的实现:
JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树
的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就
像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
在这里插入图片描述

3.10 ConcurrentHashMap是怎么分段分组的?

get操作:

Segment的get操作实现非常简单和高效,先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素。get操作的高效之处在于整个get过程都不需要加锁,除非读到空的值才会加锁重读。原因就是将使用的共享变量定义成 volatile 类型。

put操作:

当执行put操作时,会经历两个步骤:
1.判断是否需要扩容;
2.定位到添加元素的位置,将其放入 HashEntry 数组中。

插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过 CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插法),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒。

3.11 Map和Set有什么区别?

Set代表无序的,元素不可重复的集合;
Map代表具有映射关系(key-value)的集合,其所有的key是一个Set集合,即key无序且不能重复。

3.12 List和Set有什么区别?

Set代表无序的,元素不可重复的集合;
List代表有序的,元素可以重复的集合。

3.13 ArrayList和LinkedList有什么区别?

  1. ArrayList的实现是基于数组,LinkedList的实现是基于
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值