Java基础面试题

Java
319
0
0
2023-01-12

请介绍全局变量和局部变量的区别

Java中的变量分为成员变量和局部变量,它们的区别如下: 成员变量:
  1. 成员变量是在类的范围里定义的变量;
  2. 成员变量有默认初始值;
  3. 未被static修饰的成员变量也叫实例变量,它存储于对象所在的堆内存中,生命周期与对象相同;
  4. 被static修饰的成员变量也叫类变量,它存储于方法区中,生命周期与当前类相同。

局部变量:

  1. 局部变量是在方法里定义的变量;
  2. 局部变量没有默认初始值;
  3. 局部变量存储于栈内存中,作用的范围结束,变量空间会自动的释放。

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

面向对象的程序设计方法具有三个基本特征:封装、继承、多态

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

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

说一说重写与重载的区别

① 重载发生在同一个类中,若多个方法之间方法名相同、参数列表不同,则它们构成重载的关系。 ② 重载与方法的返回值以及访问修饰符无关,即重载的方法不能根据返回类型进行区分。

① 重写发生在父类子类中,若子类方法想要和父类方法构成重写关系,则它的方法名、参数列表必须与父 类方法相同。 ② 返回值要小于等于父类方法,抛出的异常要小于等于父类方法,访问修饰符则要大 于等于父类方法。 ③ 若父类方法的访问修饰符为private,则子类不能对其重写。

构造方法能不能重写

构造方法不能重写。 因为构造方法需要和类保持同名,而重写的要求是子类方法要和父类方法保持同 名。 如果允许重写构造方法的话,那么子类中将会存在与类名不同的构造方法,这与构造方法的要求是 矛盾的。

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

hashCode()用于获取哈希码(散列码), eauqls()用于比较两个对象是否相等, 它们应遵守如下规定:
  • 如果两个对象相等,则它们必须有相同的哈希码。
  • 如果两个对象有相同的哈希码,则它们未必相等。

String类有哪些方法

  • String类是Java最常用的API,它包含了大量处理字符串的方法,比较常用的有:
  • char charAt(int index):返回指定索引处的字符;
  • String substring(int beginIndex, int endIndex):从此字符串中截取出一部分子字符串;
  • String[] split(String regex):以指定的规则将此字符串分割成数组;
  • String trim():删除字符串前导和后置的空格;
  • int indexOf(String str):返回子串在此字符串首次出现的索引;
  • int lastIndexOf(String str):返回子串在此字符串最后出现的索引;
  • boolean startsWith(String prefix):判断此字符串是否以指定的前缀开头;
  • boolean endsWith(String suffix):判断此字符串是否以指定的后缀结尾;
  • String toUpperCase():将此字符串中所有的字符大写;
  • String toLowerCase():将此字符串中所有的字符小写;
  • String replaceFirst(String regex, String replacement):用指定字符串替换第一个匹配的子串;
  • String replaceAll(String regex, String replacement):用指定字符串替换所有的匹配的子串。

String可以被继承吗?

String类由final修饰,所以不能被继承。 在Java中,String类被设计为不可变类,主要表现在它保存字符串的成员变量是final的。 Java 9之前字符串采用char[]数组来保存字符,即 private final char[] value ; Java 9做了改进,采用byte[]数组来保存字符,即private final byte[] value

为什么要把String类设计为不可变类?

