学习目标:JAVA面试八股文(一.Java基础篇)
第三天开学:
内容:
- 一周掌握 Java 基础面试八股文
学习内容:
提示:作者自己的计划
计划:
- HashMap底层是 数组+链表+红黑树,为什么要用这几类结构
- HashMap和HashTable区别
- 线程的创建方式
- 线程的状态转换有什么(生命周期)
- Java中有几种类型的流
- 谈谈对反射的理解
开始介绍:
提示:自己的学习理解和资料整合
1.HashMap底层使用数组+链表+红黑树的结构是为了解决散列表中的哈希冲突问题,并提高散列表的性能和效率。
散列表(Hash Table)是一种常用的数据结构,它通过将关键字映射到一个固定大小的数组中来实现快速的数据访问。然而,在处理大量数据时,不同的关键字可能会映射到相同的数组索引上,这就是哈希冲突。
为了解决哈希冲突,HashMap采用了链地址法(Separate Chaining)的解决方案。具体来说,HashMap的每个数组元素都是一个链表(或者在链表长度过长的情况下,转化为红黑树),每个链表节点存储了键值对。当发生哈希冲突时,新的键值对会被插入到链表中。
使用链表的主要好处是简单且易于实现。但是,在链表长度过长的情况下,查找效率会降低。为了解决这个问题,Java 8引入了红黑树作为链表的替代结构。当链表长度超过阈值(默认为8)时,链表会自动转换为红黑树。红黑树的平均查找时间复杂度为O(log n),相比于链表的O(n)可以提高查询效率。
因此,HashMap底层使用数组+链表+红黑树的组合结构,既能够保持简单和灵活性,又能够在处理大量数据时保持较高的性能。通过这样的设计,HashMap可以在绝大多数情况下实现高效的键值查找和存储操作。
import java.util.HashMap;
public class HashMapExample {
public static void main(String[] args) {
// 创建一个HashMap对象
HashMap<String, Integer> scores = new HashMap<>();
// 添加学生的姓名和成绩
scores.put("Alice", 90);
scores.put("Bob", 85);
scores.put("Charlie", 95);
// 获取学生的成绩
int aliceScore = scores.get("Alice");
int bobScore = scores.get("Bob");
int charlieScore = scores.get("Charlie");
System.out.println("Alice's score: " + aliceScore);
System.out.println("Bob's score: " + bobScore);
System.out.println("Charlie's score: " + charlieScore);
}
}
在这个例子中,我们创建了一个HashMap对象 scores,它的键类型是字符串(学生的姓名),值类型是整数(学生的成绩)。
通过调用 put() 方法,我们向 scores 中添加了三个键值对,即学生的姓名和成绩。
接着,我们通过调用 get() 方法,根据学生的姓名获取他们的成绩。
HashMap内部会根据键的哈希值将键值对存储到不同的数组位置上。如果发生哈希冲突,即多个键的哈希值相同,那么这些键值对将会以链表的形式连接在一起。当链表长度超过阈值时,链表会转换为红黑树,以提高查询性能。
通过使用HashMap,我们可以根据学生的姓名快速查找并获取他们的成绩。
这只是一个简单的例子,实际上HashMap可以存储更复杂的数据类型,并且支持更多的操作。但是通过这个例子,你可以初步了解HashMap底层的数组+链表+红黑树的结构是如何工作的。
2.HashMap和HashTable区别:
HashMap和HashTable都是用于实现键值对存储的数据结构,它们的目的是为了快速地查找和存储数据。
它们的主要区别如下:
线程安全性:
HashTable是线程安全的,而HashMap则不是。这是因为HashTable的每个方法都被synchronized修饰,可以保证在多线程环境中的线程安全。而HashMap是非线程安全的,如果在多线程环境中使用HashMap时需要进行额外的同步措施。
继承关系:
HashTable是由Dictionary类继承而来的,而HashMap则是由AbstractMap类继承而来的。HashMap比HashTable更加灵活,因为它的父类AbstractMap提供了更多的可扩展性和自定义的方法。
null键和null值:
HashTable不允许null键或null值,否则会抛出NullPointerException异常。而HashMap则允许null键和null值,它们被视为特殊的键值对。
初始容量和扩容方式:
在初始容量和扩容方面,HashTable默认的初始容量为11,负载因子为0.75,当Hashtable的大小超过了负载因子所允许的最大值时,会自动扩容。而HashMap的默认初始容量为16,负载因子为0.75,当HashMap的大小超过了负载因子所允许的最大值时,会自动扩容。不同的是,HashTable在扩容时直接将容量变成原来的2倍加1,而HashMap会将容量变成原来的2倍。
因此,如果需要线程安全的键值对存储结构,可以使用HashTable,如果需要更高效和灵活的实现,并且不需要考虑线程安全问题,可以使用HashMap。
3.线程的创建方式:
通过实现Runnable接口、继承Thread类、使用Callable和Future接口、以及使用线程池,你可以根据具体的需求选择最合适的线程创建方式。
class MyRunnable implements Runnable {
public void run() {
// 线程执行的代码
}
}
// 创建线程并启动
Thread thread = new Thread(new MyRunnable());
thread.start();
class MyThread extends Thread {
public void run() {
// 线程执行的代码
}
}
// 创建线程并启动
MyThread thread = new MyThread();
thread.start();
import java.util.concurrent.*;
class MyCallable implements Callable<Integer> {
public Integer call() throws Exception {
// 线程执行的代码
return 42;
}
}
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(1);
// 提交任务并获取Future对象
Future<Integer> future = executor.submit(new MyCallable());
try {
// 获取线程执行结果
int result = future.get();
System.out.println("线程执行结果:" + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
// 关闭线程池
executor.shutdown();
}
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class MyRunnable implements Runnable {
public void run() {
// 线程执行的代码
}
}
// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(1);
// 提交任务
executor.execute(new MyRunnable());
// 关闭线程池
executor.shutdown();
4. 线程的状态转换有什么(生命周期):
在Java中,线程的生命周期可以分为6个状态:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、等待(Waiting)和终止(Terminated)。下面是每个状态的详细说明:
新建(New):当线程对象被创建时,它处于新建状态。此时该线程对象已经被分配内存,但还没有被启动。
就绪(Runnable):当调用线程的 start() 方法时,该线程进入就绪状态。此时线程已经准备好执行,并等待获取CPU时间片。
运行(Running):当线程获得CPU时间片并开始执行线程的 run() 方法时,该线程进入运行状态。此时线程正在执行代码。
阻塞(Blocked):当线程因为某些原因(如等待输入输出、等待获取锁等)无法执行时,它会进入阻塞状态。在阻塞状态下,线程不会占用CPU时间片。
等待(Waiting):当线程等待其他线程通知它可以继续执行时,它会进入等待状态。在等待状态下,线程不会占用CPU时间片。
终止(Terminated):当线程的 run() 方法执行完毕或者因为异常退出时,该线程进入终止状态。线程一旦进入终止状态,它就不能再次启动。
在线程的生命周期中,线程可以从一个状态转换到另一个状态。例如,当线程从运行状态切换到阻塞状态时,它会释放CPU时间片,让其他线程有机会执行。线程的状态转换如下:
新建(New)-> 就绪(Runnable)
就绪(Runnable)-> 运行(Running)
运行(Running)-> 就绪(Runnable)
运行(Running)-> 阻塞(Blocked)
阻塞(Blocked)-> 就绪(Runnable)
运行(Running)-> 等待(Waiting)
等待(Waiting)-> 就绪(Runnable)
运行(Running)-> 终止(Terminated)
线程的状态转换是由JVM内部的调度器控制的。在Java多线程编程中,了解线程的生命周期以及状态转换对于实现正确、高效的多线程程序非常重要。
6. Java中有几种类型的流:
在Java中,流(Stream)是处理输入/输出操作的一个重要概念。根据流的不同特性和用途,可以将Java中的流分为不同的类型。以下是常见的几种类型的流:
- 字节流(Byte Stream)
:
输入字节流:InputStream 及其子类,用于从输入源读取字节数据。
输出字节流:OutputStream 及其子类,用于向输出目标写入字节数据。
- 字符流(Character Stream):
输入字符流:Reader 及其子类,用于从输入源读取字符数据。
输出字符流:Writer 及其子类,用于向输出目标写入字符数据。
- 缓冲流(Buffered Stream):
BufferedInputStream 和 BufferedOutputStream:提供了缓冲功能,可以提高读写效率。
BufferedReader 和 BufferedWriter:提供了缓冲功能,并且可以按行读写数据。
- 数据流(Data Stream):
DataInputStream 和 DataOutputStream:用于读写基本数据类型和字符串。
- 对象流(Object Stream):
ObjectInputStream 和 ObjectOutputStream:用于读写对象数据,可以将对象直接序列化到流中或者从流中反序列化对象。
- 转换流(Conversion Stream):
InputStreamReader 和 OutputStreamWriter:用于将字节流转换为字符流,实现字符集编码的转换。
例子:
- 字节流:
// 使用字节流读取文件内容并输出到控制台
try (InputStream inputStream = new FileInputStream("input.txt")) {
int data;
while ((data = inputStream.read()) != -1) {
System.out.print((char) data);
}
} catch (IOException e) {
e.printStackTrace();
}
// 使用字节流将数据写入文件
try (OutputStream outputStream = new FileOutputStream("output.txt")) {
String data = "Hello, World!";
byte[] bytes = data.getBytes();
outputStream.write(bytes);
} catch (IOException e) {
e.printStackTrace();
}
- 字符流:
// 使用字符流读取文件内容并输出到控制台
try (Reader reader = new FileReader("input.txt")) {
int data;
while ((data = reader.read()) != -1) {
System.out.print((char) data);
}
} catch (IOException e) {
e.printStackTrace();
}
// 使用字符流将数据写入文件
try (Writer writer = new FileWriter("output.txt")) {
String data = "Hello, World!";
writer.write(data);
} catch (IOException e) {
e.printStackTrace();
}
- 缓冲流:
// 使用缓冲字节流复制文件
try (InputStream inputStream = new FileInputStream("input.txt");
OutputStream outputStream = new FileOutputStream("output.txt");
BufferedInputStream bufferedInput = new BufferedInputStream(inputStream);
BufferedOutputStream bufferedOutput = new BufferedOutputStream(outputStream)) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = bufferedInput.read(buffer)) != -1) {
bufferedOutput.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
e.printStackTrace();
}
// 使用缓冲字符流按行读取文件内容并输出到控制台
try (Reader reader = new FileReader("input.txt");
BufferedReader bufferedReader = new BufferedReader(reader)) {
String line;
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
6.谈谈对反射的理解
反射是指在运行时动态地获取和操作类的信息的能力。Java中提供了反射机制,使得我们可以在程序运行时获取类的属性、方法和构造函数等信息,并且可以动态地创建对象、调用方法、访问和修改属性等。下面是对反射的一些理解:
获取类的信息:通过反射,可以获取类的名称、父类、接口、字段、方法、构造函数等信息。这使得我们可以在程序运行时动态地了解一个类的结构和特征。
创建对象:使用反射可以在运行时动态地创建对象,即使在编译时并不知道具体的类名。通过获取类的构造函数,并调用newInstance()方法,可以实现对象的动态创建。
调用方法:通过反射,可以在运行时动态地调用类的方法,包括公有方法、私有方法以及静态方法。通过获取方法对象,并使用invoke()方法,可以实现方法的动态调用。
访问和修改属性:通过反射,可以在运行时动态地访问和修改类的属性,包括公有属性和私有属性。通过获取字段对象,并使用get()和set()方法,可以实现属性的动态访问和修改。
动态代理:反射还可以用于实现动态代理。通过创建代理对象,在代理对象中动态地处理被代理对象的方法调用,实现对被代理对象的增强。
反射机制在某些特定的场景下非常有用,例如配置文件解析、ORM框架、依赖注入等。然而,反射的使用应该谨慎,并且避免滥用,因为反射会导致一定的性能损耗,并且使程序的逻辑变得复杂和不易维护。