主要是出于安全和性能的考虑,可归纳为如下4点
  1. 由于字符串无论在任何 Java 系统中都广泛使用,会用来存储敏感信息,如账号,密码,网络路 径,文件处理等场景里,保证字符串 String 类的安全性就尤为重要了,如果字符串是可变的,容 易被篡改,那我们就无法保证使用字符串进行操作时,它是安全的,很有可能出现 SQL 注入,访 问危险文件等操作。
  2. 在多线程中,只有不变的对象和值是线程安全的,可以在多个线程中共享数据。由于 String 天然 的不可变,当一个线程”修改“了字符串的值,只会产生一个新的字符串对象,不会对其他线程的访 问产生副作用,访问的都是同样的字符串数据,不需要任何同步操作。
  3. 字符串作为基础的数据结构,大量地应用在一些集合容器之中,尤其是一些散列集合,在散列集合 中,存放元素都要根据对象的 hashCode() 方法来确定元素的位置。由于字符串 hashcode 属性 不会变更,保证了唯一性,使得类似 HashMapHashSet 等容器才能实现相应的缓存功能。由于 String 的不可变,避免重复计算 hashcode ,只要使用缓存的 hashcode 即可,这样一来大大提 高了在散列集合中使用 String 对象的性能。
  4. 当字符串不可变时,字符串常量池才有意义。字符串常量池的出现,可以减少创建相同字面量的字 符串,让不同的引用指向池中同一个字符串,为运行时节约很多的堆内存。若字符串可变,字符串 常量池失去意义,基于常量池的String.intern()方法也失效,每次创建新的字符串将在堆内 开辟出新的空间,占据更多的内存

说一说String和StringBuffer、 StringBuilder 有什么区别

① String类是不可变类,即一旦一个String对象被创建以后,包含在这个对象中的字符序列是不可改变 的,直至这个对象被销毁。 ② StringBuffer、StringBuilder都代表可变的字符串对象,它们有共同的父类 ③ AbstractStringBuilder ,并且两个类的构造方法和成员方法也基本相同。不同的是, ④ StringBuffer 是线程安全的,而StringBuilder是非线程安全的; 总结: StringBuilder性能略高。一般情况下,要创建一 个内容可变的字符串,建议优先考虑StringBuilder类。

谈谈你对面向接口编程的理解


  • 接口体现的是一种规范和实现分离的设计哲学,充分利用接口可以极好地降低程序各模块之间的耦合, 从而提高系统的可扩展性和可维护性。
  • 基于这种原则,很多软件架构设计理论都倡导“面向接口”编程, 而不是面向实现类编程,希望通过面向接口编程来降低程序的耦合

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

在Java类里只能包含成员变量、方法、构造器、初始化块、内部类(包括接口、枚举)5种成员,而 static可以修饰成员变量、方法、初始化块、内部类(包括接口、枚举),以static修饰的成员就是类成员(静态成员)。类成员属于整个类,而不属于单个对象。 对static关键字而言,有一条非常重要的规则:类成员(包括成员变量、方法、初始化块、内部类和内 部枚举)不能访问实例成员(包括成员变量、方法、初始化块、内部类和内部枚举)。因为类成员是属 于类的,类成员的作用域比实例成员的作用域更大,完全可能出现类成员已经初始化完成,但实例成员 还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错误。 static修饰的类可以被继承。

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

Java程序中的对象在运行时可以表现为两种类型,即编译时类型和运行时类型。例如 Person p = new Student(); ,这行代码将会生成一个p变量,该变量的编译时类型为Person,运行时类型为Student。 有时,程序在运行时接收到外部传入的一个对象,该对象的编译时类型是Object,但程序又需要调用该 对象的运行时类型的方法。这就要求程序需要在运行时发现对象和类的真实信息,而解决这个问题有以 下两种做法:
  • 第一种做法是假设在编译时和运行时都完全知道类型的具体信息,在这种情况下,可以先使用 instanceof运算符进行判断,再利用强制类型转换将其转换成其运行时类型的变量即可。
  • 第二种做法是编译时根本无法预知该对象和类可能属于哪些类,程序只依靠运行时信息来发现该对 象和类的真实信息,这就必须使用反射。

具体来说,通过反射机制,我们可以实现如下的操作:


  1. 程序运行时,可以通过反射获得任意一个类的Class对象,并通过这个对象查看这个类的信息;
  2. 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员;
  3. 程序运行时,可以通过反射机制生成一个类的动态代理类或动态代理对象。

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开头的集合类采用复制底层数组的方式来实现写操作。当线程对此类集合执行读 取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对此类集合执行写入操作时,集 合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对集合的写入操作都是对数 组的副本执行操作,因此它是线程安全的。

HashMap为什么线程不安全

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

描述一下Map put的过程

HashMap是最经典的Map实现,下面以它的视角介绍put的过程:
  1. 首次扩容: 先判断数组是否为空,若数组为空则进行第一次扩容(resize);
  2. 计算索引: 通过hash算法,计算键值对在数组中的索引;
  3. 插入数据: 如果当前位置元素为空,则直接插入数据; 如果当前位置元素非空,且key已存在,则直接覆盖其value; 如果当前位置元素非空,且key不存在,则将数据链到链表末端; 若链表长度达到8,则将链表转换成红黑树,并将数据插入树中;
  4. 再次扩容 如果数组中元素个数(size)超过threshold,则再次进行扩容操作。

HashMap添加数据的详细过程,如下图:

img

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

它基于hash算法,通过put方法和get方法存储和获取对象。 存储对象时,我们将K/V传给put方法时,它调用K的hashCode计算hash从而得到bucket位置,进一步 存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotrresize为原来的2倍)。 获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用 equals()方法确定键值对。 如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来。在Java 8中,如果一个 bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

介绍一下HashMap的扩容机制


  1. 数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为 了能使用位运算代替取模预算(据说提升了5~8倍)。
  2. 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数 组。这个0.75就是默认的负载因子,可由构造器传入。我们也可以设置大于1的负载因子,这样数 组就不会扩充,牺牲性能,节省内存。
  3. 为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表 转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表 提高性能。
  4. 对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈 值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈 值是7或8,因为会有一次放弃转换的操作。

ArrayList和LinkedList有什么区别


  1. ArrayList的实现是基于数组,LinkedList的实现是基于双向链表;
  2. 对于随机访问ArrayList要优于LinkedList,ArrayList可以根据下标以O(1)时间复杂度对元素进行随 机访问,而LinkedList的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的 时间复杂度是O(N);
  3. 对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList任意位置的时 候,不需要像ArrayList那样重新计算大小或者是更新索引;
  4. LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个 指向前一个元素,一个指向后一个元素。

说一说HashSet的底层结构

HashSet是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的 HashMap。它封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实 际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。

说一说TreeSet和HashSet的区别

HashSet、TreeSet中的元素都是不能重复的,并且它们都是线程不安全的,二者的区别是:
  1. HashSet中的元素可以是null,但TreeSet中的元素不能是null;
  2. HashSet不能保证元素的排列顺序,而TreeSet支持自然排序、定制排序两种排序的方式;
  3. HashSet底层是采用哈希表实现的,而TreeSet底层是采用红黑树实现的。

创建线程有哪几种方式?

创建线程有三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。
  • 通过继承Thread类来创建并启动线程的步骤如下:
  1. 定义Thread类的子类,并重写该类的run()方法,该run()方法将作为线程执行体。
  2. 创建Thread子类的实例,即创建了线程对象。
  3. 调用线程对象的start()方法来启动该线程。
  • 通过实现Runnable接口来创建并启动线程的步骤如下:
  1. 定义Runnable接口的实现类,并实现该接口的run()方法,该run()方法将作为线程执行体。
  2. 创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象,Thread对象为线 程对象。
  3. 调用线程对象的start()方法来启动该线程。
  • 通过实现Callable接口来创建并启动线程的步骤如下:
  1. 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有 返回值。然后再创建Callable实现类的实例。
  2. 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返 回值。
  3. 使用FutureTask对象作为Thread对象的target创建并启动新线程。
  4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

创建线程三种方式的优缺点?

采用实现Runnable、Callable接口的方式创建多线程的优缺点:
  • 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
  • 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资 源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
  • 劣势是,编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。

采用继承Thread类的方式创建多线程的优缺点:


  • 劣势是,因为线程类已经继承了Thread类,所以不能再继承其他父类。
  • 优势是,编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用 this即可获得当前线程。

鉴于上面分析,因此一般推荐采用实现Runnable接口、Callable接口的方式来创建多线程

说说Thread类的常用方法


  1. Thread类常用构造方法:
  • Thread()
  • Thread(String name)
  • Thread(Runnable target)
  • Thread(Runnable target, String name)

其中,参数 name为线程名,参数 target为包含线程体的目标对象。

  1. Thread类常用静态方法
  • currentThread():返回当前正在执行的线程;
  • interrupted():返回当前执行的线程是否已经被中断;
  • sleep(long millis):使当前执行的线程睡眠多少毫秒数;
  • yield():使当前执行的线程自愿暂时放弃对处理器的使用权并允许其他线程执行;
  1. Thread类常用实例方法:
  • getId():返回该线程的id;
  • getName():返回该线程的名字;
  • getPriority():返回该线程的优先级;
  • interrupt():使该线程中断;
  • isInterrupted():返回该线程是否被中断;
  • isAlive():返回该线程是否处于活动状态;
  • isDaemon():返回该线程是否是守护线程;
  • setDaemon(boolean on):将该线程标记为守护线程或用户线程,如果不标记默认是非守护线 程;
  • setName(String name):设置该线程的名字;
  • setPriority(int newPriority):改变该线程的优先级;
  • join():等待该线程终止;
  • join(long millis):等待该线程终止,至多等待多少毫秒数。

run()和start()有什么区别?

run()方法被称为线程执行体,它的方法体代表了线程需要完成的任务,而start()方法用来启动线程。 调用start()方法启动线程时,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的 run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。 总结:如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。

介绍一下线程的生命周期

在线程的生命周期中,它要经过新建(New)、就绪(Ready)、运行(Running)、阻塞 (Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直“霸占”着CPU独自运 行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、就绪之间切换。

img

Serializable接口为什么需要定义serialVersionUID变量?

serialVersionUID代表序列化的版本,通过定义类的序列化版本,在反序列化时,只要对象中所存的版 本和当前类的版本一致,就允许做恢复数据的操作,否则将会抛出序列化版本不一致的错误。 如果不定义序列化版本,在反序列化时可能出现冲突的情况, 例如:
  1. 创建该类的实例,并将这个实例序列化,保存在磁盘上;
  2. 升级这个类,例如增加、删除、修改这个类的成员变量;
  3. 反序列化该类的实例,即从磁盘上恢复修改之前保存的数据。 在第3步恢复数据的时候,当前的类已经和序列化的数据的格式产生了冲突,可能会发生各种意想不到 的问题。增加了序列化版本之后,在这种情况下则可以抛出异常,以提示这种矛盾的存在,提高数据的 安全性。

如何实现线程同步?


  1. 同步方法 即有synchronized关键字修饰的方法,由于java的每个对象都有一个内置锁,当用此关键字修饰 方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需 要注意, synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个 类。
  2. 同步代码块 即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实 现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必 要同步整个方法,使用synchronized代码块同步关键代码即可。
  3. ReentrantLock Java 5新增了一个java.util.concurrent包来支持同步,其中ReentrantLock类是可重入、互斥、实 现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其 能力。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低 程序运行效率,因此不推荐使用。
  4. volatile volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可 能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的 是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。 5. 原子变量 在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线 程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式 增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和 实用工具进行统一访问

说一说Java多线程之间的通信方式

在Java中线程通信主要有以下三种方式:
  1. wait()、notify()、notifyAll() 如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。 这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每 个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当 前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本 地方法,并且被final修饰,无法被重写。 wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对 象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。 notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放 锁后竞争锁,进而得到CPU的执行。 每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争 锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而 等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
  2. await()、signal()、signalAll() 如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通 信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的 wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的 await+signal这种方式能够更加安全和高效地实现线程间协作。 Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意 的是,Condition 的 await()/signal()/signalAll() 使用都必须在lock保护之内,也就是说,必须在 lock.lock()和lock.unlock之间才可以使用。事实上,await()/signal()/signalAll() 与 wait()/notify()/notifyAll()有着天然的对应关系。即:Conditon中的await()对应Object的wait(), Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。
  3. BlockingQueue Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用 途并不是作为容器,而是作为线程通信的工具。BlockingQueue具有一个特征:当生产者线程试 图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从 BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。 程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通 信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该 模型提供的解决方案。

作者: 杨校

出处: https://mryang.blog.csdn.net

分享是快乐的,也见证了个人成长历程,文章大多都是工作经验总结以及平时学习积累,基于自身认知不足之处在所难免,也请大家指正,共同进步。